複数vCPUをスレッドで実行しSMP起動させる——Intel MPテーブルとCPUIDのAPIC ID

virtio-blockデバイスを自前実装してディスクイメージを読み書きするまでは、vCPUを1つだけ作成しメインスレッドで実行する構成でした。今回はvCPUを複数作成し、それぞれを専用スレッドで実行することで、ゲストのLinuxカーネルに複数CPU(SMP)環境として認識させます。

複数vCPUをスレッドで実行する

複数コア環境では、電源投入直後から動作しOSのブート処理を一手に引き受けるコアをBSP(Bootstrap Processor、vCPU 0)、BSPが後から起こすまで指示待ち状態のコアをAP(Application Processor、vCPU 1以降)と呼びます。今回の実装では、BSPはメイン関数の中でそのまま実行し、APはそれぞれ専用のOSスレッドで実行します。

for vcpu_id in 1..NUM_VCPUS {
    let vcpu_fd = vm.create_vcpu(vcpu_id).unwrap();
    configure_cpuid(&kvm, &vcpu_fd, vcpu_id);
    let devices = Arc::clone(&devices);
    std::thread::spawn(move || run_vcpu(vcpu_id, vcpu_fd, devices));
}

let bsp_vcpu = vm.create_vcpu(0).unwrap();
configure_cpuid(&kvm, &bsp_vcpu, 0);
run_vcpu(0, bsp_vcpu, devices);

シリアルポート・virtio-net・virtio-blockといったデバイスの状態は、どのvCPUからのアクセスでも同じ実体を参照する必要があるため、Devices構造体にまとめてArcで共有し、各フィールドをMutexで保護しています。

pub struct Devices {
    pub guest_memory: Arc<GuestMemoryMmap<()>>,
    pub vm: Arc<VmFd>,
    pub tap: Arc<File>,
    pub disk_file: Mutex<File>,
    pub serial: Mutex<Serial<SerialIrqTrigger, NoEvents, std::io::Stdout>>,
    pub virtio_net: Arc<Mutex<VirtioNet>>,
    pub virtio_blk: Mutex<VirtioBlk>,
    pub cmos_index: Mutex<u8>,
}

vCPUの実行ループ自体はvCPUを実行して実際にLinuxカーネルを起動するで実装したものと同じで、vcpu_idごとに専用スレッドで回す形に変えただけです。レジスタの初期設定(64bitロングモードのsregs/regs)はBSP(vcpu_id == 0)にだけ行い、APはKVM_CREATE_VCPU直後のリセット状態のまま残します。理由は後述します。

Intel MPテーブルでゲストにCPU構成を伝える

KVM_CREATE_VCPUで複数のvCPUを作成しただけでは、ゲストのLinuxカーネルは自分が複数CPU環境にいることに気づきません。CPUの数とAPIC ID(CPUコアごとの識別番号)をカーネルに伝えるには、ACPIのMADTテーブルか、Intel MultiProcessor Specification(MP仕様)のテーブルのどちらかが必要です。このプロジェクトはACPIを実装していないため、MP仕様のテーブルを自前で組み立てます。

MP仕様のテーブルは2つの構造体で構成されます。

  • MPF(MP Floating Pointer Structure): "_MP_"というシグネチャを持つ16バイトの構造体で、本体(MPC)がどこにあるかを指すポインタだけを持ちます。Linuxのmpparse.cは、ベースメモリ最後の1KB(0x9fc000xa0000)など、決め打ちのいくつかの場所をこのシグネチャで探索します
  • MPC(MP Configuration Table): "PCMP"というシグネチャを持つヘッダと、CPU・バス・IOAPICなどのエントリが続く本体です。今回はCPUエントリ(vCPUの数だけ、APIC IDを0番から順に割り当て)、ISAバスのエントリ、IOAPICのエントリを含めています。IRQオーバーライドのエントリは含めていないため、ISA IRQ番号とIOAPICのピン番号は1:1で対応します(これはvm-superioでUART 16550を正確にエミュレートし、KVM_IRQ_LINEで実際に割り込みを配線するKVM_IRQ_LINEに渡しているIRQ番号の前提と一致します)
pub fn write_mptable(guest_memory: &GuestMemoryMmap<()>, num_cpus: u8) {
    let mpc_addr = MPTABLE_START + std::mem::size_of::<MpfIntel>() as u64;
    // MpcCpu × num_cpus, MpcBus, MpcIoapic を組み立てて mpc_addr 以降へ書き込み、
    // 最後に MPF を MPTABLE_START へ書き込む
}

各構造体には#[repr(C, packed)]を付け、フィールド間のパディングを一切許さないバイナリ形式であることを明示しています。チェックサムは「構造体の全バイトを合計すると256の剰余で0になる」という仕様のルールに従い、チェックサム欄を0にした状態で全バイトを合計し、その2の補数(0u8.wrapping_sub(sum))を書き込んでいます。

実行すると、ブートログにMPテーブルが読み取られたことが表示されます。

[    0.000099] found SMP MP-table at [mem 0x0009fc00-0x0009fc0f]
...
[    0.002795] Intel MultiProcessor Specification v1.4
[    0.002798] MPTABLE: OEM ID: RUSTKVM 
[    0.002798] MPTABLE: Product ID: RUSTKVM     
[    0.002799] MPTABLE: APIC at: 0xFEE00000
[    0.002814] Processor #0 (Bootup-CPU)
[    0.002820] Processor #1
[    0.002825] Processor #2
[    0.002825] Processor #3
[    0.002856] Processors: 4

CPUIDのAPIC IDをvCPUごとに設定する

