【VIRTUALIZATION.0x00】Qemu - I:Qemu 简易食用指南
2022-07-15 16:45:17 # VIRTUALIZATION

不如 VMWare👋

0x00.一切开始之前

Qemu 是一款开源的虚拟机软件,支持多种不同架构的模拟(Emulation)以及配合 kvm 完成当前架构的虚拟化(Virtualization)的特性,是当前最火热的开源虚拟机软件

image.png

Qemu 的基本运行架构如下图所示:

image.png

本篇文章笔者将简要叙述如何从源码编译特定架构的 Qemu 并进行一定程度的改造工作

PRE.安装依赖

大概需要安装这些依赖:

1
$ sudo apt -y install ninja-build build-essential zlib1g-dev pkg-config libglib2.0-dev binutils-dev libpixman-1-dev libfdt-dev

0x01.从源码编译 QEMU

一、获取 QEMU 源码

大概有两种途径:从官网下载或是直接从 Qemu 的GitHub 仓库拉下来。

I.官网下载源码

前往 qemu 的官网进行下载:

1
2
$ wget https://download.qemu.org/qemu-7.0.0.tar.xz
$ tar -xf qemu-7.0.0.tar.xz

II. GitHub 获取源码

直接从 GitHub 上面拉也行:

1
$ git clone git@github.com:qemu/qemu.git

二、配置编译选项

接下来创建 build 目录并配置对应的编译选项:

1
2
$ mkdir build && cd build
build$ ../qemu-7.0.0/configure --enable-kvm --target-list=x86_64-softmmu --enable-debug

这里我们手动指定了这几个编译选项:

  • --enable-kvm:开启 kvm 支持
  • --target-list=<架构名>:指定要编译的 CPU 架构,这里我们指定为 x86_64-softmmu 即表示我们要编译 x86 架构的 64位 CPU
  • --enable-debug:能够对 Qemu 进行调试

如果我们不指定的话会把所有架构都编译一遍,不过这里笔者只需要 x86 的;)

三、开始编译

直接 make 就完事了

1
build$ make

需要花的时间还是不短的,在笔者的小破服务器上编译大概需要十几分钟左右,大概编译了两千多个文件,完成之后在当前目录下就会有一个热乎乎的可执行文件 qemu-system_x86-64,这个就是 Qemu 的本体了

之后可以 make install 给他安到 bin 里边,这样就能直接从命令行启动了

1
build$ sudo make install

0x02.构建系统镜像并使用 vnc 连接

空有一个 qemu 的可执行文件还不行,我们最终还是要在 qemu 上面跑一个完整的操作系统的,那么这里有两种方法:

  • 使用 qemu-img 创建虚拟机镜像文件,通过 -cdrom 参数指定载入一个 ISO 镜像文件来安装一个现有的操作系统
  • 使用 debootstrap 创建 ext4 硬盘镜像,并直接运行一个现成的裸的内核镜像文件(bzImage)

一、创建虚拟机镜像文件并通过 CDROM 安装 Ubuntu

I.使用 qemu-img 创建虚拟机磁盘镜像文件

这一步比较简单,主要是用 build 目录下的 qemu-img 来完成构建:

1
2
$ ./build/qemu-img create -f qcow2 test.qcow2 20G
Formatting 'test.qcow2', fmt=qcow2 cluster_size=65536 extended_l2=off compression_type=zlib size=21474836480 lazy_refcounts=off refcount_bits=16

这里的 -f 参数指定的第一个参数为镜像格式,这里使用 QEMU 最通用的格式 qcow2;第二个参数为文件路径;第三个参数为镜像大小

参见这里

II.通过 vnc 连接完成安装

在 qemu 启动时通过 -cdrom 参数可以指定加载的ISO文件路径,这里笔者选择安装一个 Ubuntu 22.04:

1
2
$ sudo ./build/qemu-system-x86_64 -m 2G -drive format=qcow2,file=test.qcow2 -enable-kvm -cdrom ~/Download/ubuntu-22.04-desktop-amd64.iso
VNC server running on ::1:5900

参数说明如下:

  • -m:虚拟机的内存大小
  • -drive :qemu 启动时额外加载的设备,这里我们使用 format=qcow2,file=test.qcow2 指定了加载设备 test.qcow2、格式为 qcow2
  • -enable-kvm :启用 kvm 模式,需要注意的是该选项要求以 root 权限运行
  • -cdrom:指定 qemu 启动时装载的光碟文件路径

启动后 qemu 默认会在 5900 端口启动一个 VNC server,此时我们便能通过 VNC 连接到 qemu 上,需要注意的是这里只能在本地进行连接

如果是运行在远程服务器上的话,我们还需要额外指定 -vnc 参数:

1
$ sudo ./build/qemu-system-x86_64 -m 2G -drive format=qcow2,file=test.qcow2 -enable-kvm -cdrom ~/Download/ubuntu-22.04-desktop-amd64.iso -vnc yourip:0

需要注意的是 vnc 参数中 ip 后面跟着的不是端口号,而是 display numer,对于默认的 display 0 而言其监听的端口号为 5900,而 display 1 就是 5901 端口,以此类推

之后我们便能通过 vnc 连接上远程服务器上的 qemu 了,这里笔者选择使用 VNC Viewer 进行连接:

image.png

成功连接上远程服务器上的 qemu:

image.png

之后就是常规的安装流程了,不过可能是由于 qemu 模拟显卡的问题(或者是 VNC 配置的问题),在一开始的时候安装界面的颜色会有点失真:

image.png

不过在安装准备结束的时候又恢复正常的颜色了,笔者目前推测应该是和显卡驱动有关:

image.png

之后就和正常使用虚拟机没有什么区别了,下次再次启动就不需要指定 -cdrom 参数了

image.png

二、构建 ext4 磁盘镜像并运行 kernel bzImage

如果你不需要一个完整的发行版 Linux 系统环境,只是想跑一个裸的简易的内核,也可以通过下面的方式完成:

I.构建磁盘镜像

这里我们使用 debootstrap 来创建ext4硬盘镜像,直接使用由 Google 团队为 syzkaller 构建磁盘镜像的脚步即可

1
2
3
4
5
6
$ sudo apt-get install debootstrap
$ mkdir image
$ cd image
image$ wget https://raw.githubusercontent.com/google/syzkaller/master/tools/create-image.sh -O create-image.sh
image$ chmod +x create-image.sh
image$ ./create-image.sh

完成之后在当前目录下就会有一个热乎乎的 stretch.img,这便是 ext4 磁盘镜像文件了

wget 的这一步需要翻墙raw.githubusercontent.com 在国内似乎是被墙了,总之笔者记忆里从没成功在不翻墙的情况下成功上去过),若嫌麻烦可以直接 copy 笔者已经下好的

II.获取 kernel bzImage

这部分参见这里

III.运行 qemu 并通过 vnc 进行连接

创建如下 bash 脚本并运行:

1
2
3
4
5
6
7
8
9
#!/bin/bash
qemu-system-x86_64 \
-m 2G \
-smp 2 \
-kernel ./bzImage \
-append "root=/dev/sda" \
-drive file=./stretch.img,format=raw \
-enable-kvm \
-vnc yourip:0

之后还是直接用 vnc 进行连接即可(如果只需要在本地运行的话可以不用附加 -vnc 参数,而是加上 -nographic 参数):

image.png

0x03.简易 QEMU 设备编写

虽然 Qemu 支持模拟多种设备,但是并不能涵盖现存所有的设备类型,同时有的时候出于一些特殊的目的我们也需要自定义一些设备,因此本节主要讲述如何在 Qemu 当中编写一个新的设备

注:qemu 官方在 hw/misc/edu.c 中也提供了一个教学用的设备样例,red hat 则在 hw/misc/pci-testdev.c 中提供了一个测试设备,我们可以参考这两个设备来构建我们的设备

一、PCI basic knowledge

在开始之前,我们首先来补充一些基础知识

I、总线 && PCI 设备

现代计算机通常采用总线(bus)结构,即存在一根主要的公共通信干线,CPU 及各种设备都通过这跟总线进行通信;总线是可以扩展的,即可以存在多个不同类型的总线相连,不同的设备接入到不同类型的总线上

image.png

由于总线结构形似一棵树🌳,故有的时候我们也将这些设备、总线、CPU所形成的结构称之为设备树(Device Tree)

image.png

PCI 即 Peripheral Component Interconnect,是一种连接电脑主板和外部设备的总线标准,其通过多根 PCI bus 完成 CPU 与 多个 PCI 设备间的连接,,在 X86 硬件体系结构中几乎所有的设备都以各种形式连接到 PCI 设备树上;PCIe(PCI express)是新一代的总线标准,它沿用既有的PCI编程概念及信号标准,并且构建了更加高速的串行通信系统标准

