원문 캡처 · blog.boxlite.ai
원문 캡처 · blog.boxlite.ai

#0에서는 libkrun을 자체 가상 머신 모니터(VMM)로 대체하는 계획을 소개했다. 새 VMM의 부팅 경로는 PR을 하나씩 반영하는 중이며, 테스트에서는 vCPU 4개로 실제 리눅스 커널을 init까지 부팅한다(#2052). Rust 코드로 부팅 경로를 살펴보기 전에, BoxLite는 그 과정을 C 190줄로 압축한 vmmlite를 공개했다. 주석을 제외한 190줄로 기본 Ubuntu 커널을 KVM에서 부팅해 한 줄을 출력하는 PID 1까지 실행한다.

text
$ ./vmmlite bzImage initramfs.cpio
[    0.000000] Linux version 7.0.0-1014-gcp (buildd@lcy02-amd64-091) ...
...
[    1.757880] Run /init as init process
Hello from PID 1!
[    1.766049] reboot: Restarting system

실행을 시작하고 약 3초 뒤 vmmlite는 상태 코드 0으로 종료한다. C를 택한 이유는 의도적이다. 파일 하나로 구성해 ioctl() 호출 사이에 다른 요소를 두지 않았다. BoxLite의 Rust VMM도 같은 부팅 경로를 따르지만 더 세심하게 구현한다.

vmmlite는 학습용이며 프로덕션 환경에서 사용할 수 없다. 입력 파일을 검증하지 않고 샌드박스 없이 실행되며, 시리얼 포트와 리셋만 에뮬레이션한다. 마이크로 VM에서 코드를 실행하려면 BoxLite 같은 프로덕션 VMM을 사용해야 한다.

부팅 과정은 여덟 단계다

main()은 번호를 붙인 여덟 부분으로 나뉜다. 네 부분은 KVM과 통신하고, 나머지 네 부분은 게스트 메모리에 바이트를 쓴다. KVM은 게스트 명령어를 실제 CPU에서 실행하고, KVM_CREATE_IRQCHIP과 KVM_CREATE_PIT2 뒤에는 호스트 커널 안에서 인터럽트 컨트롤러와 타이머도 에뮬레이션한다. vmmlite가 맡는 일은 PC의 펌웨어와 부트로더 역할, 그리고 시리얼 포트와 리셋 라인 두 장치의 처리다.

VMM이 부트로더 역할을 한다

리눅스가 시작되기 전 게스트에서는 BIOS나 GRUB이 실행되지 않는다. 리눅스의 64비트 부팅 프로토콜은 로더가 해당 환경을 준비하면 이런 방식의 부팅을 허용한다.

  • 커널은 로드 주소에 둔다. bzImage의 64비트 부분을 1MiB에 복사한다(§3).
  • struct boot_params, 즉 ‘제로 페이지’를 0x7000에 둔다. 여기에는 설정 헤더, 명령줄, initramfs, E820 메모리 맵이 들어간다(§4).
  • 64비트 모드와 페이징을 켜고, 첫 1GiB를 자기 자신에 매핑하는 항등 매핑 페이지 테이블을 0x9000에 둔다(§6).
  • 평평한 __BOOT_CS(0x10)와 __BOOT_DS(0x18)를 지정하는 GDT를 0x500에 둔다. GDT는 네 항목으로 구성된다(§6).
  • 인터럽트를 끄고 RSI가 제로 페이지를 가리키게 한다. RFLAGS는 0x2, RSI는 0x7000으로 설정한다(§7).
  • 64비트 진입점으로 이동한다. 진입점은 로드 주소 + 0x200, 즉 0x100200이다(§7).

bzImage는 BIOS용 부트 섹터와 16비트 설정 코드로 시작한다. vmmlite는 헤더를 확인한 뒤 이 두 부분을 건너뛴다.

c
if (memcmp(k + 0x202, "HdrS", 4) || !(k[0x236] & 1))
    die("vmmlite: need a bzImage with a 64-bit entry\n");
size_t setup = ((k[0x1f1] ? k[0x1f1] : 4) + 1) * 512;
memcpy(ram + KERNEL, k + setup, ksize - setup);

그림 2는 vCPU가 시작할 때의 게스트 RAM 배치를 보여준다. 주소는 조사한 대부분의 VMM과 BoxLite의 VMM 설계 문서에서 공통으로 사용한다. 그림의 화살표는 CPU와 커널이 레지스터, 제로 페이지 필드, MP 테이블 서명 검색을 통해 각 부분을 찾는 방법을 나타낸다.

롱 모드는 KVM을 통해 몇 개의 제어 레지스터 비트를 설정해 켠다.

c
s.cr3 = PML4;
s.cr4 |= 1 << 5;
s.cr0 |= 1u << 31 | 1;
s.efer |= 1 << 10 | 1 << 8;
IOCTL(cpu, KVM_SET_SREGS, &s);
struct kvm_regs r = {
    .rip = KERNEL + 0x200,
    .rsi = ZERO_PAGE,
    .rflags = 2};

