本文内容参考:
《QEMU/KVM》源码解析与应用 —— 李强,机械工业出版社
特此致谢!
在本系列之前文章中,讲解了QEMU参数解析、QEMU Module(模块)、并且花了大力气(十几篇文章)重点讲解了QOM。这些基础讲过之后,要正式开始对于各个硬件模块的详细介绍了。但在此之前,先以一个QEMU/KVM API的使用实例作为引子和序幕。
一提到QEMU,总是会提到KVM,而反之亦是如此。正所谓是“焦不离孟,孟不离焦”。但实际上从本质上来说,QEMU和KVM可以不必互相依赖。之所以将这两者“捆绑”在一起,是因为KVM创立之初重用了QEMU的设备模拟部分。本回以一个简单的示例展示QEMU和KVM的关系,也为后边深入各模块的研究提供一个最为简单和基础的框架流程上的参考。
示例包括两部分:第一部分是一个极简版内核,其任务仅仅是向I/O端口写入数据;第二部分可以看做是一个精简版的QEMU,它的任务也很简单,就是将此极简内核写入端口的数据打印出来。
- 极简内核
极简内核代码如下(x86汇编,test.S):
start:
# 输出'H'
mov $0x48, %a1
outb %a1, $0xf1
# 输出 'e'
mov $0x65, %a1
outb %a1, $0xf1
# 输出'l'
mov $0x6c, %a1
outb %a1, $0xf1
# 输出'l'
mov $0x6c, %a1
outb %a1, $0xf1
# 输出'o'
mov $0x6f, %a1
outb %a1, $0xf1
# 输出换行符'\a'
mov $0x0a, %a1
outb %a1, $0xf1
# 停机
hlt
代码很简单,代码注释已经写得很清楚了,功能是向端口0xf1写入Hello字符串,然后调用hlt指令。
使用如下命令编译上述汇编代码:
$ as -32 test.S -o test.o
$ objcopy -O binary test.o test.bin
- 精简版QEMU
精简版的QEMU代码如下(qemu.c):
int main(void)
{
struct kvm_sregs sregs;
int ret = 0;
int kvmfd = open("/dev/kvm", O_RDWR);
ioctl(kvmfd, KVM_GET_API_VERSION, NULL);
int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
unsigned char *ram = mmap(NULL, 0x1000, PORT_READ | PORT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
int kfd = open("test.bin", O_RDONLY);
read(kfd, ram, 4096);
struct kvm_userspace_memory_region mem = {
.slot = 0,
.guest_phys_addr = 0,
.memory_size = 0x1000,
.userspace_addr = (unsigned long)ram,
};
ret = ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &mem);
int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
int mmap_size = ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL);
struct kvm_run *run = mmap(NULL, mmap_size, PORT_READ | PORT_WRITE, MAP_SHARED, vcpufd, 0);
ret = ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cds.base = 0;
sregs.cs.selector = 0;
ret = ioctl(vcpufd, KVM_SET_SREGS, &sregs);
struct kvm_regs regs = {
.rip = 0,
};
ret = ioctl(vcpufd, KVM_SET_REGS, ®s);
while (1)
{
ret = ioctl(vcpufd, KVM_RUN, NULL);
if (ret == -1)
{
printf("exit unknown\n");
return -1;
}
switch(run->exit_reason)
{
case KVM_EXIT_HLT:
puts("KVM_EXIT_HLT");
return 0;
case KVM_EXIT_IO:
putchar( ((char *)run) + run->io.data_offset));
break;
case KVM_EXIT_FAIL_ENTRY:
puts("entry error");
return -1;
default:
puts("other error");
printf("exit_reason: %d\n, run->exit_reason");
return -1;
}
}
return 0;
}
编译qemu.c文件:
$ gcc qemu.c -o light-qemu
执行编译后生成的目标可执行程序,命令及结果如下:
$ ./light-qemu
Hello
KVM_EXIT_HLT
可以看到,这个名为light-gemu的精简版QEMU输出了精简版内核向端口写入的数据。下边对qemu.c代码进行详细说明。
KVM通过一组ioctl系统调用向用户空间导出接口,这些接口能够用于虚拟机的创建、虚拟机内存的设置、虚拟机VCPU的创建与运行等。根据接口所使用的文件描述符(file descriptor, fd),KVM的这组系统调用可以分为三类:
(1)系统全局的ioctl。这类ioctl的作用对象是KVM模块本身,比如一些全局的配置项,创建虚拟机的ioctl也在此例。
(2)虚拟机相关的ioctl。这类ioctl的作用对象是一台虚拟机,比如设置虚拟机的内存布局、创建虚拟机VCPU也在此例。
(3)虚拟机VCPU相关的ioctl。这类ioctl的作用对象是一个虚拟机的VCPU,比如说开始虚拟机VCPU的运行。
以下是qemu.c中主函数代码的详细步骤:
(1)首先通过打开"/dev/kvm"获取系统中KVM子系统的文件描述符kvmfd。代码片段如下:
int kvmfd = open("/dev/kvm", O_RDWR);
(2)为了保持应用层和内核的统一,可以通过ioctl获取KVM的版本号,从而使应用层知道相关接口在内核是否有支持。代码片段如下:
ioctl(kvmfd, KVM_GET_API_VERSION, NULL);
(3)接着在kvmfd上调用ioctl创建一个虚拟机。代码片段如下:
int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
该ioctl返回一个代表虚拟机的文件描述符vmfd。这代表了一个完整的虚拟机系统,可以通过vmfd控制虚拟机的内存、VCPU等。
(4)内存是一个计算机必不可少的组件,因此在创建了虚拟机之后,需要给虚拟机分配物理内存。代码片段如下:
unsigned char *ram = mmap(NULL, 0x1000, PORT_READ | PORT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
int kfd = open("test.bin", O_RDONLY);
read(kfd, ram, 4096);
虚拟机的物理内存对应QEMU的进程地址空间,这里使用mmap系统调用分配了一页(4096,4K)虚拟机内存,并且将精简版的内核代码读入了这段空间中。
(5)之后用分配的虚拟内存地址初始化kvm_userspace_memory_region对象,然后调用ioctl(KVM_SET_USER_MEMORY_REGION),这就为虚拟机指定了一个内存条。代码片段如下:代码片段如下:
struct kvm_userspace_memory_region mem = {
.slot = 0,
.guest_phys_addr = 0,
.memory_size = 0x1000,
.userspace_addr = (unsigned long)ram,
};
ret = ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &mem);
其中:
slot —— 用来表示不同的空间;
guest_phys_addr —— 表示这段空间在虚拟机物理内存空间的位置;
memory_size —— 表示这段物理内存的大小;
userspace_addr —— 表示这段物理空间对应在宿主机上的虚拟机地址。
(6)设置好虚拟机的内存后,接着在vmfd上调用ioctl来创建虚拟机VCPU。代码片段如下:
int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
(7)每一个VCPU都有一个struct kvm_run结构,用来在用户态(QEMU)和内核态(KVM)共享数据。用户态程序需要将这段空间映射到用户空间,为此首先调用ioctl(KVM_GET_VCPU_MMAP_SIZE)得到这个结构的大小。代码片段如下:
int mmap_size = ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL);
注意这里是对kvmfd调用ioctl,因为这是对KVM所有VCPU都是一样的。
(8)接着调用mmap函数,将struct kvm_run的对象映射到用户态空间。代码片段如下:
struct kvm_run *run = mmap(NULL, mmap_size, PORT_READ | PORT_WRITE, MAP_SHARED, vcpufd, 0);
(9)为了让虚拟机VCPU运行起来,需要设置VCPU相关的寄存器。其中,段寄存器和控制寄存器等特殊寄存器存放在kvm_sregs结构中,通过ioctl(KVM_GET_SREGS)和ioctl(KVM_SET_SREGS)读取和修改;通用寄存器存放在kvm_regs结构中,通过ioctl(KVM_GET_REGS)和ioctl(KVM_SET_REGS)读取和修改。代码片段如下:
ret = ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cds.base = 0;
sregs.cs.selector = 0;
ret = ioctl(vcpufd, KVM_SET_SREGS, &sregs);
struct kvm_regs regs = {
.rip = 0,
};
ret = ioctl(vcpufd, KVM_SET_REGS, ®s);
其中最重要的是设置CS和IP寄存器,本示例中都将其设置为0。由于代码加在了虚拟机物理地址0处,所以虚拟机VCPU运行的时候直接从地址0处开始运行。
(10)至此,一个简单的虚拟机和虚拟机VCPU、内存都已经准备完毕,寄存器也设置好了,此时可以让虚拟机运行起来了。通常在一个循环中对vcpufd调用ioctl(KVM_RUN),代码片段如下:
while (1)
{
ret = ioctl(vcpufd, KVM_RUN, NULL);
if (ret == -1)
{
printf("exit unknown\n");
return -1;
}
……
}
(11)内核在处理这个ioctl时,会把VCPU调度到物理CPU上运行。VCPU在运行过程中遇到一些敏感指令时会退出。如果内核态的KVM不能处理,就会交给应用层软件处理。此时ioctl系统调用返回,并且将一些信息保存到kvm_run结构中,这样用户态程序就能够知道导致虚拟机退出的原因了,然后根据具体原因进行相应的处理。代码片段如下:
switch(run->exit_reason)
{
case KVM_EXIT_HLT:
puts("KVM_EXIT_HLT");
return 0;
case KVM_EXIT_IO:
putchar( ((char *)run) + run->io.data_offset));
break;
case KVM_EXIT_FAIL_ENTRY:
puts("entry error");
return -1;
default:
puts("other error");
printf("exit_reason: %d\n, run->exit_reason");
return -1;
}
在本示例中,虚拟机内核向端口写数据会产生原因为KVM_EXIT_IO的退出,表示虚拟机内部读写了端口。在输出了数据之后,让虚拟机继续执行,执行到最后一个hlt指令时,会产生KVM_EXIT_HLT类型的退出,此时虚拟机运行结束。
当然,与极简的内核和精简的QEMU相比,实际的QEMU和实际的操作系统内核的复杂度要远远超过这个水平,但是其基本原理都是类似的。