1. Lab1 到底要你干什么:先把这条链路看清楚
操作系统实验的第一课,最容易犯的错不是写不出代码,而是压根没搞清楚这一课在整个系统里处于什么位置。这套实验的内核代号叫 ucore,Lab1 的名字通常叫“启动与中断”,听起来像个热身,实际上它一口气塞了四件事进来:编译链接产物怎么来的、机器加电后 BIOS 做了什么、bootloader 怎么把 CPU 切到保护模式并加载 ELF 内核、内核起来之后怎么建立中断机制并让时钟开始跳。这四件事串起来,就是一条从按下电源键到屏幕上打出第一行cprintf输出的完整路径。
我带过几轮这门实验的答疑,发现一个很稳定的规律:卡在 Lab1 的人,八成不是卡在算法,而是卡在“不知道自己在看什么”。GDB 单步到某个地址,屏幕上是一串汇编,心里没有对应的画面,就只能发呆。所以这篇东西不打算按练习顺序流水账式地讲,而是先给你一张全局地图,再逐个拆关键节点,把每一步“为什么这么做”讲透。适合的人群也明确:刚接触 x86 实模式/保护模式、对链接脚本和 ELF 只有模糊印象、想把这门课真正学明白而不是抄份答案交差的人。
1.1 从加电到第一行输出的完整路径
把整条链路拆成七个阶段,脑子里有这张表,后面无论看代码还是调试都不会迷路。
| 阶段 | 执行者 | 关键动作 | 地址特征 |
|---|---|---|---|
| 1 | CPU | 复位后从固定地址取第一条指令 | CS=0xF000, EIP=0xFFF0 |
| 2 | BIOS | 自检、建立中断向量、读磁盘引导扇区 | 实模式,1MB 寻址 |
| 3 | bootloader 前半段 | 关中断、开 A20、加载 GDT、置 CR0.PE | 仍在实模式地址空间 |
| 4 | bootloader 后半段 | 长跳转进入 32 位保护模式、设栈 | 段选择子生效 |
| 5 | bootmain | 读 ELF 头、按 program header 搬运段、跳入口 | 内核链接在 1MB |
| 6 | kern_entry | 建临时页表前的准备、调用 kern_init | 高半区映射的起点 |
| 7 | kern_init | 初始化 IDT、PIC、时钟,开中断,打印 | 中断开始工作 |
这张表里最反直觉的是第 3、4 阶段的切换。很多人以为“进了保护模式”是个瞬间完成的动作,实际上它是一串有严格顺序的操作:先关中断(不然切换过程中来一个中断,IDT 还没建好,直接三重故障重启),再开 A20(不然地址回绕,1MB 以上的内存访问会绕回 0),再lgdt装载全局描述符表,最后把 CR0 的第 0 位置 1,紧跟一条长跳转刷新流水线。少任何一步,机器都会以你看不懂的方式挂掉——通常是 QEMU 窗口一片黑,或者疯狂重启。
1.2 六个练习各自卡在哪一环
这门实验的 Lab1 通常给六个练习,其中前四个偏“读”,后两个偏“写”。这个设计是有道理的:先逼你把已有的启动代码读懂,再让你动手补两个小功能。
练习 1 让你用make生成镜像并解释编译链接过程,本质是让你搞清楚bin/kernel和bin/bootblock这两个 ELF 是怎么被拼成ucore.img的。练习 2 让你单步跟踪 BIOS,是让你亲手确认“CPU 复位后第一条指令在哪”。练习 3 分析 bootloader 进保护模式的过程,练习 4 分析 ELF 加载过程。这两个是读代码的高峰,也是最容易囫囵吞枣的地方。练习 5 写print_stackframe,练习 6 补中断初始化。
我要提醒一句:练习 5 的栈回溯是后面所有实验的调试基础设施,Lab2 之后你一旦遇到页错误、GP 异常,全靠它打出调用链。这里偷懒抄一份代码,后面会加倍还回来。练习 6 的中断框架同理,Lab2 的时钟中断、Lab3 的系统调用入口,全都建立在这套__alltraps的 trapframe 结构上。
2. 环境准备:工具链的坑比代码本身更费时间
先把环境搞稳,不然你会在“代码明明是对的却跑不起来”上浪费一整天。这套实验的代码是十几年前写的,而你现在装的编译器早就换了好几代,两者之间的兼容性问题几乎是必然的。
2.1 编译器与链接器版本的三个雷
第一个雷是调试信息格式。Makefile 里很可能写着-gstabs,这是给老版本 GDB 用的格式,现代 GCC 早就把 stabs 支持删掉了,直接报unrecognized debug output level 'stabs'。解决办法是替换成-gdwarf-2,这个格式现代 GDB 全都认,而且对 32 位代码支持没问题。
第二个雷是位置无关代码。新版本 GCC 默认开启 PIE(位置无关可执行文件),链接出来的东西地址是相对的,跟你链接脚本里写死的0x100000完全对不上,加载上去就跑飞。实测必须在编译选项里加-fno-pie(老版本叫-fno-PIC),链接时加-no-pie。
第三个雷是 32 位支持。这是 32 位内核,所有编译都要-m32,而很多新系统默认没装 32 位运行库,会报cannot find -lgcc之类的错。注意这里有个细节:内核链接时用的是-nostdlib,理论上不需要 libc,但-m32仍然要求编译器能提供 32 位的辅助函数。装齐gcc-multilib这类包通常能解决。
注意:不要试图把所有警告都消掉。这套老代码在现代编译器下必然有一堆
-Wall警告,尤其是隐式声明和指针转换。把-Werror去掉,只留-Wall看着,别把时间浪费在改警告上。
链接命令的核心大概是这样(不同版本的 Makefile 写法有差异,但骨架一致):
ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/entry.o obj/kern/init.o ...-m elf_i386指定输出 32 位 x86 ELF,-T指定链接脚本。少写-m elf_i386的话,在一些平台会默认按 64 位链接,然后报一堆“无法识别的重定位”错误。这类报错看着吓人,其实就是一行参数的事,但对新手来说足够卡半天。
2.2 make 之后的产物到底长什么样
执行一次make,你会看到几个关键产物,必须清楚它们分别是什么。
obj/目录下是一堆.o目标文件,bin/kernel是链接好的 ELF 内核,bin/bootblock.out是链接好的引导扇区 ELF,但它还不能直接用——引导扇区必须是裸二进制,而且长度不能超过 510 字节(剩下 2 字节留给结束魔数0x55AA)。所以 Makefile 会再跑一次objcopy,把 ELF 的头部和符号表剥掉,得到纯粹的机器码bin/bootblock。
最后一步是用脚本把这两块拼成一个硬盘镜像bin/ucore.img:第一个扇区放 bootblock,后面依次放内核的各个扇区。这一步之所以要自己写脚本,是因为镜像格式需要精确控制扇区布局,dd命令虽然能做,但可读性太差。脚本会检查 bootblock 是否超过 510 字节,超了直接报错——这是个很好的设计,因为超了的话魔数会被挤到错误位置,BIOS 认不出这是可引导扇区。
我建议你第一次跑通之后,手动执行一次检查:
ls -l bin/bootblock od -A x -t x1z bin/bootblock | tail -n 2确认最后两个字节是55 aa,大小是 512 字节。这个习惯能帮你在后面改 bootloader 时第一时间发现“代码写多了把扇区撑爆”的问题。
3. 练习1:用反汇编把“编译链接”这件事看透
练习 1 的题目看起来很虚——“解释编译链接过程”,但它的价值在于逼你面对一个事实:你写的 C 代码和机器实际执行的东西之间,隔着一层链接脚本和一堆段布局规则。这层东西在普通应用开发里被操作系统和加载器隐藏了,而在这里,你自己就是加载器。
3.1 三条命令定位关键信息
别急着看汇编,先用objdump -h看段表,这是信息密度最高的一步。
objdump -h bin/kernel输出里你会看到.text、.rodata、.data、.bss几个段,重点看两个字段:VMA(虚拟内存地址,也就是链接地址)和 LMA(加载地址)。在 Lab1 里这两个通常是相同的,.text的 VMA 是0x100000。这个数字不是随便定的,1MB 是实模式寻址上限之上的第一个可用位置,避开低端内存里 BIOS 和显存占用的区域。
接着看 ELF 头:
readelf -h bin/kernel重点记两个值:Entry point address是内核入口的虚拟地址,Number of program headers决定后面的加载循环要跑几次。这两个值在练习 4 分析 bootmain 时会反复用到。
最后反汇编:
objdump -S bin/kernel | less-S会把源码和汇编混排,前提是编译时带了调试信息。如果你只想要纯汇编,用objdump -d。我习惯用objdump -d bin/kernel > k.asm导出来慢慢看,配合编辑器搜索函数名比在终端里翻页舒服得多。
3.2 链接地址与加载地址的错位解释
这里有个必须在练习 1 就想明白的问题:内核链接在0x100000,但 bootloader 运行时还在实模式/刚进保护模式,没有开启分页,那它怎么把内核搬到0x100000?
答案是:在这个阶段,虚拟地址等于物理地址。bootloader 跳转时用的是ph->p_va的低位地址(代码里会跟一个0xFFFFFF掩码),在 Lab1 里因为没有分页,屏蔽高 8 位和不屏蔽结果一样,都是0x100000。这个掩码的真正意义是为后续实验做准备——Lab2 之后内核会被映射到高地址0xF0100000,那时就必须靠掩码把它转回物理地址才能正确搬运。
我当年在这个掩码上纠结了很久,总觉得它在 Lab1 是“没用”的代码。后来想通了:它不是为 Lab1 写的,是为整个实验体系写的。理解这一点,你对“代码为未来预留扩展”这件事会有更具体的感受。
再补一个细节:为什么 bootblock 链接在0x7C00?因为 BIOS 读引导扇区时约定就放到这个地址。链接脚本里写死这个值,是为了让 bootloader 里的相对跳转、字符串地址引用全部按这个基址算好,装载到正确位置后不用任何重定位就能直接跑。这是“位置相关代码”的典型用法,也是为什么它必须被加载到精确地址。
4. 练习2:单步跟踪 BIOS,看清第一条指令
这个练习很多人跳过了,觉得“BIOS 跟我没关系”。但它是唯一一次让你亲眼看到 CPU 复位后行为的机会,而且能帮你建立一个重要直觉:所谓“程序从 main 开始”,在裸机上根本不成立。
4.1 GDB 远程调试怎么连
启动方式通常是两个终端,或者用 Makefile 里的make qemu-gdb与make gdb配合。QEMU 那边加上-s -S参数,-s是开 GDB stub 监听 1234 端口,-S是启动后立刻暂停,等调试器接管。这种组合有个好处:你能在第一条指令执行前就把断点打好,不会错过任何东西。
GDB 这边需要加载符号:
gdb -q -ex 'target remote :1234' -ex 'set architecture i8086' bin/kernel注意set architecture i8086这行。QEMU 默认按 32 位解释寄存器,但刚复位时 CPU 处于 16 位实模式,不加这行你看到的eip值会不对。切到 i8086 之后,用info registers看的是 16 位的cs和ip。
连上之后先看当前位置:
(gdb) info registers pc (gdb) x/10i $pc第一条指令应该是ljmp $0xf000,$0xe05b之类,位置在0xffff0。这个地址是 8086 时代留下来的约定,CPU 复位后 CS 被置为0xF000、IP 置为0xFFF0,合起来就是0xFFFF0,正好落在 BIOS 固化区域的末尾附近,那里放的就是一条跳转指令。你需要理解的是:这段地址空间是只读的 ROM 映射,不是你可以随便改的内存。
4.2 从 0xffff0 到 0x7c00 都发生了什么
继续单步,你会看到 BIOS 在做事:设置中断向量表(实模式下的 IVT,在物理内存最低 1KB)、检测内存、初始化显示模式。这些步骤都可以大致跳过,但有一步值得停一下——你会看到 BIOS 读磁盘的操作,最终把引导扇区搬到0x7c00。可以在这附近设个断点在0x7c00:
(gdb) b *0x7c00 (gdb) c命中的时候,$pc就在0x7c00,你手里的 bootblock 代码开始接手。这一刻是整个实验里最有仪式感的瞬间之一:你写的代码,第一次真正被机器执行了。
提示:单步 BIOS 时不要一路
si到底,那要按几万次。用b *地址加c的方式跳到你关心的位置,再在局部用si细看。另外 QEMU 有个很有用的日志功能:加-d in_asm -D q.log,可以把执行过的汇编全打到文件里,事后翻日志比实时单步高效得多。
5. 练习3与练习4:保护模式与 ELF 加载
这两个练习是整个 Lab1 的阅读高峰。我建议的阅读顺序是:先看bootasm.S从开头读到长跳转,再切到bootmain.c从readseg读到入口跳转。中间那条缝是“保护模式已经开始,但内核还没起来”,很多人在这里概念混乱。
5.1 A20、GDT、CR0.PE 三件事的先后顺序
bootasm.S开头的几行很关键:先cli关中断,然后清理段寄存器和方向标志。清段寄存器这一步看着莫名其妙,其实就是把残留的 BIOS 设置抹掉,让后面的段选择子失效,等 GDT 建好再重新赋值。
接着是开 A20。这段代码用的是键盘控制器转发法:往0x64端口发0xD1命令,再往0x60端口发0xDF。为什么是这个奇怪的流程?因为早期的 A20 门电路就挂在键盘控制器上,这是历史包袱,后来的芯片组也保持兼容。写代码时要先轮询0x64端口的状态位,等输入缓冲区空才能发命令,否则命令会丢。这个“等状态位”的细节在书里经常被一句话带过,但实际代码里那几行判断是必须的。
然后装载 GDT。GDT 里至少要有三个描述符:空描述符(全 0,Intel 规定第 0 项必须无效)、代码段、数据段。代码段和数据段的基址都是 0,限长0xFFFFFFFF,粒度 4KB——这套配置的含义是“整个 4GB 地址空间平铺,不做段级保护”,也就是常说的“平坦模型”。很多教程讲 GDT 讲得很玄,其实这里就一个朴素的意图:分段机制在 32 位时代已经没什么人真用了,但必须配一套能跑通的描述符把机制糊弄过去,重点放在后面的分页上。
lgdt之后设置 CR0 的 PE 位,紧跟一条ljmp $0x08, $protcseg。这条长跳转不是为了跳到哪里去,是为了刷新 CS 段选择子和指令流水线。在 PE 位置 1 的瞬间,CPU 已经按保护模式解释了,但流水线里可能还有实模式解出的指令,长跳转把流水线清空,同时把 CS 换成 GDT 里的代码段选择子。
进保护模式后,把ds、es、fs、gs、ss全部赋成数据段选择子(通常是0x10),然后设栈指针为0x7c00,调用bootmain。栈设在0x7c00是因为它向下增长不会覆盖0x7c00处的 bootloader 代码本身——栈指针设在这里,压栈往低地址走,正好避开自己。
5.2 bootmain 里两处必须看懂的代码
第一处是读 ELF 头。代码大致是先把内核起始的 8 个扇区读到临时位置,然后检查魔数是不是0x464C457F(也就是\x7fELF的小端表示)。这个检查很务实:如果读错了地方,后面按 ELF 结构解析会读到垃圾数据,直接跳飞,还不如在这里停下。检查失败后通常走一个死循环,QEMU 里表现为卡住不动——看到这个现象,先怀疑镜像是不是没生成对。
第二处是加载循环:
for (i = 0; i < ELFHDR->e_phnum; i++) { ph = ELFHDR->e_phoff + (struct proghdr *)((uintptr_t)ELFHDR + i * sizeof(struct proghdr)); if (ph->p_type == ELF_PROG_LOAD) { readseg(ph->p_va & 0xFFFFFF, ph->p_filesz, ph->p_offset); for (j = ph->p_filesz; j < ph->p_memsz; j++) { *((char *)(ph->p_va & 0xFFFFFF) + j) = 0; } } }这段里有两个容易忽略的点。一是p_filesz和p_memsz的区别:前者是文件里真实存在的字节数,后者是段在内存里应该占的大小,两者之间的差额就是.bss段——它不占文件体积,但必须清零。如果忘了清零,全局变量初值会是内存里的随机残留,症状是“变量明明初始化了却跑出奇怪的值”,极难排查。二是p_offset是段在 ELF 文件里的偏移,不是扇区号,readseg内部要自己换算成扇区并处理非对齐的情况,这个换算逻辑看着琐碎,但错了就会读进错位的数据。
最后跳入口:
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();这一步把控制权交给内核。跳过去之前,kern_entry的入口代码会重新设置一个正式的栈,因为0x7c00处的栈马上要被内核数据覆盖了。这个细节是很多人第一次读代码时的疑问:“栈不是设过了吗?”设过了,但那只是临时的。
6. 练习5:print_stackframe 与 EBP 链
这个练习代码量不到二十行,但它要求你真正理解函数调用在机器层面长什么样。我见过太多人把这段代码抄对、跑出结果、然后就走了,结果 Lab2 出异常时完全不会看栈回溯输出。
6.1 函数调用栈是怎么长出来的
先建立画面。x86 上的函数调用,call指令做两件事:把返回地址(下一条指令的地址)压栈,然后跳到目标函数。进入函数后,标准的序言是:
push %ebp mov %esp, %ebp sub $N, %esppush %ebp保存调用者的帧基址,mov %esp,%ebp把当前栈顶固定成自己的帧基址,sub分配局部变量空间。于是当前ebp指向的位置,[ebp]存的是上一个函数的ebp,[ebp+4]存的是返回地址,[ebp+8]开始是第一个参数。这就是所谓的 EBP 链——一串用栈串起来的链表,沿着[ebp]一直往上走,就能把整个调用链还原出来。
所以print_stackframe的核心就三句话:读当前ebp和eip,循环打印[ebp+4]处的返回地址和[ebp+8]开始的若干参数,然后ebp = [ebp]继续往上。循环要设上限(代码里通常定义STACKFRAME_DEPTH),否则走到最顶端时ebp变成 0 或垃圾值,读下去会触发页错误。
读寄存器的部分用内联汇编:
static inline uint32_t read_ebp(void) { uint32_t ebp; asm volatile("movl %%ebp, %0" : "=r"(ebp)); return ebp; }volatile不能省,不然编译器可能觉得这个表达式没有副作用,把你辛辛苦苦读的值优化掉。同理read_eip也有个坑:直接用movl %%eip是不合法的,x86 没有直接读 EIP 的指令,常规做法是通过call把返回地址压栈再弹出来,或者用lea配合标签地址。很多实现干脆用read_ebp()之后从[ebp+4]取eip,因为对当前帧来说两者是一回事。
6.2 实现要点与 eip-1 的原因
补全后的主体大概长这样:
void print_stackframe(void) { uint32_t ebp = read_ebp(); uint32_t eip = read_eip(); int i, j; for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i++) { eip = *((uint32_t *)ebp + 1); cprintf("ebp:0x%08x eip:0x%08x args:", ebp, eip); for (j = 0; j < 4; j++) { cprintf("0x%08x ", *((uint32_t *)ebp + 2 + j)); } cprintf("\n"); print_debuginfo(eip - 1); ebp = *((uint32_t *)ebp); } }print_debuginfo(eip - 1)里的减一是必须的。返回地址指向的是call指令的下一条指令,而查符号表是想知道“这行代码属于哪个函数”。如果下一条指令正好是另一个函数的开头(比如call foo后面紧跟bar的第一条指令),用未减一的地址查会查到bar,显示错误的函数名。减一之后地址落回call指令内部,就一定能查回正确的调用者。
跑起来之后输出大概是这样:
ebp:0x00007bf8 eip:0x00007d6c args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 kern/debug/kdebug.c:306: print_stackframe+21 ebp:0x00007bf8 ...第一行那个ebp值0x7bf8是 bootmain 的帧基址,因为 bootmain 里设的栈顶就是0x7c00,压了几个东西之后自然落到这附近。看到这一行,说明栈回溯已经一路走到了最外层,链路完整。
提示:如果你打印出来的参数值是
0xc031fcfa这种看着像乱码的数,别慌,这是正常的。栈回溯会把[ebp+8]往后读四个 32 位数当作参数,但当前函数可能根本没有四个参数,读到的是上一帧的残留数据。这个“不精确”是刻意保留的,目的是在任何异常现场都能打出尽可能多的上下文。
7. 练习6:中断描述符表与时钟中断
最后一个练习,也是对整个实验体系最重要的一块。中断机制建不起来,后面所有实验都没法做——时钟中断是调度器的心跳,系统调用要靠 trap 门,缺页异常要靠 page fault 处理。
7.1 从 IDT 填表到 __alltraps
IDT 是一张 256 项的表,每项 8 字节,描述一个中断/异常处理入口。idt_init的核心是一个循环:
extern uintptr_t __vectors[]; for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i++) { SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL); } SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER); lidt(&idt_pd);__vectors是在汇编里定义的一张地址表,每一项jmp __alltraps之前把中断号压栈。这个设计的巧妙之处在于:256 个中断入口只需要一小段桩代码,真正的处理逻辑全在__alltraps里统一完成。
SETGATE宏要填的关键字段有三个。一是段选择子,指向内核代码段;三是偏移的高 16 位和低 16 位分开存,这是历史遗留的分裂布局,写的时候容易漏掉高 16 位,症状是“中断一来就跳到莫名其妙的地方”。二是门类型,中断门(STS_IG32)会自动清 IF 位关中断,陷阱门(STS_TG32)不清。这里用中断门,是为了避免同一个中断处理过程中被同级中断打断,造成嵌套混乱。三是 DPL,大部分中断只允许内核态触发(DPL_KERNEL),只有系统调用那一个入口需要设成 DPL_USER,让用户态能主动进来。
lidt装载之后,中断向量表就算建好了,但这时候还不能开中断,因为 PIC 还没初始化,时钟也没配置好。顺序一定是:建 IDT → 初始化 PIC → 配置时钟 → 开中断。
__alltraps的汇编逻辑值得逐行看一遍。它先做SAVE_ALL:压入段寄存器、压入通用寄存器,形成一个固定的struct trapframe布局。这个布局在 C 代码里是可见的,trap函数接收的就是一个struct trapframe *指针。然后pushl %esp把 trapframe 指针当参数传给trap,调用 C 函数。返回后走__trapret,RESTORE_ALL恢复寄存器,最后iret回到被打断的地方。
这个结构的价值在于:无论中断发生在哪个函数里,被打断现场的所有寄存器都被完整保存成一块连续内存,C 代码可以随便读、随便改。trapframe里那个tf_cs字段在后面的实验中会被用来判断“中断来自内核态还是用户态”,是权限切换的关键依据。
7.2 ticks 计数与秒数打印
时钟中断的配置分两步。PIC 初始化(pic_init)把 8259A 的向量偏移设成IRQ_OFFSET(通常是 32),这样硬件中断向量就不会和 CPU 异常(0 到 31)冲突——这是个经典的设计约束,如果 PIC 也用低向量,那 IRQ0 会和除零异常撞车,谁都分不清是谁触发的。
然后是定时器频率。给0x43端口发命令字,再给0x40端口发两个字节的计数值。计算方式是从基准频率1193182Hz 除以目标频率。想要 100Hz(也就是 10ms 一次),计数值就是1193182 / 100 ≈ 11931。这个数要拆成高低两个字节发出去:
int hz = 100; int count = TIMER_FREQ / hz; outb(0x43, 0x34); outb(0x40, count & 0xFF); outb(0x40, (count >> 8) & 0xFF);分频值不能超过 65535,所以最低频率大约是 18.2Hz,再低就得分频了。这些参数我建议你亲手算一遍,别直接抄常量——算过之后你就明白为什么改频率会同时影响“每秒打印次数”和“系统时间精度”。
中断处理里最核心的几行是:
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) { ticks++; if (ticks % TICK_NUM == 0) { print_ticks(); } return; }ticks会每 10ms 加一,攒够 100 次就是一秒。print_ticks里通常会判断是不是第一次满一秒,是的话打印一句提示,这样你就能在屏幕上看到“1 second”之类的输出。如果这行输出出现了,说明整条中断链路——IDT、PIC、定时器、trapframe、iret返回——全部打通了。
注意:
ticks这类被中断处理修改的变量必须加volatile,否则开了优化之后,主循环里读到的可能是寄存器里的缓存值,永远不更新。这个坑在 Lab1 里不明显(因为主要靠中断里的打印),到 Lab2 做调度时会让你的进程永远等不到时间片。
8. 报错与排查实录
Lab1 的错误类型很集中,基本都是“机器不按你预期执行”。下面这张表是我这些年攒下来的高频问题,按现象查就行。
| 现象 | 大概率原因 | 定位手段 |
|---|---|---|
| QEMU 黑屏无输出 | 镜像没生成对,或 bootblock 超过 512 字节 | 用od看魔数位置,看生成脚本有无报错 |
| 疯狂重启 | 保护模式切换时三重故障,多半是 GDT 或中断没关 | GDB 断在lgdt之后单步 |
cprintf打出乱码 | 栈被覆盖,或 GDT 数据段配置错 | 检查栈指针设置,打印ebp值 |
| GDB 断点打不上 | 符号地址与运行地址不一致(PIE 问题) | readelf -h看 Entry,加-fno-pie重编 |
编译报stabs不支持 | 新版 GCC 移除了 stabs 格式 | 改成-gdwarf-2 |
| 中断不触发 | PIC 掩码没打开,或 IDT 没lidt | 打印 IDT 内容,检查idt_pd |
| 栈回溯只有一行 | ebp链断了,或循环条件写错 | 在print_stackframe里打印中间值 |
8.1 三个能救命的调试习惯
第一个习惯:任何“跑飞”的问题,先怀疑链接地址。在 GDB 里info registers eip看到一个陌生的高地址,八成是跳到错的段或者栈被覆盖导致ret弹出了垃圾返回地址。这时候用x/20i $eip-40看指令上下文,比盯着源码猜快得多。
第二个习惯:善用 QEMU 的日志。-d in_asm,int,cpu_reset -D q.log这一组能把指令流、中断、复位事件全记下来。机器连续重启的时候,日志里会看到一串Triple fault或者重复的复位记录,直接指向故障点。这个手段比自己加cprintf调试高效十倍,因为它在任何代码开始执行前就已经在工作。
第三个习惯:改完 bootloader 一定要看大小。我见过太多次“加了几个打印字符串,结果扇区超限”的案例。超限的表现是所有代码都正确但就是起不来,因为最后两个字节的魔数被挤掉了,BIOS 认为是无效引导扇区,直接跳过。
8.2 关于“看懂了”的自检标准
最后分享一个我用来判断自己是否真的读懂了某段代码的方法:合上源码,把这段逻辑用白板画出来,包括寄存器状态、内存布局、跳转目标。画不出来就是没懂。
拿bootmain举例,能画出下面这些才算过关:读 ELF 头时内存里放了什么、readseg的参数怎么换算成扇区、每个 program header 搬运时源和目标的地址、.bss清零的范围、最终跳转的目标地址是多少、跳过去那一刻栈指针在哪。这套东西画出来之后,Lab2 的分页映射你能省一半时间,因为分页本质上就是“在搬运之前加一层地址转换”,前面这些物理搬运的细节全都要用到。
再补一句实操建议:Lab1 做完之后,别急着删掉你的调试脚本和日志。我在后面几个实验里反复回看 Lab1 的 QEMU 日志和 GDB 命令历史,尤其是那个-S -s的启动方式和几个常用断点,直接复制过去就能用。这套实验的连续性很强,第一课攒下来的工具链,后面每一课都在还本付息。
如果你只能记住一件事,那就记住这个:Lab1 的所有代码都围绕一个朴素的目标服务——把 CPU 从一个什么都不懂的复位状态,一步步带到一个能响应中断、能打印字符的受控状态。每读一段代码,问自己“它把机器往这个目标推了哪一步”,比记任何具体地址都管用。