数行のコードでKVMホスト

こんにちは!



本日、KVMホストの作成方法に関する記事を公開します。Serge Zaitsevのブログそれを見て、C ++を使用しない人のために、独自のPythonの例を翻訳して補足しました。


KVM(Kernel-based Virtual Machine)は、Linuxカーネルに付属する仮想化テクノロジーです。つまり、KVMを使用すると、単一のLinux仮想ホスト上で複数の仮想マシン(VM)を実行できます。この場合の仮想マシンはゲストと呼ばれます。LinuxでQEMUまたはVirtualBoxを使用したことがある場合は、KVMの機能をご存知でしょう。



しかし、それは内部でどのように機能しますか?



IOCTL



KVMは、特別なデバイスファイル/ dev / kvmを介しAPI公開しますデバイスを起動するときは、KVMサブシステムにアクセスしてから、ioctlシステム呼び出しを行ってリソースを割り当て、仮想マシンを起動します。一部のioctl呼び出しは、ファイル記述子を返します。これは、ioctlを使用して操作することもできます。など、無限に?あんまり。KVMにはいくつかのAPIレベルしかありません。



  • KVMサブシステム全体を管理し、新しい仮想マシンを作成するために使用される/ dev / kvmレベル、
  • 個々の仮想マシンを管理するために使用されるVMレイヤー、
  • 1つの仮想プロセッサの動作を制御するために使用されるVCPUレベル(1つの仮想マシンは複数の仮想プロセッサで実行できます)-VCPU。


さらに、I / Oデバイス用のAPIがあります。



それが実際にどのように見えるか見てみましょう。



// KVM layer
int kvm_fd = open("/dev/kvm", O_RDWR);
int version = ioctl(kvm_fd, KVM_GET_API_VERSION, 0);
printf("KVM version: %d\n", version);

// Create VM
int vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);

// Create VM Memory
#define RAM_SIZE 0x10000
void *mem = mmap(NULL, RAM_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
struct kvm_userspace_memory_region mem = {
	.slot = 0,
	.guest_phys_addr = 0,
	.memory_size = RAM_SIZE,
	.userspace_addr = (uintptr_t) mem,
};
ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, &mem);

// Create VCPU
int vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);


Pythonの例:



with open('/dev/kvm', 'wb+') as kvm_fd:
    # KVM layer
    version = ioctl(kvm_fd, KVM_GET_API_VERSION, 0)
    if version != 12:
        print(f'Unsupported version: {version}')
        sys.exit(1)

    # Create VM
    vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0)

    # Create VM Memory
    mem = mmap(-1, RAM_SIZE, MAP_PRIVATE | MAP_ANONYMOUS, PROT_READ | PROT_WRITE)
    pmem = ctypes.c_uint.from_buffer(mem)
    mem_region = UserspaceMemoryRegion(slot=0, flags=0,
                                       guest_phys_addr=0, memory_size=RAM_SIZE,
                                       userspace_addr=ctypes.addressof(pmem))
    ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, mem_region)

    # Create VCPU
    vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);


このステップでは、新しい仮想マシンを作成し、それにメモリを割り当て、1つのvCPUを割り当てました。仮想マシンで実際に何かを実行するには、仮想マシンイメージをロードし、プロセッサレジスタを適切に構成する必要があります。



仮想マシンのロード



とても簡単です!ファイルを読み取り、その内容を仮想マシンのメモリにコピーするだけです。もちろん、mmapも良いオプションです。



int bin_fd = open("guest.bin", O_RDONLY);
if (bin_fd < 0) {
	fprintf(stderr, "can not open binary file: %d\n", errno);
	return 1;
}
char *p = (char *)ram_start;
for (;;) {
	int r = read(bin_fd, p, 4096);
	if (r <= 0) {
		break;
	}
	p += r;
}
close(bin_fd);


Pythonの例:



    # Read guest.bin
    guest_bin = load_guestbin('guest.bin')
    mem[:len(guest_bin)] = guest_bin


KVMは古い仮想マシンのようにCPU命令を次々に解釈しないため、guest.binには現在のCPUアーキテクチャの有効なバイトコードが含まれ ていると想定されます。 KVMは実際のCPUに計算を提供し、I / Oのみをインターセプトします。これが、I / Oの重い操作を行わない限り、最新の仮想マシンがベアメタルに近い高性能で実行される理由です。



