virtio-mmioとvirtqueueを自前で実装する——virtio-consoleで動作確認する

vm-superioでUART 16550を正確にエミュレートし、KVM_IRQ_LINEで実際に割り込みを配線するまで実装したシリアルポートは、実在する16550Aチップをレジスタ単位で真似るアプローチでした。今回からはVIRTIOという、仮想化専用に設計された規格を扱います。実ハードウェアの模倣を一切せず、「ゲストとホストの間でメモリ上のバッファをやり取りする」という要求だけに特化した、virtqueueという共通の仕組みを持っています。この仕組み自体はデバイスの種類に関係なく使い回せるため、まずvirtio-mmio transportとvirtqueueの基礎を実装し、既にカーネルに組み込み対応されているvirtio-consoleを題材に動作確認します。

virtio-mmioのレジスタ

virtio-mmioは、固定サイズのレジスタ群をゲスト物理メモリ上の1箇所に並べただけの、シンプルなtransportです。オフセットの定義は/usr/include/linux/virtio_mmio.hにある通りで、ホストにインストールされているカーネルヘッダ(今回使っているゲストカーネルと同じ5.15.0-187-generic)から実際の値を確認しました。

主なレジスタは次の通りです。

オフセット 名前 意味
0x000 MAGIC_VALUE 固定値"virt"(リトルエンディアンで0x74726976)。virtio-mmioデバイスである目印
0x004 VERSION 2固定(legacy版と区別するための非legacy transport)
0x008 DEVICE_ID デバイス種別(今回は3 = virtio-console)
0x010/0x014 DEVICE_FEATURES(_SEL) ホストが対応する機能ビット(64bit、32bitずつ2回に分けて読む)
0x020/0x024 DRIVER_FEATURES(_SEL) ゲストが実際に使う機能ビット
0x030 QUEUE_SEL これ以降のキュー関連レジスタが、どのキュー番号に対する操作かを選択
0x034/0x038 QUEUE_NUM_MAX/QUEUE_NUM キューの最大サイズ/実際に使うサイズ
0x044 QUEUE_READY 選択中のキューが使用可能か
0x050 QUEUE_NOTIFY 「このキューにデータを積んだ」という通知(kick)
0x060/0x064 INTERRUPT_STATUS/ACK 割り込み要因の確認・確認応答
0x070 STATUS デバイスの初期化状態(ACKNOWLEDGE→DRIVER→FEATURES_OK→DRIVER_OKと順に進む)
0x080/0x084 QUEUE_DESC_LOW/HIGH 選択中のキューのdescriptor tableアドレス(64bit)
0x090/0x094 QUEUE_AVAIL_LOW/HIGH 同、avail ringアドレス
0x0a0/0x0a4 QUEUE_USED_LOW/HIGH 同、used ringアドレス

これらをRustのmatchでオフセットごとに振り分けるだけで、レジスタアクセスは実装できます。

fn mmio_read(&self, offset: u32) -> u32 {
    match offset {
        0x000 => 0x7472_6976, // "virt"
        0x004 => 2,           // version (non-legacy)
        0x008 => VIRTIO_ID_CONSOLE,
        0x00c => 0x414b_4759, // vendor id (適当な自前の値)
        0x010 => {
            if self.device_features_sel == 1 {
                (VIRTIO_F_VERSION_1 >> 32) as u32
            } else {
                0
            }
        }
        0x034 => QUEUE_NUM_MAX,
        0x044 => self.queues[self.queue_sel as usize].ready as u32,
        0x060 => self.interrupt_status,
        0x070 => self.status,
        0x0fc => 0, // config generation
        _ => 0,
    }
}

このMMIO領域はゲストRAM(0〜256MiB)の外側の、KVM_SET_USER_MEMORY_REGIONに一切登録していないアドレス(0xd0000000)に置いています。ここへのアクセスはKVMにとって「対応するメモリがない」ため、VcpuExit::MmioRead/MmioWriteとしてVMMへ通知されます。今まで使ってきたI/Oポート(IoIn/IoOut)がポート番号でVM Exitするのに対して、こちらはメモリアドレスでVM Exitする、という違いだけで、ハンドリングの考え方自体は同じです。