在 Linux 下我们可以使用 lspci 指令查看插在当前机器的 PCI bus 上的 PCI 设备,使用 -t 参数查看树形结构,-v 参数可以查看详细信息:

这里展示的结果有 virtio 设备是因为笔者是在阿里云学生机上使用的命令,这类机器一般其实都是用 Qemu 跑的虚拟机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ lspci
00:00.0 Host bridge: Intel Corporation 440FX - 82441FX PMC [Natoma] (rev 02)
00:01.0 ISA bridge: Intel Corporation 82371SB PIIX3 ISA [Natoma/Triton II]
00:01.1 IDE interface: Intel Corporation 82371SB PIIX3 IDE [Natoma/Triton II]
00:01.2 USB controller: Intel Corporation 82371SB PIIX3 USB [Natoma/Triton II] (rev 01)
00:01.3 Bridge: Intel Corporation 82371AB/EB/MB PIIX4 ACPI (rev 03)
00:02.0 VGA compatible controller: Cirrus Logic GD 5446
00:03.0 Ethernet controller: Red Hat, Inc. Virtio network device
00:04.0 Communication controller: Red Hat, Inc. Virtio console
00:05.0 SCSI storage controller: Red Hat, Inc. Virtio block device
00:06.0 Unclassified device [00ff]: Red Hat, Inc. Virtio memory balloon
$ lspci -t -v
-[0000:00]-+-00.0 Intel Corporation 440FX - 82441FX PMC [Natoma]
+-01.0 Intel Corporation 82371SB PIIX3 ISA [Natoma/Triton II]
+-01.1 Intel Corporation 82371SB PIIX3 IDE [Natoma/Triton II]
+-01.2 Intel Corporation 82371SB PIIX3 USB [Natoma/Triton II]
+-01.3 Intel Corporation 82371AB/EB/MB PIIX4 ACPI
+-02.0 Cirrus Logic GD 5446
+-03.0 Red Hat, Inc. Virtio network device
+-04.0 Red Hat, Inc. Virtio console
+-05.0 Red Hat, Inc. Virtio block device
\-06.0 Red Hat, Inc. Virtio memory balloon

我们还可以使用 lshw -businfo 命令来获取设备信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ sudo lshw -businfo
[sudo] password for arttnba3:
Bus info Device Class Description
=========================================================
system Alibaba Cloud ECS
bus Motherboard
memory 96KiB BIOS
cpu@0 processor Intel(R) Xeon(R) Platinum 8163 CPU @ 2.50GHz
memory 2GiB System Memory
memory 2GiB DIMM RAM
pci@0000:00:00.0 bridge 440FX - 82441FX PMC [Natoma]
pci@0000:00:01.0 bridge 82371SB PIIX3 ISA [Natoma/Triton II]
pci@0000:00:01.1 storage 82371SB PIIX3 IDE [Natoma/Triton II]
pci@0000:00:01.2 bus 82371SB PIIX3 USB [Natoma/Triton II]
usb@1 usb1 bus UHCI Host Controller
usb@1:1 input QEMU USB Tablet
pci@0000:00:01.3 bridge 82371AB/EB/MB PIIX4 ACPI
pci@0000:00:02.0 display GD 5446
pci@0000:00:03.0 network Virtio network device
virtio@0 eth0 network Ethernet interface
pci@0000:00:04.0 communication Virtio console
virtio@1 generic Virtual I/O device
pci@0000:00:05.0 storage Virtio block device
virtio@2 /dev/vda disk 42GB Virtual I/O device
virtio@2,1 /dev/vda1 volume 39GiB EXT4 volume
pci@0000:00:06.0 generic Virtio memory balloon
virtio@3 generic Virtual I/O device
system PnP device PNP0b00
input PnP device PNP0303
input PnP device PNP0f13
storage PnP device PNP0700
communication PnP device PNP0501
veth073b1a5 network Ethernet interface
veth2c8670f network Ethernet interface
vethc0202a2 network Ethernet interface
veth49e878e network Ethernet interface

II、PCI 设备编号

每个PCI 设备都有着三个编号:总线编号(Bus Number)、设备编号(Device Number)与功能编号(Function Number),作为设备的唯一标识;在此之上还有 PCI 域的概念,一个 PCI 域上最多可以连接 256 根 PCI 总线

当我们使用 lspci 命令查看 PCI 设备信息时,在每个设备开头都可以看到形如 xx:yy.z 的十六进制编号,这个格式其实是 总线编号:设备编号.功能编号,当我们使用 lspci -v 查看 PCI 设备信息时,在总线编号前面的 4 位数字便是 PCI 域的编号

III、PCI 设备配置空间

每个 PCI 逻辑设备中都有着其自己的配置空间(configuration space),通常是设备地址空间的前 64 字节(新版的设备还扩展了 0x40~0xFF 这段配置空间),其中存放了一些设备的基本信息,如生厂商信息、IRQ中断号、mem 空间与 io 空间的起始地址与大小等

Intel 芯片组中我们可以使用 IO 空间的 CF8/CFC 地址(端口)来访问 PCI 设备的配置寄存器:

  • CF8CONFIG_ADDRESS,即 PCI 配置空间地址端口。
  • CFHCONFIG_DATA,即 PCI 配置空间数据端口。

当我们往 CONFIG_ADDRESS 端口填入对应的设备标识后,就可以从 CONFIG_DATA 端口上读写 PCI 配置空间的内存, CONFIG_ADDRESS 端口的格式如下:

  • 31 位:Enable 位
  • 23:16 位:总线编号
  • 15:11 位:设备编号
  • 10:8 位:功能编号
  • 7:2 位:配置空间寄存器编号
  • 1:0 位:恒为 00

除了通过端口访问外,我们也可以通过 MMIO 的方式访问一个 PCI 设备的地址空间

现在我们来看 PCI 配置空间的结构,PCI 设备分为 BridgeAgent 两类,故配置空间也分为相应的两类

Agent 类型配置空间又被称为 Type 00h,格式如下图所示:

image.png

相应地,Bridge 类型配置空间被称为 Type 01h,与 Agent 类型配置空间大同小异:

image.png

简单介绍几个比较重要的字段:

  • 设备标识相关:

    • Vendor ID:生产厂商的 ID,例如 Intel 设备通常为 0x8086

    • Device ID:具体设备的 ID,通常也是由厂家自行指定的

    • Class Code:类代码,用于区分设备类型

    • Revision ID:PCI 设备的版本号,可以看作 Device ID 的扩展

  • 设备状态相关:

    • Status:设备的状态字寄存器,各 bit 含义如下图所示:

      image.png

    • Command:设备的状态字寄存器,各 bit 含义如下图所示:

      image.png

  • 设备配置相关:

    • Base Address Registers:决定了 PCI 设备空间映射到系统空间的具体位置,有两种映射方式:MMIO 与 PMIO,映射方式由最低位决定,不可更改

      • MMIO 下的 BAR:

        image.png

      • PMIO 下的 BAR:

        image.png

    • Interrupt Pin:中断引脚,该寄存器表示设备所连接的引脚

    • Interrupt Line:中断编号

前面我们讲到 lspci 命令,我们可以使用 -s 来通过指定查看的具体 PCI 设备,通过 -m 查看部分信息,通过 -nn 查看比较详细的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ lspci -vv -s 00:02.0 -m
Device: 00:02.0
Class: VGA compatible controller
Vendor: Cirrus Logic
Device: GD 5446
SVendor: Red Hat, Inc.
SDevice: QEMU Virtual Machine

$ lspci -vv -s 00:02.0 -nn
00:02.0 VGA compatible controller [0300]: Cirrus Logic GD 5446 [1013:00b8] (prog-if 00 [VGA controller])
Subsystem: Red Hat, Inc. QEMU Virtual Machine [1af4:1100]
Control: I/O+ Mem+ BusMaster- SpecCycle- MemWINV- VGASnoop- ParErr- Stepping- SERR+ FastB2B- DisINTx-
Status: Cap- 66MHz- UDF- FastB2B- ParErr- DEVSEL=fast >TAbort- <TAbort- <MAbort- >SERR- <PERR- INTx-
Region 0: Memory at fc000000 (32-bit, prefetchable) [size=32M]
Region 1: Memory at febd0000 (32-bit, non-prefetchable) [size=4K]
Expansion ROM at 000c0000 [disabled] [size=128K]
Kernel driver in use: cirrus
Kernel modules: cirrusfb, cirrus