最初に実行しようとする小さなゲストVMカーネルは次のとおり です。アセンブラに慣れていない場合、上記の例は、ループ内のレジスタをインクリメントしてポート0x10に値を出力する小さな16ビットの実行可能ファイルです。



#

# Build it:

#

# as -32 guest.S -o guest.o

# ld -m elf_i386 --oformat binary -N -e _start -Ttext 0x10000 -o guest guest.o

#

.globl _start

.code16

_start:

xorw %ax, %ax

loop:

out %ax, $0x10

inc %ax

jmp loop








起動されたKVM仮想プロセッサは、実際のx86プロセッサと同じようにいくつかのモードで動作できるため、意図的に古風な16ビットアプリケーションとしてコンパイルしました。最も単純なモードは「実際の」モードで、前世紀から16ビットコードを実行するために使用されてきました。実モードはメモリアドレス指定が異なり、記述子テーブルを使用する代わりに直接です。実モード用にレジスタを初期化する方が簡単です。



struct kvm_sregs sregs;
ioctl(vcpu_fd, KVM_GET_SREGS, &sregs);
// Initialize selector and base with zeros
sregs.cs.selector = sregs.cs.base = sregs.ss.selector = sregs.ss.base = sregs.ds.selector = sregs.ds.base = sregs.es.selector = sregs.es.base = sregs.fs.selector = sregs.fs.base = sregs.gs.selector = 0;
// Save special registers
ioctl(vcpu_fd, KVM_SET_SREGS, &sregs);

// Initialize and save normal registers
struct kvm_regs regs;
regs.rflags = 2; // bit 1 must always be set to 1 in EFLAGS and RFLAGS
regs.rip = 0; // our code runs from address 0
ioctl(vcpu_fd, KVM_SET_REGS, &regs);


Pythonの例:



    sregs = Sregs()
    ioctl(vcpu_fd, KVM_GET_SREGS, sregs)
    # Initialize selector and base with zeros
    sregs.cs.selector = sregs.cs.base = sregs.ss.selector = sregs.ss.base = sregs.ds.selector = sregs.ds.base = sregs.es.selector = sregs.es.base = sregs.fs.selector = sregs.fs.base = sregs.gs.selector = 0
    # Save special registers
    ioctl(vcpu_fd, KVM_SET_SREGS, sregs)

    # Initialize and save normal registers
    regs = Regs()
    regs.rflags = 2  # bit 1 must always be set to 1 in EFLAGS and RFLAGS
    regs.rip = 0  # our code runs from address 0
    ioctl(vcpu_fd, KVM_SET_REGS, regs)


ランニング



コードがロードされ、レジスタの準備が整います。始めましょう?仮想マシンを起動するには、各vCPUの「実行状態」へのポインターを取得してから、I / Oまたはその他によって中断されるまで仮想マシンが実行されるループに入る必要があります。制御がホストに戻される操作。



int runsz = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
struct kvm_run *run = (struct kvm_run *) mmap(NULL, runsz, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu_fd, 0);

for (;;) {
	ioctl(vcpu_fd, KVM_RUN, 0);
	switch (run->exit_reason) {
	case KVM_EXIT_IO:
		printf("IO port: %x, data: %x\n", run->io.port, *(int *)((char *)(run) + run->io.data_offset));
		break;
	case KVM_EXIT_SHUTDOWN:
		return;
	}
}


Pythonの例:



    runsz = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0)
    run_buf = mmap(vcpu_fd, runsz, MAP_SHARED, PROT_READ | PROT_WRITE)
    run = Run.from_buffer(run_buf)

    try:
        while True:
            ret = ioctl(vcpu_fd, KVM_RUN, 0)
            if ret < 0:
                print('KVM_RUN failed')
                return
             if run.exit_reason == KVM_EXIT_IO:
                print(f'IO port: {run.io.port}, data: {run_buf[run.io.data_offset]}')
             elif run.exit_reason == KVM_EXIT_SHUTDOWN:
                return
              time.sleep(1)
    except KeyboardInterrupt:
        pass


これで、アプリケーションを実行すると、次のように表示されます完全なソースコードは、次のアドレスで入手できます(間違いに気付いた場合は、コメントを歓迎します!)。



IO port: 10, data: 0

IO port: 10, data: 1

IO port: 10, data: 2

IO port: 10, data: 3

IO port: 10, data: 4

...








あなたはそれをコアと呼んでいますか?



おそらく、これはすべてあまり印象的ではありません。代わりにLinuxカーネルを実行するのはどうですか?



