一、写在前面:为什么调试比写代码更重要

在操作系统开发这个领域,有一个极其残酷的事实:你写的代码几乎不可能一次通过。与普通的应用程序开发不同,当你手搓操作系统时,你面对的是没有标准库、没有运行时环境、甚至没有内存保护的裸机环境。一个小的错误——比如栈没有正确初始化、段描述符的某个位写错了、GDT 的界限少算了 1——都可能导致整个系统崩溃,表现为黑屏、重启、死循环,或者干脆什么反应都没有。

我在多年学习和实践操作系统开发的过程中,深刻体会到一句话:会调试的人,才能真正写出能跑的操作系统。调试器不是“出了问题才想起来用”的工具,而是你从第一行引导代码开始就应该熟练掌握的核心技能。只有通过调试器,你才能真正看到 CPU 在执行你的指令时到底在想什么:寄存器里是什么值、内存里是什么内容、指令流走到了哪里、为什么跳转没有生效。

本教程假设你已经跟着之前的系列文章,用汇编语言或 C 语言写出了一些引导程序和内核代码。如果你在运行过程中遇到了各种“莫名其妙”的问题,或者你想从一开始就建立一套高效、专业的调试工作流,那么这篇文章就是为你准备的。我们将以 Bochs 这个最经典、最适合操作系统开发的模拟器为核心,从安装配置、基本命令、断点技巧,到实战调试 MBR、Loader、保护模式内核和中断处理,完整走一遍调试操作系统的全流程。

本文约两万字,建议收藏后慢慢啃。文中所有命令和代码都经过验证,可以直接复制使用。

二、Bochs 调试器概述:为什么是它

在正式进入命令和实战之前,我们先搞清楚一个问题:调试操作系统,用什么工具最好?

2.1 主流选择的对比

目前开发操作系统常见的调试方案主要有以下几种:

  • Bochs 内置调试器:Bochs 是一个完全用软件模拟 x86 架构的模拟器,它内置了一个功能强大的命令行调试器,支持断点、单步、内存查看、寄存器查看、反汇编等几乎所有调试功能。由于是纯软件模拟,它不依赖任何硬件调试特性,调试起来非常稳定可控。
  • QEMU + GDB:QEMU 性能更好,配合 GDB 可以进行源码级调试。但 GDB 的远程调试协议在实模式和早期保护模式下有一些限制,学习曲线也更陡峭。
  • VirtualBox / VMware + 串口日志:这类虚拟机性能接近真实机器,但调试能力有限,通常只能靠串口输出日志来排查问题。

对于初学者和大多数操作系统开发场景,Bochs 内置调试器是最佳选择。原因如下:

  • 无需额外配置 GDB stub,开箱即用。
  • 支持从第一条指令开始调试,也就是从 BIOS 执行的第一条指令就完全可控。
  • 对实模式、保护模式、长模式都有完整支持。
  • 调试信息非常丰富,可以看到 CPU 内部的完整状态。
  • 支持魔术断点(magic breakpoint),可以在代码中主动触发暂停。
  • 纯软件模拟的确定性,使得调试过程可重现。

当然,Bochs 的缺点也很明显:性能较慢。但对于调试来说,慢一点反而更利于观察程序行为。在实际开发中,我通常的做法是:用 Bochs 调试,用 QEMU 或 VirtualBox 跑发布版

2.2 Bochs 调试器能做什么

Bochs 自带的命令行调试器提供了以下核心能力:

  • 在任何物理地址或线性地址设置断点。
  • 单步执行和逐步跳过函数调用。
  • 查看和修改所有 CPU 寄存器,包括通用寄存器、段寄存器、控制寄存器、调试寄存器。
  • 以多种格式(字节、字、双字、四字)查看和修改物理内存和虚拟内存。
  • 反汇编当前指令附近的代码。
  • 跟踪执行流程,将每条执行的指令记录到日志。
  • 监视内存地址,当指定地址被读写时自动暂停。
  • 显示中断描述符表(IDT)、全局描述符表(GDT)、局部描述符表(LDT)和任务状态段(TSS)的内容。
  • 通过魔术断点让代码在指定位置主动停下来。

这些能力几乎覆盖了操作系统调试的所有需求。

三、Bochs 的安装与编译

Bochs 的安装方式因操作系统而异。下面分别介绍 Linux、macOS 和 Windows 下的安装方法。对于有调试需求的开发者,强烈建议从源码编译,因为发行版预编译的二进制包很多不包含调试器支持。

3.1 在 Ubuntu / Debian 上从源码编译

首先安装编译依赖:

sudo apt update
sudo apt install -y build-essential libgtk2.0-dev libncurses5-dev libsdl2-dev libx11-dev xorg-dev

然后下载 Bochs 源码。截至本文写作时,较新的稳定版本是 2.8.x:

wget https://sourceforge.net/projects/bochs/files/bochs/2.8/bochs-2.8.tar.gz
tar -xzf bochs-2.8.tar.gz
cd bochs-2.8

配置编译选项。这里的关键是启用调试器和反汇编器:

./configure \
    --enable-debugger \
    --enable-disasm \
    --enable-x86-64 \
    --enable-smp \
    --enable-long-phy-address \
    --with-x11 \
    --with-sdl2 \
    --prefix=/usr/local/bochs

参数说明:

  • --enable-debugger:启用内置调试器,这是核心选项。
  • --enable-disasm:启用反汇编支持,调试器中 u 命令依赖它。
  • --enable-x86-64:启用 64 位支持。
  • --enable-smp:支持多处理器模拟。
  • --enable-long-phy-address:支持扩展物理地址。
  • --with-x11--with-sdl2:选择显示后端,可根据需要保留一个。

然后编译并安装:

make -j$(nproc)
sudo make install

安装完成后验证:

/usr/local/bochs/bin/bochs --version

如果输出中包含 Bochs x86 Emulator 的版本信息,说明安装成功。

3.2 在 macOS 上安装

macOS 用户可以使用 Homebrew 安装,但 Homebrew 的默认版本可能没有调试器。建议从源码编译:

brew install sdl2 wxwidgets
wget https://sourceforge.net/projects/bochs/files/bochs/2.8/bochs-2.8.tar.gz
tar -xzf bochs-2.8.tar.gz
cd bochs-2.8
./configure \
    --enable-debugger \
    --enable-disasm \
    --enable-x86-64 \
    --with-sdl2 \
    --prefix=/usr/local/bochs
make -j$(sysctl -n hw.ncpu)
sudo make install

3.3 在 Windows 上安装

Windows 用户建议直接下载编译好的安装包。注意选择带调试器(Debugger)的版本。安装时勾选 Express Install,然后确保 Bochs Console 和调试功能被选中。

安装完成后,将 Bochs 的安装目录(通常是 C:\Program Files\Bochs-2.8)添加到系统的 PATH 环境变量,方便在命令行中直接使用 bochs 命令。

3.4 验证调试器可用

无论使用哪种操作系统,安装完成后都可以在命令行输入:

bochs --help

如果你看到帮助信息中包含与调试器相关的选项,或者直接运行 bochs 后出现交互式启动菜单,说明安装正确。

四、Bochs 配置文件详解

Bochs 的行为完全由配置文件控制。在我们开始调试之前,必须先理解配置文件中的各个选项,因为很多调试行为(比如日志输出、魔术断点)都是通过配置文件启用的。一个典型的 bochsrc 配置文件如下:

# ==================== Bochs 配置文件 ====================
内存大小(单位:MB)
megs: 32
BIOS ROM 镜像
romimage: file=/usr/local/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/local/bochs/share/bochs/VGABIOS-lgpl-latest
启动设备:floppy / disk / cdrom
boot: floppy
软盘驱动器
floppya: 1_44=./boot.img, status=inserted
硬盘(可选)
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata0-master: type=disk, path="./hd.img", mode=flat, cylinders=162, heads=16, spt=63
日志
log: ./bochsout.txt
logprefix: %t-%e-@%i-%d
debugger_log: ./debugger.log
显示
display_library: x, options="gui_debug"
vga: extension=vbe, update_freq=15
CPU 配置
cpu: model=core2_penryn, count=1, ips=5000000, reset_on_triple_fault=1
魔术断点(调试核心功能)
magic_break: enabled=1
PCI 和其他设备
pci: enabled=0
sound: enabled=0
mouse: enabled=0
时钟
clock: sync=realtime, time0=local

4.1 内存配置

megs: 32

这一行设置模拟器的物理内存大小为 32MB。对于学习阶段的操作系统开发来说,32MB 足够了。如果内核需要更多内存,可以适当增大。

4.2 BIOS 与启动设备

romimage: file=/usr/local/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/local/bochs/share/bochs/VGABIOS-lgpl-latest
boot: floppy

romimage 指定 BIOS ROM 镜像文件,vgaromimage 指定 VGA BIOS 镜像。boot: floppy 表示从软盘启动。常见的启动选项:

  • floppy:从软盘 A 启动。
  • disk:从硬盘启动。
  • cdrom:从光盘启动。

对应地,你需要配置相应的介质:

floppya: 1_44=./boot.img, status=inserted

这一行把 ./boot.img 文件作为一张 1.44MB 的软盘插入软驱 A。boot.img 就是我们的引导镜像。

4.3 日志配置

log: ./bochsout.txt
logprefix: %t-%e-@%i-%d
debugger_log: ./debugger.log
  • log:Bochs 主日志文件路径。
  • logprefix:日志行的前缀格式。参数含义:%t 表示时间,%e 表示事件类型,%i 表示设备 ID,%d 表示设备名称。
  • debugger_log:调试器日志文件。当你在调试器中执行 trace-on 后,所有执行的指令会记录到这个文件里。

4.4 魔术断点配置

这一项是整个调试工作流中最有用的功能之一:

magic_break: enabled=1

启用后,每当 CPU 执行 xchg bx, bx 这条指令(机器码为 87 DB)时,Bochs 会自动暂停并进入调试器。我们会在后面的实战中大量使用这个技巧。

4.5 CPU 配置

cpu: model=core2_penryn, count=1, ips=5000000, reset_on_triple_fault=1
  • model:模拟的 CPU 型号。对于大多数场景,core2_penryncorei7_ivy_bridge 都是不错的选择。
  • count:CPU 核心数量。学习阶段建议设为 1,简化调试。
  • ips:每秒指令数,影响模拟速度。
  • reset_on_triple_fault:发生三重故障(triple fault)时自动重启模拟器。调试阶段建议设为 1,方便反复尝试;发布阶段可以设为 0 让模拟器直接退出。

另外有一个非常关键的调试选项:

cpu: model=core2_penryn, count=1, ips=5000000, reset_on_triple_fault=1

如果希望 Bochs 在遇到三重故障时暂停而不是重启,可以把 reset_on_triple_fault 设为 0,并添加:

panic: action=ask

这样当 CPU 发生三重故障时,Bochs 会暂停并进入调试器,让你检查现场。

五、启动调试会话

有了配置文件之后,我们就可以启动 Bochs 并进入调试模式了。

5.1 基本启动命令

最简单的启动方式:

bochs -f bochsrc

这里 -f 参数指定配置文件路径。如果配置文件名就叫 bochsrc 且位于当前目录,可以省略 -f 参数。

启动后,你会看到一个交互式菜单:

========================================================================
                        Bochs x86 Emulator 2.8
              Built from SVN snapshot on January 1, 2025
========================================================================
Please choose one: [6]
 1. Floppy disk
 2. Disk image
 3. Read options from...
 4. Restore checkpoint
 5. Continue
 6. Begin simulation
 7. Quit now

输入 6 并回车即可开始模拟。如果要直接跳过菜单,可以用 -q 参数快速启动。

5.2 从第一条指令开始调试

默认情况下,开始模拟后 Bochs 会先执行 BIOS 代码,然后才执行你的引导程序。对于大多数调试需求,我们不关心 BIOS 的执行过程,而是想直接从引导扇区的第一条指令开始。有几种方法可以实现:

方法一:使用魔术断点(最简单)。在启动后,调试器不会自动进入。你需要在开始模拟后,当看到 <bochs:1> 这样的提示符时,先输入 c 继续,然后等待 BIOS 把软盘读入内存并跳转到 0x7c00。如果你的引导扇区第一条指令是魔术断点 xchg bx, bx,Bochs 会立即在这里暂停。

方法二:在 0x7c00 设置断点再继续。系统启动后的第一条指令通常从 0xfffffff0 开始执行 BIOS。你可以在启动后立即设置断点:

<bochs:1> pb 0x7c00
<bochs:2> c

这样当 BIOS 完成引导扇区加载并跳转到 0x7c00 时,Bochs 会自动暂停。

方法三:让 Bochs 启动后立即暂停。在配置文件中添加:

debugger_log: ./debugger.log
# 以下不是在配置文件里加的

实际上,要让 Bochs 启动后立刻暂停,可以在启动时按 Ctrl+C,或者直接在启动命令中加参数:

bochs -f bochsrc -rc debug.rc

其中 debug.rc 是一个包含调试命令的脚本文件,比如:

pb 0x7c00
c

这个脚本会在启动后自动执行,先在 0x7c00 设断点,然后继续执行。

5.3 调试器提示符

进入调试器后,你会看到这样的提示符:

Next at t=12345678
(0) [0x000000007c00] 0000:7c00 (unk. ctxt): mov ax, 0x07c0 ; b8c007
<bochs:1>

每一行的含义:

  • t=12345678:CPU 已经执行的指令总数。
  • (0):CPU 编号(多核时区分)。
  • [0x000000007c00]:下一条要执行指令的物理地址。
  • 0000:7c00:段选择子:偏移的格式。
  • mov ax, 0x07c0:反汇编得到的指令。
  • b8c007:指令的机器码。

<bochs:1> 提示符后输入命令即可。数字 1 是命令序号,方便用上下箭头查看历史。

六、Bochs 调试命令速查

Bochs 内置调试器的命令体系非常丰富。我先给出一个常用命令的总览表,然后在后面的章节中详细介绍高频命令的用法。

6.1 执行控制类

命令简写功能
continuec继续执行,直到遇到断点或手动暂停
stepis单步执行一条指令(进入函数)
nextn单步执行一条指令(跳过函数调用)
stepst单步执行,跳过重复前缀
quitq退出 Bochs

6.2 断点类

命令简写功能
breakb线性地址断点
pbreakpb物理地址断点
vbreakvb虚拟地址断点
info breakib查看所有断点
deleted删除断点
blistbl列出断点
bpe-启用断点
bpd-禁用断点

6.3 内存与寄存器类

