news 2026/10/14 12:26:23

系统引导与程序装载:从BIOS到ELF的完整链路实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
系统引导与程序装载:从BIOS到ELF的完整链路实现

最近又有人来问某高校操作系统课的第三次作业——“系统引导与程序装载”。这一题可以说是把操作系统里最底层的两件事合在一起了:机器通电之后,第一条指令从哪里来;可执行文件又是怎么从磁盘上“站”进内存、变成可以运行的程序。这两件事平时上课讲得挺抽象,一旦放到作业里自己动手写,很多人才发现原来启动过程这么脆弱,早几年的电脑上“开机”两个字背后藏着那么多条指令。

这篇东西会把这次作业从原理到实操完整拆一遍:引导扇区怎么写、为什么要切保护模式、ELF 文件怎么解析、怎么把用户程序装载到内存再跳过去执行。适合正在被这门课折磨的同学,也适合想补齐“从上电到程序跑起来”这段知识盲区的人。如果你只是想应付作业,直接抄步骤也能过,但建议还是把背后逻辑啃一啃,这部分知识面试时是真的会被问到。

1. 这题到底想让你写什么

1.1 交付物拆解:引导扇区、加载器、用户程序

先说结论,这类作业无论题目表述怎么变,最后要交的东西基本都是三样:

  • 一个引导扇区(boot sector),写在虚拟软盘或硬盘的第一个扇区里,开机后被 BIOS 自动加载到内存运行;
  • 一个装载器(loader),负责读取磁盘上的用户程序、解析它的格式、把它放到正确的内存位置;
  • 一个用户程序,通常是用汇编或 C 写的一个小功能,比如打印一串字符、做个简单计算,加载成功后能跑起来并返回结果。

有人会把引导扇区和装载器写成同一个程序,这没问题,只要你能在 512 字节内塞下读盘逻辑和解析逻辑,那算你真本事。实际做的时候,最常见的划分是:引导扇区只负责从磁盘读取一个更大的二级 loader,二级 loader 再负责解析用户程序文件。这个分层的思路和真实操作系统里“bootloader -> kernel -> init”的结构是一样的,作业虽然小,但框架是正统的。

1.2 为什么是第三次作业:承上启下

操作系统课的前两次作业,通常围绕进程或线程、内存分配这些“操作系统内部视角”的题目。第三次突然变成“系统引导与程序装载”,从课程设计角度看,是为了把前面学的抽象概念落地:你分配的内存地址,最终要怎么和可执行文件的地址对应起来?你学的“进程地址空间”,在程序还没运行之前,到底是谁把它搭起来的?

装载器就是干这个活的人。理解装载器,比背十遍“进程地址空间”的概念都管用。而引导过程则是另一条线,它让你看到操作系统是怎么从“机器什么都没有”的状态下被唤醒的。这两条线接在一起,正好覆盖了一个程序从磁盘到 CPU 执行的完整链条。课程把它放在期中前后,就是想让你在学完内存管理和进程之后,有一个直观的全程认识。后面再学文件系统、设备驱动,你会发现所有东西都挂在同一条启动链路上。

2. 引导扇区:从 0x7C00 开始的第一段代码

2.1 启动流程决定了你只有 512 字节

写引导扇区之前,必须对启动流程有一个清晰的图景。简化地说,传统 BIOS 启动时是这样的:上电后 CPU 进入实模式,第一条指令从 0xFFFF0 取到,这个地址位于 BIOS ROM 中;BIOS 完成自检后,按照启动顺序把启动设备的第一个扇区读进内存,放在物理地址 0x7C00,然后跳转到那里执行。

也就是说,你的代码不是从 0x0 开始的,而是从 0x7C00 开始的。这个地址是历史原因加兼容性要求共同决定的,现在所有模拟器、真机都默认这一套。引导扇区的大小也只有 512 字节,扇区最后一个双字节必须是0x55AA,否则 BIOS 不认为这是一个可启动扇区。

所以我常跟人说,引导扇区不是“写代码”,更像是在“一个 512 字节的盒子里做装修”。代码、数据、栈,全挤在这一个扇区里,你的汇编器如果默认生成段地址不是 0x7C00,会出现难以捉摸的乱码现象。最保险的做法,是在代码第一行写明:

