实模式Kernel开发中的反汇编排错:从启动乱码到地址修复 很多学习 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 扇区bootloader512 字节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反汇编二进制文件支持指定反汇编模式ndisasmNASM 自带的反汇编工具适合裸二进制文件如果你使用的是 Ubuntu/Debian 系列系统可以用下面的命令安装sudo apt update sudo apt install -y nasm gcc binutils qemu-system-x86macOS 上可以用 Homebrewbrew 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 - 0x004FFBIOS 数据区0x00500 - 0x07BFF可用区域0x07C00 - 0x07DFF引导扇区加载位置0x08000 - 0x9FBFF可用区域kernel 通常加载到这里0x9FC00 - 0x9FFFF扩展 BIOS 数据区0xA0000 - 0xBFFFF显存和视频 BIOS0xC0000 - 0xFFFFFBIOS 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 ofdisk.img bs512 count2880 # 写入引导扇区 dd ifboot.bin ofdisk.img convnotrunc # 从第 1 扇区开始写入 kernel dd ifkernel.bin ofdisk.img bs512 seek1 convnotruncseek1表示跳过第一个扇区再写入这样 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 QEMU5. 完整实战用反汇编定位 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 ofdisk.img bs512 count2880 dd ifboot.bin ofdisk.img convnotrunc dd ifkernel.bin ofdisk.img bs512 seek1 convnotrunc 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.binndisasm -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 [bxsi],al 0000800D 0000 add [bxsi],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 [bxsi],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 [bxsi],al 0000900D 0000 add [bxsi],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 [bxsi],al 0000901E 48 dec ax 0000901F 656C gs insb 00009021 6C insb 00009022 6F outsw现在关键指令变成了00009004 BE1E90 mov si,0x901e字符串地址和实际加载位置一致了。再启动 QEMUqemu-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、0xAA6.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 i808632 位保护模式代码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、中断处理、进入保护模式等主题。在进入保护模式之前建议先通过本文的方法把实模式下的内核加载流程彻底吃透因为后面的所有排错仍然依赖同样的反汇编分析方法。