命令简写功能
registersr查看所有寄存器
x-查看物理内存
xp-查看物理内存
u-反汇编
setpmem-修改物理内存
set $reg=value-修改寄存器
info gdt-查看 GDT
info idt-查看 IDT
info cpu-查看 CPU 状态

6.4 跟踪与监视类

命令简写功能
trace-on-开启指令跟踪
trace-off-关闭指令跟踪
watchw设置数据监视点
unwatch-删除监视点
show-显示调试信息
ptime-显示已执行时间

有了这个总览表,你可以随时在调试器中输入 helphelp 命令名 获取更详细的信息:

<bochs:1> help break

在后面的章节中,我们会深入讲解每一类命令的具体用法。

七、断点设置与条件断点

断点是调试器的灵魂。Bochs 的断点系统比很多现代调试器更底层,因为它操作的是真实的 CPU 调试寄存器(DR0-DR7)和地址比较逻辑。理解断点的工作原理,对于调试操作系统至关重要。

7.1 线性地址断点:break

break 命令在指定的线性地址处设置断点。线性地址是在处理器还没有进行分页映射之前的地址(如果分页未启用,线性地址等于物理地址)。

<bochs:1> break 0x7c00
<bochs:2> b 0x100000

在引导阶段(实模式),线性地址和物理地址是一样的。比如 0x7c00 既是线性地址也是物理地址。当程序跳转到 0x7c00 执行时,断点触发。

7.2 物理地址断点:pbreak

pbreak 设置的是物理地址断点。在保护模式启用分页后,线性地址会被映射到不同的物理地址,此时 breakpbreak 的行为就会不同。

举例:如果分页机制把线性地址 0xc0000000 映射到物理地址 0x00100000,那么:

  • break 0xc0000000:当 CPU 执行到线性地址 0xc0000000 时触发。
  • pbreak 0x00100000:当 CPU 执行到物理地址 0x00100000 时触发。

这两个断点实际上会在同一时刻触发。区别在于,对于分页映射频繁变化的场景,物理地址断点更稳定。

7.3 虚拟地址断点:vbreak

vbreak 设置的是虚拟地址断点,格式为 vbreak seg:offset。这个命令在实模式下特别有用,因为实模式下 CS 段寄存器的值乘以 16 加上偏移才是实际的线性地址。比如:

<bochs:1> vbreak 0x0000:0x7c00

这个断点在段基址为 0、偏移为 0x7c00 时触发。如果你在 0x07c0:0x0000 这样的段:偏移下执行,物理地址同样是 0x7c00,但不会触发上面的 vbreak

7.4 查看和管理断点

设置断点后,使用 info break 查看:

<bochs:5> info break
Num Type           Disp Enb Address
  1 pbreakpoint    keep y   0x0000000000007c00
  2 pbreakpoint    keep y   0x0000000000100000
  3 vbreakpoint    keep y   0x0000:0x7c00

各列含义:

  • Num:断点编号,删除时使用。
  • Type:断点类型。
  • Disp:断点的处理方式(keep 表示保留,del 表示命中后删除)。
  • Enb:是否启用(y 启用,n 禁用)。
  • Address:断点地址。

删除断点:

<bochs:6> delete 1    # 删除编号为 1 的断点
<bochs:7> delete all  # 删除所有断点

禁用和启用断点:

<bochs:8> bpd 2      # 禁用 2 号断点
<bochs:9> bpe 2      # 重新启用 2 号断点

7.5 条件断点

Bochs 的断点支持条件表达式。当断点命中时,Bochs 会检查条件,只有条件为真时才暂停。这个功能对于“只在第 N 次循环时暂停”这类需求非常有用。

条件断点的语法是在断点命令后添加 if 条件:

<bochs:1> break 0x1000 if ecx == 0x5

这条命令在 0x1000 处设置断点,只有当 ecx 寄存器的值等于 5 时才暂停。更多条件示例:

# 当 eax 大于 0x100 时暂停
break 0x2000 if eax > 0x100
当 eax 不等于 0 时暂停
break 0x3000 if eax != 0
组合条件
break 0x4000 if (eax > 0x100) && (ebx < 0x50)

条件断点在实际调试中非常实用。比如你有一个循环执行了 1000 次,只想在第 500 次时停下来检查状态,就可以设置:

break 0x7d00 if ecx == 500

7.6 一次性断点

有时你希望断点只触发一次,之后自动删除。Bochs 支持在断点命令前加 once 关键字:

<bochs:1> once pbreak 0x7c00

这个断点在第一次命中后就会自动删除,非常适合“只关心第一次到达某个位置”的场景。

八、内存查看与修改

操作系统开发中最常见的调试需求就是查看内存内容。无论是检查加载到内存中的内核文件是否正确、查看栈上的数据,还是确认页表是否建立正确,都离不开内存查看命令。

8.1 查看物理内存:x / xp

Bochs 中查看物理内存使用 xxp 命令(两者等价)。基本语法:

x /格式 物理地址 [数量]

格式说明:

  • /1b:单字节
  • /2b:双字节(字)
  • /4b:四字节(双字)
  • /8b:八字节(四字)
  • /1h:单字节十六进制
  • /2h:双字节十六进制(默认)
  • /4h:四字节十六进制
  • /1w:单字节十六进制(带空格)

实际例子:查看 BIOS 引导后加载到 0x7c00 的引导扇区内容:

<bochs:3> x /16h 0x7c00
[bochs]:
0x0000000000007c00 <bogus+       0>: 0xea 0x05 0x7c 0x00 0x00 0x63 0x6f 0x64
0x0000000000007c08 <bogus+       8>: 0x65 0x20 0x73 0x74 0x61 0x72 0x74 0x00

这里的 /16h 表示以十六进制输出 16 个字节。0xea 0x05 0x7c 0x00 0x00 是一条长跳转指令 jmp 0x0000:0x7c05,后面紧跟的 0x63 0x6f 0x64 0x65 是 "code" 字符串。

再看一个例子,查看内存中加载的内核头:

<bochs:4> x /4h 0x100000
0x0000000000100000 <bogus+       0>: 0xe85250d6 0x00000000 0x00000000 0x00000000

这里 0xe85250d6 正是 Multiboot2 规范的魔数(magic number),说明内核确实被加载到了 0x100000

8.2 查看线性地址和虚拟地址

在保护模式下,如果分页已经启用,物理地址和线性地址的映射关系就变得重要。Bochs 提供了查看线性地址内存的命令:

x /16h 线性地址

实际上 x 命令默认就是操作物理地址。要查看线性地址(经过分页映射前的),需要使用 xp 的变体或先确认映射关系。在大多数调试场景中,你很清楚自己要看的是物理内存还是线性地址。如果分页未启用,两者是相等的。

要查看虚拟地址(段:偏移形式):

<bochs:5> u /10 0x0000:0x7c00

这里 u 是反汇编命令,支持段:偏移形式。

8.3 修改内存:setpmem

setpmem 命令用于修改物理内存。语法:

setpmem 物理地址 长度 值1 值2 值3 ...

例如,把 0x7c00 处的两个字节修改为 0xeb 0xfe(这是一条 jmp $ 指令,即死循环):

<bochs:6> setpmem 0x7c00 2 0xeb 0xfe

修改后验证:

<bochs:7> x /2h 0x7c00
0x0000000000007c00 <bogus+       0>: 0xfeeb

setpmem 在调试中非常有用。比如你想临时修改某个函数让它直接返回,或者修改某个数据变量来测试不同的分支路径,都可以直接改内存。