0x100200에는 압축 해제기의 64비트 진입점인 startup_64가 있다(head_64.S). 이 코드는 실제 커널의 압축을 풀고 커널로 이동한다. 그 과정에서 vCPU는 CPUID나 페이지에 처음 접근할 때 VM 실행을 중단하지만, KVM이 해당 종료를 직접 처리한다.

CPUID 정보는 KVM_GET_SUPPORTED_CPUID에서 가져오고, 리프 1의 비트 두 개를 설정한다. Firecracker도 같은 방식을 사용한다. 비트 31은 ‘하이퍼바이저에서 실행 중’을 나타내고, 비트 24는 KVM이 에뮬레이션하는 TSC 데드라인 타이머다.

리눅스에 가상 머신 구성을 알린다

펌웨어는 커널에 CPU, 인터럽트 컨트롤러, 인터럽트 연결 방식도 알려준다. 최신 펌웨어는 ACPI 테이블을 사용하지만, vmmlite는 더 오래되고 작은 인텔 멀티프로세서(MP) 테이블을 작성한다. 240바이트 크기의 이 테이블에는 CPU 하나, ISA 버스, 0xFEC00000의 IOAPIC, ISA IRQ n을 IOAPIC 핀 n에 연결하는 경로 16개가 기록된다.

리눅스는 몇몇 고정된 위치에서 _MP_ 서명을 찾는데, 그중 한 곳은 640KiB보다 낮은 마지막 1KiB다(mpparse.c). vmmlite는 이 테이블을 0x9FC00에 기록한다. Firecracker와 libkrun, 그리고 BoxLite의 Rust VMM도 같은 위치를 사용한다. vmmlite는 이 영역을 E820 메모리 맵에서 제외한다. 테이블이나 ACPI가 없으면 리눅스는 IOAPIC과 SMP를 끈다(설계 문서).

인터럽트 경로는 KVM의 커널 내장 칩이 기본적으로 연결하는 방식과 일치한다. 회선 n은 PIC 입력 n과 IOAPIC 핀 n을 구동한다(irq_comm.c). 따라서 시리얼 포트의 IRQ 4는 리눅스가 예상하는 위치에 도달한다.

시리얼 포트와 리셋을 처리한다

vmmlite는 KVM_RUN을 반복 호출한다. 게스트가 KVM이 사용자 공간에 맡기는 작업을 할 때만 호출이 반환되며, vmmlite는 세 종류의 포트 입출력을 처리한다.

c
} else if (port >= 0x3f8 && port <= 0x3ff) {
    *io = com1(vm, port & 7, run->io.direction, *io);
} else if (run->io.direction == KVM_EXIT_IO_IN) {
    memset(io, 0, run->io.size);
} else if (port == 0x64 && *io == 0xfe) {
    return 0;
}

COM1은 0x3F8~0x3FF 포트의 16550 UART다. com1()은 리눅스의 8250 드라이버가 장치를 찾아(8250_port.c) 구형 8250으로 인식할 만큼만 레지스터 다섯 개를 에뮬레이션한다.

커널 메시지를 출력하는 일은 간단하다. 콘솔 코드는 바이트를 보낼 때마다 회선 상태 레지스터를 확인하고(8250_port.c), com1()은 항상 ‘송신기가 비어 있음’을 뜻하는 0x60을 반환한다.

PID 1의 출력은 다르다. write()는 TTY 버퍼에 기록하고, 드라이버가 인터럽트 처리기에서 버퍼를 비운다. 드라이버는 ‘THR 비어 있음’ 인터럽트를 켜고(8250_port.c), UART가 준비됐다고 알릴 때마다 다음 바이트를 쓴다(8250_port.c). 이에 따라 com1()은 ‘THR 비어 있음’ 이벤트가 대기 중인지 상태 하나를 기억하고, 그 상태에 따라 IRQ 4를 구동한다.

c
struct kvm_irq_level irq = {.irq = 4, .level = ier & 2 && thre};
IOCTL(vm, KVM_IRQ_LINE, &irq);

그림 3은 ‘Hello from PID 1!’이 VM 밖으로 나오는 과정을 보여준다. 각 포트 접근은 VM 종료를 일으키고 vmmlite가 처리한다. IRQ 4는 드라이버에 다음 바이트를 보내라고 알린다. 문자 하나를 보내는 데 최소 세 번의 종료가 든다. IIR, LSR을 읽고 바이트를 보내는 순서다. 장치를 레지스터 단위로 에뮬레이션하는 데 드는 비용이며, 실제 VMM이 대량 입출력을 virtio로 옮기는 이유이기도 하다.

마지막 종료는 리셋이다. init.c는 다음 세 호출로 구성된다.

c
int main(void) {
  if (write(1, "Hello from PID 1!\n", 18) < 0) return 1;
  tcdrain(1);
  reboot(RB_AUTOBOOT);
}