[org 0x7C00]

这句指令告诉汇编器,我们这个程序被加载到 0x7C00,所有内部地址都按这个基址来计算。漏了这句,后面的mov si, msg里msg的地址就会变成相对 0 的偏移,打印出来全是乱码。

2.2 手工写一个可打印可读盘的引导扇区

这里给一个最精简但五脏俱全的引导扇区。它做三件事:在屏幕上打印一行提示,从启动盘读取后续扇区到内存 0x1000,然后跳过去执行。

[org 0x7C00] bits 16 start: ; 初始化数据段和栈 mov ax, 0 mov ds, ax mov ss, ax mov sp, 0x7C00 ; 打印字符串 mov si, boot_msg print: lodsb test al, al jz load_kernel mov ah, 0x0E int 0x10 jmp print load_kernel: ; 读磁盘扇区到 0x1000 mov ah, 0x02 ; BIOS 读扇区功能号 mov al, 64 ; 要读64个扇区 mov ch, 0 ; 柱面0 mov cl, 2 ; 从2号扇区开始(1号是引导扇区) mov dh, 0 ; 磁头0 mov dl, 0x80 ; 硬盘0 mov bx, 0x1000 ; 目标内存地址 int 0x13 jc disk_error ; 跳到加载进来的代码 jmp 0x1000 disk_error: mov si, err_msg err_print: lodsb test al, al jz hang mov ah, 0x0E int 0x10 jmp err_print hang: cli hlt jmp hang boot_msg db "Booting...", 0 err_msg db "Disk read error!", 0 times 510-($-$$) db 0 dw 0x55AA

注意几个关键点:

int 0x13的ah=0x02是 BIOS 提供的读扇区功能,它工作在实模式下,一次能读多少取决于寄存器。al是扇区数,这里读 64 个扇区,也就是 32KB 内容,这个空间足够放一个简单的 loader 和用户程序了。目标地址放 0x1000,是故意避开 0x0000 到 0x0FFF 这段中断向量区和 BIOS 数据区。如果在 Windows/Linux 的模拟器上把第二扇区以后的内容写进磁盘镜像,dl=0x80表示第一块硬盘,用虚拟软盘的话要改成dl=0x00,这个细节非常容易漏,漏了读出来的数据全错。

还有一个容易忽略的点:jmp 0x1000是无条件直接跳转到绝对地址 0x1000。这句执行后,CPU 会把 CS 设为 0,IP 设为 0x1000。但如果你在前面没有显式初始化段寄存器,那么 CS 的值可能是别的,这句远跳转也会带偏你。所以务必在初始化阶段把ds/es/ss都清零,保持段基址一致。

2.3 引导扇区常见的三个“低级错误”

第一个错误是文件不足 512 字节就交了。引导扇区必须在磁盘第一个扇区上恰好 512 字节,多一字节不行,少一字节也不行。少了的话,模拟器可能自动补零,但真机不一定会买账。所以汇编文件结尾一定要用:

times 510-($-$$) db 0 dw 0x55AA

$-$$表示当前指令相对文件起始的偏移,times会把剩余的空位全部填零,最后写上启动标志。

第二个错是org和实际加载地址不匹配。如果虚拟机加载到 0x7C00,你声明org 0,那么所有内部访问都会偏移 0x7C00,直接跑飞。反过来,org 0x7C00但你实际上是用模拟器的-fda参数加载到软盘,地址同样是 0x7C00,那就没问题。这个地址不能假设,最好在代码开头就用调试器确认一遍。

第三个错是覆盖了关键内存区域。比如把代码加载到 0x0 到 0x3FF 这段区域,那正好覆盖了中断向量表,BIOS 的int 0x10、int 0x13可能直接失效。还有把栈设在 0x7C00 以下但不小心和加载目标区域重合,也会导致数据被覆盖。写引导扇区时,内存布局先画好,就像搬家前先规划每个房间放什么,不然跑起来一堆诡异的“抖动”。

3. 实模式与保护模式的切换

3.1 不切保护模式行不行