8.4 查看内存映射和符号信息

Bochs 还支持加载符号表,从而在调试时显示函数名和变量名。如果你的内核编译时带了调试信息(GDWARF),可以使用 ldsym 命令加载符号文件:

<bochs:1> ldsym global 0x100000 ./kernel.sym

加载后,反汇编时会显示对应的符号名,断点也可以直接使用符号名:

<bochs:2> break kernel_main

九、寄存器查看与修改

寄存器是 CPU 的“工作台”,查看寄存器状态是调试的第一动作。

9.1 查看所有寄存器:r / registers

在调试器中输入 rreg,可以看到当前 CPU 的完整寄存器状态:

<bochs:8> r
CPU0:
rax: 00000000_000007c0 rcx: 00000000_00000000
rdx: 00000000_00000000 rbx: 00000000_00000000
rsp: 00000000_0000ffd8 rbp: 00000000_00000000
rsi: 00000000_00000000 rdi: 00000000_00000000
r8 : 00000000_00000000 r9 : 00000000_00000000
r10: 00000000_00000000 r11: 00000000_00000000
r12: 00000000_00000000 r13: 00000000_00000000
r14: 00000000_00000000 r15: 00000000_00000000
rip: 00000000_00007c00
eflags 0x00000002: id vip vif ac vm rf nt IOPL=0 of df IF tf sf zf af PF cf
cs: 0x0000 (base=0x00000000 lim=0xffff, attr=0x1000)
ss: 0x0000 (base=0x00000000 lim=0xffff, attr=0x1000)
ds: 0x0000 (base=0x00000000 lim=0xffff, attr=0x1000)
es: 0x0000 (base=0x00000000 lim=0xffff, attr=0x1000)
fs: 0x0000 (base=0x00000000 lim=0xffff, attr=0x1000)
gs: 0x0000 (base=0x00000000 lim=0xffff, attr=0x1000)

这个输出信息量很大,让我逐行解读:

  • 通用寄存器:rax、rbx、rcx、rdx、rsi、rdi、rbp、rsp 以及 r8-r15。在 32 位模式下,显示的是 eax、ebx 等。
  • 指令指针:rip 指向下一条要执行的指令。这里 0x7c00 正是引导扇区的起始地址。
  • 标志寄存器:eflags 显示为十六进制值和人类可读的标志位列表。大写字母表示该标志位为 1,小写表示 0。比如 IF 大写表示中断使能标志位已置位,tf 小写表示陷阱标志位未置位。
  • 段寄存器:每个段寄存器都显示了选择子值、基地址、界限和属性。在实模式下,基地址 = 选择子 × 16。

9.2 查看特定寄存器

如果只想查看某个特定寄存器:

<bochs:9> print $eax
0x7c0
<bochs:10> print $esp
0xffd8

print 命令可以查看任何寄存器,也可以用 px(十六进制输出):

<bochs:11> px $eflags
0x00000002

9.3 修改寄存器

要修改寄存器的值,使用 set 命令:

<bochs:12> set $eax = 0x1000
<bochs:13> set $eflags = 0x202
<bochs:14> set $cs = 0x0

修改段寄存器时要特别注意:在实模式下,改 CS 会影响后续指令的取指地址。在保护模式下修改段选择子还需要确保对应的描述符存在,否则下一步执行就会触发异常。

9.4 查看控制寄存器

在保护模式调试中,控制寄存器至关重要。CR0 告诉我们是否启用了保护模式和分页,CR3 指向页目录的物理地址:

<bochs:15> print $cr0
0x80000011
<bochs:16> print $cr3
0x1000

解读 CR0 的值 0x80000011

  • bit 0(PE):1,保护模式已启用。
  • bit 4(ET):1。
  • bit 31(PG):1,分页已启用。

CR3 的值为 0x1000,说明页目录表位于物理地址 0x1000

9.5 查看调试寄存器

<bochs:17> r

在寄存器输出中,你会看到 DR0 到 DR7 的值。调试寄存器通常被调试器自身使用,但如果你想手动设置硬件断点,也可以直接操作它们。

十、反汇编与源码级调试

当程序暂停在某个位置时,下一步通常是看看“附近正在执行什么代码”。反汇编是理解汇编代码执行流程的基础工具。

10.1 反汇编命令:u / disassemble

基本用法:

u /数量 起始地址

例如,反汇编 0x7c00 开始的 10 条指令:

<bochs:4> u /10 0x7c00
00007c00: (                    ): jmp .+5               ; ea057c0000
00007c05: (                    ): xor ax, ax            ; 31c0
00007c07: (                    ): mov ds, ax            ; 8ed8
00007c09: (                    ): mov es, ax            ; 8ec0
00007c0b: (                    ): mov ss, ax            ; 8ed0
00007c0d: (                    ): mov sp, 0x7c00        ; bc007c
00007c10: (                    ): mov si, 0x7c1e        ; be1e7c
00007c13: (                    ): mov al, byte ptr ds:[si] ; 8a04
00007c15: (                    ): cmp al, 0x00          ; 3c00
00007c17: (                    ): je .+14               ; 7410

输出格式:地址、段信息(括号中为空表示无特殊段覆盖)、助记符和操作数、分号后是机器码。

如果只输入 u 不带参数,Bochs 会从当前 IP 开始反汇编。

10.2 反汇编格式选项

Bochs 支持不同的反汇编格式:

  • AT&T 格式(默认):mov %ax, %bx
  • Intel 格式mov bx, ax

切换格式:

<bochs:1> u /intel /10 0x7c00
<bochs:2> u /att /10 0x7c00

对于习惯了 NASM 或 MASM 语法的开发者,Intel 格式更易读。在上面的例子中,Bochs 默认使用的就是类似 Intel 的格式。

10.3 源码级调试

如果你的内核是用 C 语言编写并且编译时带调试信息,Bochs 可以加载 DWARF 调试信息来实现源码级调试。

编译内核时添加调试信息:

gcc -g -gdwarf-2 -O0 -c kernel.c -o kernel.o

然后生成符号文件:

objcopy --only-keep-debug kernel.elf kernel.dbg

在 Bochs 调试器中加载符号:

<bochs:1> ldsym global 0x100000 ./kernel.dbg

加载后,反汇编能显示函数名和源码行号:

<bochs:2> u /5 kernel_main

输出中会包含类似 kernel.c:12 的信息,告诉你每条指令对应源码的第几行。这是非常强大的功能,能极大提高调试效率。

10.4 内存搜索

除了反汇编,Bochs 还支持在内存中搜索字节序列。这在查找特定数据结构或字符串时非常有用:

find /物理地址范围 字节序列

例如,在 0x00x100000 范围内搜索字符串 "Hello":

<bochs:1> find 0x0 0x100000 "Hello"
Searching for 'Hello' in [0x00000000, 0x00100000)
Found at 0x00007c1e (physical address)

十一、单步执行与指令跟踪

单步执行是在最细粒度上观察程序行为的方法。当你需要精确追踪每一条指令的执行效果时,单步是唯一的选择。

11.1 单步进入:stepi / s

stepi(简写 s)执行一条指令,并进入函数调用内部。例如,当前 IP 指向一条 call 指令,执行 s 后 IP 会进入被调用函数的第一条指令。

<bochs:5> s
Next at t=12345679
(0) [0x000000007c05] 0000:7c05 (unk. ctxt): xor ax, ax ; 31c0
<bochs:6> s
Next at t=12345680
(0) [0x000000007c07] 0000:7c07 (unk. ctxt): mov ds, ax ; 8ed8