最初は同じです:open / dev / kvm、仮想マシンの作成など。ただし、定期的な間隔タイマーを追加し、TSS(Intelチップに必要)を初期化し、割り込みコントローラーを追加するには、仮想マシンレベルでさらにいくつかのioctl呼び出しが必要です。



ioctl(vm_fd, KVM_SET_TSS_ADDR, 0xffffd000);
uint64_t map_addr = 0xffffc000;
ioctl(vm_fd, KVM_SET_IDENTITY_MAP_ADDR, &map_addr);
ioctl(vm_fd, KVM_CREATE_IRQCHIP, 0);
struct kvm_pit_config pit = { .flags = 0 };
ioctl(vm_fd, KVM_CREATE_PIT2, &pit);


また、レジスタの初期化方法を変更する必要があります。Linuxカーネルには保護モードが必要なので、レジスタフラグで有効にし、特殊なケースごとにベース、セレクター、粒度を初期化します。



sregs.cs.base = 0;
sregs.cs.limit = ~0;
sregs.cs.g = 1;

sregs.ds.base = 0;
sregs.ds.limit = ~0;
sregs.ds.g = 1;

sregs.fs.base = 0;
sregs.fs.limit = ~0;
sregs.fs.g = 1;

sregs.gs.base = 0;
sregs.gs.limit = ~0;
sregs.gs.g = 1;

sregs.es.base = 0;
sregs.es.limit = ~0;
sregs.es.g = 1;

sregs.ss.base = 0;
sregs.ss.limit = ~0;
sregs.ss.g = 1;

sregs.cs.db = 1;
sregs.ss.db = 1;
sregs.cr0 |= 1; // enable protected mode

regs.rflags = 2;
regs.rip = 0x100000; // This is where our kernel code starts
regs.rsi = 0x10000; // This is where our boot parameters start


ブートパラメータとは何ですか?また、アドレス0でカーネルをブートできないのはなぜですか?bzImage形式についてさらに学ぶ時が来ました。



カーネルイメージは、実際のカーネルバイトコードが後に続くブートパラメータを持つ固定ヘッダーがある特別な「ブートプロトコル」に従います。ブートヘッダー形式については、こちらで説明しています



カーネルイメージのロード



カーネルイメージを仮想マシンに適切にロードするには、最初にbzImageファイル全体を読み取る必要があります。オフセット0x1f1を見て、そこからセットアップのセクター数を取得します。それらをスキップして、カーネルコードがどこから始まるかを確認します。さらに、bzImageの先頭から仮想マシンのブートパラメータ(0x10000)のメモリ領域にブートパラメータをコピーします。



しかし、それでも十分ではありません。仮想マシンのブートパラメータを修正して、仮想マシンを強制的にVGAモードにし、コマンドラインポインタを初期化する必要があります。



I / Oをインターセプトできるように、カーネルはttyS0にログを出力する必要があり、仮想マシンはそれをstdoutに出力します。これを行うには、カーネルコマンドラインに「console = ttyS0」追加する必要があります



しかし、その後も結果は出ません。カーネルに偽のCPUIDを設定する必要がありました(https://www.kernel.org/doc/Documentation/virtual/kvm/cpuid.txt)。おそらく、私がまとめたカーネルは、この情報に基づいて、ハイパーバイザー内で実行されているのか、ベアメタル上で実行されているのかを判断しました。



「小さな」構成でコンパイルされたカーネルを使用し、ターミナルとvirtio(Linux用のI / O仮想化フレームワーク)をサポートするためにいくつかの構成フラグを設定しました。



変更されたKVMホストとテストカーネルイメージの完全なコードは、こちらから入手できます



この画像が開始されない場合は、このリンクから入手できる別の画像を使用できます


コンパイルして実行すると、次の出力が得られます。



Linux version 5.4.39 (serge@melete) (gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~16.04~ppa1)) #12 Fri May 8 16:04:00 CEST 2020
Command line: console=ttyS0
Intel Spectre v2 broken microcode detected; disabling Speculation Control
Disabled fast string operations
x86/fpu: Supporting XSAVE feature 0x001: 'x87 floating point registers'
x86/fpu: Supporting XSAVE feature 0x002: 'SSE registers'
x86/fpu: Supporting XSAVE feature 0x004: 'AVX registers'
x86/fpu: xstate_offset[2]:  576, xstate_sizes[2]:  256
x86/fpu: Enabled xstate features 0x7, context size is 832 bytes, using 'standard' format.
BIOS-provided physical RAM map:
BIOS-88: [mem 0x0000000000000000-0x000000000009efff] usable
BIOS-88: [mem 0x0000000000100000-0x00000000030fffff] usable
NX (Execute Disable) protection: active
tsc: Fast TSC calibration using PIT
tsc: Detected 2594.055 MHz processor
last_pfn = 0x3100 max_arch_pfn = 0x400000000
x86/PAT: Configuration [0-7]: WB  WT  UC- UC  WB  WT  UC- UC
Using GB pages for direct mapping
Zone ranges:
  DMA32    [mem 0x0000000000001000-0x00000000030fffff]
  Normal   empty