我们还可以直接使用 -x 参数来查看 PCI 设备的配置空间:

1
2
3
4
5
6
$ lspci -s 00:02.0 -x
00:02.0 VGA compatible controller: Cirrus Logic GD 5446
00: 13 10 b8 00 03 01 00 00 00 00 00 03 00 00 00 00
10: 08 00 00 fc 00 00 bd fe 00 00 00 00 00 00 00 00
20: 00 00 00 00 00 00 00 00 00 00 00 00 f4 1a 00 11
30: 00 00 bc fe 00 00 00 00 00 00 00 00 00 00 00 00

在 Linux 当中我们也可以通过 procfs 或 sysfs 这样的文件系统来查看设备的相关配置信息,例如通过 /proc/bus/pci/00/00.0 文件我们同样可以查看 PCI 设备 00:02.0 的配置空间:

1
2
3
4
5
$ cat /proc/bus/pci/00/02.0 | xxd
00000000: 1310 b800 0301 0000 0000 0003 0000 0000 ................
00000010: 0800 00fc 0000 bdfe 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 f41a 0011 ................
00000030: 0000 bcfe 0000 0000 0000 0000 0000 0000 ................

通过 /sys/devices/pci0000:00/0000:00:02.0/resource 获取到的信息中每行表示一个地址空间,其中第一行为 MMIO,第二行为 PMIO,三列信息分别为起始地址、终止地址、标志位 ,:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ sudo cat /sys/devices/pci0000\:00/0000\:00\:02.0/resource
0x00000000fc000000 0x00000000fdffffff 0x0000000000042208
0x00000000febd0000 0x00000000febd0fff 0x0000000000040200
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x00000000000c0000 0x00000000000dffff 0x0000000000000212
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000

通过 /sys/devices/pci0000:00/0000:00:02.0 下的其他文件也可以访问该设备的一些其他资源信息(例如通过 resource0 可以直接访问 MMIO 空间,resource1 则为其 PMIO 空间)

IV、PCI 设备内存 & 端口空间与访问方式

前面我们讲了 PCI 设备与特性和配置相关的配置空间,现在我们来看与 PCI 设备与实际操作相关的内存映射空间与端口映射空间

所有 IO 设备的内存与端口空间需要被映射到对应的地址空间/端口空间中才能访问,这需要占用部分的内存地址空间与端口地址空间,即我们有两种映射外设资源的方式:

  • MMIO(Memory-mapped I/O):即内存映射 IO。这种方式将 IO 设备的内存与寄存器映射到指定的内存地址空间上,此时我们便可以通过常规的访问内存的方式来直接访问到设备的寄存器与内存
  • PMIO(Port-mapped I/O):即端口映射 IO。这种方式将 IO 设备的寄存器编码到指定的端口上,我们需要通过访问端口的方式来访问设备的寄存器与内存(例如在 x86 下通过 inout 这一类的指令可以读写端口)。IO 设备通过专用的针脚或者专用的总线与 CPU 连接,这与内存地址空间相独立,因此又称作 isolated I/O

完成映射之后通过相应的内存/端口访问到的便是 PCI 设备的内存/端口地址空间

例如实模式下的 0xA0000 ~ 0xBFFFF 这 128KB 地址空间通常被用作显存的映射,当我们在实模式下读写这块区域时通常便是直接读写显卡上的显存,而并非普通的内存

通过 procfs 的 /proc/iomem 我们可以查看物理地址空间的情况,其中我们便能看到各种设备所占用的地址空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ sudo cat /proc/iomem
00000000-00000fff : Reserved
00001000-0009fbff : System RAM
0009fc00-0009ffff : Reserved
000a0000-000bffff : PCI Bus 0000:00
000c0000-000c91ff : Video ROM
000c9800-000ca1ff : Adapter ROM
000ca800-000ccbff : Adapter ROM
000f0000-000fffff : Reserved
000f0000-000fffff : System ROM
00100000-7ffdffff : System RAM
1f400000-20200e70 : Kernel code
20200e71-2105843f : Kernel data
2132b000-217fffff : Kernel bss
7ffe0000-7fffffff : Reserved
80000000-febfffff : PCI Bus 0000:00
fc000000-fdffffff : 0000:00:02.0
fc000000-fdffffff : cirrus
feb80000-febbffff : 0000:00:03.0
febd0000-febd0fff : 0000:00:02.0
febd0000-febd0fff : cirrus
febd1000-febd1fff : 0000:00:03.0
febd2000-febd2fff : 0000:00:04.0
febd3000-febd3fff : 0000:00:05.0
fec00000-fec003ff : IOAPIC 0
fee00000-fee00fff : Local APIC
feffc000-feffffff : Reserved
fffc0000-ffffffff : Reserved

通过 procfs 的 /proc/ioports 我们可以查看 IO 端口情况,其中便包括各种设备对应的 PMIO 端口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
$ sudo cat /proc/ioports
0000-0cf7 : PCI Bus 0000:00
0000-001f : dma1
0020-0021 : pic1
0040-0043 : timer0
0050-0053 : timer1
0060-0060 : keyboard
0064-0064 : keyboard
0070-0071 : rtc0
0080-008f : dma page reg
00a0-00a1 : pic2
00c0-00df : dma2
00f0-00ff : fpu
0170-0177 : 0000:00:01.1
0170-0177 : ata_piix
01f0-01f7 : 0000:00:01.1
01f0-01f7 : ata_piix
0376-0376 : 0000:00:01.1
0376-0376 : ata_piix
03f2-03f2 : floppy
03f4-03f5 : floppy
03f6-03f6 : 0000:00:01.1
03f6-03f6 : ata_piix
03f7-03f7 : floppy
03f8-03ff : serial
0505-0505 : QEMU0001:00
0510-051b : QEMU0002:00
0510-051b : fw_cfg_io
0600-063f : 0000:00:01.3
0600-0603 : ACPI PM1a_EVT_BLK
0604-0605 : ACPI PM1a_CNT_BLK
0608-060b : ACPI PM_TMR
0700-070f : 0000:00:01.3
0700-0708 : piix4_smbus
0cf8-0cff : PCI conf1
0d00-adff : PCI Bus 0000:00
ae0f-aeff : PCI Bus 0000:00
af20-afdf : PCI Bus 0000:00
afe0-afe3 : ACPI GPE0_BLK
afe4-ffff : PCI Bus 0000:00
c000-c03f : 0000:00:05.0
c000-c03f : virtio-pci-legacy
c040-c05f : 0000:00:01.2
c040-c05f : uhci_hcd
c060-c07f : 0000:00:03.0
c060-c07f : virtio-pci-legacy
c080-c09f : 0000:00:04.0
c080-c09f : virtio-pci-legacy
c0a0-c0bf : 0000:00:06.0
c0a0-c0bf : virtio-pci-legacy
c0c0-c0cf : 0000:00:01.1
c0c0-c0cf : ata_piix

二、Qemu Object Model

虽然 Qemu 是使用 C 编写的,但是其代码也充满了 OOP 的思想,在 Qemu 当中有着一套叫做 Qemu Object Model 的东西来实现面向对象,主要由这四个组件构成:

  • Type:用来定义一个「类」的基本属性,例如类的名字、大小、构造函数等
  • Class:用来定义一个「类」的静态内容,例如类中存储的静态数据、方法函数指针等
  • Object:动态分配的一个「类」的具体的实例(instance),储存类的动态数据
  • Property:动态对象数据的访问器(accessor),可以通过监视器接口进行检查

类似于 Golang,在 QOM 当中使用成员嵌套的方式来完成类的继承,父类作为类结构体的第一个成员 parent 而存在,因此也不支持多继承

参见这个ppt

I、TypeInfo - 类的基本属性