리눅스는 ACPI 리셋을 시도하지만 ACPI가 없어 키보드 컨트롤러 방식으로 대체한다. 포트 0x64에 0xFE를 쓰면 CPU의 리셋 라인이 작동한다(reboot.c). vmmlite는 이 쓰기를 VM 실행의 끝으로 보고 0을 반환한다.

부팅 로그에서 각 줄의 원인을 추적한다

다음은 중첩 KVM을 사용하는 Google Cloud VM에서 Ubuntu의 7.0.0-1014-gcp 커널로 부팅한 로그 중 vmmlite가 일으킨 줄만 추린 것이다.

커널 로그원인
Command line: console=ttyS0 panic=-1§4의 cmd_line_ptr
BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] System RAM§4의 첫 E820 항목
BIOS-e820: [gap 0x000000000009fc00-0x00000000000fffff]§4에서 MP 테이블을 숨기는 빈 영역
Hypervisor detected: KVM§7의 CPUID 리프 1, 비트 31
found SMP MP-table at [mem 0x0009fc00-0x0009fc0f]§5
RAMDISK: [mem 0x1ff40000-0x1fffffff]§4의 initramfs, RAM 상단에 배치
ACPI BIOS Error (bug): A valid RSDP was not found의도적으로 ACPI 테이블을 제공하지 않음
IOAPIC[0]: apic_id 1, version 17, address 0xfec00000, GSI 0-23§5의 ID와 주소, KVM IOAPIC의 버전 0x11과 핀 24개
TSC deadline timer available§7의 CPUID 리프 1, 비트 24
serial8250: ttyS0 at I/O 0x3f8 (irq = 4, base_baud = 115200) is a 8250§8의 com1()
i8042: Can't read CTR while initializing i8042§8에서 알 수 없는 포트 읽기에 0을 반환
Hello from PID 1!init.c가 com1()과 IRQ 4를 거쳐 출력
reboot: Restarting system§8에서 init.c의 reboot()가 포트 0x64에 쓰기 직전 출력

vmmlite가 감수하는 부분은 키보드 컨트롤러 관련 지연이다. 컨트롤러 포트를 읽으면 모두 0이 반환되므로 리눅스는 도착하지 않을 바이트를 기다린다(i8042.c). i8042: Probing ports directly.가 출력된 뒤 오류가 나오기까지 0.73초가 걸린다. /init 실행까지 1.76초가 걸리는 가운데 대부분을 차지한다.

직접 실행하기

x86_64 리눅스와 KVM, /dev/kvm 접근 권한, 정적 glibc를 포함한 gcc, cpio가 필요하다. vmmlite는 호스트가 사용하는 커널을 부팅한다.

sh
git clone https://github.com/boxlite-ai/vmmlite && cd vmmlite
gcc -O2 -Wall -Wextra -o vmmlite vmmlite.c
gcc -O2 -Wall -Wextra -static -o init init.c
echo init | cpio -o -H newc > initramfs.cpio
sudo install -m 644 /boot/vmlinuz-$(uname -r) bzImage
./vmmlite bzImage initramfs.cpio

BoxLite는 Google Cloud의 중첩 KVM을 사용하는 인텔 환경과 QEMU가 에뮬레이션하는 AMD-V 환경에서 vmmlite를 부팅했다. 저장소의 CI는 푸시가 있을 때마다 GitHub Actions 실행기의 자체 커널을 부팅한다.

vmmlite에 빠진 기능

vmmlite는 지도를 보여주는 예제이지 실행용 VMM이 아니다. 이 프로젝트가 생략한 기능은 시리즈의 나머지 부분에서 다룬다.

vmmliteBoxLite의 VMM편
KVM_RUN을 한 번 도는 루프. 예상하지 못한 종료가 VM을 끝냄KVM, Hypervisor.framework, 이후 WHP를 위한 종료 처리 계약#2
vCPU를 외부에서 멈출 방법이 없음vCPU를 게스트 밖으로 안전하게 꺼내기#3
KVM의 CPUID를 복사하고 비트 두 개 설정x86의 레지스터, CPUID, MSR#4
호스트가 사용하는 커널을 그대로 사용직접 빌드한 커널#5
x86_64에서 bzImage 사용VMM이 x86_64와 arm64에서 부트로더 역할#6
COM1과 리셋만 처리하고 다른 포트는 0을 반환시리얼, RTC, 리셋을 먼저 다룬 뒤 virtio 추가#7, #10–12
KVM만 지원VMM이 종료 원인을 직접 해석하는 Hypervisor.framework 지원#8
한 줄을 출력하는 PID 1에이전트를 PID 1로 실행#13
샌드박스와 보안 강화 기능이 없음게스트 입력이 호스트를 패닉 상태로 만들어서는 안 됨#14

코드와 설계 문서

주석을 포함한 vmmlite.c는 328줄이며, 각 부분은 다이어그램이나 표로 시작한다. 주석에 오류가 있다면 이슈를 등록할 수 있다. 같은 부팅 경로를 다루는 VMM 설계 문서와 아직 진행 중인 M1 작업을 정리한 #1698도 공개돼 있다.