PAYATHON 2026

为什么程序在 QEMU 中运行正常,却在 bare metal 上崩溃?

排障赵师傅

同一个 ISO 在 QEMU 中能启动,说明它能在当前虚拟机的初始状态和启动路径下运行。代码对寄存器初值、内存内容和磁盘布局有多处隐含假设,换成真实固件和 Ventoy 的启动路径后,这些假设可能不再成立。

应先修正段寄存器和栈的初始化,补上读盘错误检查,检查 E820 缓冲区管理,并将页表清零。这些错误可能直到执行 mov cr0, eax 附近才暴露。仅凭目前的代码,还无法确定唯一根因,也没有依据认定是 Ventoy 的问题。

先初始化段寄存器和栈

启动扇区设置 org 0x7C00 后,直接执行了:

mov [boot_drive], dl
mov si, dap

这些内存地址依赖 DS。org 只影响汇编时的地址计算,不会设置处理器的段寄存器。如果 DS 不为零,启动盘号的保存位置和 BIOS 读取的 DAP 地址都会出错。

代码还在未初始化 SS:SP 时执行了 int 0x13。即使已经执行 cli,软件中断仍会使用栈,后面的 int 0x10 和 int 0x15 也是如此。

如果采用传统 BIOS 启动,且启动代码位于物理地址 0x7C00,可以这样初始化入口:

bits 16
org 0x7C00

    jmp 0x0000:start

start:
    cli
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7C00
    cld

    mov [boot_drive], dl
    sti

    mov si, dap
    mov dl, [boot_drive]
    mov ah, 0x42
    stc
    int 0x13
    jc disk_error

    jmp 0x0000:0x7E00

disk_error:
    ; 在这里保存、输出 AH 中的错误码
    cli
.halt:
    hlt
    jmp .halt

boot_drive:
    db 0

align 16
dap:
    db 0x10
    db 0
    dw 6
    dw 0x7E00
    dw 0x0000
    dq 24

times 510 - ($ - $$) db 0
dw 0xAA55

示例沿用了原来的读取数量和 LBA,用来说明初始化方式;6 和 24 是否正确,仍需核实。实际读盘前,还应通过 INT 13h, AH=41h 检查启动设备是否支持所需的扩展功能。

原代码末尾的填充也需要修正:

times 510 - ($ - $) db 0

$ - $ 恒等于零,这条语句会额外填充 510 字节,导致启动签名不在偏移 510 处。应改为:

times 510 - ($ - $$) db 0
dw 0xAA55

如果贴出的代码与实际构建代码一致,这个错误还会影响 boot.bin 的长度和后续拼接布局。

读取数量必须与设备块大小、ISO 布局对应

INT 13h, AH=42h 使用 BIOS 所呈现设备的逻辑块编号,一个块不一定是 512 字节。

例如:

BIOS 呈现的设备常见逻辑块大小读取 6 块的数据量
硬盘类设备512 字节3072 字节
无仿真启动的光盘设备2048 字节12288 字节

实际块大小需要结合启动方式和 BIOS 接口的返回信息确认。如果接口支持,可以通过 INT 13h, AH=48h 查询设备参数,检查返回的数据结构长度及每扇区字节数。

El Torito 启动目录中的初始加载计数以 512 字节为单位,后续 AH=42h 的读取单位则取决于对应的 BIOS 设备。这两个计数不能直接混用。

目前给出的构建命令是:

cat bin/boot.bin bin/kernel.bin > bin/kernel.img

这只能说明两个文件在 kernel.img 中相邻,还缺少以下信息:

  • ISO 如何生成;
  • El Torito 启动目录采用什么配置;
  • 启动映像位于哪个 ISO LBA;
  • kernel.bin 在最终 ISO 中的字节偏移;
  • BIOS 初始加载了启动映像中的多少内容。

因此,目前还没有足够依据将 LBA 硬编码为 LBA=24。如果 boot.bin 恰好是 512 字节,内核就从拼接映像的偏移 512 处开始。对于按 2048 字节块访问的设备,这个位置在块内部,不能当作下一个块的起点。

先确认最终 ISO 的数据布局,再决定直接读取到目标地址,还是先读入临时缓冲区后复制。

为什么读取超过 6 块,反而完全不能运行?

读取块数增加后,写入内存的范围也会扩大:

写入范围 = [缓冲区起始地址, 缓冲区起始地址 + 块数 × 每块字节数)