TypeInfo 这一结构体用来定义一个「类」的基本属性,该结构体定义于 include/qom/object.h 当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* TypeInfo:
* @name: 类型名.
* @parent: 父类型名.
* @instance_size: 对象大小 (#Object 的衍生物).
* 若 @instance_size 为 0, 则对象的大小为其父类的大小
* @instance_init: 该函数被调用以初始化对象(译注:构造函数).
* (译注:调用前)父类已被初始化,因此子类只需要初始化他自己的成员。
* @instance_post_init: 该函数被调用以结束一个对象的初始化,
* 在所有的 @instance_init 函数被调用之后.
* @instance_finalize: 该函数在对象被析构时调用. 其在
* 父类的 @instance_finalize 被调用之前被调用.
* 在该函数中一个对象应当仅释放该对象特有的成员。
* @abstract: 若该域为真,则该类为一个虚类,不能被直接实例化。
* @class_size: 这个对象的类对象的大小 (#Object 的衍生物)
* 若 @class_size 为 0, 则类的大小为其父类的大小。
* 这允许一个类型在没有添加额外的虚函数时避免实现一个显式的类型。
* @class_init: 该函数在所有父类初始化结束后被调用,
* 以允许一个类设置他的默认虚方法指针.
* 这也允许该函数重写父类的虚方法。
* @class_base_init: 在所有的父类被初始化后、但
* 在类自身初始化前,为所有的基类调用该函数。
* 该函数用以撤销从父类 memcpy 到子类的影响.
* @class_data: 传递给 @class_init 与 @class_base_init 的数据,
* 这会在建立动态类型时有用。
* @interfaces: 与这个类型相关的接口.
* 其应当指向一个以 0 填充元素结尾的静态数组
*/
struct TypeInfo
{
const char *name;
const char *parent;

size_t instance_size;
void (*instance_init)(Object *obj);
void (*instance_post_init)(Object *obj);
void (*instance_finalize)(Object *obj);

bool abstract;
size_t class_size;

void (*class_init)(ObjectClass *klass, void *data);
void (*class_base_init)(ObjectClass *klass, void *data);
void *class_data;

InterfaceInfo *interfaces;
};

当我们在 Qemu 中要定义一个「类」的时候,我们实际上需要定义一个 TypeInfo 类型的变量,例如下面就是一个在 Qemu 定义一个自定义类的🌰:

1
2
3
4
5
6
7
8
9
10
11
12
13
static const TypeInfo a3_type_info = {
.name = "a3_type",
.parent = TYPE_OBJECT,
.interfaces = (InterfaceInfo[]) {
{ },
},
}

static void a3_register_types(void) {
type_register_static(&a3_type_info);
}

type_init(a3_register_types);

type_init() 其实就是 constructor 这一 gcc attribute 的封装,其作用就是将一个函数加入到一个 init_array 当中,在 Qemu 程序启动时在进入到 main 函数之前会先调用 init_array 中的函数,因此这里会调用我们自定义的函数,其作用便是调用 type_register_static() 将我们自定义的类型 a3_type_info 注册到全局的类型表中

II、Class - 类的静态内容

当我们通过一个 TypeInfo 结构体定义了一个类之后,我们还需要定义一个 Class 结构体来定义这个类的静态内容,包括函数表、静态成员等,其应当继承于对应的 Class 结构体类型,例如我们若是要定义一个新的机器类,则其 Class 应当继承于 MachineClass

所有 Class 结构体类型的最终的父类都是 ObjectClass 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* ObjectClass:
*
* 所有类的基类. #ObjectClass 仅包含一个整型类型 handler
*/
struct ObjectClass
{
/*< private >*/
Type type;
GSList *interfaces;

const char *object_cast_cache[OBJECT_CLASS_CAST_CACHE];
const char *class_cast_cache[OBJECT_CLASS_CAST_CACHE];

ObjectUnparent *unparent;

GHashTable *properties;
};

下面是一个最简单的🌰:

1
2
3
4
5
struct A3Class
{
/*< private >*/
ObjectClass parent;
}

完成 Class 的定义之后我们还应当在前面定义的 a3_type_info 中添加上 Class size 与 Class 的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void a3_class_init(ObjectClass *oc, void *data)
{
// 这里的 oc 参数便是新创建的 Class,全局只有一个该实例
// 我们应当 cast 为我们自己的 Class 类型,之后再进行相应操作
// do something
}

static const TypeInfo a3_type_info = {
.name = "a3_type",
.parent = TYPE_OBJECT,
.class_size = sizeof(A3Class),
.class_init = a3_class_init,
.interfaces = (InterfaceInfo[]) {
{ },
},
}

III、Object - 类的实例对象

我们还需要定义一个相应的 Object 类型来表示一个实例对象,其包含有这个类实际的具体数据,且应当继承于对应的 Object 结构体类型,例如我们若是要定义一个新的机器类型,其实例类型应当继承自 MachineState

所有 Object 结构体类型的最终的父类都是 Object 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Object:
*
* 所有对象的基类。该对象的第一个成员为一个指向 #ObjectClass 的指针。
* 因为 C 中将一个结构体的第一个成员组织在该结构体的 0 字节起始处,
* 只要任何的子类将其父类作为第一个成员,我们都能直接转化为一个 #Object.
*
* 因此, #Object 包含一个对对象类的引用作为其第一个成员。
* 这允许在运行时识别对象的真实类型
*/
struct Object
{
/*< private >*/
ObjectClass *class;
ObjectFree *free;
GHashTable *properties;
uint32_t ref;
Object *parent;
};

下面是一个🌰:

1
2
3
4
5
struct A3Object
{
/*< private >*/
Object parent;
}

完成 Object 的定义之后我们还应当在前面定义的 a3_type_info 中添加上 Object size 与 Object 的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void a3_object_init(Object *obj)
{
// 这里的 obj 参数便是动态创建的类型实例
// do something
}

static const TypeInfo a3_type_info = {
.name = "a3_type",
.parent = TYPE_OBJECT,
.instance_init = a3_object_init,
.instance_size = sizeof(A3Object),
.class_size = sizeof(A3Class),
.class_init = a3_class_init,
.interfaces = (InterfaceInfo[]) {
{ },
},
}

IV、类的创建与释放

类似于在 C++ 当中使用 newdelete 来创建与释放一个类实例,在 QOM 中我们应当使用 object_new()object_delete() 来创建与销毁一个 QOM 类实例,本质上就是 分配/释放类空间 + 显示调用构造/析构函数

QOM 判断创建类实例的类型是通过类的名字,即 TypeInfo->name,当创建类实例时 Qemu 会遍历所有的 TypeInfo 并寻找名字匹配的那个,从而调用到对应的构造函数,并将其基类 Object->class 指向对应的 class

下面是一个🌰:

1
2
3
4
// create a QOM object
A3Object *a3obj = object_new("a3_type");
// delete a QOM object
object_delete(a3obj);

三、MemoryRegion - Qemu 中的一块内存区域

在 Qemu 当中使用 MemoryRegion 结构体类型来表示一块具体的 Guest 物理内存区域,该结构体定义于 include/exec/memory.h 当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/** MemoryRegion:
*
* 表示一块内存区域的一个结构体.
*/
struct MemoryRegion {
Object parent_obj;

/* private: */

/* The following fields should fit in a cache line */
bool romd_mode;
bool ram;
bool subpage;
bool readonly; /* For RAM regions */
bool nonvolatile;
bool rom_device;
bool flush_coalesced_mmio;
bool global_locking;
uint8_t dirty_log_mask;
bool is_iommu;
RAMBlock *ram_block;
Object *owner;

const MemoryRegionOps *ops;
void *opaque;
MemoryRegion *container; // 指向父 MemoryRegion
Int128 size; // 内存区域大小
hwaddr addr; // 在父 MR 中的偏移量
void (*destructor)(MemoryRegion *mr);
uint64_t align;
bool terminates;
bool ram_device;
bool enabled;
bool warning_printed; /* For reservations */
uint8_t vga_logging_count;
MemoryRegion *alias; // 仅在 alias MR 中,指向实际的 MR
hwaddr alias_offset;
int32_t priority;
QTAILQ_HEAD(, MemoryRegion) subregions;
QTAILQ_ENTRY(MemoryRegion) subregions_link;
QTAILQ_HEAD(, CoalescedMemoryRange) coalesced;
const char *name;
unsigned ioeventfd_nb;
MemoryRegionIoeventfd *ioeventfds;
};

在 Qemu 当中有三种类型的 MemoryRegion:

  • MemoryRegion 根:通过 memory_region_init() 进行初始化,其用以表示与管理由多个 sub-MemoryRegion 组成的一个内存区域,并不实际指向一块内存区域,例如 system_memory
  • MemoryRegion 实体:通过 memory_region_init_ram() 初始化,表示具体的一块大小为 size 的内存空间,指向一块具体的内存
  • MemoryRegion 别名:通过 memory_region_init_alias() 初始化,作为另一个 MemoryRegion 实体的别名而存在,不指向一块实际内存

MR 容器与 MR 实体间构成树形结构,其中容器为根节点而实体为子节点:

下图来自于这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
                       struct MemoryRegion
+------------------------+
|name |
| (const char *) |
+------------------------+
|addr |
| (hwaddr) |
|size |
| (Int128) |
+------------------------+
|subregions |
| QTAILQ_HEAD() |
+------------------------+
|
|
----+-------------------+---------------------+----
| |
| |
| |

struct MemoryRegion struct MemoryRegion
+------------------------+ +------------------------+
|name | |name |
| (const char *) | | (const char *) |
+------------------------+ +------------------------+
|addr | |addr |
| (hwaddr) | | (hwaddr) |
|size | |size |
| (Int128) | | (Int128) |
+------------------------+ +------------------------+
|subregions | |subregions |
| QTAILQ_HEAD() | | QTAILQ_HEAD() |
+------------------------+ +------------------------+

相应地,基于 OOP 的思想,MemoryRegion 的成员函数被封装在函数表 MemoryRegionOps 当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/*
* Memory region callbacks
*/
struct MemoryRegionOps {
/* 从内存区域上读. @addr 与 @mr 有关; @size 单位为字节. */
uint64_t (*read)(void *opaque,
hwaddr addr,
unsigned size);
/* 往内存区域上写. @addr 与 @mr 有关; @size 单位为字节. */
void (*write)(void *opaque,
hwaddr addr,
uint64_t data,
unsigned size);

MemTxResult (*read_with_attrs)(void *opaque,
hwaddr addr,
uint64_t *data,
unsigned size,
MemTxAttrs attrs);
MemTxResult (*write_with_attrs)(void *opaque,
hwaddr addr,
uint64_t data,
unsigned size,
MemTxAttrs attrs);

enum device_endian endianness;
/* Guest可见约束: */
struct {
/* 若非 0,则指定了超出机器检查范围的访问大小界限
*/
unsigned min_access_size;
unsigned max_access_size;
/* If true, unaligned accesses are supported. Otherwise unaligned
* accesses throw machine checks.
*/
bool unaligned;
/*
* 若存在且 #false, 则该事务不会被设备所接受
* (并导致机器的相关行为,例如机器检查异常).
*/
bool (*accepts)(void *opaque, hwaddr addr,
unsigned size, bool is_write,
MemTxAttrs attrs);
} valid;
/* 内部应用约束: */
struct {
/* 若非 0,则决定了最小的实现的 size .
* 更小的 size 将被向上回绕,且将返回部分结果.
*/
unsigned min_access_size;
/* 若非 0,则决定了最大的实现的 size .
* 更大的 size 将被作为一系列的更小的 size 的访问而完成.
*/
unsigned max_access_size;
/* 若为 true, 支持非对齐的访问.
* 否则所有的访问都将被转换为(可能多种)对齐的访问.
*/
bool unaligned;
} impl;
};

当我们的 Guest 要读写虚拟机上的内存时,在 Qemu 内部实际上会调用 address_space_rw(),对于一般的 RAM 内存而言则直接对 MR 对应的内存进行操作,对于 MMIO 而言则最终调用到对应的 MR->ops->read()MR->ops->write()

关于 Qemu 内存管理更多的内容就暂且不在此展开了,不过现在我们知道的是在 Qemu 中使用 MemoryRegion 结构体来表示一段内存区域,那么我们同样可以通过在设备中添加 MemoryRegion 的方式来为设备添加内存,从而实现与设备间的 MMIO 通信

同样的,为了统一接口,在 Qemu 当中 PMIO 的实现同样是通过 MemoryRegion 来完成的

四、Qemu 中 PCI 设备的编写

在补充了这么多的 Qemu 相关的知识之后,现在我们可以开始在 Qemu 中编写 PCI 设备了,这里笔者将编写一个最简单的 Qemu 设备,并将源码放在 hw/misc/a3dev.c

Qemu 当中 PCI 设备实例的基类是 PCIDevice,因此我们应当创建一个继承自 PCIDevice 的类来表示我们的设备实例,这里笔者仅声明了两个 MemoryRegion 用作 MMIO 与 PMIO,以及一个用作数据存储的 buffer:

1
2
3
4
5
6
7
8
9
10
11
#define A3DEV_BUF_SIZE 0x100

typedef struct A3PCIDevState {
/*< private >*/
PCIDevice parent_obj;

/*< public >*/
MemoryRegion mmio;
MemoryRegion pmio;
uint8_t buf[A3DEV_BUF_SIZE];
} A3PCIDevState;

以及定义一个空的 Class 模板,继承自 PCI 设备的静态类型 PCIDeviceClass,不过这一步并不是必须的,事实上我们可以直接用 PCIDeviceClass 作为我们设备类的 Class:

1
2
3
4
typedef struct A3PCIDevClass {
/*< private >*/
PCIDeviceClass parent;
} A3PCIDevClass;

以及两个将父类转为子类的宏,因为 QOM 基本函数传递的大都是父类指针,所以我们需要一个宏来进行类型检查 + 转型,这也是 Qemu 中惯用的做法:

1
2
3
4
5
6
7
#define TYPE_A3DEV_PCI "a3dev-pci"
#define A3DEV_PCI(obj) \
OBJECT_CHECK(A3PCIDevState, (obj), TYPE_A3DEV_PCI)
#define A3DEV_PCI_GET_CLASS(obj) \
OBJECT_GET_CLASS(A3PCIDevClass, obj, TYPE_A3DEV_PCI)
#define A3DEV_PCI_CLASS(klass) \
OBJECT_CLASS_CHECK(A3PCIDevClass, klass, TYPE_A3DEV_PCI)

下面我们开始定义 MMIO 与 PMIO 的操作函数,这里笔者就简单地设置为读写设备内部的 buffer,并声明上两个 MemoryRegion 对应的函数表,需要注意的是这里传入的 hwaddr 类型参数其实为相对地址而非绝对地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
static uint64_t
a3dev_read(void *opaque, hwaddr addr, unsigned size)
{
A3PCIDevState *ds = A3DEV_PCI(opaque);
uint64_t val = ~0LL;

if (size > 8)
return val;

if (addr + size > A3DEV_BUF_SIZE)
return val;

memcpy(&val, &ds->buf[addr], size);
return val;
}

static void
a3dev_write(void *opaque, hwaddr addr, uint64_t val, unsigned size)
{
A3PCIDevState *ds = A3DEV_PCI(opaque);

if (size > 8)
return ;

if (addr + size > A3DEV_BUF_SIZE)
return ;

memcpy(&ds->buf[addr], &val, size);
}

static uint64_t
a3dev_mmio_read(void *opaque, hwaddr addr, unsigned size)
{
return a3dev_read(opaque, addr, size);
}

static uint64_t
a3dev_pmio_read(void *opaque, hwaddr addr, unsigned size)
{
return a3dev_read(opaque, addr, size);
}

static void
a3dev_mmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned size)
{
a3dev_write(opaque, addr, val, size);
}

static void
a3dev_pmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned size)
{
a3dev_write(opaque, addr, val, size);
}

