MYOS の起動処理をかんたんに解説します。
MYOS をメインに解説しますが、 TOE は MYOS のサブセットとして開発が始まったので共通点も多いです。
Boot Loader 〜 カーネルエントリポイント
まずはブートローダーが起動します。ブートローダーの細かい動作は本質的ではないのでここでは省略します。 BIOS や UEFI から呼び出されて、ハードウェア情報収集、ビデオモードの初期化、カーネルの読み込みなどが終わったら CPU モードの初期設定をしてカーネルのエントリポイントに制御を移します。
余談ですが、 OS が起動時に自分で自分を読み込む一連の動作はコンピューター最大の神秘と矛盾であり、 「pull oneself up by one's bootstraps」という英文の靴紐 (bootstrap) が自分で自分を持ち上げるような違和感を連想したことから転じて、 コンピューターの起動を boot strap と呼ぶようになったと言われています。
ブートローダーからカーネルが起動されると、エントリポイントから System::init()
が呼び出されます。
System::init()
System::init()
の引数には BootInfo
という構造体が渡されます。 MYOS と TOE では詳細が異なりますがどちらも起動に必要な情報が入っています。
起動後にも必要になる情報は別の場所にコピーします。
次に、 BootInfo の画面情報をもとに main_screen
というスタティックなビットマップオブジェクトを作成します。
旧 MYOS のコンソール画面は main_screen の初期化後に個別の初期化が必要でしたが、 TOE のクラスと統合した現在では main_screen を初期化するだけでコンソール出力もできるようになります。
次に、 BootInfo のメモリ配置情報をもとに MemoryManager::init_first()
を呼び出して最低限のメモリマネージャを初期化します。
以降、 alloc
系のクラス (Box
, Vec
など) が動作するようになってヒープに動的にメモリ確保できるようになります。
現在のバージョンではブートローダーが最低限のページング設定を済ませているのでメモリマネージャはページ操作しません。
次に、 MYOS は外部の ACPI ライブラリを初期化します。*1
ACPI が利用できるようになったら ACPI の情報をもとに system.cpus
に CPU の個数分だけ領域を予約して Cpu::new()
を呼び出してブートプロセッサの初期化します。
次に、 Arch::init()
を呼び出してアーキテクチャ固有の初期化を行います。 arch 以下のクラスはアーキテクチャ固有の処理が多いのでアーキテクチャごとにかなり違います。
主な内容は CPU の初期化 (Cpu::init()
)、割り込みコントローラーとタイマーの初期化、 SMP の初期化等のシステムのコア部分の初期化です。
TOE(x86) の場合は 8259 PIC と 8253 PIT を各プラットフォームごとにほぼ同じ動作をするように設定します。
MYOS(x64) の場合は APIC と HPET を初期化して最後に SMP を有効にします。
最後に Scheduler::start()
を呼び出してスケジューラーを開始します。
スケジューラーの初期化が終わるとメインスレッドはブートプロセッサで HLT 命令を実行し続けるだけのアイドルスレッドになります。
この段階で狭義のカーネルが動作するようになります。
System::late_init()
スケジューラーが開始すると新しいプロセス (System) で System::late_init()
が起動するので以降の初期化を行います。
基本的にはここではいろんなサブシステムを初期化して最後にシェルを起動します。
まず、 MemoryManager::late_init()
を呼び出して追加のメモリ管理機能を初期化します。
現在のバージョンでは最低限のメモリマネージャ機能しか実装されていないのでここでは何もしていません。
次に、 Fs::init()
を呼び出してファイルシステムを初期化して起動時ファイルシステムがアクセス可能になります。
現在の TOE では未実装です。
次に、 RuntimeEnvironment::init()
を呼び出し、アプリケーション実行環境をサポートするための BinaryRecognizer
と Personality
が利用可能になります。
現在の TOE では未実装です。
次に、 FontManager::init()
を呼び出してフォントマネージャを初期化します。
初期化以前にも固定のシステムフォントは利用できますが、以降は FontDescriptor
による複雑なフォントも使えるようになります。
次に、 WindowManager::init()
を呼び出してウィンドウマネージャを初期化してウィンドウが使えるようになります。
次に、 HidManager::init()
を呼び出して HID マネージャを初期化します。
HID マネージャはキーボードやマウスの信号をウィンドウマネージャに渡してウィンドウメッセージに変換する仲介をします。
次に、 Arch::late_init()
を呼び出して機種固有の初期化の続きを実行します。
ここでキーボードやマウスなどのデバイスが使えるようになります。
ここまででいわゆるカーネルの初期化が終わります。
最後にエントリポイントから渡された関数 (main) に制御を移します。 現在はカーネルモードでシェルっぽいウィンドウを表示してユーザーと対話できるようにしています。 ここでユーザーがコンピューターを実際に操作可能な状態になります。
Cpu::init() と Cpu::new()
Cpu::init()
と Cpu::new()
はどちらも CPU 初期化するためのメソッドですが、役割が違います。
MYOS では IDT はシステム全体で共通ですが 、GDT はプロセッサコアごとに内容が異なります。
Cpu::init()
はシステム全体の CPU 共通の初期化をします。
具体的には、 IDT の用意をしたり CPU の世代を調べて利用可能な機能を整理したりします。
Cpu::new()
はマルチプロセッサのプロセッサコアごとの初期化で Cpu オブジェクトのインスタンスを返します。
具体的には、 GDT の初期化をしたりコア固有の ID を用意したりします。
Apic::init()
MYOS の Arch::init()
内で呼ばれる Apic::init()
はマルチプロセッサが絡んで動作がけっこう複雑なのでここで少し詳しく解説します。
SMP の重要な用語として BSP と AP があります。 BSP (Boot Strap Processor ブートストラッププロセッサ) はシステム起動時に動作しているプロセッサコアを指します。 マルチコア環境でシングルコアの OS を起動する場合は BSP だけが動いている状態になります。 AP (Application Processor アプリケーションプロセッサ) は BSP 以外の全てのプロセッサコアを指します。
SMP は Symmetric Multiprocessing (対称型マルチプロセッサ) の略で、名前の通り全てのプロセッサが対称になります。 x86/x64 の SMP では起動時に BSP を決めるプロトコルで BSP になれなかったプロセッサコアは AP として起こされるまで待機状態になります。
SMP の動作で重要になるのが APIC (Advanced Programmable Interrupt Controller) です。名前の通り PIC よりも高度な割り込みコントローラーになります。 APIC には Local APIC と I/O APIC の2種類あります。 I/O デバイスで外部割り込み (IRQ) が発生した場合は I/O APIC で割り込みを受信して各プロセッサコアにある Local APIC に対して割り込みを分配し、 Local APIC が CPU コアに対して割り込み命令を実行させます。
I/O APIC のリダイレクトテーブルでは外部割り込みをどのプロセッサコアに対して割り込みを分配するか設定することができます。 特定のプロセッサに固定で分配したり負荷状況に応じて動的に分配することもできますが、この機能を使わないシステムも多く MYOS では全ての IRQ を BSP で処理します。
SMP 環境ではその他の割り込みとして、 IPI (Inter-Processor Interrupt プロセッサ間割り込み) と Local APIC の割り込みがあります。
SMP 環境で他のプロセッサと通信するときは Local APIC を操作して IPI というコマンドを発行します。 通常の IPI は対象のプロセッサコアに対して割り込みを発生しますが、 SMP の初期化で使う INIT IPI も Startup IPI も通常の割り込みとは異なる専用のコマンドになります。
Local APIC 割り込みにはいくつか種類があります。 MYOS で利用しているのは Local APIC タイマー割り込みです。 高精度に測定できる HPET があるのに Local APIC タイマーが必要な理由は、 Local APIC タイマー割り込みはコアごとに設定することができるのでスケジューラーのプリエンプションに利用します。
まずは ACPI の情報をもとに APIC を初期化します。*2
APIC を有効にすると割り込みがすべて APIC 経由になって PIC が誤動作するといけないので PIC の IMR には 0xFF
(全て無効)に設定しておきます。
Cpu::out8(0xA1, 0xFF); Cpu::out8(0x21, 0xFF);
次に LocalApic::init()
を実行して BSP の Local APIC を有効化します。MSR にある IA32_APIC_BASE というレジスタに Local APIC のベースアドレス (通常は 0xFEE0_0000
)、 IA32_APIC_BASE_MSR_BSP フラグ、 IA32_APIC_BASE_MSR_ENABLE フラグを書き込んで有効化しています。
unsafe fn init(base: usize) { LOCAL_APIC = Mmio::from_phys(base, 0x1000).ok(); Msr::ApicBase.write( (base as u64 & !0xFFF) | Self::IA32_APIC_BASE_MSR_ENABLE | Self::IA32_APIC_BASE_MSR_BSP, ); }
次に IoApic::new()
を実行して I/O APIC を初期化します。
unsafe fn new(acpi_ioapic: &acpi::platform::IoApic) -> Self { let mut ioapic = IoApic { mmio: Mmio::from_phys(acpi_ioapic.address as usize, 0x14).unwrap(), global_int: Irq(acpi_ioapic.global_system_interrupt_base as u8), entries: 0, id: acpi_ioapic.id, lock: Spinlock::new(), }; let ver = ioapic.read(IoApicIndex::VER); ioapic.entries = 1 + (ver >> 16) as u8; ioapic }
ここでは IoApic 構造体の情報を設定しているだけで I/O APIC に特にコマンドを書き込んだりしません。 バージョンレジスタに I/O APIC がサポートしている IRQ の個数 (通常は 24) があるのでそこだけ I/O APIC にアクセスしています。 I/O APIC が複数存在するシステムも存在するようですが実際に見たことがないので詳細は不明です。
これだけで APIC が使えるようになります。意外と簡単ですね。 あとは I/O APIC のリダイレクトテーブルに IRQ の情報を書き込むとその IRQ を割り込みとして受け取ることができます。
APIC が使えるようになったら Local APIC タイマーを初期化します。
let vec_latimer = Irq(0).as_vec(); InterruptDescriptorTable::register( vec_latimer, timer_handler as usize, PrivilegeLevel::Kernel, ); LocalApic::clear_timer(); LocalApic::set_timer_div(LocalApicTimerDivide::By1); if let Ok(hpet_info) = acpi::HpetInfo::new(System::acpi()) { let hpet = Hpet::new(&hpet_info); let magic_number = 100; let deadline0 = hpet.create(TimeSpec(1)); while hpet.until(deadline0) { Cpu::spin_loop_hint(); } let deadline1 = hpet.create(hpet.from_duration(Duration::from_micros(100_0000 / magic_number))); LocalApic::TimerInitialCount.write(u32::MAX); while hpet.until(deadline1) { Cpu::spin_loop_hint(); } let count = LocalApic::TimerCurrentCount.read() as u64; APIC.lapic_timer_value = ((u32::MAX as u64 - count) * magic_number / 1000) as u32; Timer::set_timer(hpet); } else { panic!("No Reference Timer found"); } LocalApic::set_timer( LocalApicTimerMode::Periodic, vec_latimer, APIC.lapic_timer_value, );
ちょっと複雑ですが、 Local APIC タイマーは実際に設定する値と割り込み間隔の設定が単純な計算で求めるのが難しいので、 HPET を使って Local APIC タイマーに設定する値を簡易測定してます。
ここまでで APIC の初期設定が終わったのでいよいよ SMP の有効化手順をはじめます。
まずは、 Startup IPI で使うためのベクタを用意します。
Startup IPI で送信するベクタは専用の特殊なベクタで、ベクタ番号を 12 ビット左シフトしたリニアアドレスのコードが呼び出されます。
仮にベクタ番号 0x01
とすると実際の初期化コードはシフトされたリニアアドレス 0x01000
になります。*3
初期化コードの中ではスタックが利用できない (=メモリ割り当て関数を呼び出せない) ので、 STACK_CHUNK_SIZE
* プロセッサコア数分のスタックも先に確保しておきます。
let sipi_vec = InterruptVector(MemoryManager::static_alloc_real().unwrap().get()); let max_cpu = core::cmp::min(System::num_of_cpus(), MAX_CPU); let stack_chunk_size = STACK_CHUNK_SIZE; let stack_base = MemoryManager::zalloc_legacy(max_cpu * stack_chunk_size) .unwrap() .get() as *mut c_void; asm_apic_setup_sipi(sipi_vec, max_cpu, stack_chunk_size, stack_base);
asm_apic_setup_sipi
の中では AP の初期化コードを _smp_rm_payload
にあるコードブロックから Startup IPI ベクタを変換したリニアアドレスにコピーし、初期化コードで参照する BSP の情報も SMPINFO
に書き込んでおきます。 EFER は BSP の値をそのまま書き込むと EFER.LMA の解釈違いでクラッシュする環境のためここでリセットしておきます。(参考: EFER.LME と EFER.LMA - 借り初めのひみつきち)
asm_apic_setup_sipi: push rsi push rdi movzx r11d, cl shl r11d, 12 mov edi, r11d lea rsi, [rel _smp_rm_payload] mov ecx, _end_smp_rm_payload - _smp_rm_payload rep movsb mov r10d, SMPINFO mov [r10 + SMPINFO_MAX_CPU], edx mov [r10 + SMPINFO_STACK_SIZE], r8d mov [r10 + SMPINFO_STACK_BASE], r9 lea edx, [r10 + SMPINFO_GDTR] lea rsi, [rel _minimal_GDT] lea edi, [rdx + 8] mov ecx, (_end_GDT - _minimal_GDT) / 4 rep movsd mov [rdx + 2], edx mov word [rdx], (_end_GDT - _minimal_GDT) + 7 mov ecx, 1 mov [r10], ecx mov rdx, cr4 mov [r10 + SMPINFO_CR4], edx mov rdx, cr3 mov [r10 + SMPINFO_CR3], rdx sidt [r10 + SMPINFO_IDT] mov ecx, IA32_EFER rdmsr btr eax, EFER_LMA mov [r10 + SMPINFO_EFER], eax lea ecx, [r11 + _startup64 - _smp_rm_payload] mov edx, KERNEL_CS64 mov [r10 + SMPINFO_START64], ecx mov [r10 + SMPINFO_START64 + 4], edx lea rax, [rel _ap_startup] mov [r10 + SMPINFO_AP_STARTUP], rax mov eax, r10d pop rdi pop rsi ret
準備が終わったら INIT IPI を全てのプロセッサに送信して 10ms 待機します。 INIT IPI はリセット信号に近いもので、 INIT を受信した AP はリアルモードで起動して SMP BIOS が Startup IPI を待機する状態になります。
LocalApic::broadcast_init(); let timer = Timer::new(Duration::from_millis(10)); while timer.until() { Cpu::halt(); }
LocalApic::broadcast_init()
の中身はこんな感じで IPI コマンドレジスタに値を書き込んでいます。
IPI コマンドレジスタは組になっている2つの 32bit レジスタで、上位レジスタには宛先のコア ID などを設定し、下位レジスタに値を書き込むと実際にコマンド送信されます。
unsafe fn broadcast_init() { Self::InterruptCommandHigh.write(0); Self::InterruptCommand.write(0x000C4500); }
次に Startup IPI を全てのプロセッサに送信します。 これで AP 上で OS の初期化コードが走るようになります。
LocalApic::broadcast_startup(sipi_vec);
LocalApic::broadcast_startup()
の中身もこんな感じで IPI コマンドを書き込んでいます。
unsafe fn broadcast_startup(init_vec: InterruptVector) { Self::InterruptCommandHigh.write(0); Self::InterruptCommand.write(0x000C4600 | init_vec.0 as u32); }
Startup IPI を受信した AP は Startup IPI で指定した専用のベクタから起動します。ここで asm_apic_setup_sipi
であらかじめコピーしておいた _smp_rm_payload
のコードブロックが実行されます。ここからしばらくはリアルモードで、 Startup IPI を受信した複数のプロセッサが同時に実行開始するためスタックは利用できません。
まずは単純なアトミック加算で仮の CPU 番号を決めて ebp
レジスタに保存します。
SMPINFO_MAX_CPU
に設定したコア数より多くなった場合、そのコアは HLT で無限ループします。
_smp_rm_payload: cli xor ax, ax mov ds, ax mov ebx, SMPINFO mov ax, 1 lock xadd [bx], ax cmp ax, [bx + SMPINFO_MAX_CPU] jae .fail jmp .core_ok .fail: .forever: hlt jmp short .forever .core_ok: movzx ebp, ax
次に BSP からもらった SMPINFO
の情報をもとに最低限の設定でロングモードを有効にします。
一時的な GDT をロードしてセグメントを初期化し、 CR0.PE をセットしてプロテクトモードに遷移し、データセグメントやスタックセグメントを設定します。 その後 BSP で保存しておいた値で CR3、CR4、EFER を設定し、最後に CR0.PG をセットしてロングモードに遷移します。
lgdt [bx + SMPINFO_GDTR] mov eax, cr0 bts eax, CR0_PE mov cr0, eax mov ax, KERNEL_SS mov ss, ax mov ds, ax mov es, ax mov fs, ax mov gs, ax mov eax, [bx + SMPINFO_CR4] mov cr4, eax mov eax, [bx + SMPINFO_CR3] mov cr3 ,eax mov ecx, IA32_EFER xor edx, edx mov eax, [bx+ SMPINFO_EFER] wrmsr mov eax, cr0 bts eax, CR0_PG mov cr0, eax
最後に _startup64
にセグメント間ジャンプして 64bit モードに遷移します。 jmp far dword
というのはこの時点では 16bit セグメントなので far jump も 16bit で解釈されてしまうのを 32bit の far jump として解釈するようにするおまじないです。
jmp far dword [bx + SMPINFO_START64]
_startup64
はすぐに _ap_startup
にジャンプします。
初期コードから _ap_startup
に直接ジャンプしないのは、 _ap_startup
が 4GB (0x1_0000_0000) を超えるリニアアドレスに存在するためセグメント間ジャンプ命令では直接遷移できず、いったん 4GB 以下のアドレスで 64bit モードに遷移しておく必要があるためです。
_startup64: jmp [rbx + SMPINFO_AP_STARTUP]
_ap_startup
は IDT を設定し、 ebp
レジスタに保存しておいた仮の CPU 番号をもとにコアごとのスタックの割り当てをした後 apic_start_ap()
を呼び出します。
_ap_startup: lidt [rbx + SMPINFO_IDT] mov eax, ebp imul eax, [rbx + SMPINFO_STACK_SIZE] mov rcx, [rbx + SMPINFO_STACK_BASE] lea rsp, [rcx + rax] mov ecx, ebp call apic_start_ap
apic_start_ap()
では LocalApic::init_ap()
でローカル APIC の初期化、 Cpu::new()
で CPU の初期化、 System::activate_cpu()
を呼び出して CPU をアクティブにし、 TSC 同期のために BSP に再び呼び出されるまで一旦待機します。
pub unsafe extern "C" fn apic_start_ap() { let apic_id = GLOBALLOCK.synchronized(|| { let apic_id = LocalApic::init_ap(); System::activate_cpu(Cpu::new(apic_id)); apic_id }); while AP_STALLED.load(Ordering::Relaxed) { Cpu::spin_loop_hint(); }
LocalApic::init_ap()
では BSP の時より処理が少し多くなっています。
MSR で APIC を有効化し、スプリアス割り込みを有効にし*4、 Local APIC タイマーを BSP と同じ設定で初期化し、最後に自分自身の APIC ID を返します。
unsafe fn init_ap() -> ProcessorId { Msr::ApicBase .write(LOCAL_APIC.as_ref().unwrap().base() as u64 | Self::IA32_APIC_BASE_MSR_ENABLE); let apicid = LocalApic::current_processor_id(); LocalApic::SpuriousInterrupt.write(0x010F); let vec_latimer = Irq(0).as_vec(); LocalApic::clear_timer(); LocalApic::set_timer_div(LocalApicTimerDivide::By1); LocalApic::set_timer( LocalApicTimerMode::Periodic, vec_latimer, APIC.lapic_timer_value, ); apicid }
Cpu::new()
では一時的な GDT からプロセッサ固有の GDT への切り替えを行い、 APIC ID と CPU トポロジー情報から SMT の裏コア判定 *5 をし、 Cpu 構造体を初期化して返します。
また、現在のバージョンでは SSE 非対応なのでここで CR4.OSFXSR をリセットして SSE を強制的に無効化しています。
pub(crate) unsafe fn new(apic_id: ProcessorId) -> Box<Self> { asm!(" mov {0}, cr4 btr {0}, 9 mov cr4, {0} ", out(reg) _); let gdt = GlobalDescriptorTable::new(); let core_type; if (apic_id.as_u32() & Self::shared().smt_topology) == 0 { core_type = ProcessorCoreType::Main; } else { core_type = ProcessorCoreType::Sub; } Box::new(Cpu { cpu_index: ProcessorIndex(0), cpu_id: apic_id, core_type, gdt, tsc_base: 0, }) }
System::activate_cpu()
では System::cpus
に Cpu 構造体を追加してアクティブなプロセッサコア数を更新します。
pub(crate) unsafe fn activate_cpu(new_cpu: Box<Cpu>) { let shared = Self::shared(); if new_cpu.processor_type() == ProcessorCoreType::Main { shared.num_of_performance_cpus += 1; } shared.cpus.push(new_cpu); }
BSP 側のコードでは全ての AP がアクティブになるまで待機し、規定の時間内に何らかのトラブルが発生してすべての CPU の初期化が正常に終わらなかった場合には panic
します。
let timer = Timer::new(Duration::from_millis(200)); while timer.until() { let timer = Timer::new(Duration::from_millis(5)); while timer.until() { Cpu::halt(); } if System::num_of_active_cpus() == max_cpu { break; } } if System::num_of_active_cpus() != max_cpu { panic!("Some of application processors are not responding"); }
正常に AP が起動できた場合、各プロセッサの初期化の順番がランダムで仮に発行した CPU 番号もバラバラになっているので、本当の APICID で並び替えます。
最後に AP_STALLED
の値を false
に設定して TSC 同期のために待機してるすべての CPU を再開させます。
System::sort_cpus(|a, b| a.cpu_id.0.cmp(&b.cpu_id.0)); for index in 0..System::num_of_active_cpus() { let cpu = System::cpu(index); CURRENT_PROCESSOR_INDEXES[cpu.cpu_id.0 as usize] = cpu.cpu_index.0 as u8; } AP_STALLED.store(false, Ordering::SeqCst); System::cpu_mut(0).set_tsc_base(Cpu::rdtsc());
BSP 側で AP_STALLED
の値を false
にすると AP 側の待機が終了して TSC の値を調整します。
TSC はコアごとにバラバラに動作していてコアが変わると値が変わるため、 set_tsc_base()
でコアごとの差分を保存しておいて API から TSC を利用するときはこの値で同期を取ります。
また、 IA32_TSC_AUX
MSR に現在のプロセッサ番号を書き込んで RDTSCP
命令で今動作しているプロセッサのプロセッサ番号を簡単に取得できるようにします。
while AP_STALLED.load(Ordering::Relaxed) { Cpu::spin_loop_hint(); } let tsc = Cpu::rdtsc(); for index in 0..System::num_of_active_cpus() { let cpu = System::cpu(index); if cpu.cpu_id == apic_id { System::cpu_mut(index).set_tsc_base(tsc); if Cpu::has_feature_rdtscp() { Msr::TscAux.write(index as u64); } break; } }
ここまで準備をしたので AP はスケジュール可能になりました。 最後に割り込みを許可し、スケジューラーからスレッドを割り当てられるまで HLT 命令を実行し続けるプロセッサ固有のアイドルスレッドになります。
sti .loop: hlt jmp .loop