なぜvirtio-consoleを選んだか

VIRTIOにはネットワーク・ブロックデバイス・乱数生成器など多数のデバイス種別がありますが、今回はvirtio-consoleを選びました。理由は、ホストの/boot/config-5.15.0-187-genericを確認したところCONFIG_VIRTIO_CONSOLE=y(カーネル組み込み)だったのに対し、virtio_net/virtio_blk=m(別途モジュールのロードが必要)だったためです。今回はvirtqueueとtransportの基礎を確認することが目的なので、モジュールロードの仕組みを別途用意せずに済むデバイスを選びました。

virtio-consoleは受信(RX)・送信(TX)の2本のキューを持ちますが、機能ネゴシエーションでVIRTIO_CONSOLE_F_MULTIPORTのようなオプション機能を一切有効化しなければ、単一ポートのシンプルな構成で動作します。今回はVIRTIO_F_VERSION_1(非legacy transportを使うために必須のビット)だけを有効にしています。

split virtqueueの構造

virtqueueは3つのリング状の領域(いずれもゲストメモリ上に、ドライバが確保する)で構成されます。

  • descriptor table: 1件16バイトの固定長エントリ配列。addr(データの物理アドレス、8byte)・len(長さ、4byte)・flags(2byte)・next(次のエントリへのインデックス、2byte)を持つ
  • avail ring: ドライバが「このdescriptorを使ってください」とホストに伝えるためのリング。flags(2byte)・idx(2byte、これまでに積んだ総数)・ring[](descriptorの先頭インデックスの配列)
  • used ring: ホストが「処理が終わりました」とドライバに伝えるためのリング。flags(2byte)・idx(2byte)・ring[]({id: u32, len: u32}の配列)

いずれも各フィールドをオフセット計算で直接読み書きしています。

fn handle_notify(&mut self, queue_index: u32, guest_memory: &GuestMemoryMmap<()>, vm: &VmFd) {
    // queue 1 = TX(ゲスト→ホスト)。queue 0(RX)は今回はセットアップのみで扱わない。
    if queue_index != 1 {
        return;
    }
    let vq = &mut self.queues[1];
    if !vq.ready || vq.num == 0 {
        return;
    }

    let avail_idx: u16 = guest_memory
        .read_obj(GuestAddress(vq.avail_addr + 2))
        .unwrap();

    while vq.last_avail_idx != avail_idx {
        let slot = (vq.last_avail_idx % vq.num as u16) as u64;
        let head: u16 = guest_memory
            .read_obj(GuestAddress(vq.avail_addr + 4 + slot * 2))
            .unwrap();

        let mut desc_index = head;
        loop {
            let entry_addr = vq.desc_addr + (desc_index as u64) * VIRTQ_DESC_SIZE;
            let addr: u64 = guest_memory.read_obj(GuestAddress(entry_addr)).unwrap();
            let len: u32 = guest_memory.read_obj(GuestAddress(entry_addr + 8)).unwrap();
            let flags: u16 = guest_memory
                .read_obj(GuestAddress(entry_addr + 12))
                .unwrap();
            let next: u16 = guest_memory
                .read_obj(GuestAddress(entry_addr + 14))
                .unwrap();

            let mut buf = vec![0u8; len as usize];
            guest_memory.read_slice(&mut buf, GuestAddress(addr)).unwrap();
            std::io::stdout().write_all(&buf).unwrap();
            std::io::stdout().flush().unwrap();

            if flags & VIRTQ_DESC_F_NEXT == 0 {
                break;
            }
            desc_index = next;
        }

        let used_slot = (vq.used_idx % vq.num as u16) as u64;
        let elem_addr = vq.used_addr + 4 + used_slot * VIRTQ_USED_ELEM_SIZE;
        guest_memory
            .write_obj(head as u32, GuestAddress(elem_addr))
            .unwrap();
        guest_memory.write_obj(0u32, GuestAddress(elem_addr + 4)).unwrap();
        vq.used_idx = vq.used_idx.wrapping_add(1);
        guest_memory
            .write_obj(vq.used_idx, GuestAddress(vq.used_addr + 2))
            .unwrap();

        vq.last_avail_idx = vq.last_avail_idx.wrapping_add(1);
    }

    self.interrupt_status |= 0x1; // VIRTIO_MMIO_INT_VRING
    let _ = vm.set_irq_line(VIRTIO_IRQ, true);
    let _ = vm.set_irq_line(VIRTIO_IRQ, false);
}