static const MemoryRegionOps a3dev_mmio_ops = {
.read = a3dev_mmio_read,
.write = a3dev_mmio_write,
.endianness = DEVICE_LITTLE_ENDIAN,
};

static const MemoryRegionOps a3dev_pmio_ops = {
.read = a3dev_pmio_read,
.write = a3dev_pmio_write,
.endianness = DEVICE_LITTLE_ENDIAN,
};

然后是设备实例的初始化函数,在 PCIDeviceClass 当中定义了一个名为 realize 的函数指针,当 PCI 设备被载入时便会调用这个函数指针指向的函数来初始化,所以这里我们也定义一个自己的初始化函数,不过我们需要做的工作其实基本上就只有初始化两个 MemoryRegionmemory_region_init_io() 会为这两个 MemoryRegion 进行初始化的工作,并设置函数表为我们指定的函数表,pci_register_bar() 则用来注册 BAR:

1
2
3
4
5
6
7
8
9
10
11
static void a3dev_realize(PCIDevice *pci_dev, Error **errp)
{
A3PCIDevState *ds = A3DEV_PCI(pci_dev);

memory_region_init_io(&ds->mmio, OBJECT(ds), &a3dev_mmio_ops,
pci_dev, "a3dev-mmio", A3DEV_BUF_SIZE);
pci_register_bar(pci_dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &ds->mmio);
memory_region_init_io(&ds->pmio, OBJECT(ds), &a3dev_pmio_ops,
pci_dev, "a3dev-pmio", A3DEV_BUF_SIZE);
pci_register_bar(pci_dev, 1, PCI_BASE_ADDRESS_SPACE_IO, &ds->pmio);
}

最后是 Class 与 Object(也就是 instance)的初始化函数,这里需要注意的是在 Class 的初始化函数中我们应当设置父类 PCIDeviceClass 的一系列基本属性(也就是 PCI 设备的基本属性):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void a3dev_instance_init(Object *obj)
{
// do something
}