11.2 单步跳过:next / n

next(简写 n)执行一条指令,但跳过函数调用。当遇到 call 时,它会在函数返回后的下一条指令处停下。

11.3 重复单步:step [数量]

Bochs 的 s 命令支持指定执行次数,这对跳出长循环非常有用:

<bochs:10> s 100

这条命令连续执行 100 条指令然后暂停。

11.4 指令跟踪:trace-on / trace-off

指令跟踪会把每条执行的指令记录到 debugger_log 指定的文件中。开启和关闭:

<bochs:1> trace-on
<bochs:2> c
# 程序执行一段时间后按 Ctrl+C 暂停
<bochs:3> trace-off

跟踪日志的每一行记录一条执行的指令:

00004438248i[XGUI ] [.bochs] mov ax, 0x07c0
00004438249i[XGUI ] [.bochs] mov ds, ax
00004438250i[XGUI ] [.bochs] mov es, ax

指令跟踪的优点是能看到完整的执行路径,缺点是指令量大时文件会非常庞大。建议只在怀疑某个执行路径有问题时,才在关键区域开启。

11.5 条件跟踪

如果你只关心特定条件下的执行流程,可以结合断点和跟踪:

<bochs:1> trace-on
<bochs:2> break 0x7c00
<bochs:3> c
# 到达断点后执行单步跟踪
<bochs:4> s 50
<bochs:5> trace-off

这样就只记录了 0x7c00 之后的 50 条指令。

十二、实战一:调试 MBR 引导扇区

现在进入了最核心的实战环节。我们从最基础的 MBR(主引导记录)开始,用 Bochs 调试器调试一个完整的引导扇区程序。

12.1 编写测试用引导扇区

创建一个汇编源文件 boot.asm

; boot.asm - 测试用引导扇区
; 编译: nasm -f bin boot.asm -o boot.bin
; 制作镜像: dd if=boot.bin of=boot.img bs=512 count=1
org 0x7c00
start:
; 魔术断点:Bochs 会在此暂停
xchg bx, bx
; 初始化段寄存器
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00
; 打印字符串
mov si, msg
print_loop:
lodsb
test al, al
jz hang
mov ah, 0x0e
mov bx, 0x0007
int 0x10
jmp print_loop
hang:
jmp hang
msg db "Hello from boot sector!", 13, 10, 0
times 510-($-$$) db 0
dw 0xaa55

编译并制作镜像:

nasm -f bin boot.asm -o boot.bin
dd if=/dev/zero of=boot.img bs=512 count=2880
dd if=boot.bin of=boot.img bs=512 count=1 conv=notrunc

12.2 配置 Bochs 并启动

创建 bochsrc

megs: 32
romimage: file=/usr/local/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/local/bochs/share/bochs/VGABIOS-lgpl-latest
boot: floppy
floppya: 1_44=./boot.img, status=inserted
log: ./bochsout.txt
display_library: x, options="gui_debug"
cpu: model=core2_penryn, count=1, ips=5000000
magic_break: enabled=1

启动 Bochs:

bochs -f bochsrc

由于我们在代码开头设置了 xchg bx, bx(魔术断点),Bochs 在 BIOS 加载完引导扇区并跳转到 0x7c00 后会自动暂停。

12.3 检查加载状态

进入调试器后,先查看当前执行位置:

<bochs:1> r

确认 rip 指向 0x7c00cs 的值为 0x0000(有些 BIOS 可能是 0x07c0,这取决于具体实现,不影响调试)。

接下来查看加载到内存中的引导扇区前 16 个字节:

<bochs:2> x /16h 0x7c00
0x0000000000007c00 <bogus+       0>: 0xdb87 0xc031 0xd88e 0xc08e 0xd08e 0x00bc 0xbe7c 0x1e7c

对照我们的汇编代码:

  • 0xdb87xchg bx, bx 的机器码 87 DB(注意字节序,内存中显示为 87 DB)。
  • 0xc031xor ax, ax 的机器码 31 C0
  • 0xd88emov ds, ax 的机器码 8E D8
  • 0xc08emov es, ax 的机器码 8E C0
  • 0xd08emov ss, ax 的机器码 8E D0
  • 0x00bcmov sp, 0x7c00 的机器码 BC 00 7C

内存内容与我们的代码完全一致,说明加载正确。

12.4 单步执行验证

单步执行几条指令,观察寄存器的变化:

<bochs:3> s
Next at t=12345679
(0) [0x000000007c02] 0000:7c02 (unk. ctxt): xor ax, ax ; 31c0
<bochs:4> s
Next at t=12345680
(0) [0x000000007c04] 0000:7c04 (unk. ctxt): mov ds, ax ; 8ed8
<bochs:5> r

此时 rax 的值应该是 0xor ax, ax 的结果)。继续单步,观察 ds 段寄存器被设置为 0

12.5 调试打印循环

print_loop 处设置断点,观察循环的执行过程。首先是确定 print_loop 的地址。根据代码计算:

  • 起始地址:0x7c00
  • xchg bx, bx:2 字节
  • xor ax, ax:2 字节
  • mov ds, ax:2 字节
  • mov es, ax:2 字节
  • mov ss, ax:2 字节
  • mov sp, 0x7c00:3 字节
  • mov si, msg:3 字节

总共 16 字节,所以 print_loop0x7c10

设置断点并继续:

<bochs:6> pb 0x7c10
<bochs:7> c

到达断点后,查看 si 寄存器和它指向的内存:

<bochs:8> r
# 确认 si 的值
<bochs:9> x /16b 0x7c1e
0x0000000000007c1e <bogus+      30>: "Hello from boot sector!\r\n"

这里可以看到内存中确实存放着我们的字符串。接下来在 int 0x10 指令处设置断点,观察 BIOS 中断的调用:

<bochs:10> pb 0x7c19
<bochs:11> c

每次到达 int 0x10 时暂停,检查 al(要打印的字符)和 ah(功能号 0x0e)。这样就能确认每个字符都被正确打印。

十三、实战二:调试 Loader 加载器

引导扇区只是开始。真正的挑战在于调试 Loader(加载器),它负责从磁盘读取内核、建立 GDT、进入保护模式、开启分页等关键步骤。这一部分最容易出问题,也最需要调试器的帮助。

13.1 Loader 的关键调试点

一个典型的 Loader 执行流程:

  1. 读取内核文件从磁盘到内存。
  2. 检测内存容量(INT 0x15 E820 或 E801)。
  3. 建立 GDT。
  4. 关闭中断,加载 GDTR。
  5. 设置 CR0 的 PE 位,进入保护模式。
  6. 跳转到保护模式代码段。
  7. (可选)建立页表和页目录,开启分页。
  8. 跳转到内核入口。

每一步都可能出错。按顺序排查:

13.2 调试磁盘读取

磁盘读取通常使用 BIOS INT 0x13。在调用前,需要确认:

  • ah:功能号(0x02 = 读扇区)。
  • al:要读取的扇区数。
  • ch:柱面号低 8 位。
  • cl:扇区号 + 柱面号高 2 位。
  • dh:磁头号。
  • dl:驱动器号(0 = 软盘 A,0x80 = 第一块硬盘)。
  • es:bx:数据缓冲区地址。

int 0x13 调用前后设置断点:

<bochs:1> pb 0x7d00    # int 0x13 调用处
<bochs:2> pb 0x7d02    # 调用返回后的检查处
<bochs:3> c

到达断点后检查参数:

<bochs:4> r
# 检查 ax、cx、dx、es、bx 的值

执行到返回处后,检查标志位 CF(进位标志)。CF=0 表示读取成功,CF=1 表示失败。同时检查 ah 中的错误码:

<bochs:5> r
# eflags 中 cf 的大小写指示 CF 的状态
# ah 中包含错误码(如果 CF=1)

13.3 调试 GDT 建立

建立 GDT 后,在执行 lgdt 之前和之后分别设置断点。重点检查:

  • GDT 表的内容是否正确(使用 x 命令查看内存中的描述符)。
  • GDTR 是否正确加载(使用 info gdt 命令)。

假设 GDT 放在 0x900,执行 lgdt 后:

<bochs:6> info gdt
Global Descriptor Table (base=0x0000000000000900, limit=23):
GDT[0x00]=??? descriptor hi=0x00000000, lo=0x00000000
GDT[0x01]=Code segment, base=0x00000000, limit=0xfffff, Execute-Only, Non-Conforming, Accessed, 32-bit
GDT[0x02]=Data segment, base=0x00000000, limit=0xfffff, Read/Write, Accessed

这个输出清晰地显示了 GDT 的基地址、界限和每个描述符的详细信息。仔细检查描述符的类型、基地址和界限是否与你的设计一致。

如果你发现 info gdt 显示的 base 或 limit 不对,说明 lgdt 之前的装载有问题。回到 lgdt 之前,用 x 命令检查 GDT 表的内存内容:

<bochs:7> x /24b 0x900

对照 GDT 描述符的位布局,验证每个描述符的每个字段。

13.4 调试保护模式切换

进入保护模式的关键步骤:

    ; 关闭中断
    cli
; 加载 GDTR
lgdt [gdt_descriptor]
; 设置 CR0 的 PE 位
mov eax, cr0
or eax, 0x1
mov cr0, eax
; 远跳转刷新流水线并设置 CS
jmp CODE_SEG:protected_mode_entry

调试时,在 mov cr0, eax 之后、远跳转之前设置断点:

<bochs:8> pb 0x7e10    # mov cr0, eax 之后的地址
<bochs:9> c
<bochs:10> r

重点检查:

  • cr0 的 bit 0(PE)是否为 1。
  • 此时 CPU 已经在保护模式了,但 CS 还是实模式下的代码段选择子。这是正常的过渡状态——远跳转后 CS 才会更新。

继续执行到远跳转之后:

<bochs:11> pb 0x7e18    # protected_mode_entry 的地址
<bochs:12> c
<bochs:13> r

现在检查:

  • cs 的选择子是否正确(应该是你在 GDT 中定义的代码段)。
  • cs 的 base 和 limit 是否符合预期。
  • dsesss 是否已更新为数据段选择子。

13.5 调试分页开启

如果 Loader 负责开启分页,步骤通常是:

  1. 在内存中建立页目录和页表。
  2. 把页目录地址写入 CR3。
  3. 设置 CR0 的 PG 位(bit 31)。
  4. 刷新 TLB。

调试重点:

  • 页目录和页表的内容是否正确。
  • CR3 是否指向页目录。
  • CR0 的 PG 位是否设置。

检查页目录内容:假设页目录在 0x100000

<bochs:14> x /8h 0x100000
0x0000000000100000 <bogus+       0>: 0x00101007 0x00000000 0x00000000 0x00000000
0x0000000000100010 <bogus+      16>: 0x00000000 0x00000000 0x00000000 0x00000000

第一个页目录项 0x00101007 的含义:

  • 页表基地址:0x00101000(取高 20 位)。
  • 标志位:0x007 表示 Present=1、Read/Write=1、User=1。

如果要映射线性地址 0xc0000000 到物理地址 0x00000000,需要检查对应页目录项(索引 768,偏移 0xC00)的内容是否正确。

十四、实战三:调试保护模式内核

当 Loader 成功切换到保护模式并把控制权交给内核后,调试的重点从“硬件初始化”转向“软件逻辑”。这一阶段常见的问题包括:C 语言运行环境不正确、栈溢出、内存访问越界、函数调用约定错误等。

14.1 确认内核入口状态

在内核入口处设置断点。以 Multiboot 加载为例,内核入口在 0x100000

<bochs:1> pb 0x100000
<bochs:2> c

到达后检查:

  • 栈指针 esp 是否指向合理的内存区域。
  • 段寄存器是否配置正确。
  • Multiboot 魔数(eax)和 Multiboot 信息结构指针(ebx)是否正确。
<bochs:3> r
# 检查 eax 是否等于 0x2BADB002(Multiboot1)或 0x36D76289(Multiboot2)
# 检查 ebx 是否指向有效的 Multiboot 信息结构

如果魔数不对,说明 Loader 在跳转之前没有正确准备 Multiboot 头。回头检查 Loader 代码。

14.2 调试 C 函数调用

假设你的内核入口是汇编代码,它会调用 C 函数 kernel_main

extern kernel_main
_start:
mov esp, kernel_stack_top
call kernel_main
hlt
section .bss
kernel_stack: resb 4096
kernel_stack_top:

call kernel_main 处设置断点,确认栈已经设置好,然后单步进入:

<bochs:4> pb _start + 7    # call kernel_main 的地址
<bochs:5> c
<bochs:6> r
# 确认 esp 指向 kernel_stack_top
<bochs:7> s            # 进入 kernel_main
<bochs:8> r

进入 kernel_main 后,检查:

  • 栈帧是否正常建立(push ebp; mov ebp, esp)。
  • 函数序言(prologue)是否正确执行。

14.3 调试栈溢出

栈溢出是内核开发中最频繁遇到的问题。在 Bochs 中,你可以通过监视栈指针来检测它。

方法一:在栈底部设置监视点。如果栈指针试图越过栈底,Bochs 会暂停:

<bochs:9> watch write 0x9000

假设栈从 0x10000 向下增长到 0x9000。当有代码写入 0x9000(栈溢出到这个区域)时暂停。

方法二:定期检查 esp。在怀疑的代码区域设置断点,每次命中时检查 esp 是否仍高于栈底。如果 esp 越过了栈底,说明发生了栈溢出。

14.4 调试内存访问越界

保护模式提供了段界限检查。如果代码访问了超出段界限的内存,CPU 会触发异常。Bochs 会捕获这些异常并暂停。

当 Bochs 因为异常暂停时,先查看异常类型:

<bochs:10> r

检查 trapno 或类似信息来确定异常号。常见的:

  • 异常 13:一般保护异常(General Protection Fault)。
  • 异常 14:页错误(Page Fault)。
  • 异常 6:无效操作码。

对于页错误,Bochs 会显示详细信息:

  • 发生错误的线性地址。
  • 错误类型:读/写、用户态/内核态、页不存在/保护违规。

这些信息足以定位到具体的代码行。

14.5 调试死循环和死锁

死循环的表现是系统挂起(hang)。在 Bochs 中,按 Ctrl+C 强制暂停,然后查看当前执行位置:

<bochs:11> r
# 查看 rip 指向哪里
<bochs:12> u /10

反汇编当前附近的代码,看看是不是一个循环。如果是循环,检查循环条件和退出条件:

  • 循环计数器是否正确增减。
  • 比较指令是否正确。
  • 条件跳转的方向是否正确。