QUEUE_NOTIFY(オフセット0x050)にキュー番号が書き込まれたら、そのキューのavail ringを自分が前回見た位置(last_avail_idx)から現在のidxまで読み進め、descriptor chainを辿ってデータを取り出し、used ringへ処理結果を書き戻す、という流れです。RX(キュー0)側はドライバが受信用の空きバッファを大量に積んで通知してきますが(実測で256回)、今回は受け取るデータが無いため単に無視しています。

処理が終わったらinterrupt_statusにVRINGビットを立て、vm-superioでUART 16550を正確にエミュレートし、KVM_IRQ_LINEで実際に割り込みを配線するで実装したKVM_IRQ_LINEと同じ要領で、割り込みを配送しています。今回はvm_superioTriggerのような抽象化を挟まず、vm.set_irq_lineを直接呼んでいます。

カーネルにデバイスの場所を伝える

virtio-mmioはPCIのような自動列挙の仕組みを持たないため、カーネルコマンドラインで明示的に場所を教える必要があります。

virtio_mmio.device=0x200@0xd0000000:5

<レジスタ領域のサイズ>@<ベースアドレス>:<IRQ番号>という書式です。ブートログにもこの通り登録されたことが表示されます。

[    0.241329] virtio-mmio: Registering device virtio-mmio.0 at 0xd0000000-0xd00001ff, IRQ 5.

動作確認

initramfs内のプログラムから/dev/hvc0(virtio-consoleに対応するデバイスファイル)へ書き込み、実際にホストの標準出力まで届くかを確認しました。

match std::fs::OpenOptions::new().write(true).open("/dev/hvc0") {
    Ok(mut f) => {
        let _ = f.write_all(b"hello via virtio-console\n");
        let _ = f.flush();
    }
    Err(e) => {
        println!("open /dev/hvc0 failed: {e}");
    }
}

/dev/hvc0は、initramfsの/initが実行される時点では自動的には用意されません。/procと同様、devtmpfsを自分でマウントする必要があります。

unsafe {
    let target = CString::new("/dev").unwrap();
    let fstype = CString::new("devtmpfs").unwrap();
    libc::mount(std::ptr::null(), target.as_ptr(), fstype.as_ptr(), 0, std::ptr::null());
}

実行すると、Run /init as init processの直後に次の行が出力されました。

[    0.446100] Run /init as init process
hello via virtio-console
[    0.446916] reboot: Restarting system

MAGIC_VALUE/VERSION/DEVICE_IDの読み取りから始まり、機能ネゴシエーション(STATUSACKNOWLEDGEDRIVERFEATURES_OKDRIVER_OKと順に進む)、2本のキューのセットアップ、QUEUE_NOTIFYによるTX処理、KVM_IRQ_LINE経由の割り込み配送までが一通り繋がったことを確認できました。

現時点の実装ではRXキュー(ゲストへの受信データ)は一切扱っておらず、複数vCPU環境でのIRQルーティングにも対応していません。virtio-net・virtio-blockの実装では、今回作ったvirtqueueの読み書きロジックをそのまま土台にして、descriptor chainの中身の解釈だけをデバイスごとに差し替えることになります。