从 x86 qemu 最开始的启动代码开始,在 qemu 到 seabios、grub 再到近现代 Linux 的流程。探索完整的操作系统启动过程,也算是一步一脚印慢慢构建起完整的计算机科学底层框架吧。
PCMachineState 结构体
qemu 有一套用 C 语言实现面向对象的体系,也就是 QOM (QEMU Object Model) 不过本文不深入探究该机制,这一套和操作系统关系不大。不过先列出这一块继承链吧:
1
2
3
4
|
struct ObjectClass;
struct MachineState;
struct X86MachineState;
struct PCMachineClass;
|
反正挺复杂的,从最顶上的子类 PCMachineClass 看下去,有非常多的指针成员,在开头有还有一个结构体为 parent_obj 应该就是继承机制。就将其当成是类似 C++ 的面向对象吧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
typedef struct PCMachineState {
/*< private >*/
X86MachineState parent_obj;
/* <public> */
/* State for other subsystems/APIs: */
Notifier machine_done;
/* Pointers to devices and objects: */
PCIBus *pcibus;
I2CBus *smbus;
PFlashCFI01 *flash[2];
ISADevice *pcspk;
DeviceState *iommu;
BusState *idebus[MAX_IDE_BUS];
...
/* Configuration options: */
SGXEPCState sgx_epc;
CXLState cxl_devices_state;
} PCMachineState;
|
PCMachineClass 是 x86 架构的 PC 主板的核心属性的底层数据结构模板。 里面存储了好多有关的配置供虚拟化使用。
pc_system_firmware_init
故事从 qemu/hw/i386/pc_sysfw.c 开始讲起,从最开始的 pc_system_firmware_init 固件初始化函数开始,链接: https://github.com/qemu/qemu/blob/master/hw/i386/pc_sysfw.c#L214
1
2
|
void pc_system_firmware_init(PCMachineState *pcms,
MemoryRegion *rom_memory);
|
首先主要的定义了两个变量:
1
2
|
PCMachineClass *pcmc = PC_MACHINE_GET_CLASS(pcms); // 机器类型结构体 定义在 include/hw/i386/pc.h
BlockBackend *pflash_blk[ARRAY_SIZE(pcms->flash)]; // pflash 块设备数组
|
机器类型结构体这个不用多说,下面的 pflash_blk 数组就蛮有趣的。
pflash_blk 是存放 pflash 块设备的数组,对应到 qemu 中的 -drive if=pflash 参数。该参数是确定设备接口就是并行闪存(Parallel Flash),一般都是成对出现,比如:
pflash0 存放只读的固件代码
pflash1 存放可写的配置数据
反正也乱糟糟的,这里留一个 TODO 之后再来完善 pflash。
代码一开始先从 pcmc(上面 PCMachineState 实例化的变量) 查看是否支持 pci。 如果不支持 pci 就直接跳进下面代码判断是否有 IGVM 文件,没有就直接加载 bios.bin 文件返回。
1
2
3
4
5
6
7
8
9
|
if (!pcmc->pci_enabled) {
/*
* 如果 pci 没有启用,也就是要模拟不带 PCI 的早期 PC
*/
if (!X86_MACHINE(pcms)->igvm) { // 如果没有 IGVM 文件
x86_bios_rom_init(X86_MACHINE(pcms), "bios.bin", rom_memory, true); // 手动加载一份传统 BIOS
}
return; // 老式机器到这里就返回,不会执行下面的内容
}
|
先不看 x86_bios_rom_init ,这里如果没有 PCI 总线,就属于是很古老的机子,下面的各种初始化都不需要做了,如果没有 IGVM 文件在就加载 bios 好了。
而 IGVM(Independent Guest Virtual Machine) 是虚拟机初始化文件,它封装了启动虚拟机所需的所有指令和初始状态数据,这样就可以直接把内存和寄存器设置好,不需要经历固件启动过程。
接下来,qemu 通过 drive_get 函数从命令行参数中取出文件,用 pflash_cfi01_legacy_drive 加载,并取出后端句柄保存到 pflash_blk[i] 数组内。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
for (i = 0; i < ARRAY_SIZE(pcms->flash); i++) {
/* 把命令行给的 pflash 镜像文件绑定到模拟的闪存对象上
* drive_get 找到命令行对应的镜像文件
*/
pflash_cfi01_legacy_drive(pcms->flash[i],
drive_get(IF_PFLASH, 0, i));
// 立刻取出存储后端句柄,保存到 pflash_blk[i] 备用
pflash_blk[i] = pflash_cfi01_get_blk(pcms->flash[i]);
}
/*
* 检查 pflash 配置,要成对
*/
for (i = 1; i < ARRAY_SIZE(pcms->flash); i++) {
if (pflash_blk[i] && !pflash_blk[i - 1]) {
error_report("pflash%d requires pflash%d", i, i - 1);
exit(1);
}
}
|
如果没有加载 pflash ,也是检查 IGVM ,没有就按 legacy 模式加载 BIOS 。
1
2
3
4
5
6
|
if (!pflash_blk[0]) {
// 如果没有配置 pflash 0,使用 ROM 模式加载 BIOS
if (!X86_MACHINE(pcms)->igvm) { // 如果没有配置 IGVM 就加载默认 bios.bin
x86_bios_rom_init(X86_MACHINE(pcms), "bios.bin", rom_memory, false);
}
}
|
加载完 pflash 还需要检查 kvm 机制,如果 kvm 没有开启 kvm_readonly_mem 就无法使用 pflash 报错退出。
在最后映射 pflash 内存区域。
1
2
3
4
5
6
7
8
9
10
11
|
else {
if (kvm_enabled() && !kvm_readonly_mem_enabled()) {
error_report("pflash with kvm requires KVM readonly memory support");
exit(1);
}
// 映射 pflash 内存区域
pc_system_flash_map(pcms, rom_memory);
}
// 清理未使用的 pflash 设备
pc_system_flash_cleanup_unused(pcms);
|
如果使用 igvm ,不应该配置 pflash 设备,发现有 flash 设备也是退出。
1
2
3
4
5
6
7
8
9
|
if (X86_MACHINE(pcms)->igvm) {
for (i = 0; i < ARRAY_SIZE(pcms->flash); i++) {
if (pcms->flash[i]) {
error_report("pflash devices cannot be configured when "
"using IGVM");
exit(1);
}
}
}
|
到这一步,pc_system_firmware_init 就结束了。这里分成两种引导方式:legacy 和 uefi,uefi 是比较新的引导方式。这里先看看传统的 legacy 引导。
x86_bios_rom_init
故事在这里分叉,我们选择比较简单古老的路径:legacy 引导。
在 pc_system_firmware_init ,有多个分支调用 x86_bios_rom_init,这就是跳到 legacy 开始加载 bios.bin 文件。
该函数定义在 qemu/hw/i386/x86-common.c 内,链接:https://github.com/qemu/qemu/blob/master/hw/i386/x86-common.c#L1107。
1
2
|
void x86_bios_rom_init(X86MachineState *x86ms, const char *default_firmware,
MemoryRegion *rom_memory, bool isapc_ram_fw);
|
开头定义赋值了主要栈内变量,都是用来确定 bios 信息的:
1
2
3
4
5
6
7
8
|
int bios_size;
const char *bios_name;
g_autofree char *filename;
bios_name = MACHINE(x86ms)->firmware ?: default_firmware; // 确定 BIOS 文件名,优先选择用户指定,否则使用默认的 bios.bin
filename = qemu_find_file(QEMU_FILE_TYPE_BIOS, bios_name); // 查找 BIOS 文件路径
bios_size = get_bios_size(x86ms, bios_name, filename); // 获取 bios 文件大小
|
初次看
1
|
bios_name = MACHINE(x86ms)->firmware ?: default_firmware;
|
肯定会奇怪于 ?: 运算符,这不是标准 C 的运算符,至少 C 语言程序设计没讲(x)。
实际上:?: 是 GNU C 对标准三元运算符: E1?E2:E3 的非标准扩展。
E1?:E3 会被 GCC 展开成 E1?E1:E3。
接下来从文件中加载 bios 文件:
1
|
load_bios_from_file(x86ms, bios_name, filename, bios_size, isapc_ram_fw);
|
加载完成后,bios 文件就保存在 x86ms->bios 成员里,之后将 bios 映射到内存中:
1
2
3
4
5
6
7
8
9
|
// 将 BIOS 最后 128KB 映射到 ISA 空间(0xE0000-0xFFFFF)
if (!machine_require_guest_memfd(MACHINE(x86ms))) {
x86_isa_bios_init(&x86ms->isa_bios, rom_memory, &x86ms->bios,
!isapc_ram_fw);
}
// 将整个 BIOS 映射到内存顶部
memory_region_add_subregion(rom_memory,
(uint32_t)(-bios_size), // 4GB - bios_size
&x86ms->bios);
|
这里体现了整个 x86 架构的启动兼容性,既要满足复位向量在 4G 顶部的要求还要存在 ISA 空间给实模式用,这块我问了好久 AI ,非常抽象……
现代 x86 CPU 启动的时候 IP 指针指向 0xFFFFFFF0 地址,但是这一段可不是实模式可以访问得到的,实模式地址总线只有 20 位,那么只能访问 0xFFFFF0,再扯一大堆东西长跳转,段地址的概念确定地址是在 0xFFFFF0 内。
这里将 BIOS 最后的 128KB 映射到 0xE0000 ~ 0xFFFFF 满足实模式寻址的范围。
还要将整个 BIOS 映射到 4GB 内存顶部。deepseek v4 这样说的:现代 CPU 复位时确实处于“实模式”,但通过 CS 隐藏基址的魔法,第一条指令能从 4GB 顶端开始,执行一条跳转后,才真正回到传统的低 1MB 实模式环境。
到这里,CPU 就开始运行第一条 BIOS 代码了。
load_bios_from_file
上文中,有好一大段在旧的 qemu 代码中是在 x86_bios_rom_init 内,不过最新版本已经封装成函数调用:load_bios_from_file,链接:https://github.com/qemu/qemu/blob/master/hw/i386/x86-common.c#L1043。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
if (machine_require_guest_memfd(MACHINE(x86ms))) { // 检查是否需要使用 guest_memfd 分配内存
/*
* guest_memfd:客户机内存文件描述符
* Linux 内核提供的特殊内存分配机制,宿主机不能直接读取或写入这块内存
* 使用 guest_memfd
*/
memory_region_init_ram_guest_memfd(&x86ms->bios, NULL, "pc.bios",
bios_size, &error_fatal);
if (is_tdx_vm()) { // TDX 特殊配置
tdx_set_tdvf_region(&x86ms->bios);
}
} else {
/*
* 普通 RAM 内存区域
*/
memory_region_init_ram(&x86ms->bios, NULL, "pc.bios",
bios_size, &error_fatal);
}
|
前面如果使用 guest_memfd 还要做进一步的内存处理,否则就按普通 RAM 内存区域加载内存。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
if (sev_enabled() || is_tdx_vm()) {
// 机密计算环境(SEV/TDX):直接加载文件,不支持复位
void *ptr = memory_region_get_ram_ptr(&x86ms->bios); // 拿到 x86ms->bios 这块内存的宿主机可写指针
load_image_size(filename, ptr, bios_size); // 直接加载文件内容
x86_firmware_configure(0x100000000ULL - bios_size, ptr, bios_size); // 配置固件入口
} else { // 普通环境:注册为 ROM、支持复位时重新加载
memory_region_set_readonly(&x86ms->bios, !isapc_ram_fw); // 将 BIOS 内存区域设为只读
ret = rom_add_file_fixed(bios_name, (uint32_t)(-bios_size), -1); // 使用 qemu 的 ROM 机制注册固件
// (uint32_t)(-bios_size) 同样是 4G - bios_size (整数环绕
if (ret != 0) {
goto bios_error;
}
}
|
这里将内存设置为只读,又按模拟的 ROM 机制注册固件,就不细讲了。
进入 BIOS
上面 x86_bios_rom_init 执行完,虚拟机就执行 bios.bin 代码。qemu 使用的是 SeaBIOS,镜像仓库链接:https://github.com/coreboot/seabios
可以看 gdb 调试 qemu 下的 SeaBIOS 来进行 SeaBIOS 调试,更好的探索 BIOS 的魔法世界。
编译完成后用 du 看看 bios.bin 的大小,恰好是完完整整的 256KB:
1
2
|
❯ du -sh bios.bin
256K bios.bin
|
在 seabios/src/romlayout.S 的末尾有:
1
2
3
4
|
ORG 0xfff0 // Power-up Entry Point
.global reset_vector
reset_vector:
ljmpw $SEG_BIOS, $entry_post
|
org 确定当前代码的段内偏移,。然后做一个长跳转到 entry_post。
回到 qemu:
1
2
3
4
|
// 将整个 BIOS 映射到内存顶部
memory_region_add_subregion(rom_memory,
(uint32_t)(-bios_size), // 4GB - bios_size
&x86ms->bios);
|
实际上上面长跳转的指令是在 0xFFFFFFF0 上,通过 seabios 的链接脚本控制。这也恰好在 qemu 代码中映射 bios 到内存顶部的位置。
这里长跳转后就开始进入到 BIOS 的过程。
参考资料
- github qemu/qemu
- qemu docs:Independent Guest Virtual Machine (IGVM) support