自己动手利用KVM和IntelVT实现简单虚拟机

计划开发一套虚拟机最小系统。该原型系统会利用Linux原生提供的内核模块kvm.ko,使用该模块提供的API接口,自行开发一个用户态程序,实现一个最基本的虚拟机。

这个虚拟机能够运行一段x86指令代码,例如简单的算术运算,最终能够将运算结果通过IO端口写入客户机的串口设备中。这套最小系统能够模拟一个串口设备,将客户机串口设备中的数据显示在终端屏幕上。

本章是开发实践的基础章节,通过自己动手实践本章提供的源代码,能够为后续高阶内容打下坚实的基础。在动手开发之前,建议读者具备如下技术能力,在本章最后会列出建议的学习资料。

  1. 能够编写和调试简单的c语言代码
  2. 能够读懂x86汇编指令中的算术指令

通过本章的学习,能够掌握如下核心技术能力:

  1. 熟悉虚拟化开发环境,具备在用户态调试虚拟化程序的能力。
  2. 了解KVM内核API,并能够使用其中最基本的API搭建一个最小化的虚拟机系统。
  3. 了解串口设备的模拟方式,能够实现客户机与主机的信息传递。

开发调试环境准备

本节介绍开发调试环境的准备工作,包括硬件和软件的版本,操作系统的选型,本书的全部源代码均在这套开发环境下编译和运行。

硬件环境

x86架构的硬件虚拟化技术主要有两种,分属Intel和AMD两大阵营。Intel开发出了Intel Virtualization Technology (Intel VT-x),AMD开发的是AMD Secure Virtual Machine(AMD SVM)。鉴于Intel CPU广泛用于PC、笔记本和服务器市场,考虑到用于实验的硬件设备需要容易获取,读者掌握技术后能够广泛实践,本书主要以Intel的硬件虚拟化技术为基础进行讲解和分析。

基本要求:

  1. CPU: Intel CPU, 64位,支持Intel VT-x。
  2. BIOS: 需要在BIOS中支持并能够开启Intel VT。
  3. 内存: 至少4G。
  4. 磁盘: 32G 磁盘空间。

目前市面主流的PC、笔记本搭载的Intel CPU都能满足实验的要求。对于具体型号的CPU可以通过访问:https://ark.intel.com 查看CPU的具体参数,其中Advanced Technologies中列出了Intel® Virtualization Technology (VT-x)的支持情况。另外和Intel VT相关的几个技术,最好也能够支持,其中包括Intel® Virtualization Technology for Directed I/O (VT-d)和Intel® VT-x with Extended Page Tables (EPT),这两个技术能够在处理IO请求和页表映射时提供加速能力,可以作为高级功能进行探索和学习。

处理最基本的配置,这里列出作者在编写本书时用到的硬件配置。作者使用的是联想Thinkpad T440S笔记本电脑,具体配置如下,该款笔记本已经停产,理论上后续的搭载了Intel CPU的Thinkpad系列都是支持Intel VT-x的。

作者配置: 1. CPU:Intel®Core™ i5-4210U @1.70GHz。 2. BIOS: 需要在BIOS中支持并开启Inte VT。 3. 内存:8G内存。 4. 磁盘:250 SSD磁盘。

在Intel 官网上的CPU参数介绍中这颗i5的CPU是支持Intel VT-x技术的。

https://ark.intel.com/content/www/us/en/ark/products/81016/intel-core-i5-4210u-processor-3m-cache-up-to-2-70-ghz.html

在BIOS中开启Intel VT的方法如下,在开机启动时,进入BIOS设置界面,作者的笔记本是按F1键,在BIOS设置界面菜单中选择Security,在子菜单中选择Virtualization, 进入子菜单后,将Intel (R) Virtualization Technology下的选项设置为[Enabled]。

操作系统

本书的操作系统使用Linux系统,并且需要直接安装在上一小节介绍的硬件之上,不能使用虚拟机进行运行和调试。因为虚拟化开发涉及很多直接同CPU、网卡和内存等硬件直接交互的情况,虚拟机模拟出的客户机在一些硬件模拟上,无法达到完全同真实硬件一致,而处理这些细微差异会分散学习精力,所以在本书的学习过程中,作者建议直接在真实硬件上进行开发、运行和调试。对于用户态程序来说,在真实硬件上开发和在虚拟机中开发,差别不大,但是对于后续的内核模块开发,一个微小的错误就很容易引起系统panic,有可能导致文件系统的损害,造成开发代码的丢失。后续章节会深入介绍内核模块的真机开发和调试经验。

作者具体使用的操作系统是 Centos 7.6 X86_64 1810版,最小化安装,只有命令行环境,没有安装GUI界面环境,目的是最小限度安装所需的软件,避免对系统开发造成不必要的干扰。

使用的Linux内核是有两套,一套是官方自带的标准内核,该内核包含了CentOS提供的内核补丁,解决了很多安全性和稳定性问题。

Linux diykvm 3.10.0-957.el7.x86_64 #1 SMP Thu Nov 8 23:39:32 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux

另一套是基于Linux原生4.4.2编译出的内核,该内核没有添加任何补丁,在后续章节中会对自编译内核进行调试和分析。内核的编译和调试技术会在后续章节进行介绍。

Linux diykvm 4.4.2 #1 SMP Sat Jun 15 13:53:34 CST 2019 x86_64 x86_64 x86_64 GNU/Linux

读者可以从如下官网链接处下载CentOS操作系统,自行安装到开发机上。

http://isoredirect.centos.org/centos/7/isos/x86_64/CentOS-7-x86_64-DVD-1810.iso

选择CentOS作为开发环境的操作系统,主要考虑到CentOS相对于Ubuntu来说,广泛应用于生产环境,在稳定性方面表现更出色,但是不足之处是CentOS官方的软件源支持的软件相对较少,版本也比较低。为了克服这些不足,后续开发过程中会针对一些软件,直接使用源代码进行编译。

