c语言 虚拟摄像头设备_自己动手利用KVM和Intel VT实现简单虚拟机

09762072f24d0911b739563b69c959bc.png

自己动手利用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

0b1b2abd7d52f86bf643004ac4e8d17a.png

在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的安装界面,选择最小化模式安装。

a21b8514577aaf3ca606fa7a793ae7ae.png

开发工具

虚拟化开发技术主要涉及系统底层技术,以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.asm

SECTION .TEXT
    GLOBAL _start
_start:
    mov rax,1
    mov rbx,1
    add rax,rbx     ; rax=rax+rbx
    mov cx,0x0a30   ; char '0n'
    push cx
    add [rsp],al    ; int -> char
    mov rcx,rsp
output:
    mov rax,1       ; syscall write
    mov rdi,1       ; stdout
    mov rsi,rcx     ; buffer
    mov rdx,2       ; 2bytes
    syscall
exit:
    mov rax,60       ; syscall exit
    mov rdi,0
    syscall
  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,%rax
    mov $1,%rbx
    add %rbx,%rax     # rax=rax+rbx
    mov $0x0a30,%cx   # char '0n'
    push %cx
    add %al,(%rsp)    # int -> char
    mov %rsp,%rcx
output:
    mov $1,%rax       # syscall write
    mov $1,%rdi       # stdout
    mov %rcx,%rsi     # buffer
    mov $2,%rdx       # 2bytes
    syscall
exit:
    mov $60,%rax      # syscall exit
    mov $0,%rdi
    syscall

从上面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: 0x400078
        0x0000000000400078 - 0x00000000004000b1 is .TEXT
(gdb) break *0x400078
Breakpoint 1 at 0x400078
(gdb) r
Starting program: /root/code/kvm/diykvm/src/basic/01_add/intel/./add.elf

Breakpoint 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   %rbp
   0x000000000040051e <+1>:     mov    %rsp,%rbp
   0x0000000000400521 <+4>:     sub    $0x10,%rsp
   0x0000000000400525 <+8>:     movl   $0x1,-0x4(%rbp)
   0x000000000040052c <+15>:    movl   $0x1,-0x8(%rbp)
   0x0000000000400533 <+22>:    mov    -0x8(%rbp),%eax
   0x0000000000400536 <+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   rbp
   0x000000000040051e <+1>:     mov    rbp,rsp
   0x0000000000400521 <+4>:     sub    rsp,0x10
   0x0000000000400525 <+8>:     mov    DWORD PTR [rbp-0x4],0x1
   0x000000000040052c <+15>:    mov    DWORD PTR [rbp-0x8],0x1
   0x0000000000400533 <+22>:    mov    eax,DWORD PTR [rbp-0x8]
   0x0000000000400536 <+25>:    add    DWORD PTR [rbp-0x4],eax
   0x0000000000400539 <+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       232

static 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.c

static 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:%xn",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 vmxn");
            }else{
                printf("cpu not support vmxn");
                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: %dn",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 %pn",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%dn",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: %dn",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 page
    memcpy(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, %dx
    0x00, 0xd8,         // add %bl,$al
    0x04, '0',          // add $'0',%al
    0xee,               // out %al, (%dx)
    0xb0, 'n',         // mov $'n',%al
    0xee,               // 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 + %dn",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_UNKNOWNn"); 
                break;
            case KVM_EXIT_IO:
                //printf("KVM_EXIT_IO port:%xn",vcpu->kvm_run->io.port);
                handle_IO(vcpu);
                break;
            case KVM_EXIT_HLT:
                printf("KVM_EXIT_HLTn");
                vcpu->is_running=false;
                break;
            default:
                printf("KVM_EXIT unhandled reason:%dn", 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/
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值