Open4

KVMを理解りたい

ISATOISATO

KVMってなに

Linux Kernelに組み込まれた、Virtualizationの仕組み。
Linux KernelがHypervisorの一部として動作することになる。
(今の理解では、)KVMはDevice emulationはしないので、その辺りはQEMUやFirecrackerといったものにお願いすることになる。したがって、Host OSと同じCPU ArchなGuest OSを実行することができる。

https://www.redhat.com/ja/topics/virtualization/what-is-KVM

kvmtool

KVMを利用した、軽量Hypervisorの実装。
つまり、QEMUやFirecrackerと同じことをしている。

https://git.kernel.org/pub/scm/linux/kernel/git/will/kvmtool.git/

ISATOISATO

まずは動かしてみる

使ってみるためには、以下のものが必要になる

  • kvm tools
  • Linux Kernel
  • Rootfs

また、ここでは、x86_64 な環境を前提とする。

準備

wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.15.136.tar.xz
tar xf linux-5.15.136.tar.xz
cd linux-5.15.136.tar.xz
make defconfig
make -j `nproc`
cd ../
git clone git://git.kernel.org/pub/scm/linux/kernel/git/will/kvmtool.git
cd kvmtool
make -j `nproc`
ARCH="$(uname -m)"
wget https://s3.amazonaws.com/spec.ccfc.min/firecracker-ci/v1.6/${ARCH}/ubuntu-22.04.ext4

実行

sudo ./lkvm run --disk ubuntu-22.04.ext4 --kernel ~/linux-6.1.59/arch/x86_64/boot/bzImage
  Info: # lkvm run -k /home/isato/linux-6.1.59/arch/x86_64/boot/bzImage -m 448 -c 4 --name guest-143039
No EFI environment detected.
early console in extract_kernel
input_data: 0x0000000002b5740d
input_len: 0x0000000000ada20a
output: 0x0000000001000000
output_len: 0x00000000025ea420
kernel_total_size: 0x0000000002430000
needed_size: 0x0000000002600000
trampoline_32bit: 0x000000000009d000
Physical KASLR using RDRAND RDTSC...
Virtual KASLR using RDRAND RDTSC...