下图是CentOS的安装界面,选择最小化模式安装。

开发工具

虚拟化开发技术主要涉及系统底层技术,以C语言和汇编语言为主,使用的开发工具以gcc和nasm为主,其中gcc负责c语言的编译,nasm负责汇编语言的编译。其次会使用gdb进行程序的调试和分析,在后续章节中,会介绍使用kgdb进行内核调试的技术要点。所有开发工具均通过CentOS官方的yum源进行安装,如下是关键开发工具的版本和用途介绍。

  1. gcc-4.8.5,用于编译c语言源代码。
  2. nasm-2.10.07,用于编译汇编代码。
  3. git-1.8.3.1,用于获取开源软件的源代码。
  4. vim-7.4.1099,作为开发代码编辑器。
  5. GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-114.el7, 用于调试程序。

源代码src/init/init.sh提供一份开发环境初始化配置脚本,用于全部开发工具的初始化安装。

#!/bin/sh
# Project: DIY KVM 1.0
# Description: Development Init script
# Date: 2019.07.28
yum makecache
# install dev tools
yum install -y dosfstools vim net-tools git unzip zip strace
yum group install -y "Development Tools"
yum install -y epel-release
# install qemu and libvirt
yum install -y qemu-kvm qemu-img libvirt libvirt-python libvirt-client virt-install bridge-utils libguestfs-tools
yum --disablerepo=epel  -y install qemu-guest-agent
systemctl start libvirtd
systemctl enable libvirtd
# install kernel debuginfo
yum --enablerepo=base-debuginfo install -y kernel-debuginfo-$(uname -r)
yum install -y  kernel-devel

汇编语言

虚拟化开发涉及硬件底层技术,在一些情况下,使用汇编语言比C语言更适合,这里针对本书涉及的汇编知识,进行一个简介,内容更偏向于实用,对于系统性的汇编语言知识,请参考本章最后的学习资料。

汇编语言是一种用于直接操作CPU和内存的低级语言,作用是用一系列助记符来代替和表示CPU的特定指令, 每一条汇编代码对应一条或多条机器指令,省去了人工查询机器码的繁琐。

如今随着技术发展,程序员已经不需要使用汇编语言来开发程序,但是能够读懂甚至编写汇编语言仍然是程序员的高级技能。例如需要精确编写每一条机器指令,严格控制CPU运行逻辑时,只有汇编语言能够担当重任。另外对编译后的二进制代码进行分析和调试,这种情况下,由于程序缺少了必要的信息,无法被还原成高级语言,就需要借助反编译工具,将程序反编译成汇编代码,再进行后续的分析。

汇编语言有两大主流语法风格,分别是Intel风格和AT&T风格。前者多用于Visual C++的汇编工具中,后者用于gcc的汇编工具中。下面将分别使用c语言和这两种风格的汇编语法,编写一个两数相加的程序。在C语言中是两个变量相加,在汇编语言中,是两个寄存器rax和rbx相加,最终通过Linux系统调用显示在终端的标准输出上。这里除了通过介绍两数相加的程序让读者熟悉汇编语言,另外本节的虚拟机最小系统中,客户机的代码会以这个两数相加程序作为模板。

  1. C语言
/** Project: DIY KVM 1.0* Description: a+b* Date: 2019.07.28* Path: src/basic/01_add/c/add.c* */
#include <unistd.h>
#include <sys/syscall.h>int main(){int a=1;int b=1;a = a+b;char ans[2];ans[0]=a+'0';ans[1]='\n';syscall(SYS_write,1,ans,2);return 0;
}
  1. Intel风格
; Project: DIY KVM 1.0
; Description: a+b
; Author: Jingyu YANG
; Date: 2019.07.28
; Path: src/basic/01_add/intel/add.asmSECTION .TEXTGLOBAL _start
_start:mov rax,1mov rbx,1add rax,rbx     ; rax=rax+rbxmov cx,0x0a30   ; char '0\n'push cxadd [rsp],al    ; int -> charmov rcx,rsp
output:mov rax,1       ; syscall writemov rdi,1       ; stdoutmov rsi,rcx     ; buffermov rdx,2       ; 2bytessyscall
exit:mov rax,60       ; syscall exitmov rdi,0syscall
  1. AT&T风格
# Project: DIY KVM 1.0
# Description: a+b
# Author: Jingyu YANG
# Date: 2019.07.28
# Path: src/basic/01_add/att/add.s.text.global _start
_start:mov $1,%raxmov $1,%rbxadd %rbx,%rax     # rax=rax+rbxmov $0x0a30,%cx   # char '0\n'push %cxadd %al,(%rsp)    # int -> charmov %rsp,%rcx
output:mov $1,%rax       # syscall writemov $1,%rdi       # stdoutmov %rcx,%rsi     # buffermov $2,%rdx       # 2bytessyscall
exit:mov $60,%rax      # syscall exitmov $0,%rdisyscall

从上面Intel和AT&T语法对比中可以看出,这两种语法最大的区别在于赋值方向,对于Intel语法来说,是从右向左赋值,对于AT&T来说,是从左向右赋值。这一点在阅读汇编代码和调试程序时非常重要,需要明判断汇编语言的语法种类,明确赋值的方向。

在这三个例子文件夹中,都包含了Makefile文件,使用如下命令就可以进行编译并运行。

[root@diykvm intel]# make
nasm -f elf64 add.asm -o add.o
ld add.o -o add.elf
[root@diykvm intel]# make run
./add.elf
2

用户态调试

GDB是Linux软件开发最常用的调试器,功能非常丰富,例如能够查看内存,反汇编代码,对程序的特定位置下断点和单步调试。这里只针对虚拟化开发常用的gdb功能进行介绍,更佳完善的功能请参考本章最后提供的学习资料。对于远程调试和内核调试的技术,会在后续章节进行介绍。

  1. 入口点设置断点

