KVM API와 Rust를 사용해 최소한의 가상 머신 모니터를 처음부터 구현하는 방법을 단계별로 설명합니다.
Apr 15, 2026
Cloud Hypervisor나 Firecracker 같은 도구가 내부적으로 어떻게 동작하는지 궁금했다면, 최소한의 VMM을 직접 만들어 보는 것은 배우기에 가장 좋은 방법 중 하나입니다. 처음부터 하나 작성해 봅시다.
KVM(Kernel-based Virtual Machine)은 호스트를 하이퍼바이저로 바꾸는 Linux 커널 모듈입니다.
우리는 KVM API를 사용해 가상 머신을 실행하는 소프트웨어를 만들 것입니다. 업계에서는 이런 소프트웨어를 VMM 또는 Virtual Machine Monitor라고 부릅니다. 예를 들면 Cloud Hypervisor나 Firecracker가 있습니다.
KVM의 API는 ioctl 시스템 호출의 집합입니다. 여기에 VM 메모리 관리를 위한 mmap을 함께 사용하면, 이 두 가지 시스템 호출만으로도 VMM을 만들 수 있습니다.
Rust에서는 시스템 라이브러리에 대한 원시 FFI 바인딩을 제공하는 libc 크레이트를 사용해 이 시스템 호출들을 호출할 수 있습니다. 당연히 이것들은 unsafe 메서드이므로, 유효한 포인터를 통해 데이터를 전달하거나 읽는 식으로 메모리 안전성을 보장해야 합니다. 또한 Linux에서는 “모든 것은 파일이다”라는 특성이 있으므로, 이 시스템 호출로 열린 파일들을 반드시 닫아야 합니다.
가장 먼저 확인할 것은 시스템이 안정적인 KVM API 버전, 즉 상수값 12를 가지고 있는지입니다. 그렇지 않으면 애플리케이션은 실행되면 안 됩니다.
use std::{
error::Error,
fs::OpenOptions,
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
};
use std::os::raw::{c_uint, c_ulong};
const KVM_VERSION: i32 = 12;
const KVMIO: c_uint = 0xAE;
const KVM_GET_API_VERSION: c_ulong = libc::_IO(KVMIO, 0x00);
fn main() -> Result<(), Box<dyn Error>> {
let file = OpenOptions::new()
.write(true)
.custom_flags(libc::O_RDWR | libc::O_CLOEXEC)
.open("/dev/kvm")?;
let kvm_fd = file.as_raw_fd();
//
// 1. Check KVM version, it not 12 refuse to run.
//
let kvm_version = unsafe { libc::ioctl(kvm_fd, KVM_GET_API_VERSION, 0) };
println!("kvm version {kvm_version}");
if kvm_version < 0 {
let last_os_error = std::io::Error::last_os_error();
println!("error getting kvm version");
return Err(last_os_error.into());
}
if kvm_version != KVM_VERSION {
eprintln!("current kvm version: {kvm_version}, required kvm version: {KVM_VERSION}");
return Err("kvm version not supported".into());
}
Ok(())
}
이를 뜯어봅시다. 먼저 /dev/kvm 파일을 읽기/쓰기로 열고, ioctl에 전달해야 하는 파일 디스크립터를 i32로 얻습니다.
ioctl에서 첫 번째 인자는 파일 디스크립터이고, 두 번째는 연산 코드이며, 세 번째는 메모리에 대한 포인터입니다. 이 포인터의 의미는 해당 연산이 데이터를 읽는지 쓰는지에 따라 달라집니다.
KVM 연산 코드는 커널 자체가 ioctl.h 파일에서 사용하는 메서드들로 만들 수 있습니다. 다행히 libc도 _IO, _IOW(연산이 데이터를 쓸 때), _IOR(연산이 데이터를 읽을 때) 같은 const 함수를 노출합니다.
먼저 ioctl 호출 자체가 성공했는지 확인해야 합니다. 반환 코드가 음수가 아닌지 검사하면 됩니다. 하지만 이것은 성공 또는 실패만 알려줍니다. 실제 오류를 얻으려면 errno라는 다른 라이브러리 메커니즘을 사용해야 하며, 이는 std::io::Error::last_os_error() 메서드로 얻을 수 있습니다.
참고: 간결함을 위해, 여기부터는 새 코드만 보여줍니다. 완전한 프로그램은 full source를 참고하세요.
use std::os::fd::FromRawFd;
const KVM_CREATE_VM: c_ulong = libc::_IO(KVMIO, 0x01);
//
// 2. Create A VM
//
let vm_fd = unsafe { libc::ioctl(kvm_fd, KVM_CREATE_VM, 0) };
if vm_fd < 0 {
let last_os_error = std::io::Error::last_os_error();
eprintln!("vm creation error");
return Err(last_os_error.into());
}
// Own it so that fd is closed on drop
let vm_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(vm_fd) };
여기서 KVM_CREATE_VM API는 이 VM을 관리하기 위한 새로운 파일 디스크립터를 반환합니다. 이 vm_fd는 kvm_fd와 마찬가지로 그냥 정수이지만, 둘이 생성되는 방식에는 큰 차이가 있습니다. kvm_fd는 Rust의 std 라이브러리로 파일을 열고 이를 let file에 할당했습니다. file 변수는 스코프를 벗어날 때 Drop되어 파일이 닫히도록 보장합니다.
vm_fd 정수에도 같은 효과를 주기 위해 OwnedFd를 만들고, 같은 이름의 변수로 다시 가립니다. 이렇게 하면 VM 파일 디스크립터의 “소유권”을 가져오게 되고, 스코프를 벗어날 때 OwnedFd의 Drop이 실제 파일을 닫아 줍니다.
const KVM_CREATE_VCPU: c_ulong = libc::_IO(KVMIO, 0x41);
//
// 3. Create A VCPU
//
let vcpu_fd = unsafe { libc::ioctl(vm_fd.as_raw_fd(), KVM_CREATE_VCPU, 0) };
if vcpu_fd < 0 {
let last_os_error = std::io::Error::last_os_error();
eprintln!("vcpu creation error");
return Err(last_os_error.into());
}
// Own it so that fd is closed on drop
let vcpu_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(vcpu_fd) };
println!(
"kvm fd {kvm_fd}, vm fd: {}, vcpu fd: {}",
vm_fd.as_raw_fd(),
vcpu_fd.as_raw_fd()
);
이 VM을 관리하기 위해 vm_fd 파일 디스크립터를 사용해 VCPU를 생성합니다. 앞과 마찬가지로 여기서도 새 파일에 대한 새로운 파일 디스크립터 vcpu_fd를 얻게 되므로, vcpu_fd가 스코프를 벗어날 때 닫히도록 소유권을 가져옵니다.
VMM에는 가상 메모리이지만 VM에서는 물리 메모리처럼 보일 메모리를 만들어야 합니다.
VMM의 가상 주소 공간에 다음 특성을 가진 메모리 매핑을 생성합니다. 이 메모리는 읽기(PROT_READ)와 쓰기(PROT_WRITE)가 가능해야 하고, 다른 프로세스에서는 보이지 않아야 하며(MAP_PRIVATE), 내용은 0으로 초기화되어야 합니다(MAP_ANONYMOUS).
const VM_MEMORY: u64 = 2 * 4096; // 2 blocks of 4KiB
struct Mmap {
pub ptr: *mut std::os::raw::c_void,
pub len: usize,
}
impl Drop for Mmap {
fn drop(&mut self) {
println!("calling libc:munmap");
unsafe { libc::munmap(self.ptr, self.len) };
}
}
//
// 4. Create memory for guest
//
let vm_memory_mmap = unsafe {
libc::mmap(
std::ptr::null_mut(),
VM_MEMORY as usize,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_ANONYMOUS | libc::MAP_PRIVATE,
-1,
0,
)
};
if vm_memory_mmap == libc::MAP_FAILED {
let last_os_error = std::io::Error::last_os_error();
eprintln!("vm memory map failed");
return Err(last_os_error.into());
}
// take ownership, so on drop munmap is called
let vm_memory_mmap = Mmap {
ptr: vm_memory_mmap,
len: VM_MEMORY as usize,
};
mmap은 매핑된 영역에 대한 포인터를 반환합니다. 이는 그 메모리 시작 지점의 가상 주소입니다. VMM 프로그램이 이 메모리 사용을 마치면 munmap 시스템 호출을 사용해 매핑을 해제해야 합니다.
이를 위해 포인터와 메모리 크기를 Mmap 구조체에 저장하고, 여기에 Drop을 구현합니다. 그러면 vm_memory_mmap이 스코프를 벗어날 때 자동으로 munmap이 호출됩니다.
const CODE: [u8; 1] = [0xF4]; // HLT
//
// 5. Copy code to guest's physical memory - that guest will execute
//
unsafe {
std::ptr::copy_nonoverlapping(&CODE as *const u8, vm_memory_mmap.ptr as *mut u8, 1);
}
여기에는 크기가 1바이트인 CODE가 있고, 이를 게스트 메모리에 복사합니다. 이 CODE는 HLT를 나타내는 단일 x86 명령어입니다.
이제 VMM이 게스트용 메모리를 갖게 되었으므로, 이를 KVM API에 넘겨 게스트의 물리 메모리로 설정합니다.
const KVM_SET_USER_MEMORY_REGION: c_ulong = libc::_IOW::<kvm_userspace_memory_region>(KVMIO, 0x46);
//
// 6. Setup guest's physical memory
//
let vm_memory_addr = vm_memory_mmap.ptr as u64;
let userspace_memory_region = kvm_userspace_memory_region {
slot: 0,
flags: 0,
guest_phys_addr: 4096,
memory_size: VM_MEMORY,
userspace_addr: vm_memory_addr,
};
let ret = unsafe {
libc::ioctl(
vm_fd.as_raw_fd(),
KVM_SET_USER_MEMORY_REGION,
&userspace_memory_region,
)
};
if ret != 0 {
let last_os_error = std::io::Error::last_os_error();
eprintln!("error setting user memory region");
return Err(last_os_error.into());
}
여기서 흥미로운 점은 이 메모리를 게스트 물리 주소 공간에서 첫 번째 4KiB 블록 바로 다음 바이트, 즉 시작 주소에 할당한다는 것입니다. 따라서 우리가 복사한 CODE는 VM 입장에서는 인덱스 4096 바이트 위치에 있는 것으로 보입니다.
CPU 레지스터는 특정 시점에 CPU가 무엇을 하고 있는지 추적하는 값이며, 아키텍처별(x86, ARM 등)로 다릅니다.
우리는 x86 레지스터를 설정해서, VM이 가장 먼저 실행하는 것이 우리의 CODE가 되도록 만들고자 합니다.
CPU의 명령어 포인터 레지스터는 다음에 실행할 명령어의 주소를 저장합니다. 우리는 첫 번째이자 유일한 명령어를 4096 위치에 두었으므로, rip 명령어 포인터를 그 값으로 설정합니다.
const KVM_SET_REGS: c_ulong = libc::_IOW::<kvm_regs>(KVMIO, 0x82);
const KVM_GET_SREGS: c_ulong = libc::_IOR::<kvm_sregs>(KVMIO, 0x83);
const KVM_SET_SREGS: c_ulong = libc::_IOW::<kvm_sregs>(KVMIO, 0x84);
//
// 7.1 Setup regular x86 cpu registers.
// Set instruction pointer to start execution at 2nd' block of size 4096, because that's where we copied code
//
let k_regs = kvm_regs {
rip: 4096,
rflags: 0x2,
..Default::default()
};
let ret = unsafe { libc::ioctl(vcpu_fd.as_raw_fd(), KVM_SET_REGS, &k_regs) };
if ret != 0 {
let last_os_error = std::io::Error::last_os_error();
eprintln!("error setting kvm_regs");
return Err(last_os_error.into());
}
//
// 7.2 Read default x86 special registers, and update them
//
let mut k_sregs = kvm_sregs::default();
let ret = unsafe { libc::ioctl(vcpu_fd.as_raw_fd(), KVM_GET_SREGS, &k_sregs) };
if ret != 0 {
let last_os_error = std::io::Error::last_os_error();
eprintln!("error getting kvm_sregs");
return Err(last_os_error.into());
}
k_sregs.cs.base = 0;
k_sregs.cs.selector = 0;
let ret = unsafe { libc::ioctl(vcpu_fd.as_raw_fd(), KVM_SET_SREGS, &k_sregs) };
if ret != 0 {
let last_os_error = std::io::Error::last_os_error();
eprintln!("error setting kvm_sregs");
return Err(last_os_error.into());
}
이 레지스터들에 대해 더 알고 싶다면, LWN의 Using the KVM API 글을 참고하세요.
모든 VCPU에는 커널이 사용자 공간의 VMM과 통신하기 위해 사용하는 kvm_run 데이터 구조가 연결되어 있습니다. VCPU를 실행하면, VM은 다양한 이유(예: I/O)로 언제든지 종료될 수 있습니다. 그 이유는 kvm_run.exit_reason에 기록되며, VMM은 이를 처리한 뒤(예: 요청된 I/O 수행) VM 실행을 다시 이어갈 수 있습니다.
KVM은 kvm_run 데이터의 크기를 얻는 API를 제공합니다. 그런 다음 그 크기만큼의 메모리를 VMM에 매핑할 수 있으며, 이 메모리는 vcpu_fd VCPU 파일을 기반으로 합니다.
const KVM_GET_VCPU_MMAP_SIZE: c_ulong = libc::_IO(KVMIO, 0x04);
//
// 8.1. Get the size of kvm_run
//
let vcpu_mmap_size = unsafe { libc::ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0) };
if vcpu_mmap_size < 0 {
let last_os_error = std::io::Error::last_os_error();
eprintln!("error getting vcpu mmap size: {vcpu_mmap_size}");
return Err(last_os_error.into());
}
println!("vcpu mmap size: {vcpu_mmap_size} bytes");
//
// 8.2 memory map the pointer to kvm_run data structure
//
let kvm_run_mmap = unsafe {
libc::mmap(
std::ptr::null_mut(),
vcpu_mmap_size as usize,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
vcpu_fd.as_raw_fd(),
0,
)
};
if kvm_run_mmap == libc::MAP_FAILED {
let last_os_error = std::io::Error::last_os_error();
eprintln!("kvm_run mmap failed");
return Err(last_os_error.into());
}
// take ownership, so on drop munmap is called
let kvm_run_mmap = Mmap {
ptr: kvm_run_mmap,
len: vcpu_mmap_size as usize,
};
이 매핑은 MAP_SHARED이므로, 변경 사항이 VMM에도 보이고 기반이 되는 VCPU 파일에도 반영됩니다.
이제 남은 일은 VCPU를 실행하고 종료 이벤트를 처리하는 것뿐입니다. 우리의 CODE는 첫 번째 명령어에서 정지하도록 작성되어 있으므로, VMM 루프는 그 종료 이유를 받고 멈추게 됩니다.
const KVM_RUN: c_ulong = libc::_IO(KVMIO, 0x80);
//
// 9. Run VM until it executes hlt instruction in CODE
//
loop {
let ret = unsafe { libc::ioctl(vcpu_fd.as_raw_fd(), KVM_RUN, 0) };
if ret != 0 {
eprintln!("KVM_RUN errored")
}
let k_run: &kvm_run = unsafe { &*(kvm_run_mmap.ptr as *const kvm_run) };
match k_run.exit_reason {
kvm_bindings::KVM_EXIT_HLT => {
println!("KVM_EXIT_HTL");
return Ok(());
}
_ => {
eprintln!("EXIT: {:?}", k_run);
}
}
}
C의 원시 포인터를 kvm_run에 대한 Rust 참조로 변환하는 부분을 살펴봅시다.
기반 포인터가 kvm_run을 담는 유효한 메모리 레이아웃을 가리킨다는 것을 알고 있으므로, 다음과 같은 작업을 수행합니다.
kvm_run_mmap.ptr as *const kvm_run으로 캐스팅합니다.&*(...)를 사용합니다. *는 포인터를 역참조하고, 이어서 &가 즉시 그것을 빌립니다.축하합니다. 이제 첫 번째 VM을 실행했습니다!
이 VMM은 가능한 한 적은 의존성만 사용해 작성되었습니다. 이렇게 하면 가능한 한 커널에 가깝게 머물면서 실제로 무슨 일이 벌어지는지 볼 수 있습니다.
이 최소 의존성은 시스템 호출용 libc와, kvm_regs, kvm_run 같은 아키텍처별 데이터 구조를 위한 kvm-bindings입니다. kvm-bindings 크레이트는 실제 커널 코드를 사용해 bindgen으로 생성되므로, 이것이 우리가 갈 수 있는 가장 가까운 수준입니다.
여기서부터는 이 VMM을 확장해 I/O 포트 종료를 처리하거나, real-mode 코드를 실행하거나, 심지어 Linux 커널 이미지를 로드하는 것까지도 할 수 있습니다.
전체 소스 코드는 여기에서 볼 수 있습니다: 64bit/miniHype