生ioctlでKVMを叩く——12バイトの機械語だけを実行する最小VM
KVMとは何か——ハードウェア仮想化支援とVM Exitの仕組みで整理した、VMMがKVM_CREATE_VMからKVM_RUNまでを呼び出してゲストを実行する手順を、そのままコードにします。kvm-ioctls/kvm-bindingsといったrust-vmm系のクレートはまだ使わず、libcクレートだけを使って生のioctlを呼び出します。カーネルイメージやデバイスは一切用意せず、CPUのリアルモードで12バイトの機械語を実行し、結果をI/Oポート経由で受け取るところまでがゴールです。
ioctl番号はKVMの仕様そのもの
KVMのAPIは/dev/kvmをopenし、そのfdに対してioctlを発行する形で使います。KVM_CREATE_VMのようなコマンド名は実際にはただの整数で、値は/usr/include/linux/kvm.hに定義されています。
#define KVMIO 0xAE
#define KVM_GET_API_VERSION _IO(KVMIO, 0x00)
#define KVM_CREATE_VM _IO(KVMIO, 0x01)
#define KVM_GET_VCPU_MMAP_SIZE _IO(KVMIO, 0x04)_IO/_IOW/_IORは「マジックナンバー・コマンド番号・データの向き・データサイズ」を1つの整数にビットパックするLinuxのioctl共通の仕組みで、KVM固有ではなくLinux全般のioctlで使われる規約です。_IOWは「userspaceからkernelへデータを渡す」、_IORは「kernelからuserspaceへデータを返す」ことを表し、方向とサイズがコマンド番号の一部として埋め込まれます。これをRustで再現すると次のようになります。
const KVMIO: u64 = 0xAE;
const fn io(nr: u64) -> u64 {
(KVMIO << 8) | nr
}
const fn iow<T>(nr: u64) -> u64 {
(1 << 30) | ((size_of::<T>() as u64) << 16) | (KVMIO << 8) | nr
}
const fn ior<T>(nr: u64) -> u64 {
(2 << 30) | ((size_of::<T>() as u64) << 16) | (KVMIO << 8) | nr
}iow/iorは渡す構造体の型Tからsize_of::<T>()でサイズを自動計算します。ここで計算した整数値がカーネル側の定義と1ビットでもズレると、カーネルは「知らないコマンド」とみなしてENOTTYエラーを返します。方向(書き込みか読み込みか)をiowとiorで取り違えても、埋め込むサイズが対応する構造体の実際のサイズとズレても、同じように失敗します。
KVMのC構造体をRustに移植する
KVM_SET_REGSやKVM_SET_SREGSのようなコマンドは、レジスタの値をやり取りするための構造体をあわせて渡します。この構造体はカーネルとバイト列としてやり取りされるため、Rust側で定義する構造体はフィールドの順序・型・サイズをC側の定義と完全に一致させる必要があります。
CPUアーキテクチャに依存する構造体(kvm_regs、kvm_sregsとその内部で使われるkvm_segment/kvm_dtable)はlinux/kvm.hではなく/usr/include/x86_64-linux-gnu/asm/kvm.hにあります。
struct kvm_regs {
__u64 rax, rbx, rcx, rdx;
__u64 rsi, rdi, rsp, rbp;
__u64 r8, r9, r10, r11;
__u64 r12, r13, r14, r15;
__u64 rip, rflags;
};
struct kvm_segment {
__u64 base;
__u32 limit;
__u16 selector;
__u8 type;
__u8 present, dpl, db, s, l, g, avl;
__u8 unusable;
__u8 padding;
};
struct kvm_dtable {
__u64 base;
__u16 limit;
__u16 padding[3];
};これをRustでは#[repr(C)]を付けてそのまま移植します。#[repr(C)]は「Rustコンパイラの最適化でフィールド順を並び替えず、Cと同じメモリレイアウトを使う」という指定です。
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct KvmSegment {
base: u64,
limit: u32,
selector: u16,
type_: u8,
present: u8,
dpl: u8,
db: u8,
s: u8,
l: u8,
g: u8,
avl: u8,
unusable: u8,
padding: u8,
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct KvmDtable {
base: u64,
limit: u16,
padding: [u16; 3],
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct KvmSregs {
cs: KvmSegment,
ds: KvmSegment,
es: KvmSegment,
fs: KvmSegment,
gs: KvmSegment,
ss: KvmSegment,
tr: KvmSegment,
ldt: KvmSegment,
gdt: KvmDtable,
idt: KvmDtable,
cr0: u64,
cr2: u64,
cr3: u64,
cr4: u64,
cr8: u64,
efer: u64,
apic_base: u64,
interrupt_bitmap: [u64; 4],
}kvm_sregsのgdt/idtはcsなどと同じkvm_segmentではなく、別の型kvm_dtableです。ここをkvm_segment(24バイト)のまま定義してしまうと、kvm_dtable(16バイト)との差分だけ構造体全体のサイズがずれます。するとKVM_GET_SREGS/KVM_SET_SREGSのコマンド番号(サイズを埋め込んで計算する)がカーネル側の期待値と一致しなくなり、ENOTTYで失敗します。データの向きも同様で、KVM_SET_SREGSは書き込みなのでiowで計算する必要があり、ior用のマクロを使うと同じくENOTTYになります。フィールドの型・数・向きのどれか1つでもC側の定義と食い違うと、コンパイルは通るのにioctlだけが失敗するという点は、手書きでこの層を扱う際につまずきやすいポイントです。
kvm_runはもう少し事情が複雑です。実物は共通ヘッダ部分の後ろに、exit理由ごとに30種類近い構造体が入る巨大なunionが続きます。今回はI/O exitしか扱わないため、共通ヘッダとio部分だけをRustの構造体として定義し、それ以外は省略しています。mmapされたメモリ領域自体はカーネルが確保した本来のサイズがあるので、定義していないフィールドまで読み書きしなければ問題ありません。
#[repr(C)]
struct KvmRunHeader {
request_interrupt_window: u8,
immediate_exit: u8,
padding1: [u8; 6],
exit_reason: u32,
ready_for_interrupt_injection: u8,
if_flag: u8,
flags: u16,
cr8: u64,
apic_base: u64,
io_direction: u8,
io_size: u8,
io_port: u16,
io_count: u32,
io_data_offset: u64,
}ゲストに実行させる12バイト
実行させるコードは、KVM開発コミュニティで定番のサンプルkvmtest.c(LWN.netの記事「Using the KVM API」に掲載)から拝借しています。
// mov dx, 0x3f8 / add al, bl / add al, '0' / out dx, al / mov al, '\n' / out dx, al / hlt
const CODE: [u8; 12] = [
0xba, 0xf8, 0x03, 0x00, 0xd8, 0x04, 0x30, 0xee, 0xb0, 0x0a, 0xee, 0xf4,
];初期レジスタにrax=2, rbx=2を設定しておくと、このコードはal = 2 + 2を計算し、結果をASCII文字'4'に変換してI/Oポート0x3f8(シリアルポート)へout命令で出力し、続けて改行を出力してからhltで停止します。
VMを組み立てる
mainの流れは、KVMとは何か——ハードウェア仮想化支援とVM Exitの仕組みで整理した手順そのままです。
let kvm_fd = libc::open(path.as_ptr(), libc::O_RDWR);
let version = checked_ioctl(kvm_fd, KVM_GET_API_VERSION, 0);
assert_eq!(version, 12, "unexpected KVM API version: {}", version);
let vm_fd = checked_ioctl(kvm_fd, KVM_CREATE_VM, 0) as i32;ゲストメモリはmmapでホスト側に確保し、そのままKVM_SET_USER_MEMORY_REGIONでゲストの物理アドレス空間として登録します。
let mem = libc::mmap(
ptr::null_mut(),
MEM_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");
ptr::copy_nonoverlapping(CODE.as_ptr(), mem as *mut u8, CODE.len());
let region = KvmUserspaceMemoryRegion {
slot: 0,
flags: 0,
guest_phys_addr: GUEST_ADDR,
memory_size: MEM_SIZE as u64,
userspace_addr: mem as u64,
};
checked_ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, ®ion as *const _ as u64);vCPUを作り、KVMとやり取りする共有メモリ(struct kvm_run)をmmapします。このサイズは構造体定義から計算せず、KVM_GET_VCPU_MMAP_SIZEで毎回問い合わせるのがKVM APIの流儀です(kvm_runのunion部分の実サイズがカーネルのバージョンによって変わり得るため)。
let vcpu_fd = checked_ioctl(vm_fd, KVM_CREATE_VCPU, 0) as i32;
let mmap_size = checked_ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0) as usize;
let run = libc::mmap(
ptr::null_mut(),
mmap_size,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
vcpu_fd,
0,
) as *mut KvmRunHeader;レジスタの初期化では、セグメントレジスタの扱いに1つ注意点があります。KVMがリセット直後に設定するデフォルトのセグメント状態ではcs.baseがBIOSのリセットベクタを指す値になっているため、KVM_GET_SREGSで現在値を取得し、cs.baseとcs.selectorを明示的に0へ書き換えてからKVM_SET_SREGSで反映します。こうしておくと、リアルモードでの実効アドレスがCS:IP = 0000:1000のようにbase + ripで単純に計算でき、ripにゲスト物理アドレスをそのまま指定できます。
let mut sregs = KvmSregs::default();
checked_ioctl(vcpu_fd, KVM_GET_SREGS, &mut sregs as *mut _ as u64);
sregs.cs.base = 0;
sregs.cs.selector = 0;
checked_ioctl(vcpu_fd, KVM_SET_SREGS, &sregs as *const _ as u64);
let regs = KvmRegs {
rax: 2,
rbx: 2,
rflags: 0x2, // bit1は仕様上常に1
rip: GUEST_ADDR,
..Default::default()
};
checked_ioctl(vcpu_fd, KVM_SET_REGS, ®s as *const _ as u64);VM Exitを処理するループ
KVM_RUNはVM Exitが起きるまでブロックします。今回発生し得るexit理由は2種類だけです。out命令によるI/OポートアクセスがVM ExitとなるKVM_EXIT_IO、hlt命令によるKVM_EXIT_HLTです。
loop {
checked_ioctl(vcpu_fd, KVM_RUN, 0);
match (*run).exit_reason {
KVM_EXIT_HLT => break,
KVM_EXIT_IO => {
if (*run).io_direction == KVM_EXIT_IO_OUT && (*run).io_port == 0x3f8 {
let data = (run as *const u8).add((*run).io_data_offset as usize);
out.write_all(&[*data]).unwrap();
out.flush().unwrap();
}
}
other => panic!("unhandled KVM exit reason: {}", other),
}
}KVM_EXIT_IOが起きると、kvm_runのio部分にポート番号・方向・データの格納位置(data_offset、kvm_run構造体先頭からのオフセット)が書き込まれます。今回はポート0x3f8への書き込みだけを拾い、そのバイトをそのままホストの標準出力に書き出しています。実機のシリアルポートエミュレーションではなく、あくまで「ゲストが吐いた1バイトをホストがのぞき見ているだけ」の最小構成です。
実行すると、ホストの標準出力に4(および改行)が出力されます。ゲストにはOSはおろかBIOSすら存在せず、KVMが提供する最小限の実行環境の上で12バイトの機械語がそのまま動いていることになります。