vm-superioでUART 16550を正確にエミュレートし、KVM_IRQ_LINEで実際に割り込みを配線する

vCPUを実行して実際にLinuxカーネルを起動する——Intel VMX向け設定とin-kernel irqchip、initramfsでは、シリアルポートをLSR(Line Status Register)とTHR(Transmitter Holding Register)だけを扱う最小スタブで実装しました。このスタブでもカーネル自身が出力するブートログ(printk経由の同期的なポーリングI/O)は最後まで表示できましたが、レジスタの多くを省略していたため、実機のUARTとして正しく振る舞えているかは怪しい状態でした。今回はこのシリアル実装を、rust-vmmプロジェクトが提供するvm-superioクレートへ置き換え、あわせてKVM_IRQ_LINEによる実際の割り込み配線を行います。

vm-superioへの置き換え

Cargo.tomlvm-superioを追加します。

vm-superio = "0.8.1"

vm_superio::Serialは、IER(割り込み許可)/IIR(割り込み識別)/LCR(ライン制御)/MCR(モデム制御)/LSR/MSR/SCR/ボーレート分周値(DLL/DLM)まで含めた16550のレジスタをエミュレートする型です。割り込みをどう発生させるかはTriggerトレイトとして外部から注入する設計になっているので、まずは何もしない実装を渡し、レジスタ精度を上げること自体の影響を切り分けます。

struct NoIrqTrigger;

impl Trigger for NoIrqTrigger {
    type E = std::io::Error;

    fn trigger(&self) -> Result<(), std::io::Error> {
        Ok(())
    }
}

実行ループ内のI/Oハンドリングを、serial.read/serial.writeへの委譲に置き換えます。

let mut serial = Serial::new(NoIrqTrigger, std::io::stdout());
// ...
VcpuExit::IoIn(port, data) => {
    if (SERIAL_IO_BASE..SERIAL_IO_BASE + 8).contains(&port) {
        data[0] = serial.read((port - SERIAL_IO_BASE) as u8);
    }
    // ...
}
VcpuExit::IoOut(port, data) => {
    if (SERIAL_IO_BASE..SERIAL_IO_BASE + 8).contains(&port) {
        let _ = serial.write((port - SERIAL_IO_BASE) as u8, data[0]);
    }
    // ...
}

std::io::stdout()Writeを実装しているので、Serial::newの出力先にそのまま渡せます。

この状態(割り込みは一切発生させていない)で実行すると、ブートログに新しい行が1つ増えます。

[    0.286093] serial8250: ttyS0 at I/O 0x3f8 (irq = 4, base_baud = 115200) is a U6_16550A

この行は、カーネルのシリアルドライバがポートへスクラッチレジスタの読み書きやループバックテストといったハードウェア自動検出を行い、「本物の16550Aだ」と判定できた場合にだけ出力されます。以前の最小スタブでは、この自動検出に必要なレジスタ群への応答が不十分で、検出自体が行われていませんでした。

さらに、以前は表示されなかったゲストのユーザースペースプロセス(initramfs内の/init)からのwrite()出力も、この置き換えだけで(割り込みを一切配線していないにもかかわらず)表示されるようになりました。カーネルのシリアルドライバが実機の16550Aとして正しく認識できたことで、送信開始時にドライバ内部で行われる同期的な処理だけで完結できるケースがある、と考えられます。

KVM_IRQ_LINEで実際に割り込みを配線する

とはいえ、NoIrqTriggerのままでは実際に割り込みが使われているかどうかは推測の域を出ません。ここからTriggerの実装を、実際にKVMへ割り込みを通知するものに差し替えます。

struct SerialIrqTrigger {
    vm: Arc<VmFd>,
    irq: u32,
}

impl Trigger for SerialIrqTrigger {
    type E = std::io::Error;

    fn trigger(&self) -> Result<(), std::io::Error> {
        let _ = self.vm.set_irq_line(self.irq, true);
        let _ = self.vm.set_irq_line(self.irq, false);
        Ok(())
    }
}

VmFd::set_irq_line(irq, active)は、vCPUを実行して実際にLinuxカーネルを起動するvm.create_irq_chip()によって用意しておいたin-kernel PIC/IOAPICに対して、指定した番号の割り込み線を上げ下げするAPIです。trueで線を上げ、直後にfalseで下げることで、レベルトリガの信号線をパルスとして扱い、1回分の割り込みとしてゲストCPUへ配送させています。COM1(ttyS0)は伝統的にIRQ4に割り当てられています。

Triggerの実装がVmFdへの参照を保持し続ける必要があるため、vmArcで包みます。

let kvm = Kvm::new().unwrap();
let vm = Arc::new(kvm.create_vm().unwrap());

Arc<VmFd>Derefによって&VmFdと同じメソッドをそのまま呼べるので、vm.set_tss_address(...)vm.create_vcpu(0)など、これ以外の呼び出し箇所は変更不要です。

let mut serial = Serial::new(
    SerialIrqTrigger { vm: Arc::clone(&vm), irq: SERIAL_IRQ },
    std::io::stdout(),
);

割り込みが実際に使われているかを確認する

推測ではなく実際に確認するため、/proc/interruptsを読み書きするだけの検証用initプログラムを用意しました。ゲスト内で/procをマウントし、まとまった量の出力を行う前後で/proc/interruptsを読み、その差分を見る、という単純な方法です。

NoIrqTriggerのままの状態では、シリアル(IRQ4)のカウントは常に0のままでした。

  4:          0    XT-PIC      ttyS0

SerialIrqTriggerに差し替えると、出力前後でカウントが明確に増加します。

--- before ---
  4:          2    XT-PIC      ttyS0
--- after (50行出力後) ---
  4:        152    XT-PIC      ttyS0

これで、NoIrqTriggerのままでもゲストのユーザースペース出力自体は表示されていたものの、その時点では割り込みは一切使われていなかったこと、そして実際に配線すると割り込みの経路へ切り替わることの両方が確認できました。前者は「レジスタを正確にエミュレートしたことで、ドライバが割り込みを待たずに済む経路を使えていた」、後者は「割り込みを配線すれば、ドライバは(利用可能な場合)そちらを使う」という、それぞれ独立した事実です。

現時点の実装ではvCPUは1つのみで、割り込みの配送先も固定です。複数vCPU環境でのIRQルーティングや、IOAPICを介したより一般的な割り込み配送は今後実装予定です。