首页 / 资讯中心 / 文章详情

操作系统实验Lab1:512字节引导代码接管机器与内核调试

操作系统实验Lab1:512字节引导代码接管机器与内核调试 ★ FEATURED ARTICLE
操作系统实验的第一课从来不是教你怎么写调度器而是先让你把一台机器从通电那一瞬间接管过来。这个 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 filebin/kernel.img,formatraw,index0,mediadisk \ -serial mon:stdio -nographic -s -S-m 128M给虚拟机 128MB 内存这个值不用太大内核本身很小但也不能太小某些 BIOS 实现在内存过小时行为古怪。-drive那串把打包好的镜像当作第一块硬盘挂上formatraw明确告诉 QEMU 不要猜格式index0保证它是第一块盘——因为引导扇区的加载依赖 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 字节汇编器不会报错而是直接覆盖最后生成的文件大小会超过 512BIOS 只读前 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: base0 limit4G .quad 0x00CF92000000FFFF # data: base0 limit4G 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 已经进入保护模式但代码段寄存器里还是实模式留下的旧值必须靠远跳转把它换成0x8GDT 索引 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-idnone可以避免生成 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-protectorundefined reference to memcpy/memset编译器把循环优化成了库调用加-fno-builtin或自己实现这俩函数undefined reference to __udivdi3用了 64 位除法但没链接运行时库避免 64 位除法或链接libgcc链接后镜像超过 512 字节引导部分塞了太多东西精简或把功能挪进内核bootblock有魔数但启动无反应镜像拼接顺序错了引导块不在最前面检查打包脚本的dd顺序重定位错误relocation truncated to fit代码模型和地址空间不匹配加-mcmodellarge或改用-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。这个坑我踩过不止一次每次都是盯着阶梯状的输出愣几秒才想起来。这套启动链路跑通之后后面做中断、做分页、做进程切换用的都是同一套调试手法断点、寄存器、内存转储、二分缩小范围。所以这一节实验花的时间是值得的它训练的是“在没有任何运行时支持的情况下定位问题”的能力这个能力在后面每一节实验里都会反复用到。
阅读完成 · 觉得有帮助?
咨询建站