Decompressing Linux... Parsing ELF... Performing relocations... done.
Booting the kernel.
[    0.000000] Linux version 6.1.59 (isato@archdev) (gcc (GCC) 13.2.1 20230801, GNU ld (GNU Binutils) 2.41.0) #1 SMP PREEMPT_DYNAMIC Sat Oct 21 01:37:03 JST 2023
[    0.000000] Command line: noapic noacpi pci=conf1 reboot=k panic=1 i8042.direct=1 i8042.dumbkbd=1 i8042.nopnp=1 earlyprintk=serial i8042.noaux=1 console=ttyS0 root=/dev/vda rw
[    0.000000] BIOS-provided physical RAM map:
[    0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable
[    0.000000] BIOS-e820: [mem 0x000000000009fc00-0x000000000009ffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000000f0000-0x00000000000ffffe] reserved
[    0.000000] BIOS-e820: [mem 0x0000000000100000-0x000000001bffffff] usable
[    0.000000] printk: bootconsole [earlyser0] enabled
[    0.000000] ERROR: earlyprintk= earlyser already used
[    0.000000] NX (Execute Disable) protection: active
[    0.000000] DMI not present or invalid.
[    0.000000] Hypervisor detected: KVM
[    0.000000] kvm-clock: Using msrs 4b564d01 and 4b564d00
[    0.000001] kvm-clock: using sched offset of 310923831 cycles
[    0.001866] clocksource: kvm-clock: mask: 0xffffffffffffffff max_cycles: 0x1cd42e4dffb, max_idle_ns: 881590591483 ns
[    0.007791] tsc: Detected 3892.684 MHz processor
[    0.009468] last_pfn = 0x1c000 max_arch_pfn = 0x400000000
[    0.011565] Disabled
[    0.012342] x86/PAT: MTRRs disabled, skipping PAT initialization too.
...

参考にしたもの

ISATOISATO

読んでみる

RUNを追う

run を引数に受け取ると、これを実行する

int kvm_cmd_run(int argc, const char **argv, const char *prefix)
{
	int ret = -EFAULT;
	struct kvm *kvm;

	kvm = kvm_cmd_run_init(argc, argv);
	if (IS_ERR(kvm))
		return PTR_ERR(kvm);

	ret = kvm_cmd_run_work(kvm);
	kvm_cmd_run_exit(kvm, ret);

	return ret;
}

init は大体 struct kvmを作ってる

static struct kvm *kvm_cmd_run_init(int argc, const char **argv)
{
	static char default_name[20];
	unsigned int nr_online_cpus;
	struct kvm *kvm = kvm__new();

	if (IS_ERR(kvm))
		return kvm;
struct kvm {
	struct kvm_arch		arch;
	struct kvm_config	cfg;
	int			sys_fd;		/* For system ioctls(), i.e. /dev/kvm */
	int			vm_fd;		/* For VM ioctls() */
	timer_t			timerid;	/* Posix timer for interrupts */

	int			nrcpus;		/* Number of cpus to run */
	struct kvm_cpu		**cpus;

	u32			mem_slots;	/* for KVM_SET_USER_MEMORY_REGION */
	u64			ram_size;	/* Guest memory size, in bytes */
	void			*ram_start;
	u64			ram_pagesize;
	struct mutex		mem_banks_lock;
	struct list_head	mem_banks;

	bool			nmi_disabled;
	bool			msix_needs_devid;

	const char		*vmlinux;
	struct disk_image       **disks;
	int                     nr_disks;

	int			vm_state;

#ifdef KVM_BRLOCK_DEBUG
	pthread_rwlock_t	brlock_sem;
#endif
};

以下でkvm構造体で定義されているcpu数分、pthreadを作ってVMをbootさせる

static int kvm_cmd_run_work(struct kvm *kvm)
{
	int i;

	for (i = 0; i < kvm->nrcpus; i++) {
		if (pthread_create(&kvm->cpus[i]->thread, NULL, kvm_cpu_thread, kvm->cpus[i]) != 0)
			die("unable to create KVM VCPU thread");
	}

	/* Only VCPU #0 is going to exit by itself when shutting down */
	if (pthread_join(kvm->cpus[0]->thread, NULL) != 0)
		die("unable to join with vcpu 0");

	return kvm_cpu__exit(kvm);
}

スレッドが実行される

argには自身のCPU番号の struct kvm_cpu が渡されている

static void *kvm_cpu_thread(void *arg)
{
	char name[16];

	current_kvm_cpu = arg;

	sprintf(name, "kvm-vcpu-%lu", current_kvm_cpu->cpu_id);
	kvm__set_thread_name(name);

	if (kvm_cpu__start(current_kvm_cpu))
		goto panic_kvm;

	return (void *) (intptr_t) 0;
struct kvm_cpu {
	pthread_t		thread;		/* VCPU thread */

	unsigned long		cpu_id;

	struct kvm		*kvm;		/* parent KVM */
	int			vcpu_fd;	/* For VCPU ioctls() */
	struct kvm_run		*kvm_run;
	struct kvm_cpu_task	*task;

	struct kvm_regs		regs;
	struct kvm_sregs	sregs;
	struct kvm_fpu		fpu;

	struct kvm_msrs		*msrs;		/* dynamically allocated */

	u8			is_running;
	u8			paused;
	u8			needs_nmi;

	struct kvm_coalesced_mmio_ring	*ring;
};

cpuが仕事をし始める

signalを設定し、while loopが始まる。

int kvm_cpu__start(struct kvm_cpu *cpu)
{
	sigset_t sigset;

	sigemptyset(&sigset);
	sigaddset(&sigset, SIGALRM);

	pthread_sigmask(SIG_BLOCK, &sigset, NULL);

	signal(SIGKVMEXIT, kvm_cpu_signal_handler);
	signal(SIGKVMPAUSE, kvm_cpu_signal_handler);
	signal(SIGKVMTASK, kvm_cpu_signal_handler);

	kvm_cpu__reset_vcpu(cpu);

	if (cpu->kvm->cfg.single_step)
		kvm_cpu__enable_singlestep(cpu);

	while (cpu->is_running) {
		...
	}

while loopの大部分では、VM EXITが発生した時のケアをしている。
exitした時のreasonによって分岐し、それぞれ処理する。
どういった分岐があるかをCopilotに出してもらった。

  • KVM_EXIT_UNKNOWN: 何もしない
  • KVM_EXIT_DEBUG: レジスタとコードを表示する
  • KVM_EXIT_IO: 入出力をエミュレートする
  • KVM_EXIT_MMIO: メモリマップドI/Oをエミュレートする
  • KVM_EXIT_INTR: CPUが実行中の場合は何もしない。そうでない場合は、KVMから出る
  • KVM_EXIT_SHUTDOWN: KVMから出る
  • KVM_EXIT_SYSTEM_EVENT: システムイベントのタイプを表示し、すべてのシステムイベントをシャットダウン要求として扱う。

割と単純ではある。

ISATOISATO

いつVM Enterしたか

よくあるIntel VT-x の解説だとVM実行前にVM Entryをすることで、Guest OSで動いているモードにCPUがなる。
https://syuu1228.github.io/howto_implement_hypervisor/part1.html

ここからは、CPU命令的にどこでVMが動き始めるかを探してみる。

まずは、KernelのソースからVM Entry(vmlaunch命令)をしている部分を探して、kvmtoolまで浮上してみる。
Kernelは実際に動かしたlinux-5.15.136を見る。

vmlaunch命令

vmlaunch命令はアセンブリで実際に書かれている。
これは __vmx_vcpu_run 関数の中で実行される。
arch/x86/kvm/vmx/vmenter.S

YM_FUNC_START(__vmx_vcpu_run)
	push %_ASM_BP
	mov  %_ASM_SP, %_ASM_BP
...
	jz .Lvmlaunch
...
.Lvmlaunch:
	vmlaunch
	jmp .Lvmfail

この関数は
arch/x86/kvm/vmx/vmx.c で呼ばれている。

static noinstr void vmx_vcpu_enter_exit(struct kvm_vcpu *vcpu,
					struct vcpu_vmx *vmx,
					unsigned long flags)
{
	kvm_guest_enter_irqoff();

	/* L1D Flush includes CPU buffer clear to mitigate MDS */
	if (static_branch_unlikely(&vmx_l1d_should_flush))
		vmx_l1d_flush(vcpu);
	else if (static_branch_unlikely(&mds_user_clear))
		mds_clear_cpu_buffers();
	else if (static_branch_unlikely(&mmio_stale_data_clear) &&
		 kvm_arch_has_assigned_device(vcpu->kvm))
		mds_clear_cpu_buffers();

	vmx_disable_fb_clear(vmx);

	if (vcpu->arch.cr2 != native_read_cr2())
		native_write_cr2(vcpu->arch.cr2);

	vmx->fail = __vmx_vcpu_run(vmx, (unsigned long *)&vcpu->arch.regs,
				   flags);

	vcpu->arch.cr2 = native_read_cr2();

	vmx_enable_fb_clear(vmx);

	kvm_guest_exit_irqoff();
}

そしてこれは同じファイルの以下関数で呼ばれている。

static fastpath_t vmx_vcpu_run(struct kvm_vcpu *vcpu)
{
	struct vcpu_vmx *vmx = to_vmx(vcpu);
	unsigned long cr3, cr4;
...
	/* The actual VMENTER/EXIT is in the .noinstr.text section. */
	vmx_vcpu_enter_exit(vcpu, vmx, __vmx_vcpu_run_flags(vmx));

さて、ここで vmx_vcpu_run関数がどこで呼ばれているかだが、これは少しややこしい。
直接は呼ばれておらず、この関数は構造体のメンバにされている。
これは、struct kvm_x86_ops という構造体が、vmx(Intelの仮想化支援)とsmv (AMDの仮想化支援)を抽象化しているためである。

static struct kvm_x86_ops vmx_x86_ops __initdata = {
...
	.run = vmx_vcpu_run,

したがって、vmx_vcpu_run は直接ではなく構造体を通して呼ばれることになる。
また、抽象化されたため、ここからはCPU依存ではない実装になってくる。