Linux KVM 作为学习工具
底层系统编程是一项艰巨的任务,如果在裸机上直接工作,那么掌握中断处理和内存分段/分页领域的专业知识可能是一个耗时且令人沮丧的过程。 一个替代选择是使用虚拟机或 Linux KVM 模块从头开始快速创建和运行您自己的迷你内核。
KVM(基于内核的虚拟机)模块将 Linux 主机转变为 VMM(虚拟机监视器),并且自 2.6.20 版本以来已包含在主线 Linux 内核中。 VMM 允许多个操作系统在计算机上同时运行。 这些客户操作系统在真实的(物理)处理器上执行,但 VMM(或 hypervisor)保留对某些真实系统资源的选择性控制,例如物理内存和 I/O 功能。
当客户机尝试对受控资源执行操作时,VMM 会从客户机手中夺取控制权,并以防止其干扰其他客户操作系统的方式执行该操作。 就客户机而言,它认为自己正在没有 VMM 的平台上运行——也就是说,它具有在真实机器上运行的错觉。 例如,客户机可以执行内存分页和分段以及中断操作,而不会干扰其他客户操作系统或 VMM 本身内的相同机制。
正常的 Linux 进程有两种执行模式:内核模式和用户模式。 KVM 添加了第三种模式:客户机模式(图 1)。 当客户机进程执行非 I/O 客户机代码时,它将在客户机模式或可能更好命名的客户机用户模式下运行。 这是图 1 中“客户机模式”框内显示的“用户”模式。 在内核模式(客户机内核)下,进程处理由于 I/O 或其他特殊指令而从客户机用户模式退出的情况。 这是图 1 中“客户机模式”框内显示的“内核”模式。 在用户模式下,进程代表客户机执行 I/O。 这是图 1 中正常的“用户模式”框内显示的“I/O 操作”框。 有关 KVM 自身如何运行的更多信息,请参阅 KVM 网站和许多关于它的在线文章。
此处提供的示例需要安装了 KVM 模块的最新 Linux 内核和 LibKVM 库,以便从用户空间与模块进行交互。 您可以从您喜欢的发行版安装相应的软件包,或者编译 KVM 源代码包(来自 SourceForge)以创建模块和 LibKVM 库。 请注意,KVM 模块仅适用于具有硬件虚拟化支持的平台; 大多数较新的 Intel 和 AMD 64 位处理器都具有此支持。
本文的其余部分将展示如何构建一系列客户机模式程序(内核)以及一个用户模式程序来模拟它们的 I/O(虚拟机启动器)。
当代计算机机器的基本组件是内存、一个或多个 CPU 以及一个或多个 I/O 设备。 因此,虚拟机应该具有这三种组件。 Linux KVM 能够处理虚拟机的内存和 CPU(借助硬件帮助)。 第三个要素,I/O,目前留给程序员,必须以自定义方式处理。
例如,KVM 发行版附带 qemu-kvm,这是一个修改后的 QEMU 程序,它使用 LibKVM 构建虚拟机并模拟各种 I/O 设备,例如 VGA 卡、PS/2 鼠标和键盘以及 IDE 磁盘。 我们不会在这里使用 qemu-kvm,而是从头开始编写虚拟机启动器,以保持我们的第一个示例简单,并了解像 qemu-kvm 这样的程序是如何工作的。
KVM 模块公开了一个字符设备 (/dev/kvm) 用于与用户空间交互。 为了简单起见,我们不会直接访问此设备,而是通过 LibKVM(API 在 libkvm.h 中定义)。 使用清单 1 中显示的方法来构建虚拟机启动器(代码基于 Avi Kivity 的测试驱动程序,包含在 KVM 源代码中)。
清单 1. 用于我们的启动器的 LibKVM 方法
kvm_context_t kvm_init(struct kvm_callbacks *callbacks, void *opaque); int kvm_create(kvm_context_t kvm, unsigned long phys_mem_bytes, void **phys_mem); int kvm_create_vcpu(kvm_context_t kvm, int slot); void *kvm_create_phys_mem(kvm_context_t kvm, unsigned long phys_start, unsigned long len, int log, int writable); int kvm_run(kvm_context_t kvm, int vcpu);
首先,使用 kvm_init() 创建 KVM 上下文。 第一个参数是 kvm_callbacks 结构,用于指定当 I/O 或某些系统敏感指令在虚拟机内部执行时要调用的处理程序——例如,当客户机执行类似这样的操作时
mov $0x0a,%al outb %al,$0xf1 // output value 0x0a to I/O port 0xf1
客户机将从客户机模式退出,并且在用户模式下调用配置的 outb() 回调函数(其第二个和第三个参数的值分别为 0xf1 和 0x0a)。
最初,使用虚拟回调。 在名为 my_callbacks 的变量中创建并引用它们,如清单 2 所示。 大多数字段名称都是不言自明的,但有关每个字段的简要说明,请参阅 libkvm.h 中结构定义中的注释。
清单 2. I/O 回调(在 launcher.c 中使用)
static int my_inb(void *opaque, int16_t addr, uint8_t *data) { puts ("inb"); return 0; } static int my_inw(void *opaque, uint16_t addr, uint16_t *data) { puts ("inw"); return 0; } static int my_inl(void *opaque, uint16_t addr, uint32_t *data) { puts ("inl"); return 0; } static int my_outb(void *opaque, uint16_t addr, uint8_t data) { puts ("outb"); return 0; } static int my_outw(void *opaque, uint16_t addr, uint16_t data) { puts ("outw"); return 0; } static int my_outl (void *opaque, uint16_t addr, uint32_t data) { puts ("outl"); return 0; } static int my_pre_kvm_run(void *opaque, int vcpu) { return 0; } ... and similar for my_mmio_read, my_mmio_write, my_debug, my_halt, my_shutdown, my_io_window, my_try_push_interrupts, my_try_push_nmi, my_post_kvm_run, and my_tpr_access static struct kvm_callbacks my_callbacks = { .inb = my_inb, .inw = my_inw, .inl = my_inl, .outb = my_outb, .outw = my_outw, .outl = my_outl, .mmio_read = my_mmio_read, .mmio_write = my_mmio_write, .debug = my_debug, .halt = my_halt, .io_window = my_io_window, .try_push_interrupts = my_try_push_interrupts, .try_push_nmi = my_try_push_nmi, // added in kvm-77 .post_kvm_run = my_post_kvm_run, .pre_kvm_run = my_pre_kvm_run, .tpr_access = my_tpr_access };
要创建虚拟机本身,请使用 kvm_create(),其第二个参数是虚拟机所需的 RAM 量(以字节为单位),第三个参数是一个位置的地址,该位置将反过来包含为虚拟机保留的内存空间(图 1 中的“客户机内存”框)的起始地址。 请注意,kvm_create() 不 为虚拟机分配内存。
要创建第一个虚拟 CPU,请使用 kvm_create_vcpu(),并将 slot 参数的值设置为 0——版本小于 65 的版本在调用 kvm_create() 期间创建第一个虚拟 CPU。
有几种方法可以为虚拟机分配内存——例如,kvm_create_phys_mem()。 kvm_create_phys_mem() 的第二个参数是请求区域的起始物理地址,在客户机内存中(在虚拟机的伪“物理内存”中,而不是在主机的物理内存中)。 第三个参数是区域的长度,以字节为单位。 第四个参数指示是否应在请求的区域中激活脏页日志记录,第五个参数指示页面是否可以写入。 成功后,它返回分配的内存区域的位置,作为调用进程的虚拟地址空间中的地址。
在同一个 KVM 上下文中调用清单 1 的函数以创建您的第一个虚拟机,并使用 kvm_run() 执行它。 仅当 my_callbacks 中指向的 I/O 处理程序返回非零值或发生客户机操作系统和 KVM 都无法处理的异常时,此函数才会返回。
清单 3 包含启动器的代码,包括 load_file() 函数,用于将客户机内核映像从文件复制到虚拟机的内存空间。 为什么这个映像被复制到客户机内存空间偏移量 0xf0000 处? 因为实模式的工作方式,如下一节所述。
清单 3. 我们的第一个虚拟机启动器 (launcher.c)
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <libkvm.h> /* callback definitions as shown in Listing 2 go here */ void load_file(void *mem, const char *filename) { int fd; int nr; fd = open(filename, O_RDONLY); if (fd == -1) { fprintf(stderr, "Cannot open %s", filename); perror("open"); exit(1); } while ((nr = read(fd, mem, 4096)) != -1 && nr != 0) mem += nr; if (nr == -1) { perror("read"); exit(1); } close(fd); } #define MEMORY_SIZE (0x1000000) /* 16 Mb */ #define FIRST_VCPU (0) int main(int argc, char *argv[]) { kvm_context_t kvm; void *memory_area; /* Second argument is an opaque, we don't use it yet */ kvm = kvm_init(&my_callbacks, NULL); if (!kvm) { fprintf(stderr, "KVM init failed"); exit(1); } if (kvm_create(kvm, MEMORY_SIZE, &memory_area) != 0) { fprintf(stderr, "VM creation failed"); exit(1); } #ifndef KVM_VERSION_LESS_THAN_65 if (kvm_create_vcpu(kvm, FIRST_VCPU)) { fprintf(stderr, "VCPU creation failed"); exit(1); } #endif memory_area = kvm_create_phys_mem(kvm, 0, MEMORY_SIZE, 0, 1); load_file(memory_area + 0xf0000, argv[1]); kvm_run(kvm, FIRST_VCPU); return 0; }
与 x86 架构兼容的处理器可以支持不同的操作模式。 其中两种是 16 位实地址模式。 至少在今天,最常用的是 32 位保护模式。 处理器在通电或复位后以实地址模式启动(因此平台初始化代码必须为此模式编写),并跳转到地址 0xFFFF0 处的指令。 通常,BIOS 的初始化例程位于此处。 我们的简单内核的第一条指令将位于此处,以便在平台启动后立即接管控制权。 虽然使用 KVM 可以直接在保护模式下启动虚拟机,但我们的启动器不会这样做,以便学习如何在通电后操作 PC。
16 位实地址模式是从 Intel 8086 处理器继承的传统模式,它能够寻址高达 1Mb 的内存。 1Mb 是 220 字节,因此地址需要 20 位。 鉴于 8086 的寄存器只有 16 位宽,因此地址是通过配对两个值来构建的。 第一个值用作选择器(存储在段寄存器中),第二个值用作偏移量。 有了这些,物理地址通过以下公式计算:16 * 选择器 + 偏移量。
例如,选择器:偏移量 0xDEAD:0xBEEF 表示物理地址 0xEA9BF。 要将选择器 (0xDEAD) 乘以 16,只需在数字右侧添加一个 0 (0xDEAD0)。 然后加法变为以下
0xDEAD0 + 0x0BEEF ------- 0xEA9BF
请注意,给定选择器的固定值,只能引用 64Kb 的内存(偏移量的允许范围)。 大于 64Kb 的程序必须使用多段代码。 我们将使我们的内核保持简单,并使其适合单个 64Kb 段。 我们的启动器会将内核映像放在最后一个段(0xFFFF0 入口点所在的位置)。 最后一个段从 0xF0000 开始,如下计算所示
Start of the last segment = (Maximum 8086 Memory) - (Segment Size) = 1MB - 64KB = 0x100000 - 0x10000 = 0xF0000
这的内存映射如图 2 所示。
我们现在可以用汇编器编写内核,其第一条指令位于偏移量 0xFFFF0 处。 请注意,与许多处理器不同,x86 处理器没有复位“向量”。 它不 使用 0xFFFF0 处的值作为复位代码的位置; 而是开始执行 位于 0xFFFF0 的代码。 因此,放置在 0xFFFF0 的“正常”代码是跳转到实际的复位代码。
我们的第一个内核如清单 4 所示。 它只是将 AX 寄存器设置为 0,然后永远循环。
清单 4. kernel1.S
.code16 // Generate 16-bit code start: // Kernel's main routine xor %ax, %ax 1: jmp 1b // Loop forever . = 0xfff0 // Entry point ljmp $0xf000, $start
在倒数第二行中,点 (.) 指的是当前位置计数器。 因此,当我们写
. = 0xfff0
我们指示汇编器将当前位置设置为地址 0xFFF0。 在实模式下,地址 0xFFF0 相对于当前段。 段偏移量在哪里指定? 它来自清单 3 中对 load_file() 的调用。 它将内核加载到偏移量 0xF0000 处。 这与汇编器偏移量相结合,会将 ljmp 放置在地址 0xFFFF0 处,正如要求的那样。
内核二进制文件应该是原始的 64Kb 16 位实地址模式映像,而不是正常的 ELF 二进制文件(Linux 使用的标准二进制格式)。 为此,我们需要一个特殊的链接器脚本。 我们为此使用 GNU ld,当然,它接受脚本文件来提供对链接过程的显式控制。
链接器是一个将输入二进制文件组合成单个输出文件的程序。 预计每个文件都具有,除其他外,节列表,有时带有相关的数据块。 链接器的功能是将输入节映射到输出节。 默认情况下,GNU ld 使用特定于主机平台的链接器脚本,您可以使用 -verbose 标志查看该脚本
$ gcc -Wl,-verbose hello-world.c
要构建我们的内核,我们不使用默认脚本,而是使用简单的脚本 kernel16.lds,如清单 5 所示。
清单 5. 链接器脚本 kernel16.lds
OUTPUT_FORMAT(binary) SECTIONS { . = 0; .text : { *(.init) *(.text) } . = ALIGN(4K); .data : { *(.data) } . = ALIGN(16); .bss : { *(.bss) } . = ALIGN(4K); .edata = .; }
SECTIONS 命令控制如何进行映射以及如何在内存中放置输出节。 指令遵循语法
.output-section : [optional-args] { input-section, input-section, ... }
kernel16.lds 脚本将当前位置设置为偏移量 0x0。 然后,输出 .text 节将从那里开始,并将包含任何 .init 和 .text 输入节的内容。
接下来,我们将当前位置对齐到 4KB 边界,并创建 .data 和 .bss 输出节。 使用 kernel16.lds 生成内核映像,如清单 6 所示。
清单 6. 构建 16 位内核映像
$ gcc -nostdlib -Wl,-T,kernel16.lds kernel1.S -o kernel1 $ ls -oh kernel1 -rwxr-xr-x 1 djprotti 64K 2008-10-17 19:09 kernel1
-nostdlib 标志避免链接标准系统启动文件和库(这些文件和库在我们的虚拟机内部不可用)。 在此之后,我们有了 64Kb 16 位实地址内核映像。
清单 7. Makefile
# If KVM was compiled from sources and you have errors about # missing asm/kvm*.h files, copy them from # kvm-XX/kernel/include/asm/* to {prefix}/include/asm/ CC=gcc KERNEL16_CFLAGS=-nostdlib -ffreestanding -Wl,-T,kernel16.lds all: launcher kernel1 launcher: launcher.o $(CC) launcher.o /usr/lib/libkvm.a -o launcher launcher.o: kernel1: kernel1.S $(CC) $(KERNEL16_CFLAGS) kernel1.S -o kernel1 clean: rm *.o launcher kernel1
使用以下命令启动以 kernel1 作为客户机的虚拟机
$ ./launcher kernel1
如果一切顺利,您将看不到任何输出,并且客户机内核应该会消耗其所有可用的 CPU。 如果您在另一个控制台中运行 top 命令,并且看到类似于清单 8 的输出(启动器进程的 CPU 使用率为 100%),那么您的内核正在您的第一个 KVM 虚拟机中运行!
清单 8. 我们的启动器运行时 top 的输出
PID USER S %CPU %MEM TIME+ COMMAND 8002 djprotti R 100 0.8 1:53.19 launcher 7428 djprotti S 0 0.8 0:04.45 gnome-terminal 8005 djprotti R 0 0.0 0:00.02 top 1 root S 0 0.0 0:03.92 init 2 root S 0 0.0 0:00.00 kthreadd 3 root S 0 0.0 0:00.12 migration/0 4 root S 0 0.0 0:02.76 ksoftirqd/0 5 root S 0 0.0 0:00.01 watchdog/0
现在,让我们构建一个与世界通信的内核。 首先,选择一个 I/O 端口并使用它来实现“串行端口”。 将选择的端口命名为 IO_PORT_PSEUDO_SERIAL(如清单 10 所示),然后修改启动器中的 outb 回调,以将发送到此端口的字节解释为打印到串行控制台的字符,并将它们重定向到启动器的标准输出,如清单 9 所示。
清单 9. launcher.c 中的伪串行端口实现
#include "runtime.h" static int my_outb (void *opaque, uint16_t addr, uint8_t data) { if (addr == IO_PORT_PSEUDO_SERIAL) if (isprint(data) || data == '\n') putchar(data); else putchar('.'); else printf("outb: %x, %d\n", addr, data); fflush (NULL); return 0; }
然后,构建第二个内核 (kernel2),其唯一任务是将“Hello\n”打印到其伪串行端口,然后停止,如清单 10 所示。
清单 10. kernel2.S
#include "runtime.h" .code16 start: mov $0x48,%al // H outb %al,$IO_PORT_PSEUDO_SERIAL mov $0x65,%al // e outb %al,$IO_PORT_PSEUDO_SERIAL mov $0x6c,%al // l outb %al,$IO_PORT_PSEUDO_SERIAL mov $0x6c,%al // l outb %al,$IO_PORT_PSEUDO_SERIAL mov $0x6f,%al // o outb %al,$IO_PORT_PSEUDO_SERIAL mov $0x0a,%al // new_line outb %al,$IO_PORT_PSEUDO_SERIAL hlt // halt the processor . = 0xfff0 ljmp $0xf000, $start
清单 11. runtime.h
#ifndef __RUNTIME_H__ #define __RUNTIME_H__ // port to use for general purpose output #define IO_PORT_PSEUDO_SERIAL 0xf1 #endif /* __RUNTIME_H_ */
构建启动器和 kernel2,并像往常一样运行它们。 输出应该类似于这样
$ ./launcher kernel2 Hello
现在 top 命令应该显示启动器进程的 CPU 使用率为 0%,因为其虚拟 CPU 已停止。
作为最后一个示例,清单 12 中显示了一个改进的内核,它使用 OUTSB 字符串输出指令和 REP 前缀来重复它 CX 指定的次数。 有趣的是,此代码生成 仅一个 I/O 退出以输出整个字符串。 将此与之前的 kernel2 进行比较,后者为每次 outb 执行生成一个 I/O 退出,并具有上下文切换带来的相关开销。 您可以使用来自 KVM 源代码的 kvm_stat Python 脚本来查看虚拟机的这种和其他行为。
清单 12. kernel3.S(使用 OUTSB 输出)
#include "runtime.h" .code16 start: mov $(IO_PORT_PSEUDO_SERIAL), %dx cs lea greeting, %si mov $14, %cx cs rep/outsb // kvm_stat reports only // *one* io_exit using this hlt .align 16 greeting: .asciz "Hello, World!\n" . = 0xfff0 ljmp $0xf000, $start
LEA 和 OUTSB 指令之前的 CS 前缀是必需的,以便从代码段获取数据(问候字符串)。
在这一点上,您已经具备了试验各种实模式代码的基础。 您可以扩展示例以设置 IDT 并处理中断或添加更多 I/O 设备。 一个好的起点是中断,以了解中断上下文的约束,另一个起点是研究 LibKVM 方法的其余部分。
但是,实模式不足以学习当前内核在 x86 平台上所做的所有事情。 因此,在后续文章中,我们将稍微扩展我们的启动器,以便处理在 32 位保护模式下运行的内核。 这种更改将使我们能够用 C 语言编写内核,从而可以快速开发更大的内核。 它还将为试验分段、分页、特权级别(两个或多个环)等打开大门。
请记住,底层系统编程是一项具有挑战性的任务,但借助 Linux KVM,它可以变得容易。 因此,继续编码,享受乐趣,您将在过程中学到很多关于计算机系统如何工作的知识!
Duilio Javier Protti (duilio.j.protti@intel.com) 是 Intel Corp. 在阿根廷科尔多瓦的一名软件工程师。 他目前在一个专门从事虚拟化技术的团队工作。 在加入 Intel 之前,他编写了 LibCMT(用于可组合内存事务的库),是 Infinity XMMS 插件的维护者,并为各种开源项目做出了贡献,例如 Nmap、Libvisual 等。