CPUID(CPUに機能を問い合わせるx86命令)のleaf 1(EAXに1を指定して呼び出した場合の応答)には、EBXレジスタの上位8ビット(EBX[31:24])に「このCPU自身の初期APIC IDは何番か」という値が入ります。ゲストカーネルはSMP起動処理の中でこの値を使って「自分がどのCPUか」を判別するため、MPテーブル側で割り当てたAPIC IDと一致させる必要があります。

pub fn configure_cpuid(kvm: &Kvm, vcpu: &VcpuFd, vcpu_id: u64) {
    let mut cpuid = kvm.get_supported_cpuid(KVM_MAX_CPUID_ENTRIES).unwrap();
    for entry in cpuid.as_mut_slice().iter_mut() {
        if entry.function == 1 {
            entry.ebx = (entry.ebx & 0x00ff_ffff) | ((vcpu_id as u32) << 24);
        }
    }
    vcpu.set_cpuid2(&cpuid).unwrap();
}

書き換えるのはAPIC IDのバイトだけで、EBX23:16やEDX28には触れていません。これらのビットは、CPUID leaf 4(キャッシュ階層・実コア数)の値と組み合わせて、ゲストカーネルがsmp_num_siblings(1コアあたりの論理CPU数)を計算するために使われます。kvm.get_supported_cpuid()はホストの生のCPUID応答を返すため、leaf 1だけをvCPU数に合わせて書き換えてleaf 4(ホストの実コア数のまま)を放置すると、この2つのleafの情報が矛盾し、smp_num_siblingsが0になることがあります。この状態でAPを起動しようとすると、apic_id_is_primary_thread()(そのAPIC IDが物理コアの「代表スレッド」かどうかを判定する関数)の中でシフト量が負になり、次のようなUBSAN(Undefined Behavior Sanitizer)の警告がdmesgに出ます。

UBSAN: shift-out-of-bounds in arch/x86/kernel/apic/apic.c:2371:13
shift exponent -1 is negative
...
 apic_id_is_primary_thread.cold+0x17/0x1f
 topology_is_primary_thread+0x2f/0x50
 cpu_up+0xc5/0xf0

この未定義動作が起きると、以降のAP起動判定が正しく機能せず、MPテーブルには4CPU分のエントリがあるにもかかわらず、実際にはBSPしか起動しない(smpboot: Total of 1 processors activated)という状態になります。このプロジェクトではハイパースレッディングを再現しない、独立コアN個というシンプルなモデルを採用しているため、対処としてEBX[23:16]とEDX[28]には一切触れず、ホストからそのままコピーされた値を残すことで、この矛盾自体を発生させないようにしています。

APはリセット状態のまま起動を待つ

BSPが送るINIT-SIPI-SIPI(APを起動させるためのIntel仕様の一連の割り込み)は、in-kernel irqchip(KVM_CREATE_IRQCHIPで用意した、各vCPUのローカルAPICを含む)によってKVM内部で処理されます。VMM側で明示的にAP宛ての割り込みを組み立てる必要はなく、APのvCPUはKVM_CREATE_VCPU直後のリセット状態(mp_stateはKVM_MP_STATE_UNINITIALIZED)のままvcpu.run()を呼び続けるだけで、SIPI受信後にKVMが自動でCS:IPをSIPIベクタに設定してくれます。

mp_stateがUNINITIALIZEDの状態でKVM_RUNを呼ぶと、KVMはSIPIをまだ受け取っていないことを示すEAGAINを返します。これはKVMのAPIとして想定された動作で、他のVMM実装(たとえばkvmtool)でもEAGAIN/EINTRはエラー扱いせずそのままKVM_RUNを呼び直す、という扱いになっています。今回の実装でも同様に、単純なリトライループとして扱っています。

loop {
    let exit = match vcpu.run() {
        Ok(exit) => exit,
        Err(e) if e.errno() == libc::EAGAIN => continue,
        Err(e) => panic!("[vcpu {vcpu_id}] KVM_RUN failed: {e}"),
    };
    // ... 通常のexit処理
}

動作確認

4つのvCPUで起動すると、ブートログのsmp: Bringing up secondary CPUs ...以降で3つのAPすべてが起動に成功し、最終的に4CPU分がまとめて認識されます。

[    0.446958] smp: Bringing up secondary CPUs ...
[    0.447778] x86: Booting SMP configuration:
[    0.448434] .... node  #0, CPUs:      #1
[    0.126193] Disabled fast string operations
[    0.531841]  #2
[    0.126193] Disabled fast string operations
[    0.615790]  #3
[    0.126193] Disabled fast string operations
[    0.699853] smp: Brought up 1 node, 4 CPUs
[    0.700480] smpboot: Max logical packages: 4
[    0.701134] smpboot: Total of 4 processors activated (29578.52 BogoMIPS)

1CPUあたりのBogoMIPS(Calibrating delay loopで計算される、CPU速度の相対的な目安値)は7392.04で、4CPU合計の29578.52はほぼその4倍です。CPUの数が実際に反映されていることが、この数値からも確認できます。

virtio-net・virtio-blockの初期化とゲスト内での読み書きテストも正常に完了し、reboot=k経由のリセット要求によるVMMの終了処理まで一通り確認できました。

現時点の実装の制限

BSPがreboot=kによるi8042経由のリセット要求を検知するとVMMのメインスレッドはそこで終了しますが、APを実行しているスレッドは明示的に終了処理を行わず、プロセス終了時にまとめて破棄されます。Linuxのreboot()システムコールは実際にはAP側を先に停止させてからBSPが最後にリセット要求を送る順序になっているため、通常は問題になりませんが、スレッドの起動・終了をVMM側で明示的に管理する仕組みそのものは今後実装予定です。また、割り込みの配送先は現時点でも固定のままで、vCPUごとに異なる割り込みを配送するIRQルーティングには対応していません。