你可以单步执行几轮循环,观察循环变量的变化:

<bochs:13> s
<bochs:14> s
<bochs:15> s
<bochs:16> r

十五、实战四:调试中断处理程序

中断是操作系统最核心的机制之一。调试中断处理程序涉及 IDT 的正确性、中断入口的现场保护与恢复、以及中断返回路径的正确性。这部分出错的概率极高,因为任何一个寄存器没有正确保存或恢复都可能导致不可预测的行为。

15.1 检查 IDT

在设置好 IDT 并执行 lidt 后,使用 info idt 检查:

<bochs:1> info idt
Interrupt Descriptor Table (base=0x0000000000001000, limit=2047):
IDT[0x00]=32-Bit Interrupt Gate target=0x0008:0x00200000, DPL=0
IDT[0x01]=32-Bit Interrupt Gate target=0x0008:0x00200010, DPL=0
...
IDT[0x20]=32-Bit Interrupt Gate target=0x0008:0x00200100, DPL=0
IDT[0x21]=32-Bit Interrupt Gate target=0x0008:0x00200110, DPL=0

每个中断门都显示了:

  • 门类型(Interrupt Gate / Trap Gate / Task Gate)。
  • 目标代码段选择子和偏移。
  • 特权级别(DPL)。

检查每个中断门的目标地址是否指向正确的中断处理函数。如果 info idt 显示的目标地址不对,回到 lidt 之前,检查 IDT 表的内存内容。

15.2 调试外部中断流程

当键盘敲击或定时器触发时,CPU 会通过 PIC(可编程中断控制器)接收中断。调试流程:

首先,在键盘中断处理函数入口处设置断点(键盘中断是 IRQ1,中断向量号是 0x21):

<bochs:2> pb 0x200100    # 键盘中断处理函数地址
<bochs:3> c

然后让程序继续运行,在 Bochs 的图形窗口中按一个键。Bochs 应该会暂停在键盘中断处理函数入口。

进入后检查:

  • 现场是否完整保存(所有被修改的寄存器是否已压栈)。
  • 是否要发送 EOI(End of Interrupt)给 PIC。
<bochs:4> r
# 检查栈上的现场保存情况
<bochs:5> x /8h 0x9ff0

15.3 调试中断返回

iret 指令处设置断点,确认返回前现场已经恢复:

<bochs:6> pb 0x2001f0    # iret 指令地址
<bochs:7> c
<bochs:8> r
# 检查所有需要恢复的寄存器

然后单步执行 iret

<bochs:9> s
<bochs:10> r

检查 iret 之后的 cseipeflags 是否与进入中断时一致。

15.4 调试异常 vs 中断

异常和中断的区别在于异常是同步的(由当前指令触发),中断是异步的(由外部硬件触发)。对于异常,Bochs 会在触发时暂停并显示详细信息。

在 IDT 设置好之后,如果发生异常,CPU 会通过 IDT 中的异常门跳转到处理函数。Bochs 不会默认暂停,除非异常处理函数中没有设置断点。你可以在异常处理函数入口设置断点:

<bochs:11> pb 0x200000    # 异常 0(除零)处理函数
<bochs:12> pb 0x2000d0    # 异常 13(GPF)处理函数
<bochs:13> pb 0x2000e0    # 异常 14(Page Fault)处理函数
<bochs:14> c

当代码触发这些异常时,Bochs 会暂停在对应的处理函数入口,你可以检查栈上的错误码和返回地址来确定触发异常的位置。

十六、魔术断点:代码中的调试暂停

魔术断点(Magic Breakpoint)是 Bochs 最具特色的功能之一。它能让你在代码的任意位置主动触发调试暂停,而不需要预先知道该位置的物理地址。这对于调试复杂的控制流非常实用。

16.1 魔术断点的工作原理

魔术断点使用了一条特殊的指令:xchg bx, bx。这条指令在逻辑上什么都不做(bx 和 bx 交换),相当于空操作(NOP)。但其机器码 87 DB 被 Bochs 特殊识别。当配置文件中 magic_break: enabled=1 时,CPU 每次执行 xchg bx, bx 都会暂停并进入调试器。

16.2 在汇编中使用

在 NASM 汇编中:

; 在任何需要暂停的位置插入
xchg bx, bx

在 GAS 语法中:

    xchg %bx, %bx

16.3 在 C 语言中使用

如果你在 C 语言中编写内核,可以封装一个函数:

static inline void bochs_magic_break(void) {
    __asm__ __volatile__("xchg %%bx, %%bx" : : : "memory");
}

然后在任何需要暂停的地方调用:

void kernel_main(void) {
    bochs_magic_break();  // 暂停在这里
    init_gdt();
    bochs_magic_break();  // 暂停在这里
    init_idt();
    bochs_magic_break();  // 暂停在这里
}

16.4 魔术断点的最佳实践

魔术断点在以下场景中特别有用:

  • 初始化流程的阶段性标记:在每个初始化步骤完成后插入魔术断点,逐步确认每个阶段都正常通过。
  • 代码路径验证:在多个分支路径中插入魔术断点,观察实际走的是哪条路径。
  • 关键数据操作前后:在修改重要数据结构前后插入魔术断点,检查数据变化。

需要注意的是,xchg bx, bx 会占用 2 个字节的代码空间。在正式发布版本中,可以通过条件编译或宏定义来移除这些魔术断点。

另外,Bochs 还提供了间隔魔术断点:在配置文件中设置:

magic_break: enabled=1, lazy=1

lazy 模式可以控制魔术断点触发的频率。

十七、Bochs 日志系统详解

日志是调试的另一双眼睛。Bochs 的日志系统非常灵活,可以记录从 CPU 执行到设备 I/O 的各种信息。

17.1 日志的基本用法

Bochs 提供了几种将消息写入日志的方法。最常用的是直接向端口 0xE9 输出数据。Bochs 会捕获对这些端口的写操作并记录到日志中。

在汇编中:

    mov dx, 0xe9
    mov al, 'H'
    out dx, al
    mov al, 'i'
    out dx, al
    mov al, 10    ; 换行
    out dx, al

在 C 语言中:

static inline void bochs_putchar(char c) {
    __asm__ __volatile__("outb %0, $0xe9" : : "a"(c));
}
static inline void bochs_puts(const char *s) {
while (*s) {
bochs_putchar(*s);
s++;
}
}

这些输出会出现在 Bochs 的终端和日志文件中。这是最简单的内核日志方式。

17.2 日志级别与过滤

Bochs 的内部日志有多个级别。在配置文件中可以过滤:

log: ./bochsout.txt
# 只记录错误和严重错误
log: ./bochs_errors.txt, level=error
# 只记录 CPU 相关的调试信息
log: ./bochs_cpu.txt, device=cpu

合理的日志配置能帮你快速定位问题,而不被海量无关信息淹没。

17.3 调试器日志

debugger_log 指定的文件专门记录调试器相关信息,包括 trace-on 期间的指令跟踪。这个文件和主日志分开,方便分析。

17.4 日志最佳实践

  • 在开发阶段保持详细日志,在性能测试时关闭。
  • 使用 0xE9 端口输出关键里程碑信息。
  • 在异常处理函数中输出错误上下文到 0xE9,这样即使系统崩溃也能留下最后的日志。
  • 结合指令跟踪(trace-on)和 0xE9 日志,可以重建完整的执行时间线。