从 0x7E00 开始读取时:

每块字节数6 块的结束地址,不包含该地址7 块的结束地址,不包含该地址
5120x8A000x8C00
20480xAE000xB600

新增的写入范围可能覆盖栈、E820 结果或其他仍在使用的数据。也可能是读取请求失败,但代码没有检查错误。还有一种可能:原先缺失的数据碰巧与内存中的旧内容相容,增加读取量后,程序的表现随之改变。

这些可能性需要通过错误码和内存布局逐一验证。仅凭“超过 6 块失败”,无法判断 BIOS 是否只能读取 6 块,也无法判断内核加载地址是否过低。

确认布局后,按实际需要计算读取块数:

读取块数 = ceil((块内起始偏移 + 所需文件字节数) / 每块字节数)

同时检查请求长度、缓冲区边界和固件限制,必要时分批读取。

分清是哪一次 mov cr0, eax 后出错

代码中有两次不同的模式切换:

or eax, CR0_PE
mov cr0, eax

以及:

or eax, CR0_PG
mov cr0, eax

前一次启用保护模式,后一次启用分页,并进入 IA-32e 模式的切换过程。两处故障的排查重点不同。

设置 CR0.PE 后出错

优先检查:

  1. lgdt 是否从正确地址读取了描述符;
  2. GDT 是否完整加载,是否被其他写操作覆盖;
  3. 代码段选择子是否正确;
  4. 远跳转的目标地址是否与链接地址一致。

即使 lgdt 执行成功,GDT 中的段描述符也可能无效。处理器可能直到执行后面的远跳转时才读取目标代码段描述符,并在此时触发 #GP。

进入保护模式前,应再次执行 cli。如果此时没有有效的保护模式 IDT,处理异常时可能再次发生异常,最终导致三重故障并复位。cli 只能屏蔽可屏蔽硬件中断,无法阻止 #GP、#PF 等异常。

设置 CR0.PG 后出错

优先检查页表,因为启用分页后,处理器立即需要通过新页表获取后续指令。

当前代码建立的映射是:

PML4[0] -> PDPT[0] -> PD[0] -> 一个 2 MiB 大页

它只对从线性地址零开始的前 2 MiB 做了恒等映射。代码注释中的 “Virtual 1 GiB entry” 不表示整个 1 GiB 都已映射。

切换时使用的指令、远跳转目标、GDT 和栈,都必须位于有效映射内。还要检查 CPU 是否支持 long mode 和 PAE,以及相关控制寄存器的状态是否满足要求。

页表必须完整清零,不能依赖 .bss 的初始内容

下面这段代码可能导致 QEMU 与真机表现不同:

section .bss

align 4096
PMT:
resb 4096

resb 只为 .bss 预留内存,BIOS 不会因此将这些内存清零。使用 objcopy -O binary 后,也不会有 ELF 加载器自动完成通常由它负责的 .bss 初始化。

还要注意页表项的写入宽度:

mov [PMT], eax

这条指令只写入 32 位,而每个长模式页表项有 64 位。如果高 32 位残留非零数据,可能形成错误的物理地址或保留位组合。其他未初始化的表项也可能意外有效。

进入 32 位保护模式并设置好平坦数据段后,可以先清零页表,再填写条目:

bits 32

; 前提:ES 是基址为 0 的可写数据段。
; PMT、PDPT、PD、PT 连续,各占 4096 字节。
cld
xor eax, eax
mov edi, PMT
mov ecx, (4 * 4096) / 4
rep stosd

mov eax, PDPT
or eax, 0x003                 ; Present | Read/Write
mov dword [PMT], eax

mov eax, PD
or eax, 0x003
mov dword [PDPT], eax

mov dword [PD], 0x083         ; Present | Read/Write | PS

最后一项将从物理地址零开始的 2 MiB 映射到相同的线性地址。各页表仍须按 4096 字节对齐,并放在可用且没有冲突的内存中。

启动阶段还应初始化整个 .bss,但要安排好顺序,避免清除正在使用的栈,或再次清掉已经写入 .bss 的 E820 结果。增加磁盘读取量无法替代 .bss 初始化。

E820 查询也可能在切换前破坏内存

原代码使用:

mov ax, map_start
shr ax, 4
mov es, ax
mov di, 0

这段地址计算有两个限制:

  • mov ax, map_start 只能容纳 16 位地址;
  • 右移四位后设置 DI=0,会丢失地址的低四位,只有 map_start 按 16 字节对齐时才能得到原地址。