static void a3dev_class_init(ObjectClass *oc, void *data)
{
DeviceClass *dc = DEVICE_CLASS(oc);
PCIDeviceClass *pci = PCI_DEVICE_CLASS(oc);

pci->realize = a3dev_realize;
pci->vendor_id = PCI_VENDOR_ID_QEMU;
pci->device_id = 0x1919;
pci->revision = 0x81;
pci->class_id = PCI_CLASS_OTHERS;

dc->desc = "arttnba3 test PCI device";
set_bit(DEVICE_CATEGORY_MISC, dc->categories);
}

最后就是为我们的 PCI 设备类型注册 TypeInfo 了,这里别忘了我们的接口中应当增加上 PCI 的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static const TypeInfo a3dev_type_info = {
.name = TYPE_A3DEV_PCI,
.parent = TYPE_PCI_DEVICE,
.instance_init = a3dev_instance_init,
.instance_size = sizeof(A3PCIDevState),
.class_size = sizeof(A3PCIDevClass),
.class_init = a3dev_class_init,
.interfaces = (InterfaceInfo[]) {
{ INTERFACE_CONVENTIONAL_PCI_DEVICE },
{ },
},
};

static void a3dev_register_types(void) {
type_register_static(&a3dev_type_info);
}

type_init(a3dev_register_types);

最后我们在 meson 构建系统中加入我们新增的这个设备,在 hw/misc/meson.build 中加入如下语句:

1
softmmu_ss.add(when: 'CONFIG_PCI_A3DEV', if_true: files('a3dev.c'))

并在 hw/misc/Kconfig 中添加如下内容,这表示我们的设备会在 CONFIG_PCI_DEVICES=y 时编译:

1
2
3
4
config PCI_A3DEV
bool
default y if PCI_DEVICES
depends on PCI

之后编译 Qemu 并附加上 -device a3dev-pci ,之后随便起一个 Linux 系统,此时使用 lspci 指令我们便能看到我们新添加的 pci 设备:

image.png

我们使用如下程序来测试我们的设备的输入输出,需要注意的是这需要 root 权限:

PMIO,使用 iopl 更改端口权限后便能通过 in/out 类指令读写端口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/io.h>

int main(int argc, char **argv, char **envp)
{
unsigned short port_addr;

if (argc < 2) {
puts("[x] no port provided!");
exit(EXIT_FAILURE);
}

if (iopl(3) < 0) {
puts("[x] no privilege!");
exit(EXIT_FAILURE);
}

port_addr = atoi(argv[1]);

printf("[+] a3dev port addr start at: %d\n", port_addr);

puts("[*] now writing into a3dev-pci...");

for (int i = 0; i < 0x100 / 4; i++) {
outl(i, port_addr + i * 4);
}

puts("[+] writing done!");

printf("[*] now reading from a3dev-pci...");
for (int i = 0; i < 0x100 / 4; i++) {
if (i % 8 == 0) {
printf("\n[--%d--]", port_addr + i * 4);
}
printf(" %d ", inl(port_addr + i * 4));
}

puts("\n[+] reading done!");
}

PMIO 测试成功,设备读写功能正常:

image.png

MMIO,使用 mmap 映射 sys 目录下设备的 resource0 文件即可直接读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>
#include <sys/mman.h>

void mmio_write(uint32_t *addr, uint32_t val)
{
*addr = val;
}

uint32_t mmio_read(uint32_t *addr)
{
return *addr;
}

int main(int argc, char **argv, char **envp)
{
uint32_t *mmio_addr;
int dev_fd;

dev_fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0",
O_RDWR | O_SYNC);
if (dev_fd < 0) {
puts("[x] failed to open mmio file! wrong path or no root!");
exit(EXIT_FAILURE);
}

mmio_addr = (uint32_t*)
mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, dev_fd, 0);
if (mmio_addr == MAP_FAILED) {
puts("failed to mmap!");
exit(EXIT_FAILURE);
}

puts("[*] start writing to a3dev-pci...");
for (int i = 0; i < 0x100 / 4; i++) {
mmio_write(mmio_addr + i, i);
}
puts("[+] write done!");

printf("[*] start reading from a3dev-pci...");
for (int i = 0; i < 0x100 / 4; i++) {
if (i % 8 == 0) {
printf("\n[--%p--]", mmio_addr);
}
printf(" %u ", mmio_read(mmio_addr + i));
}
puts("\n[+] read done!");
}

MMIO 测试成功,设备读写功能正常:

image.png

0x04.自定义 QEMU 机器类型

众所周知在 Qemu 当中有很多种不同的机器类型,其表示着包含一些默认设备(包含PCIe显卡、以太网控制器、SATA控制器等)的虚拟芯片组,例如 pc 对应于 Intel 的 440FX 芯片组(这也是 Qemu 默认选择的机器类型)

image.png

Qemu 主要支持两种大的 x86 芯片组:i440FX 和 Q35,后者相比前者而言的一个大的亮点便是增加了对 PCIe 的支持:

image.png

我们可以使用 -machine 选项来指定我们要创建的虚拟机的机器类型,通过 -machine ? 选项可以查看当前支持的机器类型:

image.png

但自带的机器类型通常往往无法满足我们多样化的要求,因此有的时候我们需要自行编写一种机器类型来满足我们的需求

一、添加源码文件与编译选项

在 Qemu 源码目录中,与具体支持的硬件相关的代码都放在 hw/ 目录下,例如默认的 PC 架构便定义于 hw/i386/pc.c,因此若是我们想要定义一种新的机器类型则在该目录下进行定义是最好的

老版本的 Qemu 是纯粹基于 Makefile 进行构建的,而现在的新版本 Qemu 中则是使用 meson 进行项目构建,因此笔者接下来将会同时介绍两种配置方法

I、新版本 Qemu 配置方式(meson)

这里我们选择定义一种新的机器类型名为 a3-pc,并在 hw/i386/a3-pc 下创建如下目录结构:

1
2
3
4
5
6
7
$ tree hw/i386/a3-pc/
hw/i386/a3-pc/
├── accel.c
├── machine.c
└── meson.build

0 directories, 3 files

三个文件说明如下:

  • meson.build:meson 项目构建文件
  • machine.c:机器的主体代码
  • accel.c:自定义的 accelerator 代码

meson.build 中写入如下内容:

1
2
3
4
5
a3pc_ss = ss.source_set()
a3pc_ss.add(files('accel.c'))
a3pc_ss.add(files('machine.c'))

i386_ss.add_all(when: 'CONFIG_A3_PC', if_true: a3pc_ss)

之后在 hw/i386/meson.build 中添加该语句:

1
subdir('a3-pc')

这里笔者选择创建一个 i386 类型的机器,因此我们还需要修改 hw/i386/Kconfig,添加如下内容:

1
2
config A3_PC
bool

configs/devices/i386-softmmu/default.mak 末尾添加如下内容,使得我们的新的机器类型会被默认编译进去:

1
CONFIG_A3_PC=y

II、老版本 Qemu 配置方式(makefile)

如果是版本稍微老一点的 Qemu 则应当在 hw/a3-pc 下创建如下目录结构:

1
2
3
4
5
6
7
$ tree ./hw/a3-pc/
./hw/a3-pc/
├── accel.c
├── machine.c
└── Makefile.objs

0 directories, 3 files

三个文件说明如下:

  • Makefile.objs:机器的 Makefile 文件
  • machine.c:机器的主体代码
  • accel.c:自定义的 accelerator 代码,也可以直接用默认的 TCG accelerator

并在 Makefile.objs 中添加如下内容:

1
2
obj-$(CONFIG_A3_PC) += accel.o
obj-$(CONFIG_A3_PC) += machine.o

之后在 hw/Makefile.objs 中添加上该配置:

1
2
3
4
5
devices-dirs-y = core/
ifeq ($(CONFIG_SOFTMMU), y)
# ...
devices-dirs-$(CONFIG_A3_PC) += a3-pc/
endif

这里我们通过添加一个新的选项 CONFIG_A3_PC 来控制是否要进行该类型机器的编译

笔者选择创建一个 i386 类型的机器,因此我们还需要修改 hw/i386/Kconfig,添加如下内容,表示一个空白的机器,后面我们若是需要添加硬件则还需要在这部分进行改动:

1
2
config A3_PC
bool

最后我们在源码根目录的 configure 文件中添加如下内容即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
supported_a3_pc_target() {
test "$a3_pc" = "yes" || return 1
glob "$1" "*-softmmu" || return 1
case "${1%-softmmu}" in
x86_64)
return 0
;;
esac
return 1
}