Movable zone start for each node
Early memory node ranges
  node   0: [mem 0x0000000000001000-0x000000000009efff]
  node   0: [mem 0x0000000000100000-0x00000000030fffff]
Zeroed struct page in unavailable ranges: 20322 pages
Initmem setup node 0 [mem 0x0000000000001000-0x00000000030fffff]
[mem 0x03100000-0xffffffff] available for PCI devices
clocksource: refined-jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 7645519600211568 ns
Built 1 zonelists, mobility grouping on.  Total pages: 12253
Kernel command line: console=ttyS0
Dentry cache hash table entries: 8192 (order: 4, 65536 bytes, linear)
Inode-cache hash table entries: 4096 (order: 3, 32768 bytes, linear)
mem auto-init: stack:off, heap alloc:off, heap free:off
Memory: 37216K/49784K available (4097K kernel code, 292K rwdata, 244K rodata, 832K init, 916K bss, 12568K reserved, 0K cma-reserved)
Kernel/User page tables isolation: enabled
NR_IRQS: 4352, nr_irqs: 24, preallocated irqs: 16
Console: colour VGA+ 142x228
printk: console [ttyS0] enabled
APIC: ACPI MADT or MP tables are not detected
APIC: Switch to virtual wire mode setup with no configuration
Not enabling interrupt remapping due to skipped IO-APIC setup
clocksource: tsc-early: mask: 0xffffffffffffffff max_cycles: 0x25644bd94a2, max_idle_ns: 440795207645 ns
Calibrating delay loop (skipped), value calculated using timer frequency.. 5188.11 BogoMIPS (lpj=10376220)
pid_max: default: 4096 minimum: 301
Mount-cache hash table entries: 512 (order: 0, 4096 bytes, linear)
Mountpoint-cache hash table entries: 512 (order: 0, 4096 bytes, linear)
Disabled fast string operations
Last level iTLB entries: 4KB 64, 2MB 8, 4MB 8
Last level dTLB entries: 4KB 64, 2MB 0, 4MB 0, 1GB 4
CPU: Intel 06/3d (family: 0x6, model: 0x3d, stepping: 0x4)
Spectre V1 : Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Spectre V2 : Spectre mitigation: kernel not compiled with retpoline; no mitigation available!
Speculative Store Bypass: Vulnerable
TAA: Mitigation: Clear CPU buffers
MDS: Mitigation: Clear CPU buffers
Performance Events: Broadwell events, 16-deep LBR, Intel PMU driver.
...


明らかに、これはまだかなり役に立たない結果です。initrdまたはrootパーティションがなく、このカーネルで実行できる実際のアプリケーションもありませんが、KVMがそれほどひどく強力なツールではないことを証明しています。



結論



本格的なLinuxを実行するには、仮想マシンホストをさらに高度にする必要があります。ディスク、キーボード、グラフィックス用のいくつかのI / Oドライバーをシミュレートする必要があります。ただし、一般的なアプローチは同じです。たとえば、initrdのコマンドラインパラメータを同じ方法で構成する必要があります。ディスクはI / Oをインターセプトし、適切に応答する必要があります。



ただし、KVMを直接使用するように強制する人は誰もいません。libvirtがあります。これは、KVMやBHyveなどの低レベルの仮想化テクノロジーに適した使いやすいライブラリです。



KVMについて詳しく知りたい場合は、kvmtoolソースを参照することをお勧めします。それらはQEMUよりもはるかに読みやすく、プロジェクト全体がはるかに小さく単純です。



あなたが記事を楽しんだことを望みます。GithubTwitterで



ニュースをフォローするrss経由でサブスクライブできます



TimewebエキスパートによるPythonの例を含むGitHubGistへのリンク:(1)および(2)



All Articles