ゲストメモリを複数リージョンに分割する——KVMのメモリスロットとホスト・ゲスト間の直接共有
生ioctlでKVMを叩く——12バイトの機械語だけを実行する最小VMでは、mmapで確保したホストメモリをKVM_SET_USER_MEMORY_REGIONで1つだけゲストに登録し、そこにコードを配置して実行しました。今回はこのメモリ登録の仕組みそのものを掘り下げます。メモリリージョンをコード用とデータ用の2つのスロットに分割して登録し、ゲストが計算結果をデータ領域へ書き込んだあと、ホストがそれをioctlを一切使わずに直接読み出すところまでを実装します。
メモリリージョンが表しているもの
KVM_SET_USER_MEMORY_REGIONに渡すKvmUserspaceMemoryRegion構造体は、「ホストのどのメモリ範囲を、ゲストのどの物理アドレスとして見せるか」という対応関係を1件登録するためのものです。
#[repr(C)]
struct KvmUserspaceMemoryRegion {
slot: u32,
flags: u32,
guest_phys_addr: u64,
memory_size: u64,
userspace_addr: u64,
}slot: リージョンの識別番号。複数のリージョンを登録する場合は、それぞれ別の番号を割り当てるguest_phys_addr: ゲストCPUから見た物理アドレスuserspace_addr: そのリージョンの実体となる、ホスト側のmmapで確保した仮想アドレスmemory_size: リージョンのサイズ(バイト数)。guest_phys_addr/memory_sizeはページサイズ(4KB)境界に揃っている必要があるflags: リージョンの属性。KVM_MEM_READONLYを立てると書き込み禁止のリージョンになる(今回は使わず0)
重要なのは、userspace_addrとguest_phys_addrが同じメモリを指しながら別々の番地体系だという点です。ホストプロセスはそのメモリをuserspace_addrという自分の仮想アドレスとして扱い、ゲストCPUは同じメモリをguest_phys_addrという別の番地として認識します。この対応関係は、CPUのEPT(Extended Page Table)というハードウェア機構がゲストのメモリアクセスのたびに参照し、実際のホスト物理メモリへ変換しています。KVMとは何か——ハードウェア仮想化支援とVM Exitの仕組みで触れたVM Exitの仕組みとは別に、メモリアクセス自体はこのEPTによってVM Exitを起こさずに完結する点も押さえておくと後のフェーズで役立ちます。
リージョンを2つに分割する
コード用とデータ用でリージョンを分けるため、mmap呼び出しをヘルパー関数にまとめます。
const CODE_ADDR: u64 = 0x1000;
const CODE_SIZE: usize = 0x1000;
const DATA_ADDR: u64 = 0x2000;
const DATA_SIZE: usize = 0x1000;
unsafe fn map_guest_memory(size: usize) -> *mut libc::c_void {
let mem = unsafe {
libc::mmap(
ptr::null_mut(),
size,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
-1,
0,
)
};
assert_ne!(mem, libc::MAP_FAILED, "mmap(guest memory) failed");
mem
}MAP_ANONYMOUSで確保したメモリはカーネルによって0初期化されることが保証されています。この性質は、ゲスト実行前にデータ領域が0であることを確認する形で後述のコード中でも利用します。
コード用リージョン(slot 0)とデータ用リージョン(slot 1)を、それぞれ別のguest_phys_addrで登録します。
let code_mem = map_guest_memory(CODE_SIZE);
ptr::copy_nonoverlapping(CODE.as_ptr(), code_mem as *mut u8, CODE.len());
let code_region = KvmUserspaceMemoryRegion {
slot: 0,
flags: 0,
guest_phys_addr: CODE_ADDR,
memory_size: CODE_SIZE as u64,
userspace_addr: code_mem as u64,
};
checked_ioctl(
vm_fd,
KVM_SET_USER_MEMORY_REGION,
&code_region as *const _ as u64,
);
let data_mem = map_guest_memory(DATA_SIZE);
let data_region = KvmUserspaceMemoryRegion {
slot: 1,
flags: 0,
guest_phys_addr: DATA_ADDR,
memory_size: DATA_SIZE as u64,
userspace_addr: data_mem as u64,
};
checked_ioctl(
vm_fd,
KVM_SET_USER_MEMORY_REGION,
&data_region as *const _ as u64,
);
let data_before = *(data_mem as *const u8);
assert_eq!(data_before, 0, "data region should start zeroed");CODE_ADDR(0x1000)とDATA_ADDR(0x2000)は隣接するリージョンですが、それぞれ4KB(0x1000バイト)のサイズを確保しているため範囲が重なることはありません。
ゲスト側でデータ領域へ書き込む
ゲストに実行させる機械語に、計算結果をデータ領域へ書き込む命令を追加しました。
const CODE: [u8; 15] = [
0xba, 0xf8, 0x03, // mov dx, 0x3f8
0x00, 0xd8, // add al, bl
0xa2, 0x00, 0x20, // mov [0x2000], al
0x04, 0x30, // add al, '0'
0xee, // out dx, al
0xb0, 0x0a, // mov al, '\n'
0xee, // out dx, al
0xf4, // hlt
];0xa2, 0x00, 0x20はmov [0x2000], al(moffs8へのストア、MOV moffs8, AL)で、alの値をDSセグメント基準の0x2000番地へ直接書き込みます。ここではraxとrbxを加算した生の値(al = 4)をASCII変換前にデータ領域へ書き込み、その後で従来通り'0'を足してシリアルポート(0x3f8)へASCII文字として出力しています。同じ計算結果を、シリアル出力用とメモリ書き込み用の2通りの手段でゲストの外へ渡している形です。
ホストがゲストメモリを直接読む
KVM_EXIT_HLTでループを抜けたあと、data_memのポインタから直接メモリを読み出します。
let data_after = *(data_mem as *const u8);
println!(
"host read guest physical 0x{:x} directly: {}",
DATA_ADDR,
data_after
);シリアル出力(KVM_EXIT_IOのハンドリング)はVM Exitを介した「ゲストが能動的にホストへ渡した1バイト」でしたが、こちらはioctlを一切呼ばず、ホストが最初から持っているmmapのポインタを読むだけです。ゲストメモリの実体はホストプロセスの通常のメモリなので、KVMのAPIを介さずに読み書きできます。この性質は、後のフェーズでカーネルイメージやvirtioのバッファをゲストメモリへ配置する際の基本になります。
実行すると、シリアル経由の4(改行付き)に続けてhost read guest physical 0x2000 directly: 4が出力され、同じ計算結果を2つの経路でホストが受け取れていることが確認できます。
現時点の実装ではリージョンはコードとデータの2つだけで、リージョン同士の重なりチェックやKVM_MEM_READONLYのような属性も使っていません。実際のLinuxカーネルを起動するフェーズでは、カーネルイメージ・initramfs・zero pageなど複数の領域を同時に扱うことになるため、この「複数リージョンを登録し、ホストとゲストで同じメモリを別々の番地体系から参照する」という仕組み自体が土台になります。