很多学习 OS 开发的朋友,一开始都会卡在“代码写完了,编译也通过了,但一运行就是黑屏、乱码、重启”这个阶段。问题往往不是代码逻辑本身有多难,而是你看不见 CPU 当前到底在执行什么、数据访问到了哪里、跳转目标是否和预期一致。
系统镜像不像普通应用程序,它没有调试器帮你把源码和运行位置对应起来,尤其是实模式下的 kernel,几乎是在“裸奔”。你唯一能直接观测到的,就是镜像里的二进制内容。这个时候,理解反汇编并会用反汇编工具排查问题,就成了 OS 开发入门阶段必须掌握的一项核心技能。
本文将围绕“实模式下 kernel 开发”这个场景,完整演示一套系统镜像反汇编排错方法。内容包括实模式内存布局、镜像生成过程、objdump 和 ndisasm 的用法,以及一个从“启动乱码”到“定位根因”再到“修复验证”的完整实战案例。无论你是刚开始写 bootloader,还是已经能跑通一个小内核,这篇文章都值得收藏备查。
1. 背景与核心概念
1.1 什么是实模式,为什么 OS 开发绕不开它
实模式(Real Mode)是 x86 处理器保留至今的一种运行模式。在实模式下,CPU 只能访问 1MB 地址空间,通过“段寄存器 + 偏移地址”的方式计算物理地址,计算公式是:
物理地址 = 段寄存器值 × 16 + 偏移地址
例如0x9000:0x0000对应的物理地址是0x90000。
计算机上电后,CPU 首先进入的就是实模式,然后 BIOS 完成硬件自检和初始化,最后把启动设备的第 0 个扇区加载到物理地址0x7C00,并跳转过去执行。也就是说,任何自制操作系统,无论后面想不想进入保护模式,第一步都必须在实模式下完成引导扇区编写和 kernel 的初步加载。
实模式下没有虚拟内存、没有权限分级、没有异常处理机制,代码写错一个跳转地址或段寄存器,系统就会立刻表现出异常行为。这种环境对调试能力提出了很高的要求。
1.2 系统镜像到底是什么
这里说的系统镜像,不是 Linux 发行版的 ISO 文件,而是开发 OS 时生成的一个可启动磁盘镜像,通常是一个.img文件。它保存了磁盘的完整二进制内容,包括引导扇区、kernel 代码、文件系统数据等。
最简单的镜像结构如下:
| 扇区位置 | 内容 | 说明 |
|---|---|---|
| 第 0 扇区 | bootloader | 512 字节,BIOS 自动加载到 0x7C00 |
| 第 1 扇区及之后 | kernel | 由 bootloader 从磁盘读取到内存指定位置 |
我们平时说的“实模式下 kernel 开发”,本质上就是先写一个 bootloader,让它把真实的 kernel 从磁盘读到内存,再跳转过去执行。而“系统镜像反汇编排错”,就是把镜像文件当作纯二进制数据,用反汇编工具还原其中的 CPU 指令,从而排查代码为什么没有按预期执行。
1.3 为什么反汇编是排错的关键手段
在 OS 开发中,编译阶段的错误通常比较好查,汇编器或编译器会给出明确提示。真正的难点在于运行期问题:镜像可以启动,但行为异常。此时你的代码已经变成了机器码,光是“读源码找错误”是不够的,因为运行结果取决于 CPU 真正看到了什么,而不是你以为写了什么。
反汇编解决的就是这个信息断层问题:
- 查看镜像中某个位置的指令序列。
- 确认跳转指令的目标地址是否和加载地址一致。
- 确认数据访问指令(例如
mov si, msg)生成的操作数是否正确。 - 对比源码、链接脚本、反汇编结果,找出地址错位、段基址错误、指令宽度错误等问题。
简单来说,反汇编能把“CPU 看到的二进制”翻译成“人能读懂的汇编”,是连接源码和实际运行行为的桥梁。
2. 环境准备与版本说明
做实模式 OS 开发和镜像排错,需要准备以下工具。具体版本可以根据你的系统环境调整,本文重点是演示配置和排错思路。
2.1 工具清单
| 工具 | 作用 |
|---|---|
| NASM | 编写和编译 16 位汇编代码,生成原始二进制 |
| GCC / LD | 编译和链接 C 语言 kernel(本文以汇编为主) |
| QEMU | 虚拟机运行镜像,模拟启动过程 |
| dd | 制作和写入磁盘镜像 |
| xxd / hexdump | 查看镜像原始十六进制内容 |
| objdump | 反汇编二进制文件,支持指定反汇编模式 |
| ndisasm | NASM 自带的反汇编工具,适合裸二进制文件 |
如果你使用的是 Ubuntu/Debian 系列系统,可以用下面的命令安装:
sudo apt update sudo apt install -y nasm gcc binutils qemu-system-x86macOS 上可以用 Homebrew:
brew install nasm gcc binutils qemu版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
2.2 示例项目结构
为了便于后续实战,我们建立一个干净的目录:
os-demo/ ├── boot.asm # 引导扇区源码 ├── kernel.asm # 实模式 kernel 源码 ├── build.sh # 一键构建镜像脚本 └── disk.img # 生成的系统镜像在动手之前,先把目录建好:
mkdir os-demo cd os-demo3. 实模式 kernel 与系统镜像的核心原理
3.1 实模式下的内存布局
在排错之前,必须对实模式的内存布局有清晰认识。下面是一张简化版的内存分布表:
| 地址范围 | 用途 |
|---|---|
| 0x00000 - 0x003FF | 中断向量表 |
| 0x00400 - 0x004FF | BIOS 数据区 |
| 0x00500 - 0x07BFF | 可用区域 |
| 0x07C00 - 0x07DFF | 引导扇区加载位置 |
| 0x08000 - 0x9FBFF | 可用区域(kernel 通常加载到这里) |
| 0x9FC00 - 0x9FFFF | 扩展 BIOS 数据区 |
| 0xA0000 - 0xBFFFF | 显存和视频 BIOS |
| 0xC0000 - 0xFFFFF | BIOS ROM 和硬件映射 |
bootloader 被 BIOS 固定加载到0x7C00,而 kernel 加载到哪里,完全由你在 bootloader 代码里决定。通常会把 kernel 加载到0x8000到0x9000附近的可用区域。
这里有个关键点:kernel 源码里写的地址(通过org或链接脚本指定)必须和 bootloader 实际加载它的地址一致。如果不一致,轻则打印乱码,重则直接死机重启。
3.2 系统镜像的组成结构
一个最小的可启动镜像由两部分组成:
第一部分是引导扇区,共 512 字节,最后两个字节必须是小端序的0x55 0xAA。BIOS 检查到这两个字节时,才认为这是一个可启动磁盘。
第二部分是 kernel,被 bootloader 通过 BIOS 中断读取到内存。在软盘镜像中,逻辑扇区号从 0 开始,引导扇区占用第 0 扇区,kernel 一般从第 1 扇区开始存放。
生成镜像时,不能直接拼文件,因为引导扇区必须恰好占据文件开头的 512 字节。常用的做法是先创建空白镜像,然后分别写入 bootloader 和 kernel:
# 创建一个 1.44MB 的空白软盘镜像 dd if=/dev/zero of=disk.img bs=512 count=2880 # 写入引导扇区 dd if=boot.bin of=disk.img conv=notrunc # 从第 1 扇区开始写入 kernel dd if=kernel.bin of=disk.img bs=512 seek=1 conv=notruncseek=1表示跳过第一个扇区再写入,这样 kernel 就从镜像的第 1 扇区开始。
3.3 从 bootloader 到 kernel 的完整加载流程
整个启动流程可以用文字描述为:
- 计算机上电,CPU 进入实模式。
- BIOS 自检,初始化中断向量表。
- BIOS 读取启动设备的第 0 扇区到
0x7C00。 - CPU 跳转到
0x7C00执行 bootloader。 - bootloader 初始化段寄存器和栈。
- bootloader 调用
int 0x13读取磁盘上的 kernel 到内存指定位置。 - bootloader 用远跳转指令进入 kernel。
- kernel 执行自己的初始化逻辑,打印信息或进入保护模式。
无论哪一步出问题,最后的现象都可能是黑屏或乱码。而反汇编排错,就相当于把第 6 和第 7 步之间“地址是否对得上”这个问题显性化。
4. 反汇编工具使用详解
4.1 用 ndisasm 反汇编裸二进制
ndisasm是 NASM 自带的工具,非常适合反汇编没有文件头、没有符号信息的裸二进制镜像。基本用法是:
ndisasm -o 0x7c00 boot.bin-o参数可以指定起始地址,让反汇编结果显示的逻辑地址和实际加载地址一致。比如引导扇区加载到0x7C00,那么-o 0x7c00会让第一列地址从0x7C00开始显示。
下面是一次典型输出:
00007C00 31C0 xor ax,ax 00007C02 8ED8 mov ds,ax 00007C04 8ED0 mov ss,ax 00007C06 BC007C mov sp,0x7c00 00007C09 B80090 mov ax,0x9000 00007C0C 8EC0 mov es,ax这种输出方式最接近 CPU 的视角,适合排查指令编码和跳转目标。
4.2 用 objdump 反汇编二进制镜像
objdump是 binutils 自带工具,功能比ndisasm更丰富。反汇编裸二进制时,需要指定目标格式、指令集架构和反汇编模式:
objdump -D -b binary -m i386 -M i8086 boot.bin参数含义:
-D:反汇编所有段,不管能否被识别为代码。-b binary:输入文件是纯二进制。-m i386:指定架构为 x86。-M i8086:使用 16 位实模式反汇编规则。
输出格式大致如下:
boot.bin: file format binary Disassembly of section .data: 00000000 <.data>: 0: 31 c0 xor %ax,%ax 2: 8e d8 mov %ds,%ax 4: 8e d0 mov %ss,%ax 6: bc 00 7c mov $0x7c00,%sp 9: b8 00 90 mov $0x9000,%ax c: 8e c0 mov %es,%ax使用objdump的优点是参数丰富,既能反汇编 16 位实模式代码,也能反汇编 32 位保护模式代码,并且可以配合链接脚本查看符号地址。
4.3 在 QEMU 调试中直接查看反汇编
除了直接反汇编镜像文件,QEMU 还提供了 gdb 调试接口,可以在运行时动态查看 CPU 正在执行的指令。启动方式:
qemu-system-i386 -fda disk.img -s -S然后在另一个终端连接调试器:
gdb target remote :1234 set architecture i8086 x/10i $cs*16+$eip不过对于入门阶段,我建议先从静态反汇编入手,因为大部分地址错位问题在静态反汇编阶段就能定位,不需要等到运行时。
4.4 工具选择建议
| 场景 | 推荐工具 |
|---|---|
| 快速查看镜像某段指令 | ndisasm |
| 对比源码和机器码 | objdump |
| 查看带符号的 C 内核 | objdump -d |
| 运行时动态断点调试 | gdb + QEMU |
5. 完整实战:用反汇编定位 kernel 启动失败
下面我们来做一个完整实验。我会故意写一个存在加载地址不一致问题的“最小系统”,然后通过反汇编一步步定位问题。整个过程和实际开发中遇到的排错场景非常接近。
5.1 准备一个最小可复现项目
首先是引导扇区源码,它负责加载 kernel 到物理地址0x90000,然后跳转执行。这里我把0x90000表示为0x9000:0x0000。
文件路径:os-demo/boot.asm
; boot.asm [org 0x7c00] BITS 16 start: xor ax, ax mov ds, ax mov ss, ax mov sp, 0x7c00 ; 读取 kernel 到 0x9000:0x0000,物理地址 0x90000 mov ax, 0x9000 mov es, ax xor bx, bx mov ah, 0x02 mov al, 1 ; 读取 1 个扇区 mov ch, 0 mov cl, 2 ; 从第 2 扇区开始 mov dh, 0 mov dl, 0x00 ; 使用第一个软盘驱动器 int 0x13 jc read_error ; 跳转到 kernel jmp 0x9000:0x0000 read_error: mov si, err_msg call print_string jmp hang print_string: lodsb or al, al jz .done mov ah, 0x0e int 0x10 jmp print_string .done: ret err_msg db "disk read error", 13, 10, 0 times 510-($-$$) db 0 dw 0xaa55接下来是 kernel 源码。我给它的org设置成了0x8000,但 bootloader 实际上把它加载到了0x9000。这是一个刻意制造的典型错误。
文件路径:os-demo/kernel.asm
; kernel.asm [org 0x8000] BITS 16 start: mov ax, cs mov ds, ax mov si, msg call print_string hlt print_string: lodsb or al, al jz .done mov ah, 0x0e int 0x10 jmp print_string .done: ret msg db "Hello from kernel!", 0 times 512-($-$$) db 05.2 编写构建脚本并生成镜像
为了减少重复输入,我写了一个构建脚本。
文件路径:os-demo/build.sh
#!/bin/bash nasm -f bin boot.asm -o boot.bin nasm -f bin kernel.asm -o kernel.bin dd if=/dev/zero of=disk.img bs=512 count=2880 dd if=boot.bin of=disk.img conv=notrunc dd if=kernel.bin of=disk.img bs=512 seek=1 conv=notrunc echo "build ok"执行构建:
chmod +x build.sh ./build.sh然后用 QEMU 启动镜像:
qemu-system-i386 -fda disk.img预期正常情况下会显示 “Hello from kernel!”,但实际现象是:屏幕上出现一堆乱码,然后卡住。这就是我们要排查的问题。
5.3 第一步:反汇编引导扇区
先反汇编boot.bin,确认 bootloader 的加载地址和跳转地址是否正确。
ndisasm -o 0x7c00 boot.bin输出关键部分:
00007C00 31C0 xor ax,ax 00007C02 8ED8 mov ds,ax 00007C04 8ED0 mov ss,ax 00007C06 BC007C mov sp,0x7c00 00007C09 B80090 mov ax,0x9000 00007C0C 8EC0 mov es,ax 00007C0E 31DB xor bx,bx 00007C10 B402 mov ah,0x2 00007C12 B001 mov al,0x1 00007C14 B500 mov ch,0x0 00007C16 B102 mov cl,0x2 00007C18 B600 mov dh,0x0 00007C1A B200 mov dl,0x0 00007C1C CD13 int 0x13 00007C1E 721A jc 0x7c3a 00007C20 EA00009000 jmp 0x9000:0x0000关键一行是最后:
00007C20 EA00009000 jmp 0x9000:0x0000这确认了 bootloader 会跳转到物理地址0x90000执行 kernel 代码。引导扇区本身没有问题,问题应该出在 kernel 侧。
5.4 第二步:反汇编 kernel 镜像
接下来反汇编kernel.bin:
ndisasm -o 0x8000 kernel.bin输出:
00008000 8CC8 mov ax,cs 00008002 8ED8 mov ds,ax 00008004 BE1E80 mov si,0x801e 00008007 E80500 call 0x800f 0000800A F4 hlt 0000800B 0000 add [bx+si],al 0000800D 0000 add [bx+si],al 0000800F AC lodsb 00008010 0AC0 or al,al 00008012 7404 jz 0x8018 00008014 B40E mov ah,0xe 00008016 CD10 int 0x10 00008018 EBF5 jmp 0x800f 0000801A C3 ret 0000801B 0000 add [bx+si],al 0000801E 48 dec ax 0000801F 656C gs insb 00008021 6C insb 00008022 6F outsw这里出现了一个非常明显的问题。
在0x8004处,指令为:
BE1E80 mov si,0x801e这条指令把数据段偏移设置为0x801E。按照源码,msg标签确实在 org 为0x8000的地址空间中位于0x801E。但是,当 kernel 实际被加载到0x9000并执行时,msg字符串位于物理地址0x901E处,而si却被设置成了0x801E。
也就是说,print_string会从物理地址0x801E开始读取数据,而不是从0x901E读取。读到的内容变成了非预期的数据,于是屏幕上出现乱码。
5.5 第三步:对比源码定位根因
回到源码,问题根源一目了然:
- bootloader 加载 kernel 到
0x9000:0x0000,物理地址0x90000。 - kernel 源码却使用
org 0x8000,把所有内部地址都按0x8000为基准计算。
CPU 跳转到0x90000后,指令本身能执行,因为机器码里的指令操作码没有变化。但凡是需要生成绝对地址的指令,例如mov si, msg,计算出来的基准地址还是0x8000,这就会导致数据访问错位。
类似的错位还会影响:
- 绝对跳转指令
- 绝对调用指令
- 数据缓冲区地址
- 中断向量设置
5.6 修复并验证
修复方式很简单:保持 bootloader 的加载地址不变,把 kernel 的org改成和加载地址一致,即0x9000。
文件路径:os-demo/kernel.asm
; kernel.asm 修复后 [org 0x9000] BITS 16 start: mov ax, cs mov ds, ax mov si, msg call print_string hlt print_string: lodsb or al, al jz .done mov ah, 0x0e int 0x10 jmp print_string .done: ret msg db "Hello from kernel!", 0 times 512-($-$$) db 0重新构建并反汇编:
./build.sh ndisasm -o 0x9000 kernel.bin输出:
00009000 8CC8 mov ax,cs 00009002 8ED8 mov ds,ax 00009004 BE1E90 mov si,0x901e 00009007 E80500 call 0x900f 0000900A F4 hlt 0000900B 0000 add [bx+si],al 0000900D 0000 add [bx+si],al 0000900F AC lodsb 00009010 0AC0 or al,al 00009012 7404 jz 0x9018 00009014 B40E mov ah,0xe 00009016 CD10 int 0x10 00009018 EBF5 jmp 0x900f 0000901A C3 ret 0000901B 0000 add [bx+si],al 0000901E 48 dec ax 0000901F 656C gs insb 00009021 6C insb 00009022 6F outsw现在关键指令变成了:
00009004 BE1E90 mov si,0x901e字符串地址和实际加载位置一致了。再启动 QEMU:
qemu-system-i386 -fda disk.img屏幕上正常显示:
Hello from kernel!排错完成。
6. 常见排错问题与排查思路
6.1 常见错误现象对照表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 启动后黑屏无输出 | bootloader 没有正确跳转到 kernel | 反汇编 boot.bin,检查跳转地址 |
| 屏幕输出乱码后卡死 | kernel 的 org 和实际加载地址不一致 | 反汇编 kernel.bin,检查数据访问地址 |
| 反复重启 | 引导扇区末尾缺少 0x55AA,或 int 0x13 读取失败 | 用 xxd 检查镜像最后两字节 |
| 打印内容少了一部分 | 读取扇区数不足,kernel 没完整加载 | 增大 al 的读取扇区数,或检查 kernel 大小 |
| 反汇编结果“指令错乱” | 反汇编模式选错,用 32 位模式反汇编 16 位代码 | 添加-M i8086或-o指定正确起始地址 |
| QEMU 提示 “Boot failed” | 磁盘镜像不是可启动介质 | 确认第 510、511 字节是 0x55、0xAA |
6.2 系统镜像反汇编排错标准流程
遇到启动异常时,我建议按下面的流程排查,既省时间,也不容易漏掉关键点。
第一步,确认镜像基本结构。用xxd查看引导扇区末尾:
xxd disk.img | tail -n 5确认55 aa出现在第 510、511 字节。
第二步,确认 bootloader 加载地址和跳转地址。
ndisasm -o 0x7c00 boot.bin核对mov ax, 加载段、mov es, ax、jmp 段:偏移是否和你预期的物理地址一致。
第三步,确认 kernel 内部地址。
ndisasm -o 加载地址 kernel.bin重点查看所有mov si/ di/ bx, 立即数指令,以及call、jmp的绝对目标地址。
第四步,把反汇编结果和源码对照。如果反汇编中访问的地址比源码标注的地址固定偏移了一段,说明org或链接脚本配置和 bootloader 加载地址不匹配。
第五步,修复后重新生成镜像,再次反汇编验证。
这套流程虽然简单,但能解决大部分实模式 kernel 开发初期遇到的黑屏、乱码、重启问题。
7. 最佳实践与工程建议
7.1 把“反汇编验证”做成固定动作
很多初学者在编译通过后,会直接跳到运行测试,中间少了“反汇编验证”这一步。建议在每次更新 kernel 后,都顺手反汇编一次,尤其是检查数据和代码段相关的绝对地址。习惯了以后,你会发现很多问题在运行前就能发现。
7.2 保证源码、链接脚本和镜像的地址一致
实际项目中,kernel 可能由多个汇编文件或 C 文件组成,这时单纯靠org已经不够,需要引入链接脚本。无论用哪种方式,最终都要保证以下三个地址一致:
- bootloader 读取 kernel 时使用的段地址和偏移。
- 跳转指令写入的
jmp 段:偏移。 - kernel 编译链接时指定的加载地址。
只要有一个对不上,就会出现类似本文的乱码问题。一个推荐的检查方式是:在设计阶段就把加载地址写进项目 README,例如“kernel 加载到 0x9000”,避免后期忘记。
7.3 区分 16 位和 32 位反汇编模式
实模式代码通常按 16 位编码,但如果 kernel 切换到了保护模式,后续指令就是 32 位编码。反汇编时不能一刀切,需要根据代码段实际运行的模式选择参数。
- 16 位实模式代码:
objdump -D -b binary -m i386 -M i8086 - 32 位保护模式代码:
objdump -D -b binary -m i386
如果模式选错,反汇编结果会完全不可读,产生大量奇怪的指令。
7.4 善用 QEMU 参数辅助排错
QEMU 提供了几个非常实用的调试参数:
qemu-system-i386 -fda disk.img -no-reboot -d int-no-reboot:遇错不自动重启,方便观察最后画面。-d int:打印中断调用日志,能看到 int 0x13、int 0x10 的调用情况。-s -S:配合 gdb 进行运行时调试。
在排错时,这些参数能节省大量时间。
7.5 保持镜像文件可追溯
镜像文件是二进制产物,建议给构建脚本加上版本和构建时间信息。例如在镜像的保留扇区写入一个版本号字符串,这样即使拿到一个旧的.img文件,也能快速判断它对应哪一版代码。这个小习惯在多人协作或长期迭代时尤其有用。
8. 总结与下一步学习方向
本文完成了一个“实模式下 kernel 开发 + 系统镜像反汇编排错”的完整闭环。核心收获可以总结为三点:
第一,实模式下的地址计算和内存布局是所有排错的基础。只有知道 CPU 把代码加载到了哪里,以及代码认为自己加载到了哪里,才能判断问题是否由地址错位引起。
第二,反汇编工具是看到“CPU 眼中的世界”的唯一窗口。ndisasm适合快速查看裸二进制,objdump适合带符号和指定模式的深度分析,两者配合能解决绝大多数启动异常问题。
第三,排错时不要只盯着源码,更要把源码、反汇编结果、镜像加载地址放在一起对比。地址一致性问题在实模式开发中几乎无法避免,而反汇编是最高效的验证手段。
如果你刚入门 OS 开发,接下来可以继续学习软盘文件系统(FAT12)、中断处理、进入保护模式等主题。在进入保护模式之前,建议先通过本文的方法把实模式下的内核加载流程彻底吃透,因为后面的所有排错,仍然依赖同样的反汇编分析方法。