无论是C语言还是汇编语言编写的ELF程序,gdb都可以进行调试,但是对于汇编语言编写的程序,无法在main函数上下断点,这里介绍如何在ELF程序的第一条指令的位置,即程序入口点设置断点。 在加载被调试的程序后,使用命令info files能够显示ELF文件的入口点(Entry point),然后使用break命令对该地址设置断点。

[root@diykvm intel]# gdb ./add.elf
This GDB was configured as "x86_64-redhat-linux-gnu".
(gdb) info files
Symbols from "/root/code/kvm/diykvm/src/basic/01_add/intel/add.elf".
Local exec file:`/root/code/kvm/diykvm/src/basic/01_add/intel/add.elf',file type elf64-x86-64.Entry point: 0x4000780x0000000000400078 - 0x00000000004000b1 is .TEXT
(gdb) break *0x400078
Breakpoint 1 at 0x400078
(gdb) r
Starting program: /root/code/kvm/diykvm/src/basic/01_add/intel/./add.elfBreakpoint 1, 0x0000000000400078 in _start ()
(gdb) x/i $pc
=> 0x400078:    mov    $0x1,%eax
(gdb)
  1. 反汇编函数

对于c语言编写的程序,可以使用disassemble命令反汇编函数。这里对main函数进行反汇编,gdb默认以AT&T语法显示出了a+b的汇编代码。

(gdb) disassemble main
Dump of assembler code for function main:0x000000000040051d <+0>:     push   %rbp0x000000000040051e <+1>:     mov    %rsp,%rbp0x0000000000400521 <+4>:     sub    $0x10,%rsp0x0000000000400525 <+8>:     movl   $0x1,-0x4(%rbp)0x000000000040052c <+15>:    movl   $0x1,-0x8(%rbp)0x0000000000400533 <+22>:    mov    -0x8(%rbp),%eax0x0000000000400536 <+25>:    add    %eax,-0x4(%rbp)0x0000000000400539 <+28>:    mov    -0x4(%rbp),%eax
  1. 设置汇编语法

在上一个例子中,gdb默认使用的是AT&T语法,可以通过命令set disassembly-flavor intel将默认的汇编语法改为Intel语法。下面这个例子展示了相同地址上的机器指令已经被反汇编成Intel汇编语法。

(gdb) disassemble main
Dump of assembler code for function main:0x000000000040051d <+0>:     push   rbp0x000000000040051e <+1>:     mov    rbp,rsp0x0000000000400521 <+4>:     sub    rsp,0x100x0000000000400525 <+8>:     mov    DWORD PTR [rbp-0x4],0x10x000000000040052c <+15>:    mov    DWORD PTR [rbp-0x8],0x10x0000000000400533 <+22>:    mov    eax,DWORD PTR [rbp-0x8]0x0000000000400536 <+25>:    add    DWORD PTR [rbp-0x4],eax0x0000000000400539 <+28>:    mov    eax,DWORD PTR [rbp-0x4]
  1. 单步调试的配置

gdb中可以使用nisi命令进行指令级别的单步调试,在使用时,建议配置display/i $pc在每次单步调试后,都能显示接下来即将执行的一条指令。下面例子展示了使用display命令后的效果。

(gdb) display/i $pc
(gdb) ni
6        * */
1: x/i $pc
=> 0x40052c <main+15>:  mov    DWORD PTR [rbp-0x8],0x1
(gdb) ni
7       #include <unistd.h>
1: x/i $pc
=> 0x400533 <main+22>:  mov    eax,DWORD PTR [rbp-0x8]

本小节介绍了开发调试环境准备工作,从硬件到操作系统再到开发工具,由底层到上层介绍了虚拟化开发所需要的资源信息,本书中所有的源代码均可以在这个环节中进行编译、执行和调试。虚拟化开发属于系统底层开发技术,本小节的后半部分,以一个两数相加的程序为例,介绍了汇编语言的开发过程,最后介绍了gdb进行调试的技术要点。由于本书专注于虚拟化开发,无法对汇编语言和GDB调试展开更细致的介绍,请感兴趣的读者参考本章最后的学习资料进行更全面和深入的学习。

KVM内核API

上一小结介绍了如何准备虚拟化开发调试环境,本小结将会介绍KVM API的基础知识。

KVM设备

KVM API由内核模块kvm.ko实现,以设备的形式暴露给用户态程序使用,设备名称为/dev/kvm

在开发环境中,kvm.ko模块默认是自动加载的,KVM设备在模块加载时自动创建。如果找不到/dev/kvm, 可以尝试手动加载kvm模块。x86平台上主流的硬件虚拟化技术有两种,Intel VT-x和 AMD svm, kvm.ko 模块只是对这两种硬件虚拟化的包装,根据CPU的不同,kvm.ko模块还依赖于 kvm-intel.ko 或者 kvm-amd.ko,分别对应这两种硬件虚拟化技术。

以下脚本展示了,对kvm设备和kvm内核模块的探测情况,在检测到没有启用kvm内核模块时,会进行主动加载。

TODO code

在Linux kernel 4.4.2代码中,KVM设备注册是在kvm_main.c文件的kvm_init()中,将kvm设备注册成为杂项设备, 设备编号为232,并且为该设备绑定了ioctl的处理函数kvm_dev_ioctl()。

// Path: kernel/virt/kvm/kvm_main.c
// 232 = /dev/kvm       Kernel-based virtual machine (hardware virtualization extensions)#define KVM_MINOR       232static struct file_operations kvm_chardev_ops = {.unlocked_ioctl = kvm_dev_ioctl,.compat_ioctl   = kvm_dev_ioctl,.llseek     = noop_llseek,
};static struct miscdevice kvm_dev = {KVM_MINOR,"kvm",&kvm_chardev_ops,
};int kvm_init(void *opaque, unsigned vcpu_size, unsigned vcpu_align,struct module *module){...r = misc_register(&kvm_dev);...
}

ioctl调用模式

因为虚拟机的创建和控制均涉及用户态(ring3)向内核态(ring0)通信,所以无法直接使用传统的函数调用方式。KVM开发者选择了在内核层创建/dev/kvm设备,然后让用户态程序以ioctl模式操作该设备进行通信这种方式。

iotcl函数原型如下:

int ioctl(int fd, unsigned long request, ...);

ioctl全称是input and output control, 是一个用于设备输入和输出的系统调用。第一个参数是文件描述符fd, 通过open()系统调用获得。第二个参数是请求码,内核处理函数根据请求码区分不同的请求操作,后续是一串可变数量的补充参数。

除了使用ioctl模式,用户态程序和内核通信,还可以选择传统的系统调用(syscall),但是系统调用ID是在内核编译时确定好的,不方便动态增加。也可以选择/proc文件系统或/sys文件系统,但是/proc文件系统主要用于显示内核状态,而/sys主要用于对内核配置进行简单配置。最后还可以选择netlink,以类似socket通信的方式同内核进行交互,但是这种方式和ioctl相比,调用过程更加复杂。

以下是kvm ioctl处理函数kvm_dev_ioctl()的部分实现,主要实现流程是根据ioctl请求码,分别进行相应的处理操作,包括返回KVM版本信息或者创建虚拟机等。

// Path: kernel/virt/kvm/kvm_main.cstatic long kvm_dev_ioctl(struct file *filp,unsigned int ioctl, unsigned long arg)
{long r = -EINVAL;switch (ioctl) {case KVM_GET_API_VERSION:if (arg)goto out;r = KVM_API_VERSION;break;case KVM_CREATE_VM:r = kvm_dev_ioctl_create_vm(arg);break;case KVM_CHECK_EXTENSION:r = kvm_vm_ioctl_check_extension_generic(NULL, arg);break;...
}

核心API

介绍了KVM设备对象和通信方式后,这里会介绍KVM API的三个调用层次,并列举说明核心的API:

  • 系统层

最外层是系统层,该层能够查询和设置KVM全局的配置信息,客户端通过打开/dev/kvm设备获得文件描述符kvm_fd, 对这个全局的文件描述符使用ioctl,配合相应的请求码进行系统层的查询和设置操作。例如如下两个操作都是系统层API。

  1. 查询KVM版本的请求操作

ioctl(kvm_fd, KVM_GET_API_VERSION,0)

该请求会固定返回整数12,表示即使后续KVM API会持续改进,也会保持API的兼容性。

  1. 创建虚拟机文件描述符的操作

vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0)

该请求会创建一个新的虚拟机,并返回相应的文件描述符vm_fd,用于后续虚拟机层API的操作。

  • 虚拟机层

中间层是虚拟机层,负责操作对于虚拟机的配置信息。本层API通过对系统层返回的虚拟机文件描述符vm_fd进行ioctl操作,配合相应的请求码,负责对单个虚拟机进行控制。其中关键的API有:

  1. 设置虚拟机内存
struct kvm_userspace_memory_region region={.slot = 0,.guest_phys_addr = 0,.memory_size = ram_size,.userspace_addr = (u64)ram_start};ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, &region);

该API向内核传递了一个region的结构体指针,描述了虚拟机内存的分配情况。

该结构体中,slot 表示内存条插槽,guest_phys_addr 表示在虚拟机中的物理地址起始位置,memory_size 表示该内存的大小,最后的userspace_addr 传入的是用户层申请的内存地址。 通过该API,用户层将申请的一片按页对齐的内存提交给内核层,用于设置虚拟机的内存。

  1. 新建虚拟CPU

KVM 支持虚拟多核处理器,通过对mv_fd调用ioctl,使用KVM_CREATE_VCPU作为命令字,并且传入vcpu序号,可以新建虚拟CPU。

vcpu->vcpu_fd = ioctl(kvm->vm_fd, KVM_CREATE_VCPU, i);

  • 虚拟CPU层

最内层是虚拟CPU层,负责对具体CPU的控制。该层API包括针对具体CPU的寄存器进行设置和启动虚拟CPU的操作。

  1. 读取和写入CPU寄存器

以下代码首先读取了vcpu的段寄存器,然后对代码段寄存器cs进行了归零设置。

ioctl(vcpu->vcpu_fd, KVM_GET_SREGS, &(vcpu->sregs));vcpu->sregs.cs.selector =0;vcpu->sregs.cs.base = 0;ioctl(vcpu->vcpu_fd, KVM_SET_SREGS, &(vcpu->sregs));
  1. 启动虚拟CPU

ioctl(vcpu->vcpu_fd, KVM_RUN, 0)

通过对vcpu_fd使用ioctl调研,传入KVM_RUN操作码,就可以启动当前CPU,这次调用是一次同步调用,一旦调用开始,虚拟机就会运行,直到遇到虚拟机退出的情况。能够引起虚拟机退出的指令包括一些特权指令,端口IO指令等。

本段从用户态视角介绍了KVM核心API的三个层次和一些典型的API,具体这些API在内核层的实现,后续会在内核层逐步展开介绍。

虚拟机创建和运行

在介绍了KVM核心API后,本段会介绍创建和运行虚拟机的主要流程。这里宏观的流程图如下:

TODO 流程图

  1. 初始化KVM设备
  2. 创建虚拟机
  3. 初始化虚拟机内存
  4. 初始化vcpu
  5. 初始化代码
  6. 启动vcpu
  7. 处理虚拟机退出事件
  8. 转到第6步,继续启动vcpu

串口通信原理

上一小节介绍了KVM核心API和虚拟机启动流程,本节将会研究虚拟机和宿主机的通信方式,在众多通信方式中,选择最简单有效的串口通信方式进行介绍。

在最小系统的实践中,当虚拟机完成计算任务,就会使用串口通信的方式,将计算结果输出到串口设备中,宿主机可以接管该IO请求,接收虚拟机发出的字符结果。

串口设备介绍

不同于网络通信,串口通信在x86物理平台上使用的机会比较少,本段会介绍一些基本的串口通信的概念。

串口是串行接口(serial interface)的简称, 在该接口上,数据按位(bit)进行发送和接收。尽管传输速度慢,但是串口通信的优势是硬件和上层的驱动程序实现简单,这一优势常用于硬件设备之间的互联互通。另外串口设备初始化时机非常早,有利于对外输出设备初始化信息,是操作系统真机调试中最稳定和最常用的接口。

串口有非常多的代名词。例如com1口,这里是windows操作系统中设备管理器的常用代号,一般是指第一个通信端口(communication port),在老式的台式机中,com1口就是第一个串口。

这个端口一般在机箱背后,是9针的一个接口,也叫RS232接口,这里RS-232是美国电子工业联盟(EIA)制定的串行数据通信的接口标准,对电气特性、逻辑电平和各种信号线功能都作了规定。

另外在还有资料使用UART(Universal Asynchronous Receiver/Transmitter)来代表串口,因为这个端口使用的通信方式是异步(Asynchronous)通信,通过START和STOP信号来标明传输的开始和结束,而不是像同步通信那样,使用时钟信号来传输数据。

串口通信经常用于嵌入式开发,在嵌入式领域,使用TTL(Transistor-transistor logic)来指代串口。在嵌入式领域,使用3根线路(接地、发送、接收)就可以进行串口通信,但是TTL与RS232最大的不同是,TTL高电平1是>=2.4V,低电平0是<=0.5V, 而RS232采用-15V~-3V代表逻辑"1",+3V~+15V代表逻辑"0",这就导致虽然两种接口都是串口,但是无法直接连通。

在Linux系统中,第一个串口设备是/dev/ttyS0, 对于没有串口的笔记本可以购买USB转串口的设备,这时第一个设备名称为/dev/ttyUSB0

在串口通信中,如下参数需要通信双方配置一致,才能够进行正确的通信。

  1. 波特率(baud rate):波形每秒震荡的次数,对于串口通信,一次波形震荡就代表传输一个bit。常用设置值为9600。
  2. 数据位:一次传输数据占用的bit位,一般是8bit。
  3. 奇偶校验:如果是偶校验,校验位会将每次数据位传输过程中的1补齐为偶数个,如果是奇校验,则补齐为奇数个。一般不设置奇偶校验位。
  4. 停止位:一般是1个bit,表示一次传输数据的结束。
  5. 流控制:是否有流控策略,一般没有。

以上默认值中,传输一个byte,需要1bit开始位+8bit的数据位+1bit结束位共10bit,对于boud rate为9600的串口通信,传输速度是960 B/s( byte per second)。 对于如今以G为单位的网络速度实在是太慢了,但是串口通信利用其实现简单,运行稳定的特点,仍然服务于嵌入式开发,网络设备配置和操作系统调试等领域。

本段只是对串口设备和相关概念进行了一些简单的介绍,方便读者理解虚拟机和宿主机的串口通信方式,对于串口通信领域更深层次的探索,请参考本节最后的参考资料。

通信选择策略

在熟悉了串口设备后,这里列举出一些可供选择的虚拟机和宿主机之间的其他通信方式,然后分析为什么选择串口通信作为最小系统的通信方式。

  1. 网络通信:网络通信需要VMM实现虚拟网卡,并且在虚拟机中安装了相应的网卡驱动,虽然速度比串口通信要快,但是需要实现的模块太多,还不适合在最小系统中使用,后续会专门介绍虚拟网卡的实现。
  2. 内存通信:在介绍KVM核心API时,VMM能够通过KVM_SET_USER_MEMORY_REGION请求吗注册虚拟机内存,该内存在VMM和虚拟机内部都可以访问,利用这片内存区域的特定区域,可以实现基于内存的高速通信。但是通信出了要考虑传输的数据,还要考虑开始和结束的机制,利用内存通信,需要建立完善的启停机制,这一点不适合在最小系统中使用。
  3. 寄存器通信:KVM API也提供了查询虚拟机寄存器的API,可以指定某个不常用的寄存器作为VMM和虚拟机通信的桥梁,但是如果遇到在虚拟机中该寄存器被使用,就会造成通信内容错误。
  4. 其他外设通信:虚拟机除了借助串口进行通信外,还可以借助显示器、键盘鼠标、USB设备等外设进行通信,但是和网络通信一样,都需要VMM实现相应的虚拟设备,不适合在最小系统中使用。

综合上面的分析,串口通信,因为其结构简单,容易实现的特点,非常适合在最小系统中作为虚拟机和宿主机通信的桥梁。

虚拟串口实现

选定通信方式之后,本段会介绍如何在宿主机客户层接管串口IO请求,实现一个虚拟串口。首先介绍在x86体系架构中,负责串口通信的指令。然后介绍在VMM中如何处理串口通信请求。

  1. 串口通信指令 在x86体系架构中,串口通信使用的是IO端口(Port I/O)通信模式。IO端口是CPU与外设直接的一种通信方式,共有65535个端口(0x0000~0xFFFF)供CPU与外设进行数据通信,其中第一个串口的端口就是0x03f8,要注意的是这些端口的地址并不是内存地址。

CPU使用指令IN 和 OUT 来写和读相应端口的数据。这里只介绍向串口写数据的指令EE, 该指令将AL寄存器的1 byte数据,写入DX寄存器对应的IO端口上。因为串口的IO端口是2字节地址,所以无法使用立即数直接作为IO端口,必须先设置DX寄存器。

EE  OUT DX, AL  Output byte in AL to I/O port address in DX.
  1. 处理串口请求

当虚拟机执行EE这条指令后,虚拟机会从运行模式退出到VMM,VMM会根据返回码判断是否是串口通信请求,然后做相应的处理。如下代码显示了将串口传来的字节打印在宿主机的屏幕上。

int reason = vcpu->kvm_run->exit_reason;switch (reason){...case KVM_EXIT_IO://printf("KVM_EXIT_IO port:%x\n",vcpu->kvm_run->io.port);handle_IO(vcpu);break;...}

首先通过判断exit_reason是否为KVM_EXIT_IO来确定退出原因是IO端口请求。

void handle_IO(struct kvm_cpu* vcpu){if (vcpu->kvm_run->io.direction == KVM_EXIT_IO_OUT){u8* src = (u8*)vcpu->kvm_run;u64 offset = vcpu->kvm_run->io.data_offset;u64 tot_size = (vcpu->kvm_run->io.size)*(vcpu->kvm_run->io.count);write(STDERR_FILENO, src+offset, tot_size);}else{perror("unsupported io");}
}

其次在vcpu->kvm_run->io结构中,包含了通信的方向(direction),数据的偏移地址(offset), 和数据大小(size)和请求次数(count).

最后将虚拟机传入的数据,写入STDERR_FILENO中,就会在宿主机中打印出串口设备传入的字符。

总结

本节通过对串口通信的介绍,并将串口通信和其他通信方式进行了比较,确定了在最小系统中,使用串口通信作为主要的虚拟机和宿主机直接的通信方式。

最小系统开发

在了解KVM核心API和虚拟机运行流程后,本小节会讲解如何开发一个虚拟机的最小系统,该系统能够运行一个支持x86算术指令的虚拟机。

运行场景

首先展示一下这个虚拟机是如何运行的。

最小系统会加载一段x86指令,然后设置好虚拟机的cs段寄存器和ip寄存器,指向第一条指令。这段指令将BL和AL两个寄存器相加,然后结果存到AL寄存器中,然后通过串口通信输出到串口设备中,最后在VMM中接收到IO端口的请求,吧串口数据显示在屏幕上。运行2+2的结果如下:

[root@diykvm basic]# make
gcc -std=gnu99 main.c -g -O0 -o diykvm_basic.elf
[root@diykvm basic]# make run
./diykvm_basic.elf
cpu support vmx
kvm version: 12
allocated 536870912 bytes from 0x7f34aeb92000
init cpu0
vcpu mmap size: 12288
task: 2 + 2
result:
4
KVM_EXIT_HLT

最小系统模型

这里总结一下最小系统的模型。在下图中,最小系统主要分为初始化模块、VM装载模块和运行模块。在运行模块中会使用KVM API进行虚拟机的管理,并且利用串口通信模块和虚拟机进行通信。

TODO 图

核心代码

本段介绍关键的核心代码。

首先介绍main()函数,负责调用各个模块的实现函数。其中包括:

  1. 初始化模块:依次调用kvm_init()初始化KVM环境, mem_init()初始化内存,vcpu_init()初始化vcpu。
  2. VM装载模块:调用install_code()装载预先存好的vm指令,然后调用reset_cpu()设置cs和ip寄存器。
  3. 运行模块:主要由kvm_cpu_run()实现。
  4. 结束模块:主要有cleanup()实现一些结束的工作。

在深入介绍各种模块之前,首先介绍一下最小系统中使用的结构体。 TODO 需要清理一下结构体

struct kvm {struct kvm_arch     arch;struct kvm_config   cfg;int         sys_fd;     /* For system ioctls(), i.e. /dev/kvm */int         vm_fd;      /* For VM ioctls() */timer_t         timerid;    /* Posix timer for interrupts */int         nrcpus;     /* Number of cpus to run */struct kvm_cpu      *cpus[MAX_VCPU_NUM];u32         mem_slots;  /* for KVM_SET_USER_MEMORY_REGION */u64         ram_size;void            *ram_start;u64         ram_pagesize;struct list_head    mem_banks;bool            nmi_disabled;const char      *vmlinux;struct disk_image       **disks;int                     nr_disks;int         vm_state;
};

在main()函数中,会按顺序调用各个模块。

int main(){struct kvm *kvm = NULL;int ret=0;kvm = (struct kvm*)malloc(sizeof(struct kvm));do{ret = kvm_init(kvm);...ret = mem_init(kvm);...ret = vcpu_init(kvm,KVM_CFG_VCPU_NUM);ret = install_code(kvm,shell_code,sizeof(shell_code));ret = reset_cpu(kvm);}while(0);kvm_cpu_run(kvm);cleanup(kvm);...return ret;
}

以下是各个模块的介绍。

  1. 初始化模块:

kvm_init()函数首先检测CPU是否支持Intel VT-x技术,即使用CPUID指令判断是否支持vmx。接着按照KVM API调用规范,先打开/dev/kvm设备,然后判断KVM_API版本信息。最后调用KVM_CREATE_VM API创建虚拟机文件描述符vm_fd, 最后是进行一些KVM扩展功能的判定。

int kvm_init(struct kvm *kvm){int kvm_fd = 0;int vm_fd = 0;int ret = 0;do{if (cpu_support_vmx()){printf("cpu support vmx\n");}else{printf("cpu not support vmx\n");ret = -1;break;}kvm_fd = open("/dev/kvm",O_RDWR|O_CLOEXEC);...kvm->sys_fd = kvm_fd;ret = ioctl(kvm_fd, KVM_GET_API_VERSION,0);printf("kvm version: %d\n",ret);...vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);...kvm->vm_fd = vm_fd;ret = ioctl(kvm_fd ,KVM_CHECK_EXTENSION, KVM_CAP_USER_MEMORY);...//TODO other ext check}while(0);return ret;
}

mem_init()函数用于初始化虚拟机内存,首先使用mmap()申请一片按页对齐的内存,默认是512M(KVM_CFG_RAM_SIZE),然后将内存地址和大小填充到kvm_userspace_memory_region 结构体中,最后调用KVM_SET_USER_MEMORY_REGION API将虚拟机内存和vm_fd绑定。

int mem_init(struct kvm* kvm){int ret=0;u64 ram_size = KVM_CFG_RAM_SIZE;void* ram_start=NULL;ram_start = mmap(NULL, ram_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON | MAP_NORESERVE, -1,0);...madvise(ram_start, ram_size, MADV_MERGEABLE);printf("allocated %lld bytes from %p\n",ram_size,ram_start);kvm->ram_start = ram_start;kvm->ram_size = ram_size;kvm->ram_pagesize = getpagesize();struct kvm_userspace_memory_region region={.slot = 0,.guest_phys_addr = 0,.memory_size = ram_size,.userspace_addr = (u64)ram_start};ret = ioctl(kvm->vm_fd, KVM_SET_USER_MEMORY_REGION, &region);...return ret;
}

vcpu_init()函数针对每个vcpu进行初始化,最小系统为了简单,最多只支持一个vcpu。初始化过程主要分三个阶段,首先调用KVM_CREATE_VCPU创建vcpu_fd, 其次调用KVM_GET_VCPU_MMAP_SIZE获取每个vcpu占用的内存大小,最后根据上一步获取的内存大小,为每个vcpu申请内存,vcpu的数据,例如寄存器等都保存在kvm_run这个结构体中。

int vcpu_init(struct kvm* kvm, int vcpu_num){int ret = 0;if (vcpu_num!=1){perror("only support 1 vcpu");ret = -1;return ret;}kvm->nrcpus = vcpu_num;for (int i=0;i< kvm->nrcpus; i++){printf("init cpu%d\n",i);struct kvm_cpu * vcpu=NULL;vcpu = (struct kvm_cpu*)malloc(sizeof(struct kvm_cpu));...vcpu->kvm = kvm;vcpu->cpu_id = i;vcpu->vcpu_fd = ioctl(kvm->vm_fd, KVM_CREATE_VCPU, i);...int mmap_size = ioctl(kvm->sys_fd, KVM_GET_VCPU_MMAP_SIZE, 0);printf("vcpu mmap size: %d\n",mmap_size);...vcpu->kvm_run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu->vcpu_fd, 0 );...vcpu->is_running = true;kvm->cpus[i]=vcpu;}return ret;
}
  1. VM装载模块:

装载vm指令的函数install_code()比较简单,就是将预先存好指令数组run_code,使用memcpy()复制到虚拟机内存中的offset偏移位置,这里选择0x1000偏移,是为了让VM指令处于第2页内存中,其中一页内存是4K bytes(0x1000)。这个偏移值会影响后续的cpu寄存器初始化过程。

int install_code(struct kvm* kvm, u8* run_code, int size){u16 offset = 0x1000; // second pagememcpy(kvm->ram_start+offset, run_code, size);return 0;
}

这里详细描述一下vm指令。首先将0x03f8赋值与dx寄存器,0x03f8是第一个串口的IO端口。然后将al和bl寄存器相加,结果存在al中。后面指令是将al中的数字通过与字符0相加,得到ASCII字符的数字表示,方便在串口设备上输出。随后两次调用out指令,将al中的字符和换行符\n输出到串口中。最后一条指令hlt是停机指令,标志着运行结束。

还需要介绍的是,x86指令系统分为很多种执行模式,这里使用的是16位实模式(real mode), 随着虚拟机的开发,还会支持32位保护模式(protected mode), 64位长模式(long mode)。

u8 shell_code[]={0xba, 0xf8, 0x03,   // mov $0x3f8, %dx0x00, 0xd8,         // add %bl,$al0x04, '0',          // add $'0',%al0xee,               // out %al, (%dx)0xb0, '\n',         // mov $'\n',%al0xee,               // out %al,(%dx)0xf4                // hlt
};

reset_cpu()主要是初始化vcpu的cs段寄存器和ip寄存器,另外最小系统实现的是ax寄存器和bx寄存器相加的操作,这里传入2+2的任务。还需要设置rflags为16位实模式(real mode)。

int reset_cpu(struct kvm* kvm){u16 offset = 0x1000;struct kvm_cpu* vcpu = kvm->cpus[0];ioctl(vcpu->vcpu_fd, KVM_GET_SREGS, &(vcpu->sregs));vcpu->sregs.cs.selector =0;vcpu->sregs.cs.base = 0;ioctl(vcpu->vcpu_fd, KVM_SET_SREGS, &(vcpu->sregs));vcpu->regs = (struct kvm_regs) {/* 16-bit real mode  */.rflags = 0x0000000000000002ULL,.rip    = offset,.rax    = 2,.rbx    = 2};printf("task: %d + %d\n",vcpu->regs.rax, vcpu->regs.rbx);ioctl(vcpu->vcpu_fd, KVM_SET_REGS, &(vcpu->regs));return 0;
}
  1. 运行模块:

kvm_cpu_run()函数会在一个循环中调用KVM_RUN, 根据vcpu数据结构kvm_run中的exit_reason值来判断KVM退出的原因。比较重要的两个原因,第一个是KVM_EXIT_IO,需要处理IO端口的请求,在最小系统中就是串口通信的请求,第二个是KVM_EXIT_HLT,就是vm指令中最后一个hlt指令,这时需要退出循环,结束最小系统的工作。

void kvm_cpu_run(struct kvm* kvm){printf("result:\n");struct kvm_cpu* vcpu = kvm->cpus[0];while(vcpu->is_running){int ret = ioctl(vcpu->vcpu_fd, KVM_RUN, 0);if (ret<0 && (ret!=EINTR && ret !=EAGAIN)){perror("KVM_RUN failed");break ;}int reason = vcpu->kvm_run->exit_reason;switch (reason){case KVM_EXIT_UNKNOWN:printf("KVM_EXIT_UNKNOWN\n"); break;case KVM_EXIT_IO://printf("KVM_EXIT_IO port:%x\n",vcpu->kvm_run->io.port);handle_IO(vcpu);break;case KVM_EXIT_HLT:printf("KVM_EXIT_HLT\n");vcpu->is_running=false;break;default:printf("KVM_EXIT unhandled reason:%d\n", reason);}}return ;
}
  1. 结束模块:

cleanup()主要负责回收虚拟机内存。

kvm_run unmap

void cleanup(struct kvm* kvm){munmap(kvm->ram_start, kvm->ram_size);
}

能力提升

在完成最小系统后,可以对其进行功能优化和改造, 例如增加虚拟机加载功能,可以先将虚拟机指令编译成一个bin文件,然后在代码中动态加载该虚拟机,这样方便对其他x86指令集进行实验。还可以体验不同的x86指令,观察最小系统没有处理的KVM退出原因,这些未处理的功能将会在后续章节进行补充。

例如如下例子:

  1. vm指令生成器
  2. 加载器
  3. hello world VM程序
  4. CPUID指令

总结

本章实现了一个简单的虚拟机最小系统,希望大家继续关注。

学习资料

  1. 汇编语言
  2. GDB调试

参考资料

  1. https://www.kernel.org/doc/Documentation/virtual/kvm/api.txt
  2. 串行通信技术——面向嵌入式开发 I S B N :9787121358609
  3. https://c9x.me/x86/html/file_module_x86_id_222.html
  4. https://lwn.net/Articles/658511/

自己动手利用KVM和Intel VT实现简单虚拟机相关推荐

  1. 开源项目-基于Intel VT技术的Linux内核调试器

    本开源项目将硬件虚拟化技术应用在内核调试器上,使内核调试器成为VMM,将操作系统置于虚拟机中运行,即操作系统成为GuestOS,以这样的一种形式进行调试,最主要的好处就是调试器对操作系统完全透明.如下 ...

  2. 逐渐成熟 Intel VT技术性能初探

    作者:IT168评测中心 Lucifer  2007-06-25    [IT168评测中心]当前非常热门的Virtualization虚拟化技术的出现和应用其实已经有数十年的历史了,在早期,这个技术 ...

  3. Intel VT学习笔记(九)—— EPT应用示例

    Intel VT学习笔记(九)-- EPT应用示例 内存保护 EPT violation 代码实现 参考资料 内存保护 描述:尝试使用EPT将一块特定的物理内存保护起来. 先来选择一块物理地址,那么这 ...

  4. Intel VT学习笔记(七)—— EPT物理地址转换

    Intel VT学习笔记(七)-- EPT物理地址转换 要点回顾 EPT 支持检测 9-9-9-9-12分页 实验:EPT物理地址转换 参考资料 要点回顾 在上一篇中,已经初步实现了最小VT框架,但实 ...

  5. Intel VT学习笔记(六)—— VM-Exit Handler

    Intel VT学习笔记(六)-- VM-Exit Handler Reutrn To DriverEntry VM-Exit Handler External interrupt I/O instr ...

  6. Intel VT学习笔记(五)—— 调试技巧

    Intel VT学习笔记(五)-- 调试技巧 要点回顾 INT 3失效 调试技巧 参考资料 要点回顾 在上一篇中,我们主要学习了如何填写Guest state fields的各项字段,以及如何对错误码 ...

  7. Intel VT学习笔记(四)—— VMCS(下)

    Intel VT学习笔记(四)-- VMCS(下) 要点回顾 VM-Exit Information Guest state fields 代码实现 参考资料 要点回顾 在上一篇中,我们了解了如何设置 ...

  8. Intel VT学习笔记(二)—— VMXEVMXON

    Intel VT学习笔记(二)-- VMXE&VMXON VT生命周期 VMXE VMXON 准备工作 VMXON region 代码实现 参考资料 VT生命周期 描述: 软件通过执行VMXO ...

  9. Intel VT学习笔记(三)—— VMCS(上)

    Intel VT学习笔记(三)-- VMCS(上) 要点回顾 VMCS 设置字段 错误排查 Fields Host-State Area VM-Control Fields 代码实现 参考资料 要点回 ...

最新文章

  1. 【内推】滴滴出行视觉计算组招聘算法实习生
  2. 单例设计模式八种方式——5) 懒汉式(线程安全,同步代码块) 6) 双重检查 7) 静态内部类 8) 枚举
  3. GWT与Eclipse集成开发初步研究
  4. Android Studio 构建
  5. jax-rs jax-ws_信守承诺:针对JAX-RS API的基于合同的测试
  6. 怎样为wordpress主题的文章列表添加无插件分页?
  7. 前端学习(1935)vue之电商管理系统电商系统之实现权限的默认勾选功能
  8. [示例] 使用 TStopwatch 计时
  9. linux dstat工具
  10. python 判断中文字符数量_python判断列表里数量python中文乱码问题大总结
  11. 网上支付(支付宝/银联)
  12. Mac如何解决vi vim光标移动慢问题
  13. 一个正经的前端学习 开源 仓库(阶段十九)
  14. SpringBoot兼容人大金仓数据库
  15. jmeter接口性能测试实例
  16. 系统集成项目管理师 高项论文 项目进度管理
  17. iOS7到iOS8 一个通用的横竖屏幕切换总结
  18. uniapp h5在浏览器唤起app
  19. 我只是想使用一下微软在线文档
  20. 肉价再次上涨 国家宏观调控成效遭受市场考验(转)

热门文章

  1. codeforces 721E Road to Home
  2. Windows系统下使用Sublime搭建nodejs环境
  3. Lintcode: k Sum II
  4. hdu 3948(后缀数组+RMQ)
  5. latex二元关系符号
  6. idea中构造器和toString方法覆写的快捷键
  7. 写论文时的一些高大上词句
  8. 使用Opencv的一些注意事项
  9. 科大星云诗社动态20210411
  10. [云炬商业计划书阅读分享] 体育器材