有些人贪省事,引导扇区加载完用户程序,直接就在实模式下跳过去跑。实模式下所有代码默认是 16 位,访问内存靠段:偏移,合成物理地址时左移四位。一个段最大 64KB,超过 1MB 的内存访问需要特殊处理。你如果只是想打印个字符,确实可以不切。

但作业既然叫“系统引导与程序装载”,很多评分标准里就明确要求有实模式到保护模式的切换过程,这不仅是考察 GDT 原理,也为后面装载 ELF 做准备。现代操作系统的可执行程序,比如你写一个 C 程序,编译器默认生成 32 位或 64 位指令,入口地址经常在 0x08048000 这种高地址。你要在实模式下用真实地址去访问它,一是访问不到,二是用户程序用的栈、数据段、代码段全是平坦的内存模型。所以不切保护模式,你后面连 ELF 的代码段都执行不了。

你说:“我作业里的用户程序用汇编写成 16 位,不切也能跑啊。”那你赢了,但这是绕开了题目本意。切保护模式这个步骤,本质上就是在告诉你:操作系统要从实模式的“16位分段寻址”过渡到保护模式的“平坦寻址+权限控制”,这不是没事找事,而是必须迈过的一道坎。

3.2 最小 GDT 搭建细节

保护模式下的内存访问不再靠“段寄存器左移四位”来合成地址,而是通过段寄存器里的选择子,去查全局描述符表(GDT)。要进入保护模式,你得先造一张 GDT。一个能跑的最简 GDT 只需要三个描述符:一个空描述符、一个代码段描述符、一个数据段描述符。代码段和数据段都可以覆盖整个 4GB 线性空间,也就是基地址 0、段界限 0xFFFFF(4KB 粒度下对应 4GB)。

代码写法并不复杂:

gdt_start: dq 0 ; 空描述符 gdt_code: dw 0xFFFF ; 段界限 0-15 位 dw 0x0000 ; 基地址 0-15 位 db 0x00 ; 基地址 16-23 位 db 0x9A ; 存在位=1,DPL=0,代码段,非一致,可读 db 0xCF ; 粒度为4KB,32位模式,上限 0xFFFFF db 0x00 ; 基地址 24-31 位 gdt_data: dw 0xFFFF dw 0x0000 db 0x00 db 0x92 ; 数据段,可读写 db 0xCF db 0x00 gdt_end: gdt_ptr: dw gdt_end - gdt_start - 1 dd gdt_start

注意,这里的dd gdt_start在 16 位汇编里会被解析为 32 位地址,因为lgdt指令需要的是线性地址,而不是段相对地址。如果你用org 0x7C00,那么gdt_start的地址是0x7C00 + 偏移,这是对的。但如果你的引导扇区被加载到别的地址,这里的地址就要相应变化。

进入保护模式的经典三部曲是:

cli ; 关中断 lgdt [gdt_ptr] ; 加载 GDT mov eax, cr0 or eax, 1 mov cr0, eax ; 打开保护模式开关 jmp 0x08:flush_pm ; 远跳到代码段选择子 0x08

那个jmp 0x08:flush_pm是一个远跳转,0x08是代码段选择子,对应 GDT 里第二个描述符。远跳转有两个作用:一是刷新 CPU 流水线里预取的模式切换前指令,二是把 CS 段寄存器更新为保护模式下的代码段选择子。少了这一步,后面执行第一条保护模式指令就可能异常。

3.3 切换保护模式后必须马上处理的三件事

进入保护模式不是终点,后面还有三件事要做,否则程序活不过三秒。

第一件事,重新初始化所有段寄存器。cli后立即执行mov ax, 0x10; mov ds, ax; mov es, ax; mov ss, ax,这里0x10是数据段选择子。不重新设置ds,你后面访问内存用的还是实模式下的段值,保护模式下这个值的含义完全不同,大概率立即异常。

第二件事,设置栈指针。实模式下栈顶常设在 0x7C00 附近,进入保护模式后栈可以重新设到一个高位地址,比如mov esp, 0x90000。注意栈是向下增长的,别把栈设在代码段可能覆盖的区域。要是栈设得太低,函数调用时直接压进代码区,调试起来那叫一个惨。

