ゲストメモリを複数リージョンに分割する——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_addrguest_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_ADDR0x1000)とDATA_ADDR0x2000)は隣接するリージョンですが、それぞれ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, 0x20mov [0x2000], al(moffs8へのストア、MOV moffs8, AL)で、alの値をDSセグメント基準の0x2000番地へ直接書き込みます。ここではraxrbxを加算した生の値(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など複数の領域を同時に扱うことになるため、この「複数リージョンを登録し、ホストとゲストで同じメモリを別々の番地体系から参照する」という仕組み自体が土台になります。