如果已经保证 map_start < 0x10000,且缓冲区不会跨越段边界,可以直接使用:

xor ax, ax
mov es, ax
mov di, map_start

访问更高地址时,需要重新设计合法的 ES:DI,不能继续依赖 16 位截断。

E820 循环还需要完成以下检查和处理:

  • 每次检查 CF 和返回的 EAX == 0x534D4150;
  • 检查返回的 ECX,处理 20 字节与 24 字节记录;
  • 使用 24 字节格式时,在调用前初始化扩展属性字段;
  • 限制记录数量,防止缓冲区或 DI 越界;
  • 确认记录有效后再计数;
  • 处理完最后一条有效记录,再根据 EBX 判断是否结束。

计数器的初始化也有问题:

mov bp, 0

这只清零了 EBP 的低 16 位,后面却将整个 EBP 写入 entry_count。如果继续使用这个计数器,应初始化完整寄存器:

xor ebp, ebp

真机返回的内存布局和记录数量可能与 QEMU 不同。E820 循环如果缺少边界检查,可能先覆盖 GDT 或代码,直到模式切换时才表现为崩溃。

0x7E00 可以使用,地址上移需要同时修改寻址方式

0x7E00 紧接传统启动扇区之后,可以作为较小第二阶段的加载地址。是否合适,要看完整的内存布局,包括代码、数据、栈、E820 缓冲区、页表,以及 BIOS 使用的内存区域。

提高加载地址后,如果出现 relocation truncated to fit,应检查具体失败的重定位。

代码中的这些位置都可能受到 16 位地址限制:

mov ax, map_start
lgdt [gdt_descriptor]
jmp gdt_table_32.code_32:pm_start

在 BITS 16 中,要留意默认地址大小和远跳转的偏移宽度。可以用以下命令检查节布局、符号地址和重定位:

x86_64-elf-readelf -SW obj/kernel.elf
x86_64-elf-nm -n obj/kernel.elf
x86_64-elf-readelf -r obj/arch.o
wc -c bin/boot.bin bin/kernel.bin

裸二进制不会自行处理 ELF 元数据或重定位,实际加载位置必须与链接脚本的安排一致。一种常见做法是将实模式加载器保留在低地址,进入保护模式后,再把内核复制或加载到较高地址。使用 1 MiB 及以上地址时,还要处理 A20。

按检查点定位,避免只凭最后一条输出判断

建议按以下顺序缩小范围:

  1. 修正启动扇区填充,确认 boot.bin 为 512 字节,签名位于偏移 510。
  2. 初始化 DS、ES、SS:SP 和方向标志。
  3. 检查扩展读盘支持、CF 和 AH 错误码,确认设备块大小及实际读取位置。
  4. 核对加载后的代码、GDT 字节与构建产物一致。
  5. 暂时只测试进入 32 位保护模式,确认远跳转和段寄存器加载成功。
  6. 清零并建立页表,再测试启用分页和进入 64 位代码。
  7. 最后接入 C 内核与后续初始化。

分别在 lgdt 前、设置 CR0.PE 后、进入 pm_start 后、设置 CR0.PG 前,以及进入 lm_start 后设置检查点。进入保护模式和长模式后,应使用已初始化的串口或其他适合当前模式的输出方式,不能直接继续调用实模式 BIOS 显示服务。

QEMU 中可以启用异常和复位日志,例如:

qemu-system-x86_64 \
  -cdrom bin/kernel.iso \
  -no-reboot \
  -no-shutdown \
  -d int,cpu_reset,guest_errors \
  -D qemu.log

将 ISO 路径替换为实际文件。日志和调试器可以帮助区分 #GP、#PF,以及异常处理失败后的复位;真机上的检查点则用来确认两边从哪个阶段开始出现差异。

进入 C 代码前,还要满足所用 ABI 的栈对齐要求。对于通常的 x86-64 System V ABI,调用前应保证 RSP 按 16 字节对齐;内核代码通常还需要使用 -mno-red-zone。这些是后续运行所需的条件,目前并未证实它们是 mov cr0 附近崩溃的原因。

接下来需要补齐 linker.ld、ISO 生成配置、读盘返回状态,以及两个模式切换点各自的检查结果,才能判断故障出在读取布局、内存覆盖、GDT 还是页表。

备注:内容仅供参考。