第三件事,重新加载一个保护模式适用的中断描述符表(IDT)。切换后 BIOS 中断都不可用,如果你还需要键盘输入或输出,就要自己接管硬件中断或直接操作显存。很多作业在保护模式阶段为了避免麻烦,干脆直接往地址 0xB8000 写显存来打印字符,这样既不依赖中断,又能在屏幕上看到输出。

我见过一个特别典型的翻车现场:代码明明打印出了第一行“Entering protected mode”,然后立刻死机。后来查出来是 GDT 的gdt_ptr里的表长度算错了。长度应该是gdt_end - gdt_start - 1,有人写成了gdt_end - gdt_start,多了一个字节也能跑,但描述符边界错位,到代码段访问时就会异常。“看起来就差一个字节”的错误,调试起来特别费时间。

4. 程序装载的核心:解析 ELF

4.1 装载器到底在做什么

先说清楚一个概念:装载器不是把文件 copy 到内存就完事。它要解决几个问题:用户程序是哪种可执行格式?格式里有哪些段?每个段要放在内存的什么位置?哪些段需要从磁盘读内容,哪些段只需要在内存里清零?程序入口地址在哪?数据段、代码段的权限分别是什么?

作业里最常用的可执行文件格式是 ELF,也就是 Linux 下的标准格式。如果你在 Linux 上用gcc编译一个 C 小程序,生成的默认就是 ELF。ELF 设计得比当年 DOS 的EXE复杂不少,但你只需要关心两个结构:ELF 文件头(ELF header)和程序头表(program header table)。

用 C 写装载器是最舒服的,因为 C 的结构体可以直接对应 ELF 的二进制布局。你只需要:

  • 读文件开头固定字节数的 ELF 头;
  • 校验魔数、架构、位宽;
  • 根据 ELF 头里的e_phoff和e_phnum,读程序头表;
  • 遍历每个程序头,找到p_type == PT_LOAD的段;
  • 根据段的p_offset、p_filesz、p_memsz、p_vaddr决定拷贝多少、清零多少。

4.2 ELF 头与程序头逐项拆解

ELF 头位于文件最开头,64 位格式下固定 64 字节。你要看的关键字段包括:

  • e_ident[0]:魔数,一般是0x7F 'E' 'L' 'F';
  • e_machine:架构,i386 是 3,x86-64 是 62;
  • e_phoff:程序头表在文件中的偏移;
  • e_phentsize:每个程序头的大小;
  • e_phnum:程序头的数量;
  • e_entry:程序入口地址。

这里最容易踩的坑是:e_phentsize不是sizeof(Elf32_Phdr),虽然多数情况下相等,但你的装载器不能假设编译平台和目标可执行文件一致。应该显式把e_phentsize当作输入,用e_phoff + i * e_phentsize去定位每一个程序头。

程序头是一个更细的结构,重点字段是:

字段含义常见值
p_type段类型1 表示 PT_LOAD,也就是要加载的段
p_offset段内容在文件中的偏移通常是 0x1000 的倍数
p_vaddr段第一个字节要放到的虚拟地址例如 0x08048000
p_filesz文件中的大小可能比 p_memsz 小
p_memsz内存中的大小包含 bss 段,通常比 p_filesz 大
p_flags段的权限读=4,写=2,执行=1

你要做的就是找到所有p_type == PT_LOAD的段。对一个典型的 ELF,第一个 PT_LOAD 段通常是代码段,权限是 R+X,p_filesz和p_memsz相等;第二个 PT_LOAD 段通常是数据段加 bss 段,权限是 R+W,p_memsz比p_filesz大一些。多出来的部分就是 bss 段,里面是未初始化的全局变量,装载时要清零。

4.3 一个可运行的简易装载流程

假设你已经用某种方式把用户程序文件读到了内存缓冲区file_buf里,目标物理内存从LOAD_ADDR开始,那么装载的核心流程用 C 写出来大概是这样:

