两行代码中的KVM主机

你好!



今天,我们发布了一篇有关如何编写KVM主机的文章。我们在Serge Zaitsev的博客上看到了它,并为不使用C ++的用户翻译和补充了我们自己的Python示例。


KVM(基于内核的虚拟机)是Linux内核随附的一种虚拟化技术。换句话说,KVM允许您在单个Linux虚拟主机上运行多个虚拟机(VM)。在这种情况下,虚拟机称为来宾。如果您曾经在Linux上使用过QEMU或VirtualBox,那么您知道KVM的功能。



但是它是如何在后台运行的呢?



国际奥委会



KVM通过特殊的设备文件/ dev / kvm公开API启动设备时,您访问KVM子系统,然后进行ioctl系统调用以分配资源并启动虚拟机。一些ioctl调用返回文件描述符,也可以使用ioctl对其进行操作。等等广告无限吗?并不是的。KVM中只有几个API级别:



  • / dev / kvm级别,用于管理整个KVM子系统并创建新的虚拟机,
  • 用于管理单个虚拟机的VM层,
  • VCPU级别,用于控制一个虚拟处理器(一个虚拟机可以在多个虚拟处理器上运行)的操作-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);


在此步骤中,我们创建了一个新的虚拟机,为其分配了内存,并分配了一个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


假定guest.bin包含当前CPU体系结构的有效字节码,因为KVM不会像旧虚拟机一样依次解释CPU指令。 KVM将计算交给真正的CPU,并且仅拦截I / O。这就是为什么除非您要执行I / O繁重的操作,否则现代虚拟机将以接近裸机的高性能运行。



这是我们将首先尝试运行的微型来宾虚拟机内核: 如果您不熟悉汇编器,则上面的示例是一个微型16位可执行文件,该文件在循环中递增寄存器并将值输出到端口0x10。



#

# 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








我们有意将其编译为一个古老的16位应用程序,因为启动的KVM虚拟处理器可以像真正的x86处理器一样在多种模式下运行。最简单的模式是“实数”模式,自上世纪以来一直用于运行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,创建虚拟机,等等。但是,我们需要在虚拟机级别上再进行一些ioctl调用,以添加定期间隔计时器,初始化TSS(对于Intel芯片是必需的)并添加中断控制器:



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


引导参数是什么,为什么不能仅从零地址引导内核?现在是时候进一步了解bzImage格式了。



内核映像遵循特殊的“启动协议”,其中有一个固定的标头,上面带有启动选项,后跟实际的内核字节码。引导标头格式在此处描述。



加载内核映像



为了将内核映像正确加载到虚拟机中,我们需要首先读取整个bzImage文件。我们查看偏移量0x1f1,然后从那里获取设置的扇区数。我们将跳过它们以查看内核代码的起始位置。此外,我们会将启动参数从bzImage的开头复制到虚拟机启动参数(0x10000)的内存区域。



但是,即使那样还不够。我们将需要更正虚拟机的引导参数,以将其强制为VGA模式并初始化命令行指针。



我们的内核需要将日志写入ttyS0,以便我们可以拦截I / O,然后我们的虚拟机将其打印到stdout。为此,我们需要在内核命令行中添加“ console = ttyS0”



但是即使那样,我们也不会得到任何结果。我必须为我们的内核设置一个虚假的CPU ID(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示例链接到GitHub Gist:(1)(2)



All Articles