十八、高级调试技巧

这一章把一些分散但非常实用的技巧集中起来,它们能帮你解决很多“疑难杂症”。

18.1 监视点(Watchpoint)

断点关注的是指令的执行位置,监视点关注的是数据的读写。当一个内存地址被读取或写入时,监视点触发暂停。

设置监视点:

<bochs:1> watch read 0x100000
<bochs:2> watch write 0x100004
<bochs:3> watch readwrite 0x100008

查看监视点:

<bochs:4> info watch

删除监视点:

<bochs:5> unwatch 1

监视点非常适合追踪“某个变量被谁修改了”这类问题。当监视点触发时,Bochs 会暂停在触发读写的指令处,直接定位到肇事代码。

18.2 内存断点

内存断点是监视点的一种特殊形式,专门用于监视代码段。当 CPU 执行到被监视的内存页时触发。这个功能在某些 CPU 上不支持(取决于调试寄存器的数量限制)。

18.3 使用调试脚本

如果每次启动调试都要重复输入一系列命令,可以把它们写入脚本文件。例如 debug.rc

# 调试启动脚本
pb 0x7c00
c
# 到引导扇区后暂停,检查状态
u /20 0x7c00
r

启动时加载:

bochs -f bochsrc -rc debug.rc

18.4 关键地址的别名

如果你经常调试同一个地址,可以在脚本中使用 Bochs 的 define 功能或者直接依赖符号表。加载符号表后,断点可以直接使用函数名,而不是硬编码地址。这能显著提高调试效率。

18.5 调试多重启和快照

Bochs 支持检查点(checkpoint)功能,可以在关键状态保存快照,之后从这个状态恢复:

<bochs:1> checkpoint save state1
# 程序继续执行一段时间
<bochs:2> checkpoint restore state1

这对于调试难以重现的问题特别有用。你可以在问题即将发生前保存快照,然后反复恢复重现和调试。

十九、常见问题与解决方案

这一章汇总了我在调试操作系统过程中遇到的最常见问题以及排查方法。

19.1 黑屏且无任何输出

这是最常见的症状。排查顺序:

  1. 引导扇区是否被正确加载:在 0x7c00 设置断点,看 Bochs 是否能停在那里。如果不停,说明 BIOS 没有成功加载引导扇区——检查镜像文件是否正确制作(特别是 0x55AA 签名)。
  2. 代码是否真的在执行:在引导扇区第一条指令设置断点,确认代码确实开始执行。
  3. 模块是否正常输出:如果你使用 INT 0x10 输出,确认 AH 功能号、AL 字符、BH 页码和 BL 颜色属性是否正确。
  4. 跳转是否正确:检查跳转目标是否计算正确。实模式下的长跳转尤其容易出错。

19.2 三重故障(Triple Fault)

三重故障意味着 CPU 连续遇到三个无法处理的异常。常见原因:

  • GDT 配置错误:段描述符的基地址或界限设置错误。
  • 加载 GDTR 时的地址或界限错误。
  • 进入保护模式后 CS 选择子指向了无效描述符。
  • 栈指针指向无效内存。
  • IDT 未正确设置,且发生了中断或异常。

排查三重故障的最佳方法:

  1. 在配置文件中设置 cpu: reset_on_triple_fault=0panic: action=ask
  2. 在保护模式切换前后的关键位置设置魔术断点。
  3. 逐步缩小问题范围:先让代码能进入保护模式,再逐步添加后续功能。

19.3 页错误(Page Fault)

页错误是启用分页后最常见的问题。排查步骤:

  1. 确认页目录和页表的内容正确(使用 x 命令查看)。
  2. 确认 CR3 指向正确的页目录物理地址。
  3. 确认 CR0 的 PG 位已设置。
  4. 确认所有被访问的线性地址都有对应的页表项。
  5. 检查页表项的标志位:Present=1、Read/Write 权限是否匹配访问类型、User/Supervisor 权限是否匹配当前特权级。

Bochs 在页错误时会显示错误的线性地址和错误类型,这是定位问题的关键信息。

19.4 系统随机崩溃

随机崩溃通常与以下因素有关:

  • 同步的中断问题:没有正确初始化 PIC 或没有在适当的时候屏蔽中断。
  • 内存管理错误:使用未初始化或已释放的内存。启用分页后,可能访问了没有映射的内存。
  • 栈溢出:内核栈空间不足,导致覆盖了相邻数据。

对于栈溢出,可以在栈底部设置监视点快速定位。

19.5 中断不触发

如果你预期某个中断应该触发但它没有:

  1. 确认 IDT 正确设置(info idt)。
  2. 确认对应的中断门存在且目标地址正确。
  3. 确认 IF 标志位已设置(eflags)。
  4. 如果是外部中断,确认 PIC 已正确初始化和取消屏蔽。
  5. 如果是软件中断(int 指令),确认中断号正确(大于 0x1F 的中断号不会触发异常)。

二十、调试工作流总结

经过前面大量实战,我可以总结出一套高效的调试工作流。这个流程不一定是最快的,但它能确保你系统性地排除问题,而不是随机尝试。

20.1 调试前的准备

  1. 代码编译时保留符号信息。无论是汇编还是 C,都尽量保留符号表。
  2. 在关键位置预置魔术断点。初始化流程的每个阶段、关键函数入口和出口、异常处理入口,都可以预置。
  3. 配置好日志。确保 0xE9 端口日志能在崩溃前留下最后的信息。
  4. 准备好调试启动脚本。在需要反复断点调试时使用 -rc 参数加载命令脚本。

20.2 逐步隔离问题

  1. 从引导到内核,一段一段验证。每次只前进一小步,确认当前步骤正确后再继续。
  2. 二分法排查。如果在 A 和 B 之间出了问题,先检查中点 C,看问题在 A→C 还是 C→B。
  3. 每次修改只改变一个变量。一次修改多个因素会掩盖真正的问题。
  4. 记录每次修改和观察到的现象。调试笔记能帮助你在多次尝试后理清头绪。

20.3 高效使用 Bochs 调试器的建议

  • 魔术断点用于快速导航,地址断点用于精确定位。
  • 条件断点减少反复暂停。在循环中使用条件断点,只在关键迭代时暂停。
  • 指令跟踪用在小范围。不要全局开启,只在怀疑的执行区间开启。
  • 监视点追踪数据变化。当不确定谁修改了某个变量时,用监视点直接定位。
  • 符号表让调试更直观。加载符号后,断点和反汇编都能显示函数名,大幅提高可读性。

二十一、结语与进阶建议

调试操作系统是一场持久战,没有捷径,但有方法可循。Bochs 内置调试器是你最忠实的伙伴,它给你的不只是“看到问题”的能力,更是“理解系统”的窗口。每次在调试器中检查寄存器、追踪指令流、查看内存映射,你都在加深对 x86 架构和操作系统原理的理解。

建议你从今天开始,把调试器融入到日常开发流程中。写一段代码,就在关键位置放一个魔术断点;做一次修改,就用调试器验证一次;遇到问题,先冷静分析再动手修改。随着经验的积累,你会发现自己对系统的控制力越来越强,能写出越来越复杂、越来越健壮的操作系统代码。

最后,如果本文对你有所帮助,欢迎关注后续系列文章。下一篇我们将深入探讨从保护模式到分页机制的完整实现,并在调试中实际观察页表映射的全过程。祝你调试愉快,早日手搓出自己的操作系统!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