#include <stdint.h> #include <string.h> typedef struct { unsigned char e_ident[16]; uint16_t e_type; uint16_t e_machine; uint32_t e_version; uint32_t e_entry; uint32_t e_phoff; uint32_t e_shoff; uint32_t e_flags; uint16_t e_ehsize; uint16_t e_phentsize; uint16_t e_phnum; } Elf32_Ehdr; typedef struct { uint32_t p_type; uint32_t p_offset; uint32_t p_vaddr; uint32_t p_paddr; uint32_t p_filesz; uint32_t p_memsz; uint32_t p_flags; uint32_t p_align; } Elf32_Phdr; void load_elf(char *file_buf, size_t file_size, uint32_t load_addr) { Elf32_Ehdr *ehdr = (Elf32_Ehdr *)file_buf; if (ehdr->e_ident[0] != 0x7F || ehdr->e_ident[1] != 'E' || ehdr->e_ident[2] != 'L' || ehdr->e_ident[3] != 'F') { return; } Elf32_Phdr *phdr = (Elf32_Phdr *)(file_buf + ehdr->e_phoff); for (int i = 0; i < ehdr->e_phnum; i++) { if (phdr[i].p_type != 1) continue; // PT_LOAD uint32_t dst = load_addr + phdr[i].p_vaddr; memcpy((void *)dst, file_buf + phdr[i].p_offset, phdr[i].p_filesz); if (phdr[i].p_memsz > phdr[i].p_filesz) { memset((void *)(dst + phdr[i].p_filesz), 0, phdr[i].p_memsz - phdr[i].p_filesz); } } void (*entry)(void) = (void (*)(void))(load_addr + ehdr->e_entry); entry(); }

这个写法非常简化,因为真实装载器还要考虑段对齐、物理内存分配、页表建立、权限设置。但在课程作业的裸机环境里,你如果已经把内存统一映射成“从 load_addr 起的连续空间”,那这种直接用p_vaddr做偏移的映射方式是能跑的。

注意这里我用了一个load_addr作为基址,用户程序里所有的地址都是相对这个基址加上p_vaddr。如果你的用户程序链接时用了-Ttext 0x0,那么代码里所有符号地址都是从 0 开始的,直接加load_addr就是最终物理地址。如果用户程序链接地址是0x08048000,你想把它装到0x10000,那就不能用我上面这种简单加法,需要重定位或要求用户程序链接成位置无关代码(PIC)。最省事的做法是统一约定:链接地址、虚拟地址、物理地址三者一致,至少在这个作业里先保证一致。

4.4 “装载”和“拷贝文件”不是一回事

很多人写完装载器就奇怪:为什么我明明把 ELF 文件整体 copy 到内存,跳进去就崩?原因很简单,ELF 文件在磁盘上是“文件视图”,而程序运行需要的是“内存视图”。文件里除了代码段和数据段,还混着 ELF 头、程序头表、节头表,以及各种调试信息。这些内容对应的内存区域可能根本不应该被映射为可执行代码。

而且 ELF 的各个段是按页对齐的,p_offset和p_vaddr都对齐到页边界。如果你把整个文件从偏移 0 直接搬到内存 0x1000 处,那么 ELF 头所在的位置在内存视图里是文件开头的那一段,处理器跳转到入口地址时,正好跳到一段可能是 ELF 头或其它元数据的位置,自然跑不起来。

你得按“程序头”来搬,只搬PT_LOAD段,而且每个段搬到它该去的地址。这就像搬家时你不会把整份装修图纸也搬进客厅墙上,只会把家具摆到它该在的位置。理解了这一点,你才能理解为什么操作系统装载一个程序要逐段映射,而不是简单“读文件到内存”。

5. 地址约定与整条链路的串联

5.1 一张内存布局约定表

裸机环境下没有虚拟内存,所有地址都是物理地址,所以你得提前和用户程序约法三章。我建议在说读里用统一约定,写进作业报告里也是加分项。我常用的布局是:

区域地址范围用途
引导扇区0x7C00 - 0x7DFF启动代码
二级 loader0x1000 - 0x7BFF从磁盘加载的装载器代码
用户程序0x10000 - 0x1FFFF从 ELF 解析后装载的用户程序
栈0x90000 向下增长保护模式下的栈
显存0xB8000 - 0xBFFFF文本模式显存

这个表最关键的是“不冲突”。引导扇区加载到 0x7C00,二级 loader 放在 0x1000,两者之间有一段空闲,但这些区域本身互不覆盖。用户程序放 0x10000,是在 64KB 以下之外,需要保护模式才能稳定访问,所以也逼着你把切换保护模式的代码放在用户程序运行之前。

