Linux KVM 作为学习工具

作者:Duilio Javier Protti

底层系统编程是一项艰巨的任务,如果在裸机上直接工作,那么掌握中断处理和内存分段/分页领域的专业知识可能是一个耗时且令人沮丧的过程。 一个替代选择是使用虚拟机或 Linux KVM 模块从头开始快速创建和运行您自己的迷你内核。

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 网站和许多关于它的在线文章。

Linux KVM as a Learning Tool

图 1. 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;
}

16 位实地址模式

与 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 所示。

Linux KVM as a Learning Tool

图 2. 实地址模式内存映射

我们的 16 位实地址模式内核

我们现在可以用汇编器编写内核,其第一条指令位于偏移量 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 包含用于构建内核和启动器的命令。

清单 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,它可以变得容易。 因此,继续编码,享受乐趣,您将在过程中学到很多关于计算机系统如何工作的知识!

资源

Dr Paul Carter 撰写的 PC 汇编语言优秀书籍: drpaulcarter.com/pcasm

KVM 源代码: sourceforge.net/projects/kvm

Duilio Javier Protti (duilio.j.protti@intel.com) 是 Intel Corp. 在阿根廷科尔多瓦的一名软件工程师。 他目前在一个专门从事虚拟化技术的团队工作。 在加入 Intel 之前,他编写了 LibCMT(用于可组合内存事务的库),是 Infinity XMMS 插件的维护者,并为各种开源项目做出了贡献,例如 Nmap、Libvisual 等。

加载 Disqus 评论