Featured image of post 操作系统启动过程探索之旅(0)

操作系统启动过程探索之旅(0)

字数: 2905

从 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 的过程。

参考资料

  1. github qemu/qemu
  2. qemu docs:Independent Guest Virtual Machine (IGVM) support