如果你用[org 0x1000]写二级 loader,那么在引导扇区里jmp 0x1000就正好跳到 loader 开头。如果二级 loader 内部再解析 ELF 并跳转用户程序,那么整个链路就是:

BIOS -> boot sector (0x7C00) -> loader (0x1000) -> user program (0x10000)

这条链路越短,出问题的可能性越低。有些同学喜欢把 loader 塞进引导扇区,硬凑 512 字节,这是很好的挑战,但如果你第一次做课程作业,还是老老实实分层吧。调试起来清晰得多。

5.2 从引导到装载再到跳转的执行顺序

整个程序的执行顺序可以拆成六个阶段,我在报告里也建议按这个结构来写:

  1. BIOS 把引导扇区读到 0x7C00,跳到 0x7C00;
  2. 引导扇区初始化段寄存器和栈;
  3. 用int 0x13把磁盘 2 号扇区开始的内容读到 0x1000;
  4. 引导扇区通过jmp 0x1000跳到 loader;
  5. loader 进入保护模式,解析磁盘上的 ELF 用户程序,装载到 0x10000;
  6. 设置好栈、数据段、入口地址,跳转用户程序执行。

这里每一步之间都有“交接”。交接最容易出问题,因为两边可能对内存地址、寄存器状态、栈的位置都有自己的假设。比如 loader 跳到用户程序之前,一定要把ds、es、esp全部设置为用户程序可用的状态。引导到 loader 的时候,如果 loader 是 16 位代码,你还需要把cs设为正确的实模式段值。

一个常见的体验是:前四步都正常,到了跳转用户程序就崩溃。这种情况十有八九是地址假设不一致。比如你在 ELF 头里看到程序入口是 0x08048000,但你实际把段搬到了 0x10000,入口地址就找不到对应的数据了。解决办法就是在链接用户程序时指定加载地址,比如用ld -Ttext 0x10000,或者在装载器里按相对偏移重新映射入口地址。无论如何,必须让“链接地址 = 装载地址”,至少保证入口地址在装载后有内容。

5.3 用户程序入口和链接地址如何对齐

这里展开说说链接地址的问题。假设用户程序是一个 16 位实模式程序,你直接用 nasm 编:

org 0x10000 bits 32 start: ; ...

这样汇编期就把所有符号地址定到 0x10000 了,装载时只要把代码放到 0x10000,跳转就没问题。

如果用户程序是 C 写的,比如:

void main() { ... }

编译时默认会生成一个链接地址很高、带 ELF 头的可执行文件。你想把它放到裸机上,需要告诉链接器不要使用默认的入口地址。比较粗暴但好用的方法是:

gcc -m32 -ffreestanding -nostdlib -Wl,-Ttext=0x10000 -Wl,--no-dynamic-linker -o user.elf user.c

这样入口地址会被定位到 0x10000,装载器把 PT_LOAD 段映射到 0x10000,然后跳到e_entry就行。实际作业里有人用 32 位模式,有人用 64 位,都有可能。我一般建议第一次做这题的同学用 32 位模式,因为地址布局、GDT 都短,调试信息也少,64 位还需要处理长模式和页表,复杂度高了不少。

但话说回来,如果你连用户程序都还没跑通,别急着上 C 语言。先用一个最简汇编程序,比如打印一个字符,把整条链路验证通了,再把用户程序换成一个 C 写的编译器产物,难度会平滑很多。这是做底层开发最通用的“增量验证”思路。

6. 踩坑实录与调试方法

6.1 QEMU + GDB 联合调试配置

裸机程序最难受的地方是没有断点,没有异常日志,一个段错误直接黑屏。我强烈建议学会用 QEMU 加 GDB 的组合来调试,这比反复烧录真机不知道高到哪里去了。

启动 QEMU 时加上-s -S:

qemu-system-i386 -fda disk.img -s -S

-S让 QEMU 启动后暂停在第一条指令,-s开启默认端口 1234 的 GDB 调试服务。然后在另一个终端里启动 GDB:

gdb target remote :1234 set architecture i386

