
1. Lab1 到底要你干什么先把这条链路看清楚操作系统实验的第一课最容易犯的错不是写不出代码而是压根没搞清楚这一课在整个系统里处于什么位置。这套实验的内核代号叫 ucoreLab1 的名字通常叫“启动与中断”听起来像个热身实际上它一口气塞了四件事进来编译链接产物怎么来的、机器加电后 BIOS 做了什么、bootloader 怎么把 CPU 切到保护模式并加载 ELF 内核、内核起来之后怎么建立中断机制并让时钟开始跳。这四件事串起来就是一条从按下电源键到屏幕上打出第一行cprintf输出的完整路径。我带过几轮这门实验的答疑发现一个很稳定的规律卡在 Lab1 的人八成不是卡在算法而是卡在“不知道自己在看什么”。GDB 单步到某个地址屏幕上是一串汇编心里没有对应的画面就只能发呆。所以这篇东西不打算按练习顺序流水账式地讲而是先给你一张全局地图再逐个拆关键节点把每一步“为什么这么做”讲透。适合的人群也明确刚接触 x86 实模式/保护模式、对链接脚本和 ELF 只有模糊印象、想把这门课真正学明白而不是抄份答案交差的人。1.1 从加电到第一行输出的完整路径把整条链路拆成七个阶段脑子里有这张表后面无论看代码还是调试都不会迷路。阶段执行者关键动作地址特征1CPU复位后从固定地址取第一条指令CS0xF000, EIP0xFFF02BIOS自检、建立中断向量、读磁盘引导扇区实模式1MB 寻址3bootloader 前半段关中断、开 A20、加载 GDT、置 CR0.PE仍在实模式地址空间4bootloader 后半段长跳转进入 32 位保护模式、设栈段选择子生效5bootmain读 ELF 头、按 program header 搬运段、跳入口内核链接在 1MB6kern_entry建临时页表前的准备、调用 kern_init高半区映射的起点7kern_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 里至少要有三个描述符空描述符全 0Intel 规定第 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. 练习5print_stackframe 与 EBP 链这个练习代码量不到二十行但它要求你真正理解函数调用在机器层面长什么样。我见过太多人把这段代码抄对、跑出结果、然后就走了结果 Lab2 出异常时完全不会看栈回溯输出。6.1 函数调用栈是怎么长出来的先建立画面。x86 上的函数调用call指令做两件事把返回地址下一条指令的地址压栈然后跳到目标函数。进入函数后标准的序言是push %ebp mov %esp, %ebp sub $N, %esppush %ebp保存调用者的帧基址mov %esp,%ebp把当前栈顶固定成自己的帧基址sub分配局部变量空间。于是当前ebp指向的位置[ebp]存的是上一个函数的ebp[ebp4]存的是返回地址[ebp8]开始是第一个参数。这就是所谓的 EBP 链——一串用栈串起来的链表沿着[ebp]一直往上走就能把整个调用链还原出来。所以print_stackframe的核心就三句话读当前ebp和eip循环打印[ebp4]处的返回地址和[ebp8]开始的若干参数然后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()之后从[ebp4]取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_stackframe21 ebp:0x00007bf8 ...第一行那个ebp值0x7bf8是 bootmain 的帧基址因为 bootmain 里设的栈顶就是0x7c00压了几个东西之后自然落到这附近。看到这一行说明栈回溯已经一路走到了最外层链路完整。提示如果你打印出来的参数值是0xc031fcfa这种看着像乱码的数别慌这是正常的。栈回溯会把[ebp8]往后读四个 32 位数当作参数但当前函数可能根本没有四个参数读到的是上一帧的残留数据。这个“不精确”是刻意保留的目的是在任何异常现场都能打出尽可能多的上下文。7. 练习6中断描述符表与时钟中断最后一个练习也是对整个实验体系最重要的一块。中断机制建不起来后面所有实验都没法做——时钟中断是调度器的心跳系统调用要靠 trap 门缺页异常要靠 page fault 处理。7.1 从 IDT 填表到 __alltrapsIDT 是一张 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 函数。返回后走__trapretRESTORE_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 从一个什么都不懂的复位状态一步步带到一个能响应中断、能打印字符的受控状态。每读一段代码问自己“它把机器往这个目标推了哪一步”比记任何具体地址都管用。