supported_target() {
case "$1" in
# ...
supported_a3_pc_target "$1" && return 0
print_error "TCG disabled, but hardware accelerator not available for '$target'"
return 1
}
# ...
for opt do
optarg=$(expr "x$opt" : 'x[^=]*=\(.*\)')
case "$opt" in
--help|-h) show_help=yes
;;
#...
;;
--enable-a3-pc) a3_pc="yes"
;;
# ...
# 这一块可以放在 supported_whpx_target 的那个语句块下面
if supported_a3_pc_target $target; then
echo "CONFIG_A3_PC=y" >> $config_target_mak
echo "CONFIG_A3_PC=y" >> $config_host_mak
fi

如果我们想要改变编译出来的可执行文件的名字,还可以在 Makefile.target 中修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
ifdef CONFIG_USER_ONLY
# user emulator name
QEMU_PROG=qemu-$(TARGET_NAME)
QEMU_PROG_BUILD = $(QEMU_PROG)
else
ifdef CONFIG_A3_PC
# arttnba3 type machine
QEMU_PROG=a3-pc
else
# system emulator name
QEMU_PROG=qemu-system-$(TARGET_NAME)$(EXESUF)
endif

二、定义新的 Machine Type

I、machine.c:machine 基本定义

虽然 Qemu 是使用 C 语言编写的,但是在 Qemu 当中同样使用了 OOP 的思想,通过结构体嵌套的形式实现继承

在 Qemu 当中使用 MachineState 结构体类型表示一个通用虚拟机的状态,使用 MachineClass 结构体类型表示一个通用的虚拟机类型,因此对于我们需要创建的新的机器类型,我们需要分别定义他的状态类与类型类,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "qemu/osdep.h"
#include "qemu-common.h"

#include "hw/boards.h"
#include "qom/object.h"
#include "sysemu/sysemu.h"

typedef struct A3PCMachineState {
/*< private >*/
MachineState parent;

Notifier machine_done;
} A3PCMachineState;

typedef struct A3PCMachineClass {
/*< private >*/
MachineClass parent;
} A3PCMachineClass;

这里对于继承自 MachineState 的子类我们添加了一个新的 Notifier 类型的成员,可以用来在后面构建事件通知链

我们还需要定义一些相应的父子类间转型的宏,以及一个表示新增的 a3-pc 类型的宏:

1
2
3
4
5
6
7
#define TYPE_A3PC_MACHINE MACHINE_TYPE_NAME("a3-pc")
#define A3PC_MACHINE(obj) \
OBJECT_CHECK(A3PCMachineState, (obj), TYPE_A3PC_MACHINE)
#define A3PC_MACHINE_GET_CLASS(obj) \
OBJECT_GET_CLASS(A3PCMachineClass, obj, TYPE_A3PC_MACHINE)
#define A3PC_MACHINE_CLASS(klass) \
OBJECT_CLASS_CHECK(A3PCMachineClass, klass, TYPE_A3PC_MACHINE)

接下来我们定义 MachineState 与 MachineClass 的初始化函数,这里只是一个最最简单的空模板,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static void a3_pc_machine_init_done(Notifier *notifier, void *data)
{
}

static void a3_pc_machine_init(MachineState *machine)
{
A3PCMachineState *ms = A3PC_MACHINE(machine);

ms->machine_done.notify = a3_pc_machine_init_done;
qemu_add_machine_init_done_notifier(&ms->machine_done);
}

static void a3_pc_machine_class_init(ObjectClass *oc, void *data)
{
MachineClass *mc = MACHINE_CLASS(oc);

mc->init = a3_pc_machine_init;
// 这里设置了一个参数,指定了使用我们自己的 accelerator
mc->default_machine_opts = "accel=a3acl";
}

接下来我们需要声明一个 TypeInfo 类型的变量,用来表示我们新建的这一种机器类型:

1
2
3
4
5
6
7
8
9
10
static const TypeInfo a3_pc_machine_info = {
.name = TYPE_A3PC_MACHINE,
.parent = TYPE_MACHINE,
.instance_size = sizeof(A3PCMachineState),
.class_size = sizeof(A3PCMachineClass),
.class_init = a3_pc_machine_class_init,
.interfaces = (InterfaceInfo[]) {
{ },
},
};

最后就是注册我们的新机器类型了,这里使用 type_init() 来完成,原理是 gcc constructor attribute 使其会调用 a3_pc_machine_register() 来注册 a3_pc_machine_info

1
2
3
4
5
static void a3_pc_machine_register(void)
{
type_register_static(&a3_pc_machine_info);
}
type_init(a3_pc_machine_register);

II、accel.c:accelerator 定义

接下来就是定义我们自己的 accelerator,因为 Qemu 默认需要一个 accelerator,但如果再去和原有的 accelerator 做适配就太麻烦了(因为👴是懒🐕),所以这里我们自己定义一个空的 accelerator,不过这一部分我们只需要声明一个新的 TypeInfo 类型变量即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include "qemu/osdep.h"
#include "qemu/module.h"
#include "hw/boards.h"
#include "hw/qdev-core.h"
#include "sysemu/accel.h"

bool a3_pc_allowed;

static int a3_pc_init(MachineState *ms)
{
MachineClass *mc = MACHINE_GET_CLASS(ms);

/*
* opt out of system RAM being allocated by generic code
*/
mc->default_ram_id = NULL;

return 0;
}

static void a3_pc_accel_class_init(ObjectClass *oc, void *data)
{
AccelClass *ac = ACCEL_CLASS(oc);
static GlobalProperty compat[] = {
{ "migration", "store-global-state", "off" },
{ "migration", "send-configuration", "off" },
{ "migration", "send-section-footer", "off" },
};

ac->name = "A3ACL";
ac->init_machine = a3_pc_init;
ac->allowed = &a3_pc_allowed;
ac->compat_props = g_ptr_array_new();

compat_props_add(ac->compat_props, compat, G_N_ELEMENTS(compat));
}

#define TYPE_A3_ACCEL ACCEL_CLASS_NAME("a3acl")

static const TypeInfo a3_pc_accel_type = {
.name = TYPE_A3_ACCEL,
.parent = TYPE_ACCEL,
.class_init = a3_pc_accel_class_init,
};

static void a3_pc_type_init(void)
{
type_register_static(&a3_pc_accel_type);
}

type_init(a3_pc_type_init);

*新版本 accelerator 额外添加 ops

需要注意的是 qemu 的 7.0 和 5.0 的版本之间代码架构有一定的改动,所以对于 7.0 版本我们还需要额外定义一个 AccelClassOps:

当然,也可以直接用原有的 accelerator ,比如说 tcg,直接在 machine.c 代码中指定 accel=tcg 即可

添加文件:accel/a3acl/a3acl-accel-ops.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*
* QEMU A3ACL vCPU common functionality
*
*
* Copyright (c) 22 arttnba3
*
* Just modify it like what you want : )
*/

#include "qemu/osdep.h"
#include "qemu-common.h"
#include "sysemu/accel-ops.h"

static void a3acl_handle_interrupt(CPUState *cpu, int mask)
{
// do nothing
}

static void a3acl_kick_vcpu_thread(CPUState *unused)
{
// do nothing
}

static void a3acl_start_vcpu_thread(CPUState *cpu)
{
// do nothing
}

static void a3acl_accel_ops_init(AccelOpsClass *ops)
{
// 这几个函数可以按照个人需要来进行改造,笔者这里仅作占位符
ops->create_vcpu_thread = a3acl_start_vcpu_thread;
ops->kick_vcpu_thread = a3acl_kick_vcpu_thread;
ops->handle_interrupt = a3acl_handle_interrupt;
}

static void a3acl_accel_ops_class_init(ObjectClass *oc, void *data)
{
AccelOpsClass *ops = ACCEL_OPS_CLASS(oc);

ops->ops_init = a3acl_accel_ops_init;
}

static const TypeInfo a3acl_accel_ops_type = {
.name = ACCEL_OPS_NAME("a3acl"),
.parent = TYPE_ACCEL_OPS,
.class_init = a3acl_accel_ops_class_init,
.abstract = true,
};
module_obj(ACCEL_OPS_NAME("a3acl"));

static void a3acl_accel_ops_register_types(void)
{
type_register_static(&a3acl_accel_ops_type);
}
type_init(a3acl_accel_ops_register_types);

添加文件:accel/a3acl/meson.build

1
2
3
4
5
6
a3acl_ss = ss.source_set()
a3acl_ss.add(files(
'a3acl-accel-ops.c',
))

specific_ss.add_all(when: 'CONFIG_A3ACL', if_true: a3acl_ss)

修改文件:accel/meson.build

