为什么程序在 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 块的结束地址,不包含该地址 |
|---|---|---|
| 512 | 0x8A00 | 0x8C00 |
| 2048 | 0xAE00 | 0xB600 |
新增的写入范围可能覆盖栈、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 后出错
优先检查:
lgdt是否从正确地址读取了描述符;- GDT 是否完整加载,是否被其他写操作覆盖;
- 代码段选择子是否正确;
- 远跳转的目标地址是否与链接地址一致。
即使 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。
按检查点定位,避免只凭最后一条输出判断
建议按以下顺序缩小范围:
- 修正启动扇区填充,确认
boot.bin为 512 字节,签名位于偏移 510。 - 初始化
DS、ES、SS:SP和方向标志。 - 检查扩展读盘支持、CF 和 AH 错误码,确认设备块大小及实际读取位置。
- 核对加载后的代码、GDT 字节与构建产物一致。
- 暂时只测试进入 32 位保护模式,确认远跳转和段寄存器加载成功。
- 清零并建立页表,再测试启用分页和进入 64 位代码。
- 最后接入 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 还是页表。
备注:内容仅供参考。