因为引导代码是 16 位实模式,第一次断点通常下在*0x7C00:

b *0x7C00 c

接下来就能用si单步执行、用x/10i $eip看当前指令、用info registers看寄存器状态。痛苦的地方在于实模式下的地址不是纯eip,你用x/10i $cs*16+$eip才能看到当前指令。进入保护模式后,GDB 对分段的理解时常会乱,这时候我习惯直接把断点下在保护模式的入口地址上,比如b *0x1000,配合c跳过去,让 GDB 重新获取上下文。

如果发现某条int 0x13之后CF=1,那么读盘逻辑有问题,可以直接看ah寄存器里的 BIOS 错误码。ah=0x01是非法命令,ah=0x02是地址标记找不到,ah=0x04是目标扇区找不到。这些错误码背下来,调试读盘代码能少走一半弯路。

6.2 问题速查表

我把带学生做这类作业时遇到的问题整理成了表,按“现象 -> 大概率原因 -> 排查动作”的方式列出来,直接对着找就行。

现象大概率原因排查动作
屏幕无输出引导扇区标志 0x55AA 没写对,或org声明不对用xxd boot.bin看最后两字节;在0x7C00下断点看第一条指令
输出了第一个字符后乱码lodsb读到非字符串内容,msg地址偏移不对检查[org 0x7C00];用 GDB 看si的值和访问的地址
读盘后跳转死循环目标地址没代码,或读取扇区数不够用objdump -d确认 loader 大小;调大al扇区数
int 0x13后 CF=1dl设备号错误,或磁盘镜像里 loader 没写进去确认使用-fda还是-hda;用dd把 loader 写入正确的扇区偏移
进入保护模式后 ubuntu 虚拟机重启或 GDB 报错没有远跳转刷新流水线,或 GDT 长度错检查jmp 0x08:flush_pm;用si单步跟进 CR0 置位后的位置
装载 ELF 后跳转崩溃p_vaddr、p_offset、入口地址三者的换算错误用readelf -h和readelf -l逐个对比,确认每个 PT_LOAD 段的映射
用户程序能进但数据全错段描述符权限不对,或数据段选择子没重新加载确认 GDT 数据段描述符设置了写权限,进入保护模式后给ds/es/ss赋值对应选择子
栈溢出覆盖了代码栈顶设在低地址,且用户程序也在低地址把栈设到 0x90000 或更高,确保和代码区、数据区隔离

这张表不能代替你从头到尾看懂流程,但在卡壳时能帮你快速缩小范围。我调过一个人的作业,死活加载不了用户程序,最后发现是他用dd写磁盘镜像时把 loader 写到第 0 扇区了,覆盖了引导扇区,而引导扇区自己还在那儿打印“Booting...”,看起来好像一切正常,但一读第二扇区就读出自己。

6.3 我的一些操作习惯

裸机调试有一个原则:每次只改一个变量。很多同学一上来就写 500 行汇编,编译错误消除后直接跑,挂了也不知道哪一步出的问题。正确做法是一步一步来:

  • 先写一个只打印字符的引导扇区,跑通;
  • 再加读盘逻辑,把 loader 加载上来并跳到 loader 里打印字符,跑通;
  • 再加保护模式切换,在保护模式下打印字符,跑通;
  • 再写用户程序装载器,先装载一个最简的裸二进制用户程序,跑通;
  • 最后才正儿八经解析 ELF。

每步都有一个视觉反馈点,屏幕上有输出就是“活着”,没输出就是“挂了”,定位起来飞快。

另外,生成磁盘镜像用dd的时候注意conv=notrunc,不然第二条命令会截断文件:

dd if=boot.bin of=disk.img bs=512 count=1 conv=notrunc dd if=loader.bin of=disk.img bs=512 seek=1 conv=notrunc

如果多写几个扇区,用seek=1、seek=2依次指定扇区号。至于 ELF 用户程序文件怎么放进去,就看作业规定了,有的作业要求直接从 ELF 文件本身做软盘镜像,有的要求把 ELF 放在一个固定偏移。多读几遍题目,别想当然。