1
2
3
4
5
6
7
8
if have_system
subdir('hvf')
subdir('qtest')
subdir('kvm')
subdir('xen')
subdir('stubs')
subdir('a3acl') # 加上这句
endif

修改文件:accel/Kconfig

1
2
3
4
# 添加上这:
config A3ACL
bool
default y

三、添加设备结构🕊

现在我们已经有了一台可以运行的空白的机器——但包括 CPU 在内的所有设备目前暂且都是不存在的,因此我们需要手动地构造机器的设备结构

I、添加新的 PCIe Host Bridge

一个空的机器什么都没有,那自然是什么都干不了的,所以我们首先需要为这个机器添加上一个 PCIe Host Bridge,从而让我们的机器可以添加新的 PCIe 设备

惯例地就是定义一个新的 PCIe Host Bridge 类型的新 PCIe 设备:

添加文件:include/hw/pci-host/a3pc.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef HW_A3_PC_PCIE_HOST_H
#define HW_A3_PC_PCIE_HOST_H

#include "exec/memory.h"
#include "hw/pci/pcie_host.h"

typedef struct A3PCPCIEHost {
PCIExpressHost parent_obj;

MemoryRegion mem;
MemoryRegion io;
} A3PCPCIEHost;

#define TYPE_A3_PC_PCIE_HOST "a3-pc-pcie-host"
#define A3_PC_PCIE_HOST(obj) \
OBJECT_CHECK(A3PCPCIEHost, (obj), TYPE_A3_PC_PCIE_HOST)

#endif /* HW_A3_PC_PCIE_HOST_H */

添加文件:hw/pci-host/a3pc.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include "qemu/osdep.h"
#include "qemu-common.h"

#include "exec/memory.h"
#include "hw/qdev-properties.h"
#include "hw/pci/pci.h"
#include "hw/pci/pcie_host.h"
#include "hw/pci-host/a3pc.h"
#include "qemu/error-report.h"

static void a3_pc_host_init(Object *obj)
{
// nothing to do
}

static void a3_pc_pcie_set_irq(void *opaque, int irq_num, int level)
{
warn_report("A3-PC: not support INTx (irq %d, level %d)",
irq_num, level);
}

static int a3_pc_pcie_swizzle_map_irq_fn(PCIDevice *pci_dev, int pin)
{
warn_report("A3-PC: not support INTx (pin %d)", pin);
return 0;
}

static void a3_pc_pcie_host_realize(DeviceState *dev, Error **errp)
{
PCIHostState *pci = PCI_HOST_BRIDGE(dev);
A3PCPCIEHost *h = A3_PC_PCIE_HOST(dev);
SysBusDevice *sbd = SYS_BUS_DEVICE(dev);

memory_region_init(&h->mem, OBJECT(h), "a3-pc-mem", 16);
memory_region_init(&h->io, OBJECT(h), "a3-pc-io", 16);
sysbus_init_mmio(sbd, &h->mem);

/*
* A PCIe host in QEMU is required to provide
* a pair of callbacks: set_irq() and map_irq()
*/
pci->bus = pci_register_root_bus(dev, "a3-pcie0",
a3_pc_pcie_set_irq,
a3_pc_pcie_swizzle_map_irq_fn,
h, &h->mem, &h->io,
0, 1, TYPE_PCIE_BUS);
}

static Property a3_pc_pcie_host_props[] = {
DEFINE_PROP_END_OF_LIST(),
};

static void a3_pc_class_init(ObjectClass *oc, void *data)
{
DeviceClass *dc = DEVICE_CLASS(oc);

dc->realize = a3_pc_pcie_host_realize;
set_bit(DEVICE_CATEGORY_BRIDGE, dc->categories);
device_class_set_props(dc, a3_pc_pcie_host_props);
}

static const TypeInfo a3_pc_pcie_host = {
.name = TYPE_A3_PC_PCIE_HOST,
.parent = TYPE_PCI_HOST_BRIDGE,
.instance_size = sizeof(A3PCPCIEHost),
.instance_init = a3_pc_host_init,
.class_init = a3_pc_class_init,
};

static void a3_pc_pcie_host_register(void)
{
type_register(&a3_pc_pcie_host);
}

type_init(a3_pc_pcie_host_register);

修改文件:hw/pci-host/meson.build

老版本没测了,自己想该怎么改吧(笑)

1
2
# A3 devices
pci_ss.add(when: 'CONFIG_PCI', if_true: files('a3pc.c'))

之后我们在我们的机器类型中加上 PCI 相关的两个指针成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct A3PCMachineState {
/*< private >*/
MachineState parent;

/* <public> */

/* State for other subsystems/APIs: */
Notifier machine_done;

/* Pointers to devices and objects: */
PCIBus *bus;

/*
* QEMU requires the entire PCI(e) hierarchy be attached to
* a PCI(e) bus, so BES-VNC machine has to implement one.
*/
PCIHostState *pci;
} A3PCMachineState;

最后在机器初始化函数中初始化一个我们自定义的这个 PCIe 设备即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void a3_pc_machine_init(MachineState *machine)
{
A3PCMachineState *ms = A3PC_MACHINE(machine);
DeviceState *dev = qdev_new(TYPE_A3_PC_PCIE_HOST);

sysbus_realize_and_unref(SYS_BUS_DEVICE(dev), &error_fatal);
ms->pci = PCI_HOST_BRIDGE(dev);

memory_region_add_subregion(get_system_memory(), 0,
sysbus_mmio_get_region(SYS_BUS_DEVICE(dev), 0));

ms->machine_done.notify = a3_pc_machine_init_done;
qemu_add_machine_init_done_notifier(&ms->machine_done);
}

注意新版本和老版本的 API 不同,在老版本中应当使用如下 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void a3_pc_machine_init(MachineState *machine)
{
A3PCMachineState *ms = A3PC_MACHINE(machine);

DeviceState *dev = qdev_create(NULL, TYPE_A3_PC_PCIE_HOST);

qdev_init_nofail(dev);
ms->pci = PCI_HOST_BRIDGE(dev);

memory_region_add_subregion(get_system_memory(), 0,
sysbus_mmio_get_region(SYS_BUS_DEVICE(dev), 0));

ms->machine_done.notify = a3_pc_machine_init_done;
qemu_add_machine_init_done_notifier(&ms->machine_done);
}

完成这些步骤之后我们的新机器就能随意插入各种 PCI 设备了;)

II、添加新的 CPU 插槽🕊

当然,我们的机器还缺少了 CPU,没有 CPU 的机器自然是跑不起来的,因此这里我们还需要在我们的机器类型当中添加上相应的 CPU 插槽,由于 Qemu 内部的基础 x86 机器架构已经实现好了基础框架,所以我们直接改为继承自对应的 x86 基础机器类即可

当然,如果是纯纯自定义的异架构,这里还是得自己手动写一套…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
typedef struct A3PCMachineState {
/*< private >*/
X86MachineState parent;

/* <public> */

/* State for other subsystems/APIs: */
Notifier machine_done;

/* Pointers to devices and objects: */
PCIBus *bus;

/*
* QEMU requires the entire PCI(e) hierarchy be attached to
* a PCI(e) bus, so BES-VNC machine has to implement one.
*/
PCIHostState *pci;
} A3PCMachineState;

typedef struct A3PCMachineClass {
/*< private >*/
X86MachineClass parent;

/*< public >*/

/* Default CPU model version. See x86_cpu_set_default_version(). */
int default_cpu_version;
} A3PCMachineClass;

不过机器定义的文件当中需要改动的部分会比预想的要多,所以这里就先🕊🕊🕊了

Extra.自定义 CPU 🕊

🕊🕊🕊

四、编译运行🕊

由于我们新建立的机器类型为 x86 架构的机器,因此我们需要在执行 configure 脚本时指定 --target-list=x86_64-softmmu

这里需要注意的是前前面笔者提供了两种设置 CONFIG_A3_PC 的选项:如果我们是直接通过修改 default.mak 使得 CONFIG_A3_PC=y,则直接编译即可;若我们是通过修改了 configure 来指定 CONFIG_A3_PC 的值,则创建编译脚本的时候我们需要手动指定 --enable-a3-pc 来编译上我们新增的机器类型

编译完成后我们便能够看到我们新添加的机器类型 a3-pc

1
2
3
4
5
6
7
build$ ./qemu-system-x86_64 -machine ?
Supported machines are:
microvm microvm (i386)
pc Standard PC (i440FX + PIIX, 1996) (alias of pc-i440fx-7.0)
pc-i440fx-7.0 Standard PC (i440FX + PIIX, 1996) (default)
#...
a3-pc (null)

🕊🕊🕊