QEmu 采用了一套由 Kconfig 发展而来的 Domain-Specific Language (DSL 领域特定语言),和 meson 相结合。其特点是对于模块编译的依赖关系较为严格(QEmu 文档自己说的),在大量不同种类的主板之间也可以对同样的模块采用同样的共享代码。对于开发者来说,一方面添加新的设备较为容易;另一方面也使QEmu易于裁剪,可以把依赖树以外的代码全部去掉。
为了实现以上功能,QEmu设计了一套自己的构建流程,主要由 Target、Kconfig和meson组成。Target指的是QEmu所定义的一系列构建目标,分别对应一套最根本的若干配置项;Kconfig是用于计算配置项依赖的配置工具,用于从Target的根本配置项出发、通过依赖关系得到所有的配置项内容;meson则用于根据配置项生成构建列表,扮演类似make、cmake的角色。
Target - 构建的起点
QEmu的构建以 target
为起点(TARGET_*
),每个 target
都定义了一系列的选项子集(CONFIG_*
),以此层层向下依赖确定每个模块的编译是否进行。
关于目标和相应顶层选项的配置文件位于 config/
中。有趣的是,虽然文档里写的是 default-configs
,但是这个目录设置在 QEmu v6.1 版本被改成了 config/
,它们实际上是同样的东西。
即:
qemu/configs
├── devices
│ ├── ...
│ ├── aarch64-softmmu
│ │ ├── default.mak
│ │ └── minimal.mak
│ ├── ppc-softmmu
│ │ └── default.mak
│ ├── riscv64-softmmu
│ │ └── default.mak
│ ├── x86_64-softmmu
│ │ └── default.mak
│ └── ...
├── meson
│ └── windows.txt
└── targets
├── ...
├── riscv64-linux-user.mak
├── riscv64-softmmu.mak
├── x86_64-bsd-user.mak
├── x86_64-linux-user.mak
├── x86_64-softmmu.mak
└── ...
这里列出了我们比较关心的几个 target
,除了这些以外还有许多其他不同平台的 target
。configs/targets
中的内容即为我们可以作为 target
构建的目标,以 configs/targets/riscv64-softmmu.mak
为例,其内容为:
TARGET_ARCH=riscv64
TARGET_BASE_ARCH=riscv
TARGET_SUPPORTS_MTTCG=y
TARGET_XML_FILES= gdb-xml/riscv-64bit-cpu.xml gdb-xml/riscv-32bit-fpu.xml gdb-xml/riscv-64bit-fpu.xml gdb-xml/riscv-64bit-virtual.xml
TARGET_NEED_FDT=y
而 configs/devices/riscv64-softmmu/default.mak
中的内容则是:
# Default configuration for riscv64-softmmu
# Uncomment the following lines to disable these optional devices:
#
#CONFIG_PCI_DEVICES=n
CONFIG_SEMIHOSTING=y
CONFIG_ARM_COMPATIBLE_SEMIHOSTING=y
# Boards:
#
CONFIG_SPIKE=y
CONFIG_SIFIVE_E=y
CONFIG_SIFIVE_U=y
CONFIG_RISCV_VIRT=y
CONFIG_MICROCHIP_PFSOC=y
CONFIG_SHAKTI_C=y
每个目标分别对应了一套配置依赖树。确定对应的Target也就是确定了配置依赖树的根节点——用于计算后续所依赖配置的最初的几个配置。随后,这些配置项会基于源码树不同目录中Kconfig的内容推断出更细节的、下一层的配置项取值。
Kconfig - 依赖关系计算
这个Kconfig跟Linux内核源码树里面的Kconfig其实差不多是一个东西。对于一个配置项来说,它可能具有某些依赖(依赖项没启用它不能启用)、某些默认数值(如果没有显式说明的话它就取这个默认值),它可以导致其他的一些选项一定被选上,或者让某些选项具有一些默认的内容(如果没有显式说明的话这个配置项应该就得是这个值)。对于多个配置项来说,前述的这些规则对同一个配置项产生的推断结果可能又会产生冲突。Kconfig的作用就是记录开发者所定义的这些依赖关系、处理推断产生的冲突,并根据给定的根本配置项推断得出所有需要的配置项的值。
在Linux内核构建中,用户可以用 menuconfig
这样的工具更深入地调整各种各样的配置项的取值;而在QEmu中则显得局限得多,因为用户基本只需要关心到底选择哪一个 Target 进行构建。对于QEmu的开发者而言,往往是需要通过修改 Kconfig
和 meson.build
文件去添加新的依赖关系和代码模块,也不需要过于细致地手动选择配置项的值。
在QEmu源码树所有有代码的地方(或者说,源码树的各级结点处)都会见到对应的 Kconfig
文件,其语法和Linux内核树中的 Kconfig 类似,采用和目录结构相同的树形结构构建出一个Kconfig树。
Kconfig中的配置单元称为“元素”,每个“元素”具有不同类型的值,以及赋值的规则和条件。
它们的语法规则如下:
source gua/Kconfig # 将子目录中 gua/Kconfig 的配置信息包括进来
config GUA_BOARD # 该元素的名称是 GUA_BOARD
bool # 该元素的数值类型是 bool
depends on <expr> # 若 <expr> 为 n 则为 n
select <symbol> [if <expr>] # 若为 y 则令 <symbol> 为 y
default <value> [if <expr>] # 默认情况下为 <value>
imply <symbol> [if <expr>] # 令 <symbol> 默认情况下为 y
以 hw/riscv/Kconfig
为例,其内容如下:
...
config IBEX
bool
config SIFIVE
bool
select MSI_NONBROKEN
...
config SIFIVE_U
bool
select CADENCE
select HART
select SIFIVE
select UNIMP
config SPIKE
bool
select HART
select HTIF
select SIFIVE
...
值得注意的是,和Linux内核源码树中的Kconfig一样,这里的每一个元素名(如 SPIKE
)实际上代表的就是配置项 CONFIG_*
(即 CONFIG_SPIKE
)。其中的 select
是一个很强的依赖规则。一般来说,如果当前元素是一个板卡的话,会 select
这个板卡上必然存在的子系统或者特性——因为这种情况下被 select
的目标元素取值通常不会被其他元素左右;否则会更倾向于用 imply
。依据这种默契,我们可以通过Kconfig更好地理解需要打交道的虚拟设备。
meson - 执行构建
按照 QEMU Internals 的说法,添加机器模型需要改动的构建文件是 Makefile.objs
。在 QEmu v5.2版本中也被替换为了 meson.build ,体现了 QEmu 构建体系的变化。因此,我们也可以通过这个更像 Makefile 的表亲来分析 meson.build 所代表的角色。以 QEmu v5.1 中的 hw/riscv/Makefile.objs
为例,其中的主要文件内容如下:
obj-y += boot.o
obj-$(CONFIG_SPIKE) += riscv_htif.o
obj-$(CONFIG_HART) += riscv_hart.o
obj-$(CONFIG_OPENTITAN) += opentitan.o
obj-$(CONFIG_SIFIVE_E) += sifive_e.o
obj-$(CONFIG_SIFIVE_E) += sifive_e_prci.o
obj-$(CONFIG_SIFIVE) += sifive_clint.o
obj-$(CONFIG_SIFIVE) += sifive_gpio.o
obj-$(CONFIG_SIFIVE) += sifive_plic.o
obj-$(CONFIG_SIFIVE) += sifive_test.o
obj-$(CONFIG_SIFIVE_U) += sifive_u.o
obj-$(CONFIG_SIFIVE_U) += sifive_u_otp.o
obj-$(CONFIG_SIFIVE_U) += sifive_u_prci.o
obj-$(CONFIG_SIFIVE) += sifive_uart.o
obj-$(CONFIG_SPIKE) += spike.o
obj-$(CONFIG_RISCV_VIRT) += virt.o
可以看到,这里用了一个比较有意思的做法,把所有对应配置项为 y
的模块都加入了 obj-y
编译列表中,剩下的加入 obj-n
中(以 obj-$(CONFIG_SPIKE)
为例,如果 CONFIG_SPIKE=y
那么这个符号就是 obj-y
)。 obj-y
就是稍后会实际进行编译的列表。而在 QEmu v9.1.0 中, hw/riscv/meson.build
的内容如下:
iscv_ss = ss.source_set()
riscv_ss.add(files('boot.c'))
riscv_ss.add(when: 'CONFIG_RISCV_NUMA', if_true: files('numa.c'))
riscv_ss.add(files('riscv_hart.c'))
riscv_ss.add(when: 'CONFIG_OPENTITAN', if_true: files('opentitan.c'))
riscv_ss.add(when: 'CONFIG_RISCV_VIRT', if_true: files('virt.c'))
riscv_ss.add(when: 'CONFIG_SHAKTI_C', if_true: files('shakti_c.c'))
riscv_ss.add(when: 'CONFIG_SIFIVE_E', if_true: files('sifive_e.c'))
riscv_ss.add(when: 'CONFIG_SIFIVE_U', if_true: files('sifive_u.c'))
riscv_ss.add(when: 'CONFIG_SPIKE', if_true: files('spike.c'))
riscv_ss.add(when: 'CONFIG_MICROCHIP_PFSOC', if_true: files('microchip_pfsoc.c'))
riscv_ss.add(when: 'CONFIG_ACPI', if_true: files('virt-acpi-build.c'))
hw_arch += {'riscv': riscv_ss}
这边的内容可读性就比较高了,会先根据配置项做一个判断,判断为 y
的加入列表 riscv_ss
中。可以看出,这两个文件都试图完成一个任务:根据配置(CONFIG_*
)的值决定要不要在编译中添加对应的叶子节点模块(指的是QEmu源码树的最末端的模块)。叶子结点负责将需要编译的模块收集起来,而最终的构建则交由顶层结点进行。
将自己的模块加入编译
这里可以参考 A deep dive into QEMU: a new machine 的做法——先判断自己的代码是属于什么架构的,如果是一个新的架构,那么在 hw/
下单开一个子目录,然后照葫芦画瓢加上配套的 Kconfig 和 meson.build 文件(当然, meson.build
比该文写就时的 Makefile.objs
要复杂上不少,可能需要到 configs/
中修改 Target 配置);如果只是一个架构下的新机器模型(比如说 hw/riscv/
),那么只需要在 hw/riscv/meson.build
中模仿 riscv_ss.add(files('riscv_hart.c))
加上 riscv_ss.add(files('Your_File.c'))
就可以加入编译了。
在代码中,则需要根据QEmu编写设备文件的规范,把 TypeInfo
等 QOM 注册新设备所需的数据结构都准备好,这样就可以编译得到包含自己编写的新设备的QEmu。由于 QEmu 采用了一套内置的面向对象模型 QOM ,使用函数指针的方式使用与设备相关的代码,函数甚至可以与其他设备代码中的符号重名,就像内核代码一样,不专门导出的符号是不能在其他文件中使用的——反过来说,单文件如果不做特殊的处理,就几乎是一个独立的命名空间。