最后再说一个小习惯:写代码前先用readelf -l user.elf看一遍程序头,把每个段的p_vaddr、p_filesz、p_memsz抄在纸上,然后手工推算一遍装载器的目标地址。这事看起来很笨,但能防止你把程序头的字段搞反。我就见过有人把p_offset当成p_vaddr用,结果 ELF 头被当成代码装载,跳转进去必然什么都没发生。

结尾

我个人做了这么多年底层相关的开发,回过头看这类课程作业,最大的价值不是让你背会 GDT 的结构,也不是让你熟练写出int 0x13的调用参数,而是逼你在一个“没有系统帮你兜底”的环境里,亲手把电源按下之后到 main 函数执行之间那段路走一遍。走完这段路,你再回头学虚拟内存、学进程调度,会发现那些概念一下子都有了落脚的实处。

如果你正在写这个作业,我的建议是别急着抄网上的完整代码,先把“我要在哪一步看到什么输出”写清楚,然后一步一验证。这个习惯不只对这个作业有用,以后做操作系统相关的课程设计、内核实验,甚至参加工作后做嵌入式开发,都能让你少熬几个大夜。等你的引导扇区打印出第一行字符、装载器把用户程序带起来那一刻,那种成就感,真比调通一个 Web 接口爽多了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 12:26:20

C语言+EasyX五子棋:图形界面入门实战

直接打开 VS&#xff0c;新建一个空项目&#xff0c;敲上几百行 C 代码&#xff0c;屏幕上弹出带棋盘的交互界面&#xff0c;鼠标点一下就能落子&#xff0c;电脑还能跟你对弈。这件事听起来不算惊天动地&#xff0c;但对于刚学完 C 语言、整天在黑窗口里打 printf 的人来说&am…

作者头像 李华
网站建设 2026/10/14 12:26:10

BERT+CRF中文命名实体识别实战:从模型搭建到训练调优全流程

1. 为什么踩完 BiLSTM 还得换 BERT 上一篇文章里&#xff0c;我用 BiLSTMCRF 把中文命名实体识别跑通了一个基础版本&#xff0c;效果其实还行&#xff0c;F1 能到 82 左右。但很快你就会发现一个扎心的事&#xff1a;模型对“一词多义”基本无能为力。比如“苹果”在“苹果发布…

作者头像 李华
网站建设 2026/10/14 12:26:04

双层扭曲液晶偏振光栅的Lumerical仿真优化

1. 项目背景与核心价值液晶偏振光栅作为一种新型衍射光学元件&#xff0c;在增强现实(AR)显示、激光整形和光学通信等领域展现出独特优势。传统单层液晶光栅存在视角依赖性强、衍射效率随入射角度变化显著等问题。而通过引入双层扭曲结构&#xff0c;我们能够有效拓宽工作角度范…

作者头像 李华
网站建设 2026/10/14 12:26:01

Claude Skill 安装全指南:方法、踩坑与验证

去年底我把工作流从纯手工提示词切到 Claude Skill 之后&#xff0c;最大的感受是&#xff1a;这玩意儿的安装过程远比用法本身更容易劝退人。很多人第一次下载 skill 之后根本不知道该往哪里放&#xff0c;或者装完发现完全没生效&#xff0c;最后只能回去继续用那种一大段提示…

作者头像 李华
网站建设 2026/10/14 12:25:46

BMI计算器前端实现全攻略:输入校验、单位换算与健康标准判定

BMI计算器这个小工具&#xff0c;说起来简单&#xff0c;做起来却有不少门道。很多人觉得它就是“体重除以身高的平方”&#xff0c;套个公式就完事&#xff0c;但真正上手做过的人都知道&#xff0c;从输入校验到单位换算&#xff0c;从标准判定到结果展示&#xff0c;每一个环…

作者头像 李华
网站建设 2026/10/14 12:25:46

Java抽象类与接口的演进:从泾渭分明到融合共生

1. 先聊聊抽象类和接口为什么会“泾渭分明”“抽象类和接口有什么区别&#xff1f;”这个问题我入行就被问过&#xff0c;后来带团队面试也常拿来问候选人。早些年标准答案大家背得滚瓜烂熟&#xff1a;抽象类可以有方法体&#xff0c;接口在 Java 8 之前只能有抽象方法&#x…

作者头像 李华