virtio-blockデバイスを自前実装してディスクイメージを読み書きする——2つのvirtio-mmioデバイスを共存させる
virtio-netデバイスを自前実装してTAP経由でホストと通信する——ping疎通で動作確認するまででvirtio-mmioとvirtqueueの読み書きロジックは固まっているため、今回はそれを土台にvirtio-blockデバイスを実装します。virtio-netとの一番の違いは、ゲストの仮想NICから見た1つのMMIOデバイスだけを相手にしていたところに、2つ目のvirtio-mmioデバイスを追加して両者を同時に扱う必要が出てくる点です。
2つ目のvirtio-mmioスロットを追加する
これまでは0xd0000000番地にvirtio-netだけを配置していましたが、今回は0xd0000200番地にvirtio-blockを追加します。カーネルコマンドラインにはvirtio_mmio.device=パラメータをスペース区切りで2つ並べます。
virtio_mmio.device=0x200@0xd0000000:5 virtio_mmio.device=0x200@0xd0000200:6virtio-mmioドライバのこのパラメータはdevice_param_cbで登録されたコールバック型のモジュールパラメータで、配列型パラメータではありません。そのため、複数デバイスを指定する際はカンマ区切りではなく、パラメータ自体を繰り返す必要があります。
VMM側もMMIOアクセスをアドレス範囲で振り分けるようにします。
VcpuExit::MmioRead(addr, data) => {
if (VIRTIO_MMIO_NET_BASE..VIRTIO_MMIO_NET_BASE + VIRTIO_MMIO_SIZE).contains(&addr) {
let offset = (addr - VIRTIO_MMIO_NET_BASE) as u32;
let value = virtio_net.lock().unwrap().mmio_read(offset);
// ...
} else if (VIRTIO_MMIO_BLK_BASE..VIRTIO_MMIO_BLK_BASE + VIRTIO_MMIO_SIZE).contains(&addr) {
let offset = (addr - VIRTIO_MMIO_BLK_BASE) as u32;
let value = virtio_blk.mmio_read(offset);
// ...
}
}マジックバイトやバージョンといった制御レジスタ(オフセット0x000〜0x0fc)の並びはvirtio-mmio仕様で共通なので、VirtioNetと同じ骨格をそのままVirtioBlkにも使い回せます。デバイスごとに違うのは、DeviceID・feature bit・config spaceの中身だけです。
virtio_blk_reqは常に3つのdescriptorで構成される
virtio-netではdescriptor chainの長さがフレームサイズによって可変でしたが、virtio-blockのリクエストは仕様上、常に「ヘッダ→データ→ステータス」の3descriptor構成になります。ヘッダはtype(4byte)・ioprio(4byte)・sector(8byte)の16byte固定です。
// 1st descriptor: virtio_blk_outhdr { type: u32, ioprio: u32, sector: u64 } (16 bytes)
let hdr_entry = vq.desc_addr + (head as u64) * VIRTQ_DESC_SIZE;
let hdr_addr: u64 = guest_memory.read_obj(GuestAddress(hdr_entry)).unwrap();
let hdr_next: u16 = guest_memory.read_obj(GuestAddress(hdr_entry + 14)).unwrap();
let req_type: u32 = guest_memory.read_obj(GuestAddress(hdr_addr)).unwrap();
let sector: u64 = guest_memory.read_obj(GuestAddress(hdr_addr + 8)).unwrap();
// 2nd descriptor: data buffer
let data_entry = vq.desc_addr + (hdr_next as u64) * VIRTQ_DESC_SIZE;
let data_addr: u64 = guest_memory.read_obj(GuestAddress(data_entry)).unwrap();
let data_len: u32 = guest_memory.read_obj(GuestAddress(data_entry + 8)).unwrap();
let data_next: u16 = guest_memory.read_obj(GuestAddress(data_entry + 14)).unwrap();
// 3rd descriptor: status byte
let status_entry = vq.desc_addr + (data_next as u64) * VIRTQ_DESC_SIZE;
let status_addr: u64 = guest_memory.read_obj(GuestAddress(status_entry)).unwrap();3つの位置が固定なので、virtio-netのようにdescriptorをloopで辿る必要がなく、nextポインタを順番にたどるだけで済みます。typeは読み込み(VIRTIO_BLK_T_IN=0)か書き込み(VIRTIO_BLK_T_OUT=1)かを表し、結果は最後のdescriptorが指す1byteのステータス(VIRTIO_BLK_S_OK=0など)に書き戻します。
let mut status = VIRTIO_BLK_S_OK;
match req_type {
VIRTIO_BLK_T_IN => {
let mut buf = vec![0u8; data_len as usize];
if disk.seek(SeekFrom::Start(sector * SECTOR_SIZE)).is_ok()
&& disk.read_exact(&mut buf).is_ok()
{
guest_memory.write_slice(&buf, GuestAddress(data_addr)).unwrap();
} else {
status = VIRTIO_BLK_S_IOERR;
}
}
VIRTIO_BLK_T_OUT => {
let mut buf = vec![0u8; data_len as usize];
guest_memory.read_slice(&mut buf, GuestAddress(data_addr)).unwrap();
if disk.seek(SeekFrom::Start(sector * SECTOR_SIZE)).is_err()
|| disk.write_all(&buf).is_err()
{
status = VIRTIO_BLK_S_IOERR;
}
}
_ => {
status = VIRTIO_BLK_S_UNSUPP;
}
}sectorはセクタ番号(512byte単位)で渡されるため、実際のファイルオフセットへはsector * SECTOR_SIZEで変換します。バックエンドはホスト上の普通のファイル(disk.img)で、seekしてからread_exact/write_allするだけの単純な実装です。
ディスク容量をconfig spaceで公開する
virtio-netがMACアドレスをconfig space(オフセット0x100以降)で公開していたのと同様に、virtio-blockはcapacity(512byteセクタ数、64bit)を公開します。64bitのconfig spaceフィールドは32bitずつ2回に分けて読まれるため、下位32bitを0x100、上位32bitを0x104に割り当てます。
0x100 => self.capacity_sectors as u32,
0x104 => (self.capacity_sectors >> 32) as u32,capacity_sectorsはVMM起動時にディスクイメージファイルの実サイズから計算しています。
let disk_size = disk_file.metadata().unwrap().len();
let mut virtio_blk = VirtioBlk::new(disk_size / SECTOR_SIZE);カーネルモジュールはvmlinuzと同じバージョンのものを使う
virtio_net.koのときと同様、virtio_blk.koもホストの/usr/lib/modules/以下からinitramfsへコピーしてinsmodします。ここで、コピー元のカーネルバージョンを間違えるとロードに失敗します。今回使っているvmlinuzは、ホストが実際に起動している稼働中カーネルとは異なるバージョンでビルドされていたため、uname -rで得られる現在のカーネルバージョンのモジュールをそのままコピーすると、vermagicの不一致でinsmodが失敗しました。
$ file vmlinuz
vmlinuz: Linux kernel x86 boot executable bzImage, version 5.15.0-187-generic ...vmlinuz自体のヘッダにビルド対象のカーネルバージョン文字列が埋め込まれているため、fileコマンドで確認できます。ここで表示されたバージョンと同じディレクトリからvirtio_blk.koを取得することで解決しました。
cp /usr/lib/modules/5.15.0-187-generic/kernel/drivers/block/virtio_blk.ko initramfs/lib/modules/動作確認: ホスト⇔ゲストの往復でデータが本当に届くことを確認する
virtio-blockはファイルシステムを介さない生のブロックデバイスなので、mountはせず、initramfs内のRust製initから直接/dev/vdaを読み書きしてテストしました。
まずホスト側で、VM起動前にディスクイメージのsector1へマーカー文字列を書き込んでおきます。
dd if=/dev/zero of=disk.img bs=1M count=64
printf 'HOST_TO_GUEST_TEST_DATA_OK' | dd of=disk.img bs=1 seek=512 conv=notruncゲスト側では、そのsector1を読み込んで内容をシリアルコンソールへ出力したあと、別のマーカーをsector2へ書き込みます。
let mut disk = File::options().read(true).write(true).open("/dev/vda").unwrap();
let mut buf = [0u8; 512];
disk.seek(SeekFrom::Start(512)).unwrap();
disk.read_exact(&mut buf).unwrap();
println!("virtio-blk read from host: {}", String::from_utf8_lossy(&buf).trim_end_matches('\0'));
let message = b"GUEST_TO_HOST_TEST_DATA_OK";
let mut write_buf = [0u8; 512];
write_buf[..message.len()].copy_from_slice(message);
disk.seek(SeekFrom::Start(1024)).unwrap();
disk.write_all(&write_buf).unwrap();起動ログには、2つ目のvirtio-mmioデバイスの登録から、virtio_blkドライバによる/dev/vdaの認識、そしてゲスト内での読み書きテストの結果までが順に表示されました。
virtio-mmio: Registering device virtio-mmio.0 at 0xd0000000-0xd00001ff, IRQ 5.
virtio-mmio: Registering device virtio-mmio.1 at 0xd0000200-0xd00003ff, IRQ 6.
virtio_blk virtio1: [vda] 131072 512-byte logical blocks (67.1 MB/64.0 MiB)
eth0 up: 192.168.100.2/24
virtio-blk read from host: HOST_TO_GUEST_TEST_DATA_OK
virtio-blk write to host: okゲスト側のログが「成功した」と言っているだけでは、実際にホストのファイルへ届いているかまでは分かりません。VM終了後にホスト側で直接ディスクイメージのバイト列を確認します。
$ xxd -s 1024 -l 32 disk.img
00000400: 4755 4553 545f 544f 5f48 4f53 545f 5445 GUEST_TO_HOST_TE
00000410: 5354 5f44 4154 415f 4f4b 0000 0000 0000 ST_DATA_OK......ゲストが書き込んだGUEST_TO_HOST_TEST_DATA_OKがそのまま残っており、ホストが用意したデータをゲストが読み取れること、ゲストが書き込んだデータがホストのファイルへ永続化されることの両方を確認できました。virtio-netのTAP経由の通信と、virtio-blockのディスクI/Oが、同じVM内で同時に動作していることも合わせて確認しています。
現時点の実装の制限
今回のvirtio-blockは、1リクエストにつきデータdescriptorが1つだけの構成にのみ対応しており、複数セグメントに分割されたリクエストは扱えません。またVIRTIO_BLK_T_FLUSHのようなキャッシュフラッシュ系のコマンドや、VIRTIO_BLK_F_ROなどのfeature bitのネゴシエーションも未対応です。ファイルシステムを介さず生のブロックデバイスとして読み書きするところまでの確認に留めており、ゲスト内でのマウントや実際のファイル配置は今後実装予定です。