操作系统实验的第一课,从来不是教你怎么写调度器,而是先让你把一台机器从通电那一瞬间接管过来。这个 Lab 的核心任务就一句话:写一段不超过 512 字节的引导代码,把编译好的内核镜像从磁盘搬进内存,切到 32 位保护模式,然后跳进内核的 C 入口。听上去不难,但它把操作系统实验里最容易劝退人的几件事一次性全塞给你了——交叉编译、链接脚本、内存布局、ELF 格式、QEMU启动参数、GDB远程调试。我在带新人过这个实验的时候发现,真正卡住的往往不是概念,而是“我明明照着敲的,为什么屏幕上一片黑”。这篇文章就把这个实验从设计思路到踩坑记录完整拆一遍,适合刚接触内核开发的同学,也适合做过上层应用、想往下看一眼机器到底怎么跑起来的人。你不需要提前会汇编,但得愿意对着反汇编和寄存器发呆半小时。
1. 先搞清楚这个实验到底要你交付什么
1.1 Lab1 的真实目标不是“写内核”,而是“接管机器”
很多人第一次看实验说明,会把注意力放在“内核”两个字上,觉得要写一大堆 C 代码。实际上这一节实验交付的内核部分只有几十行,主体工作是启动链条:引导扇区负责把磁盘上的 ELF 文件读进内存,然后交出控制权。真正的内核入口里,你可能只做一件事——往显存或串口打印一行字,证明自己活着。
所以这一节实验的评分点不是功能多,而是链条完整。从 BIOS 把 512 字节加载到0x7C00,到保护模式段寄存器全部重载,再到 ELF 每个PT_LOAD段被搬到p_pa指向的物理地址,最后e_entry被调用——这四步任何一环断了,你在屏幕上看到的就是光标闪烁或者 QEMU 直接重启。理解这一点很关键,因为后面所有调试手段都是围绕“定位链条断在哪一环”来用的,而不是围绕“我的代码逻辑对不对”。
我在旁边看新手做这个实验,最常见的误区是急着往内核里塞功能,比如想做键盘输入、想做分页。结果启动代码里一个段选择子写错,前面所有努力都表现为“没反应”。正确的心态是:先把这条最小链路跑通到能打印字符,再谈别的。
1.2 为什么第一个实验总从启动流程下手
有人会问,为什么不直接从 C 语言写一个内核主函数开始,非要从汇编和磁盘读取搞起。原因在于,计算机加电后的状态和你写 C 代码时假设的状态完全不是一回事。加电时 CPU 处于实模式,只有 20 位地址线可用,寻址上限 1MB,没有分页,段寄存器里装的是段基址的高 4 位,栈也没建好,全局变量所在的内存区域甚至还不是可写的。
这些约束你不主动处理,编译器生成的代码就会在你不注意的地方崩掉。比如你在内核里定义一个全局数组,链接脚本把它放在某个地址,但那段物理内存可能是显存区或者 BIOS ROM 映射区,写进去就是石沉大海。启动流程这一课的意义,是逼你把“程序运行需要哪些前提”这件事想清楚:栈在哪、数据段在哪、代码被放在哪个物理地址、CPU 处于什么模式。
这也是我觉得这个实验设计得比较狠的地方——它不给你任何隐藏的魔法,所有前提都得自己搭。等你能把机器从 16 位实模式带到 32 位保护模式,再回头看那些“操作系统自动帮你做的事”,理解会完全不一样。
1.3 目录结构与构建产物对照表
动手之前先花十分钟把目录摸清楚,比后面瞎找文件强得多。典型的目录布局大致是这样(不同版本的实验框架命名略有差异,但职责划分基本一致):
| 路径 | 职责 | 你需要改吗 |
|---|---|---|
boot/bootasm.S | 引导扇区汇编,完成实模式到保护模式切换 | 需要补充或理解 |
boot/bootmain.c | 读磁盘、解析 ELF、跳转内核 | 核心工作量在这 |
kern/entry.S | 内核入口,建立栈后调用 C 函数 | 少量修改 |
kern/init.c | 内核初始化,打印验证信息 | 用来验证 |
kern/kernel.ld | 链接脚本,决定各段落在哪个地址 | 需要读懂 |
libs/ | 极简的printf、字符串、IO 端口封装 | 一般不动 |
Makefile | 编译、链接、打包镜像 | 需要读懂关键选项 |
tools/ | 签名、格式转换等辅助脚本 | 一般不动 |
构建产物也值得先理一遍:汇编和 C 文件先编成.o,引导部分链接成bootblock,内核部分链接成kernel,最后用工具把bootblock放到镜像开头、内核按扇区对齐追加在后面,生成一个可以当硬盘挂载给 QEMU 的kernel.img。你要建立的直觉是:磁盘上的镜像布局和内存里的布局是两套东西,前者由打包脚本决定,后者由链接脚本决定,中间靠bootmain.c里的读取逻辑衔接。
注意:先看 Makefile 里的打包规则,再看链接脚本。顺序反了容易把“文件在磁盘上的偏移”和“段在内存里的地址”搞混,这是新手最常犯的认知错误。
2. 环境搭建:工具链的选择比想象中更影响后面几天的心情
2.1 gcc -m32 与交叉编译工具链的取舍
这里有个绕不开的选择:用本机 gcc 加-m32参数,还是装一套i386-elf-gcc交叉工具链。
前者的好处是省事,大多数发行版装个gcc-multilib就能用,编译速度快,调试时符号和源码对应关系也直观。坏处是它会偷偷依赖宿主机的 C 库和启动文件,一旦你忘了加-nostdlib、-ffreestanding这类参数,链接阶段就会冒出_start重定义、__libc_start_main未定义之类的诡异错误。而且不同发行版的 gcc 默认行为差异不小,同一份 Makefile 换台机器可能就要改。
后者干净得多,交叉工具链只面向裸机目标,默认不链接任何标准库,行为稳定可预期。缺点是要额外装几百兆的东西,配置 PATH 也得留神。我的建议是:如果你打算把这个实验做完整、后面还要继续往下走,直接上交叉工具链,一次配置省掉后面反复排查环境问题的时间。如果只是想把这一节跑通看一眼效果,-m32也够用,但必须把编译参数写全。
下面这组参数是我实测下来比较稳的组合,无论走哪条路线都建议保留:
CFLAGS := -m32 -fno-builtin -fno-stack-protector -nostdinc \ -ffreestanding -Wall -O2 -g -gdwarf-2逐个说下为什么。-m32生成 32 位代码,这个实验的所有地址假设都建立在 32 位平坦模型上。-fno-builtin禁止编译器把memcpy、memset这类调用替换成内建实现,否则你会遇到“我明明写了memcpy,链接却说找不到符号”的情况。-fno-stack-protector关掉栈保护,因为栈保护依赖运行时库里的__stack_chk_fail,裸机环境下没有这东西。-nostdinc不搜索系统头文件,避免不小心引入宿主机上那些用不了的声明。
-ffreestanding告诉编译器“这是独立环境”,它会影响一些优化假设,比如不假设main一定存在。-g -gdwarf-2是给 GDB 用的,后者指定调试信息格式,某些版本的 GDB 对默认格式支持不好,显式指定能省掉“符号能读但行号对不上”的毛病。-O2和-g同时开是没问题的,但如果你在调试时发现变量被优化没了,可以临时降到-O0,但要注意-O0下代码体积会变大,可能顶破引导扇区那 512 字节的限制。
2.2 依赖安装与最小验证
依赖不多,但漏一个就卡住。基础包包括编译器、汇编器、链接器、QEMU 系统模拟器、GDB。以常见的 Debian 系发行版为例,大致是build-essential、gcc-multilib、qemu-system-x86、gdb这几个。如果你走交叉工具链路线,还要加上对应的binutils交叉版本和gcc交叉版本。
装完别急着编译,先做两个最小验证。第一个是确认 32 位编译能力:
echo 'int main(){return 0;}' > /tmp/t.c gcc -m32 /tmp/t.c -o /tmp/t && file /tmp/tfile输出里应该看到ELF 32-bit字样。如果报找不到crt1.o或者-lgcc,就是缺 multilib,补装即可。第二个验证是 QEMU 能不能起来:
qemu-system-i386 --version版本号不用太纠结,近几年发布的都够用。这里有个小坑:有些系统上装的是qemu-system-x86_64,它也能模拟 32 位,但启动参数和默认机器类型略有差异,Makefile 里如果写死了qemu-system-i386而系统只有 x86_64 那个二进制,就会报命令不存在。可以做个软链,或者直接改 Makefile,别在这上面浪费时间。
提示:把工具链版本记在 README 里。这个实验的 Makefile 对编译器版本比较敏感,尤其是链接器的默认行为,换个环境出问题时,有版本记录能省掉一半排查功夫。
2.3 第一次跑起来:QEMU 参数逐个解释
环境就绪后,先用实验框架自带的镜像跑一次,确认“能跑起来”这件事本身没问题,再去改代码。启动命令里每个参数都有讲究:
qemu-system-i386 -m 128M \ -drive file=bin/kernel.img,format=raw,index=0,media=disk \ -serial mon:stdio -nographic -s -S-m 128M给虚拟机 128MB 内存,这个值不用太大,内核本身很小,但也不能太小,某些 BIOS 实现在内存过小时行为古怪。-drive那串把打包好的镜像当作第一块硬盘挂上,format=raw明确告诉 QEMU 不要猜格式,index=0保证它是第一块盘——因为引导扇区的加载依赖 BIOS 从第一块盘读,顺序错了就起不来。
-serial mon:stdio把串口和 QEMU 监控台一起重定向到当前终端,这样内核往串口打印的内容你能直接看到,同时按特定组合键还能切到监控台做退出、重启操作。-nographic关掉图形窗口,全部走终端,调试时更方便复制日志。-s是-gdb tcp::1234的简写,开一个 GDB 服务端口。-S表示启动时暂停,等调试器连上再跑。
这两个参数一起用,你会看到 QEMU 起来后什么都不干,终端也没输出——这是正常的,它在等 GDB。如果你只是想先看一眼运行效果,把-s -S去掉即可。我个人的习惯是:调试阶段常驻-s -S,验证阶段去掉,两种模式切换着用。
3. 启动链条逐级拆解
3.1 512 字节的约束如何决定了引导代码的写法
引导扇区的硬约束是 512 字节,末尾两个字节必须是0x55和0xAA。BIOS 加电自检后,会把这 512 字节原封不动读到物理地址0x7C00,然后跳过去执行。注意这里没有任何重定位、没有符号解析,你就站在0x7C00这个地址上,段寄存器还是 BIOS 留下的值。
这个约束直接决定了代码组织方式。引导部分不能依赖复杂的库函数,不能有大的静态数据,连字符串打印都得自己写循环往显存0xB8000搬。我在写的时候会把引导代码拆成两段思路:前一段只做最必要的硬件状态整理(关中断、清方向标志、把段寄存器置零),后一段做模式切换。任何“锦上添花”的功能一律砍掉,塞进内核里做。
还有一个细节很多人忽略:引导扇区里不要假设磁盘号。BIOS 把启动盘号放在DL寄存器里传进来,你应该在最早的时候把它存到一个安全的地方(比如某个固定内存地址或者压栈保存),后面读磁盘时用这个值,而不是硬编码0x80。硬编码在只有一块盘的环境下没问题,一旦你后面要挂第二块盘做实验,就会莫名其妙读错设备。
汇编文件里结尾的写法大致是这样:
.org 510 .word 0xaa55.org 510把当前地址推到第 510 字节,紧接着写两个字节的魔数,正好凑满 512。如果你前面的代码超过了 510 字节,汇编器不会报错,而是直接覆盖,最后生成的文件大小会超过 512,BIOS 只读前 512 字节,你的魔数就被截掉了,表现为“启动后毫无反应”。所以我强烈建议在 Makefile 里加一条大小检查:
if [ $$(stat -c %s bin/bootblock) -gt 512 ]; then \ echo "bootblock overflow"; exit 1; fi这条检查能救你很多次。
3.2 从实模式到保护模式:GDT、CR0 与跳转
保护模式和实模式最大的区别是段寄存器的含义变了。实模式下段寄存器里放的是段基址除以 16,保护模式下放的是 GDT 里的索引加属性位。要切换,必须先建好 GDT,再置位控制寄存器 CR0 的第 0 位(PE 位),最后执行一次远跳转刷新代码段寄存器。
GDT 至少要有三个描述符:空描述符(索引 0,处理器要求必须存在)、代码段、数据段。代码段描述符的属性字节大致是0x9A(可执行、可读、已访问位由 CPU 自己置),数据段是0x92(可写)。粒度位设成 1 表示段限长以 4KB 为单位,配合限长0xFFFFF,就能覆盖整个 4GB 平坦空间。
描述符的结构是 8 字节,拆得比较碎,手工拼容易错。我一般在汇编里用宏来生成,避免数位错位:
.set SEG_SZ_32, 0xC0000000 .set STA_X, 0x8 .set STA_W, 0x2 .set STA_R, 0x2 gdt: .quad 0x0000000000000000 # null .quad 0x00CF9A000000FFFF # code: base=0 limit=4G .quad 0x00CF92000000FFFF # data: base=0 limit=4G gdtdesc: .word 0x17 .long gdtgdtdesc里那个0x17是 GDT 限长,等于表长度减一,三个描述符共 24 字节,减一就是 23,即0x17。这个值写错是经典错误,写成0x18不会立刻崩,但加载时会多读一个字节,某些情况下表现为后续内存访问异常,非常难查。
置位 PE 位和跳转的顺序不能错:
lgdt gdtdesc movl %cr0, %eax orl $0x1, %eax movl %eax, %cr0 ljmp $0x8, $protcsegljmp这一句是关键。置位 PE 后,CPU 已经进入保护模式,但代码段寄存器里还是实模式留下的旧值,必须靠远跳转把它换成0x8(GDT 索引 1,即代码段)。跳完之后,数据段寄存器也得挨个重载:
protcseg: movw $0x10, %ax movw %ax, %ds movw %ax, %es movw %ax, %fs movw %ax, %gs movw %ax, %ss漏掉任何一个,后面用到的段都可能带着旧值,访问内存时按错误的基址算,表现出来就是“数据莫名其妙不对”。
注意:不要在置位 PE 之后、远跳转之前插入任何内存访问指令。这个窗口期 CPU 状态是“已经保护模式但段寄存器还是旧值”,任何访存都可能触发异常,而这时中断向量表还没建好,异常处理直接三重故障,机器重启,你什么都看不到。
3.3 ELF 装载:为什么不能直接整块 memcpy
拿到磁盘读能力之后,最直觉的做法是“把内核文件从头到尾读进内存”。这个做法是错的,原因在于 ELF 文件里包含了大量不需要加载的内容:节头表、符号表、调试信息,还有各个段之间的文件对齐空隙。真正需要进内存的只有那些标记为PT_LOAD的程序头所描述的段。
正确的流程是三步。第一步,把 ELF 文件头读到一块临时缓冲区(常见做法是放在0x10000,这个地址在常规内存里,安全且够用)。第二步,从文件头里取出程序头表的偏移e_phoff和表项个数e_phnum,再把整张程序头表读进来。第三步,遍历每个程序头,只处理e_type == PT_LOAD的项,把p_offset处、长度为p_filesz的内容搬到物理地址p_pa,剩下p_memsz - p_filesz的部分(BSS 段)清零。
struct elfhdr *elf = (struct elfhdr *)0x10000; readseg((uintptr_t)elf, 4096, 0); if (elf->e_magic != ELF_MAGIC) { goto bad; } struct proghdr *ph = (struct proghdr *)((uint8_t *)elf + elf->e_phoff); struct proghdr *eph = ph + elf->e_phnum; for (; ph < eph; ph++) { if (ph->p_type != ELF_PROG_LOAD) continue; readseg(ph->p_pa, ph->p_memsz, ph->p_offset); } ((void (*)(void))((struct elfhdr *)elf)->e_entry)();这里有三个容易出错的点。第一,程序头表的偏移通常不在文件开头 4KB 内,你只读 4096 字节拿到的可能只是文件头,e_phoff指向的数据还没读进来,直接解引用就是垃圾。稳妥做法是读完文件头后再单独按e_phoff读一次程序头表区域。第二,readseg一般是按扇区读的,长度参数最好自己向上对齐到扇区,否则末尾几个字节可能读不到。第三,跳转地址必须用e_entry,不能硬编码某个常量,因为链接脚本改一下,入口地址就变了。
还有一个隐蔽的坑:p_pa是物理地址,此阶段还没开分页,线性地址等于物理地址,所以直接把数据写到p_pa是对的。如果有人先在链接脚本里用了高位地址(比如 0xC0000000 以上),然后又没做地址转换,写进去的就是一片不存在的地方,表现是跳转后执行到全零内存,CPU 把0x00当成add [eax], al一路执行下去,最终跑飞。
3.4 链接脚本与地址计算
链接脚本决定每个段落在哪个物理地址,这个文件不长,但每行都有意义。
OUTPUT_FORMAT("elf32-i386") ENTRY(kern_entry) SECTIONS { . = 0x100000; .text : { *(.text .text.*) } .rodata : { *(.rodata .rodata.*) } .data : { *(.data .data.*) } .bss : { *(.bss .bss.*) *(COMMON) } }0x100000就是 1MB,这个起点不是随便选的。实模式下 20 位地址线能寻到 1MB,其中0x00000到0x9FC00大约 640KB 是常规内存,0xA0000往后到0xFFFFF被显存、BIOS ROM 和映射区占了。所以 1MB 往上才是连续可用的干净内存。把内核放在这里,既避开了低端那些被硬件占用的区域,也方便后续扩展。
ENTRY声明告诉链接器入口符号是谁,链接出来的 ELF 头里e_entry就会填这个符号的地址。bootmain.c里跳转用的正是它,两边对上才跳得过去。如果你改了入口函数名却没改ENTRY,会得到一个指向 0 的入口地址,表现是跳进一片空白。
.bss段那行有个*(COMMON),这是为了收拢未初始化的全局变量。有些编译器版本会把它们放在 COMMON 段,不写这句就会链接时提示 COMMON 段无归属,或者被放到奇怪的位置。
提示:链接脚本里可以用
PROVIDE定义几个边界符号,比如etext、edata、end,然后在 C 里声明成外部变量拿来算内核占用范围。后面做物理内存管理时这几个符号会非常有用,现在顺手加上不亏。
段对齐也值得提一句。如果你后面打算做分页,最好给每个段做 4KB 对齐,加一句. = ALIGN(4096);在段定义之前。这一节实验不做也完全能跑,但提前想一步能少改一次。
3.5 入口代码与栈的建立
核跳进来之后,第一条执行的指令在kern/entry.S里。此时 CPU 已经在保护模式,段寄存器是平坦模型,但栈指针还是 BIOS 留下的野值。所以在调用任何 C 函数之前,必须先把栈建起来。
kern_entry: movl $bootstacktop, %esp xorl %ebp, %ebp call kern_init 1: hlt jmp 1b栈是一块在.data里预留的静态数组,比如 8KB:
.section .data .globl bootstack bootstack: .space 8192 .globl bootstacktop bootstacktop:注意bootstacktop是数组末尾的地址,栈从高地址往低地址长,所以%esp要设成 top 而不是 base。这个细节写反了,第一次压栈就会覆盖掉前面的数据,如果那块数据恰好是 GDT 或者某个全局变量,症状会非常离奇——比如代码跑着跑着突然跳到别处。
C 入口函数里我一般先做两件小事验证:把.bss段清零,然后打印一行信息。清零.bss这一步不能省,虽然链接脚本保证了段占位,但物理内存加电后的内容是随机的,不清零的全局变量初值不受控,后面做任何逻辑都可能出玄学问题。打印可以走显存,直接往0xB8000写字符加属性字节,也可以走串口,往0x3F8端口写。串口的好处是输出能被重定向到终端,方便复制和对比,我推荐先用串口。
4. 构建系统与调试手段
4.1 Makefile 里几个关键编译选项
除了前面说的编译参数,Makefile 里还有几处值得看。链接时要用-N或者显式指定-T kernel.ld,-N会让代码段和数据段都可写(对早期调试方便,但不规范),正式做法是只用-T指定脚本,属性交给脚本控制。链接选项里加--build-id=none可以避免生成 note 段,那东西对裸机没用,还会让镜像里多出一段需要跳过的内容。
打包规则也要看一眼。典型做法是用objcopy把 ELF 转成纯二进制,再和引导块拼接。如果这里用了objcopy -O binary,它会默认把从最低地址到最高地址之间所有内容都输出,中间的空隙用零填充。这意味着如果链接脚本里各段地址不连续,生成的镜像会包含大量填充,体积暴涨。所以要么保证地址连续,要么用-j参数只抽取特定段。
还有一点:调试符号。objcopy转二进制后符号表就没了,但你需要用带符号的 ELF 来给 GDB 加载。所以构建产物里要保留一份没被 strip 的kernel文件,GDB 那边加载它,QEMU 那边跑的是kernel.img,两边地址对得上靠的是链接脚本没变。
4.2 GDB 远程调试实操
调试这个实验,我基本只用 GDB,图形化工具反而碍事。启动 QEMU 时带上-s -S,然后在另一个终端:
gdb bin/kernel (gdb) target remote :1234 (gdb) b kern_init (gdb) b *0x7c00 (gdb) ctarget remote :1234连上 QEMU 的调试端口。b *0x7c00在引导扇区第一条指令上下断点,这个断点用的是物理地址。注意此时符号表还没生效,你只能按地址断,想看指令就x/20i 0x7c00反汇编出来。
继续往下走时,si是单步一条指令,ni是单步但跳过 call。看寄存器用info registers,看内存用x/16xw 0x100000,看栈用x/8xw $esp。有几个操作我调试时反复用:
一是检查 GDT 是否被正确加载,x/3gx &gdt看三个描述符,再info registers gdtr看基址和限长对不对。二是检查 CR0 的 PE 位,p/x $cr0,最低位应该是 1。三是验证 ELF 是否被搬对了,在跳转前x/4i $pc,或者在kern_init断点处info registers eip,看是不是链接脚本里那个0x100000附近。
有个小技巧:GDB 里可以定义断点命令,让它在命中时自动打印信息再继续。比如在某个地址命中时打印寄存器然后c,这样能连续观察多次调用,不用手动按几十次回车。
(gdb) b *0x7c00 (gdb) commands > info registers > x/5i $pc > c > end最后提醒一点:QEMU 在断点停下时,整个虚拟机是冻结的,包括硬件定时器。所以如果你在调试中断相关代码,别指望靠单步观察定时器行为,得换个思路,比如在中断处理函数里打点。
4.3 最小可用输出:让内核“说话”
在没有 printf、没有屏幕驱动的早期阶段,一个可靠的输出通道比什么都重要。串口是最省事的,初始化只要几行:
static inline void outb(uint16_t port, uint8_t data) { __asm__ volatile("outb %0, %1" : : "a"(data), "d"(port)); } static inline uint8_t inb(uint16_t port) { uint8_t ret; __asm__ volatile("inb %1, %0" : "=a"(ret) : "d"(port)); return ret; } void serial_init(void) { outb(0x3F8 + 1, 0x00); // 关中断 outb(0x3F8 + 3, 0x80); // 开 DLAB outb(0x3F8 + 0, 0x03); // 波特率分频低字节 outb(0x3F8 + 1, 0x00); // 分频高字节 outb(0x3F8 + 3, 0x03); // 8N1 outb(0x3F8 + 2, 0xC7); // 开 FIFO,清空 } void serial_putc(char c) { while ((inb(0x3F8 + 5) & 0x20) == 0); outb(0x3F8, c); }0x3F8是 COM1 的数据端口,后面几个偏移分别对应中断使能、FIFO 控制、线路控制、调制解调器控制和线路状态。0x3F8 + 5的 bit 5 是“发送保持寄存器空”标志,循环等它置位再写,就不会丢字符。这段代码不长,但每次实验我都留一份,因为它是最快能告诉你“程序到底跑到哪了”的手段。
显存输出也值得会一点,因为不依赖重定向,直接看 QEMU 窗口就能验证。显存起始地址0xB8000,每个字符占两个字节:低字节是 ASCII 码,高字节是属性(前景色、背景色、闪烁)。写一个字符就是往0xB8000 + (行 * 80 + 列) * 2放 ASCII,下一个字节放0x07(黑底灰字)。行数从 0 开始,一屏 25 行 80 列。这个方式的好处是零依赖,坏处是打印长字符串要自己管理光标位置,还得处理滚屏,所以只适合做启动初期的“我活过来了”验证。
提示:串口初始化的顺序不能乱,尤其
DLAB开关。先置位线路控制寄存器最高位,才能把波特率分频值写进数据端口;写完再把最高位清掉,否则后续所有数据都会跑到分频寄存器里去,表现为“初始化正常但一个字都打不出来”。
5. 常见问题与排查速查表
5.1 编译期问题
编译期问题相对好查,报错信息一般比较明确,难的是那些“能编过但结果不对”的情况。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
undefined reference to __stack_chk_fail | 没关栈保护 | 加-fno-stack-protector |
undefined reference to memcpy/memset | 编译器把循环优化成了库调用 | 加-fno-builtin,或自己实现这俩函数 |
undefined reference to __udivdi3 | 用了 64 位除法但没链接运行时库 | 避免 64 位除法,或链接libgcc |
| 链接后镜像超过 512 字节 | 引导部分塞了太多东西 | 精简或把功能挪进内核 |
bootblock有魔数但启动无反应 | 镜像拼接顺序错了,引导块不在最前面 | 检查打包脚本的dd顺序 |
重定位错误relocation truncated to fit | 代码模型和地址空间不匹配 | 加-mcmodel=large或改用-m32 |
最后一条值得展开。默认的小代码模型假设代码和数据的相对距离在 2GB 以内,如果你的链接地址设得很高,或者混用了 64 位编译选项,链接器就会报这个错。这个实验里通常不会碰到,但一旦你改成高位地址映射,它就会冒出来。
还有一个隐性问题:不同版本的 gcc 对inline的处理不一样。C99 之后inline函数的语义在“是否生成外部定义”上有分歧,如果你在头文件里写了inline函数又在多个.c里包含,链接时可能报重复定义。稳妥做法是用static inline,或者遵守实验框架里已经约定的写法,别自作主张改。
5.2 运行期问题
运行期问题才是真正花时间的部分,因为现象往往只有“黑屏”“重启”“卡住”几种,但原因千差万别。我按发生阶段整理一张速查表:
| 阶段 | 现象 | 排查方向 |
|---|---|---|
| BIOS 加载后 | 完全无输出,QEMU 直接退出 | 魔数没写对、镜像大小不对、没挂成第一块盘 |
| 引导扇区执行 | 屏幕有字但立刻重启 | 三重故障,多半是保护模式切换时机错误 |
| 读磁盘 | 卡在循环里不动 | 磁盘号没保存、端口等待位判断写反、扇区数超限 |
| ELF 解析 | 跳转后跑飞 | e_phoff取值错误、p_pa没做地址映射、e_entry不对 |
| 内核入口 | 打印乱码或花屏 | 栈没设对、.bss没清零、串口波特率分频算错 |
三重故障这个现象特别值得说。它指的是 CPU 在处理一个异常时又触发异常,连续三次后硬件复位。表现出来就是“闪一下就重启”,没有任何日志。因为此时中断向量表还没建,任何异常都无处可去。所以保护模式切换阶段的代码要格外小心,宁可用最保守的写法:关中断、清方向标志、段寄存器全置零、然后一口气完成 GDT 加载和跳转。
磁盘读取卡住也很常见。ATA 端口的命令寄存器在0x1F7,发读命令前要等状态寄存器 bit 7(忙)清零、bit 3(数据准备好)相关位置位。等待循环里如果不加超时,端口状态异常时就死循环了。我在自己写的时候会加一个计数器,循环超过一定次数就报错退出,至少能看到错误信息而不是干等。
扇区数也有上限。用 PIO 模式一次读多个扇区,数量字段只有 8 位,超过 255 会溢出;而且很多实现在 128 个扇区以上就不稳定。稳妥做法是分批读,每批不超过 128 扇区,循环处理。
5.3 排查方法论:三板斧
真遇到问题时,按这三步走,比乱改代码高效得多。
第一步,确认代码到底有没有被执行。在关键位置插串口输出,或者用 GDB 断点。如果断点命中,说明前面的链条是通的,问题在后面;如果断点不命中,问题就在前面。这一步能把搜索范围砍掉一大半。
第二步,看寄存器和内存。GDB 连上之后,info registers看段寄存器、CR0、EIP 对不对,x命令看关键内存地址里的内容是不是你期望的。比如 ELF 加载后,去0x100000处看头几个字节,对照反汇编出来的指令字节,一致就说明搬对了。
第三步,缩小范围做二分。把引导部分的内核跳转临时改成死循环,看能不能停在0x7C00之后;把 ELF 加载改成只搬一个段,看能不能跳进内核第一句。这种“保留一半功能”的做法能快速定位到具体哪一步出的问题。
我个人的经验是,这个实验里 80% 的“卡住”都能用第一板斧定位,剩下 20% 里有一半是地址算错,最后那 10% 才是真正的逻辑问题。所以别一上来就怀疑自己的架构设计,先确认代码跑到了哪。
注意:改代码排查时一次只改一个地方,改完立刻验证。同时改三处再运行,就算跑通了也不知道是哪处起的作用,下次遇到同样问题还是不会。
6. 几个我认为最值钱的经验
先说一个和代码无关但影响最大的:把每次成功的构建产物备份下来。这个实验的改动是链路式的,你调通了引导、开始写 ELF 加载,结果改崩了,想退回上一个能跑的状态却发现没留备份,只能凭记忆往回改。我的做法是每通过一个里程碑就复制一份镜像并打上注释,成本几乎为零,救急的时候非常有用。
第二个经验是关于链接脚本和调试符号的对应关系。很多人调试时发现断点打不上,或者打上了但停的位置和源码对不上,根因往往是链接脚本改过之后没有重新确认符号地址。我习惯在每次改完链接脚本后,用nm看一眼入口符号的实际地址,再和readelf -h里的入口点对比,两者一致才继续往下做。这个检查只要十秒钟,能省掉后面半小时的困惑。
第三个经验是别急着优化启动代码的体积。新手常犯的毛病是为了塞进 512 字节,把代码写得极其晦涩,用了各种跳转技巧,结果一出问题完全没法读。实际上引导扇区里真正必须做的事就那么几件,写清楚了自然就装得下。真装不下,说明你把本该放进内核的逻辑前置了,该挪就挪,而不是压缩。可读性在这个阶段比几个字节重要得多。
第四个经验稍微偏一点:重视那些“看起来跑通了”的时刻。内核打印出一行字,不代表启动流程是对的。有可能你的 ELF 只搬了一个段,恰好那个段里包含了打印函数;有可能栈设错了位置,恰好那 8KB 区域没有被覆盖。我会在跑通之后做几个破坏性验证:把栈指针故意偏移一点看会不会崩,把某个段的加载去掉看会不会失败。如果去掉某个步骤程序依然正常,说明那一步根本没生效,你之前看到的“成功”是假象。这种反向验证做两三次,对整条链路的理解会比正向跑十遍都深。
最后说个具体的:串口输出的换行符。往串口写\n只换行不回车,终端里的光标会停在下一行开头但列位置不变,输出会变成阶梯状。要么写\r\n,要么在putc里遇到\n时自动补一个\r。这个坑我踩过不止一次,每次都是盯着阶梯状的输出愣几秒才想起来。
这套启动链路跑通之后,后面做中断、做分页、做进程切换,用的都是同一套调试手法:断点、寄存器、内存转储、二分缩小范围。所以这一节实验花的时间是值得的,它训练的是“在没有任何运行时支持的情况下定位问题”的能力,这个能力在后面每一节实验里都会反复用到。