1. 这不是“写个引导扇区”那么简单:从bootsect.s和setup.s看操作系统启动的底层真相
你可能在某次嵌入式课、操作系统实验课,或者自学《Orange'S》《操作系统真象还原》时,第一次看到bootsect.s和setup.s这两个文件名。它们安静地躺在一个叫oslab的目录里,名字朴素得像两行注释,但只要你真正打开它们——哪怕只是用cat扫一眼——就会发现:这不是C语言,不是Python,甚至不是你熟悉的任何高级语法;这是汇编,是寄存器、段地址、实模式、BIOS中断、磁盘扇区跳转组成的硬核拼图。而这个拼图,恰恰是整个操作系统得以站立的第一块基石。
我带过三届本科生做HIT oslab系列实验,也帮十多位跨专业转系统的开发者调试过bootloader。最常听到的一句话是:“代码就几百行,为什么卡了三天还进不了protected mode?”答案从来不在语法错误里,而在于——你是否真正理解:当电源键按下,CPU复位后执行的第一条指令,到底在和谁对话?BIOS把控制权交给你时,给了你什么、又藏了什么?为什么bootsect.s必须严格限制在512字节内?为什么setup.s要反复读取硬盘参数表却从不报错?这些不是考题里的填空,而是真实硬件世界对软件发出的、不容讨价还价的物理契约。
这个实验的核心关键词非常明确:实模式切换、BIOS中断调用、磁盘扇区加载、GDT初始化、保护模式进入、堆栈切换、段描述符设置。它不涉及图形界面,不依赖网络协议,甚至不需要键盘输入——但它决定了后续所有代码能否被执行。你可以把Linux内核想象成一艘万吨巨轮,那么bootsect.s + setup.s就是那座深埋海底、无人看见却承托整艘船体的混凝土基座。它不炫技,但一旦出错,整套系统连“Segmentation fault”都来不及抛出,直接黑屏重启。
适合谁来细读这篇?如果你正被HIT oslab实验2卡住,别急着抄GitHub上的答案;如果你刚学完x86汇编但还没见过真实引导代码如何与硬件交互;如果你在阅读Linux 0.11源码时,对boot/目录下那几段汇编始终隔着一层雾——那么这篇就是为你写的。它不讲“什么是汇编”,也不教“怎么装VMware”,而是带你一帧一帧拆解:当CPU的IP寄存器第一次指向0x7c00,接下来500毫秒内,究竟发生了多少次内存拷贝、多少次寄存器重载、多少次对硬件端口的试探性读写。所有内容均基于x86实模式规范、IBM PC AT架构文档及HIT oslab原始实验框架,无任何虚构假设,每一步操作均可在QEMU+GDB中单步验证。
2. 整体设计逻辑:为什么非得是“bootsect + setup”两段式结构?
2.1 BIOS只给512字节,但我们要干远超512字节的事
先说最根本的物理约束:PC BIOS在完成加电自检(POST)后,会将软盘或硬盘的第一个扇区(即第0磁道、第0柱面、第1扇区,LBA 0)完整读入内存地址0x7c00处,并跳转执行。这个扇区,就是主引导记录(MBR)或本实验中的bootsect.s编译结果。而它的大小,被硬件强制限定为恰好512字节——多1字节,BIOS拒绝加载;少1字节,剩余空间被填充为0,但你的代码可能因未初始化数据而崩溃。
提示:
bootsect.s末尾必须包含times 510-($-$$) db 0和dw 0xaa55这两行。前者确保总长度补足510字节(留出最后2字节放签名),后者是BIOS识别有效引导扇区的魔数。我曾见过学生因复制粘贴时漏掉times前的空格,导致汇编器报错却不提示长度问题,最终在QEMU里无限重启。
但512字节能做什么?算上必要的栈空间、临时缓冲区、BIOS调用参数,真正留给逻辑代码的空间不足400字节。你要在这里完成:
- 关闭中断(
cli)防止BIOS异步干扰; - 设置初始段寄存器(
cs=ds=es=ss=0x7c0,因BIOS加载位置固定); - 从软盘读取后续扇区(至少包括
setup.s和部分内核代码); - 将读入的数据搬移到更高内存地址(如
0x90000); - 跳转到
setup.s继续执行。
显然,单靠bootsect.s无法承载全部启动逻辑。于是,经典两段式结构诞生:bootsect.s只做最紧急、最底层的“急救包”工作——它不解析文件系统,不校验内核完整性,甚至不关心setup.s具体在哪几个扇区,它只相信一个约定:setup.s紧随其后,占据磁盘上接下来的4个扇区(HIT oslab默认配置)。这个约定,是软件与硬件之间最原始的契约。
2.2 setup.s:从“救生艇”到“登陆舰”的功能跃迁
如果说bootsect.s是紧急弹射出的救生艇,那么setup.s就是紧随其后抵达的登陆舰。它被bootsect.s从磁盘读入内存0x90000处并跳转执行,此时已脱离BIOS加载的0x7c00限制,可自由使用更大内存空间(通常为0x90000至0x9a000,约40KB)。
setup.s的核心使命,是为进入保护模式铺平所有物理道路。它必须完成以下不可绕过的任务:
获取硬件参数:通过BIOS中断
int 0x10(视频)、int 0x11(设备)、int 0x12(内存)等,读取显存模式、硬盘参数(CHS值)、可用内存上限(0x90000以下有多少KB RAM)。这些参数后续将写入内核启动参数结构体(struct boot_params),供内核C代码使用。关闭A20地址线:这是x86从实模式迈向保护模式最关键的物理开关。早期80286处理器地址线只有24根,最大寻址16MB;而80386扩展到32根,但为兼容旧程序,默认开启A20屏蔽(即地址线A20恒为0,导致内存地址高位被截断)。
setup.s必须通过键盘控制器(8042芯片)发送命令,强制开启A20,否则即使CR0.PE=1,CPU仍会陷入地址混淆。构建GDT(全局描述符表):保护模式下,段寄存器不再直接存储物理地址,而是索引GDT中的描述符。
setup.s需在内存中静态定义GDT表项,至少包含:空描述符(GDT[0])、代码段描述符(GDT[1])、数据段描述符(GDT[2])。每个描述符8字节,含基地址、段限长、访问权限等字段。GDT的起始地址和长度需通过lgdt指令加载到GDTR寄存器。切换到保护模式并跳转:设置
cr0寄存器的PE(Protection Enable)位为1,CPU立即进入保护模式。但此时CS寄存器仍指向实模式下的段选择子,必须立刻执行远跳转(ljmp $0x08,$1f),用新的代码段选择子(0x08对应GDT[1])刷新CS,使CPU开始按保护模式规则解析指令流。
这个过程看似线性,实则充满陷阱。例如,GDT表项中的基地址若填写为0x00000000,在保护模式下会被解释为线性地址0,但此时页表尚未建立,访问0地址将触发通用保护异常(#GP);又如,远跳转的目标地址1f(局部标号)若未正确定义,链接时可能产生错误偏移。这些细节,正是HIT oslab实验2要求手写汇编而非调用库函数的根本原因——它逼你直面硬件与CPU的原始契约。
2.3 为什么不用一段式?现代UEFI又为何抛弃它?
有人会问:既然最终都要进保护模式,为何不把所有逻辑塞进bootsect.s,用更紧凑的编码技巧省出空间?答案是:可靠性压倒一切。bootsect.s运行在BIOS提供的最简环境中,无内存管理、无异常处理、无调试接口。任何一处越界读写、寄存器误置、中断未关,都会导致不可恢复的死机。将其功能压缩到极致,反而提升了稳定性——就像火箭发射的点火程序,必须独立、隔离、经过千百次验证。
而UEFI的出现,则是从架构层面重构了这一流程。UEFI固件本身就是一个小型操作系统,提供文件系统驱动、图形协议、内存分配器等服务。它不再依赖512字节扇区加载,而是直接从FAT32分区中读取.efi可执行文件(如bootx64.efi),该文件可动态加载、支持调试、具备完整C运行时。HIT oslab坚持bootsect.s + setup.s,恰恰是为了让你体验那个“没有操作系统可依赖”的原始时代——在那里,每一行汇编都是与硬件的生死谈判。
3. 核心细节解析:从寄存器操作到内存布局的硬核拆解
3.1 bootsect.s:512字节内的精密编排
我们以HIT oslab标准模板中的bootsect.s为例,逐段解析其不可删减的逻辑:
.code16 .text .globl begtext, begdata, begbss, endtext, enddata, endbss ... movw $0x07c0, %ax # 设置ds和es段寄存器为0x7c0(对应物理地址0x7c00) movw %ax, %ds movw %ax, %es ...这段代码开头的.code16声明至关重要。它告诉汇编器:当前代码运行在16位模式下,所有操作数默认为16位(如movw表示word移动)。若遗漏此声明,as可能生成32位指令,导致CPU在实模式下解码失败。%ax寄存器被赋值为0x07c0,是因为实模式下物理地址 = 段地址 × 16 + 偏移地址。BIOS将扇区加载到0x7c00,故段地址应为0x7c00 / 16 = 0x7c0。
紧接着是栈的初始化:
movw $0x7c00, %ax # 栈顶设在0x7c00(即加载地址顶部) subw $0x200, %ax # 预留512字节栈空间(0x200 = 512) movw %ax, %ss # 设置栈段 movw $0xffff, %sp # 栈指针指向栈顶(注意:sp是16位寄存器)这里有个易错点:%sp被设为0xffff,而非0x0000。因为x86栈是向下增长的,push操作会使sp减小。若sp=0x0000,第一次push就会导致sp变为0xffff,但此时ss:sp指向0x7c0:0xffff,计算物理地址为0x7c0*16+0xffff = 0x17bff,已超出常规内存范围。因此,将栈顶设在0x7c00,sp初值设为0xffff,确保栈空间完全落在0x7c00至0x7dff区间内(共512字节)。
磁盘读取是bootsect.s的核心动作,使用BIOS中断int 0x13:
movb $0x02, %ah # ah=2 表示读扇区 movb $0x00, %al # al=0 表示读1个扇区 movb $0x00, %ch # 磁道号(柱面)=0 movb $0x00, %cl # 扇区号=1(注意:cl低6位为扇区号,高2位为磁道高2位) movb $0x00, %dh # 磁头号=0 movb $0x00, %dl # 驱动器号=0(软驱A:) movw $0x9000, %bx # es:bx = 目标缓冲区地址(0x9000:0x0000 = 0x90000) int $0x13 # 调用BIOS磁盘服务关键参数解读:
cl的低6位(0-5位)是扇区号,范围1-63。此处设为1,即读取第1扇区(bootsect.s自身已由BIOS读入,此调用读取的是第2扇区,即setup.s起始)。ch和cl的高2位共同构成磁道号(柱面),此处全0,表示第0磁道。es:bx必须指向有效的内存地址。0x9000:0x0000计算得物理地址0x90000,这是HIT oslab约定的setup.s加载地址,避开0x7c00附近的BIOS数据区。
注意:BIOS中断调用后,必须检查进位标志(CF)。若CF=1,表示操作失败。标准模板中常省略此检查,但在实际调试中,务必添加:
jc read_error # 若CF=1,跳转至错误处理否则,读取失败后程序会继续执行,将垃圾数据当作
setup.s运行,导致难以定位的随机崩溃。
3.2 setup.s:保护模式前的最后一道工序
setup.s的起点是0x90000,它首先需要重新设置段寄存器,因为bootsect.s跳转时并未更新ds、es:
movw $0x9000, %ax # ds/es指向0x9000段(即0x90000起始) movw %ax, %ds movw %ax, %es随后是硬件参数采集。以读取内存大小为例,调用int 0x12:
int $0x12 # BIOS中断:返回AX=内存KB数(640KB以下) movw %ax, mem_size # 将结果存入变量mem_sizeint 0x12返回的AX值是KB单位,最大值为640(对应640KB常规内存)。这个值后续会被写入内核参数结构体,供内核初始化内存管理模块使用。
A20地址线的开启是setup.s中最易出错的环节。它需与键盘控制器(8042)通信,步骤严格:
# 等待8042输入缓冲区空 wait_kbd: inb $0x64, %al # 读取8042状态端口0x64 testb $0x02, %al # 测试bit1(输入缓冲区满) jnz wait_kbd # 若满,循环等待 # 发送A20开启命令(0xd1)到8042输入端口0x60 movb $0xd1, %al outb %al, $0x64 # 再次等待缓冲区空 wait_kbd2: inb $0x64, %al testb $0x02, %al jnz wait_kbd2 # 发送A20开启数据(0xdf)到0x60 movb $0xdf, %al outb %al, $0x60整个过程需两次等待,因为8042是慢速外设。若跳过等待直接发数据,命令会被丢弃。我曾调试一个案例:学生在QEMU中开启A20失败,反复检查GDT无果,最终发现是outb指令前缺少wait_kbd2,导致0xdf数据未被8042接收,A20线始终关闭。
GDT的构建是纯数据定义,需精确计算各字段:
gdt: .word 0x1ff # GDT限长(0x1ff = 511字节,容纳3个8字节描述符+1个空描述符) .word gdt_start # GDT基地址低16位(gdt_start定义在下方) .word 0x0000 # GDT基地址高16位(因gdt_start < 0x10000,高16位为0) gdt_start: .word 0x0000, 0x0000 # 空描述符(GDT[0]) .word 0xffff, 0x0000 # 代码段限长(0xffff = 64KB)、基地址低16位 .word 0x00c0, 0x9a00 # 基地址高16位(0x00c0 << 16 = 0x00c00000)、访问权限(0x9a00 = 代码段、可执行、DPL=0) .word 0xffff, 0x0000 # 数据段限长、基地址低16位 .word 0x00c0, 0x9200 # 基地址高16位、访问权限(0x9200 = 数据段、可读写、DPL=0)关键字段说明:
- 描述符第2字(
0x00c0)的高4位(0xc)是基地址高16位,左移16位后与低16位组合成32位基地址。此处0x00c0 << 16 = 0x00c00000,即GDT基地址为0x00c00000(12MB处),这是HIT oslab为避免与内核代码冲突而选的安全位置。 - 访问权限字(
0x9a00)中,bit 7=1(P=1,段存在)、bit 4-5=10(DPL=0,最高特权级)、bit 3=1(DT=1,系统段)、bit 0-3=1010(TYPE=1010,代码段、可执行、一致)。
GDT加载后,进入保护模式的最后一步:
movl %cr0, %eax # 读取cr0寄存器 orl $0x1, %eax # 设置PE位(bit0) movl %eax, %cr0 # 写回cr0,CPU进入保护模式 # 必须立即远跳转,刷新CS寄存器 ljmp $0x08, $1f # 跳转到GDT[1](代码段)的标号1f处 .code32 # 此后代码按32位模式解析 1: movw $0x10, %ax # 加载数据段选择子(GDT[2] = 0x10) movw %ax, %ds movw %ax, %es ...ljmp $0x08,$1f中的0x08是GDT[1]的索引(0x00000000 | (1<<3) | 0x00=0x00000008),$1f是当前段内局部标号1:。这行指令强制CPU用新段选择子重新加载CS,使后续指令按保护模式规则执行。若省略此跳转,CPU虽在保护模式,但CS仍指向实模式段,会导致指令解码混乱。
3.3 内存布局全景图:从0x0000到0x100000的每一寸土地
理解bootsect.s和setup.s,本质是理解x86 PC的内存映射。下表列出HIT oslab实验中关键区域的物理地址与用途:
| 物理地址范围 | 大小 | 用途 | 关键约束 |
|---|---|---|---|
0x00000 - 0x003ff | 1KB | 实模式中断向量表(IVT) | BIOS初始化,存放256个中断入口 |
0x00400 - 0x004ff | 256B | BIOS数据区(BDA) | 存储软驱参数、内存大小等 |
0x00500 - 0x07bff | ~31KB | 系统数据区(EBDA等) | 通常空闲,但需避开 |
0x07c00 - 0x07dff | 512B | bootsect.s加载区 | 严格512字节,含栈空间 |
0x07e00 - 0x08fff | ~8KB | bootsect.s临时缓冲区 | 用于读取setup.s等 |
0x09000 - 0x09fff | 4KB | setup.s加载区(0x90000) | HIT oslab约定位置 |
0x0a000 - 0x0afff | 4KB | setup.s运行时栈/数据 | 需手动初始化 |
0x0c000 - 0x0cfff | 4KB | GDT存储区(0x00c00000) | 高地址,避免与内核冲突 |
0x100000(1MB) | - | 内核代码起始地址 | 保护模式下首个可用高端内存 |
这个布局不是随意指定的。例如,0x100000作为内核起始,是因为实模式下1MB是寻址上限(20根地址线),而保护模式开启后,CPU可访问更高地址。将内核放在1MB以上,既避开实模式遗留区域,又为后续内存管理预留空间。setup.s在跳转至内核前,会将0x100000地址写入某个寄存器(如%esi),并执行ljmp *%esi,完成最终交接。
4. 实操过程详解:从汇编、链接到QEMU单步调试的全流程
4.1 工具链准备:为什么必须用i386-elf-gcc而非普通gcc?
HIT oslab要求生成16位实模式代码,而现代Linux发行版的默认gcc(x86_64-linux-gnu-gcc)生成的是64位ELF可执行文件,且默认启用PIE(地址随机化)、栈保护等特性,与引导代码需求完全相悖。因此,必须构建交叉编译工具链:
# 下载binutils和gcc源码(版本需匹配,如binutils-2.39, gcc-12.2.0) # 编译binutils(关键:指定--target=i386-elf) ./configure --target=i386-elf --prefix=/opt/cross --disable-werror make && make install # 编译gcc(关键:禁用多库、指定目标) ./configure --target=i386-elf --prefix=/opt/cross \ --with-sysroot=/opt/cross/i386-elf \ --disable-multilib --enable-languages=c,c++ make && make install安装后,工具链路径为/opt/cross/bin/,核心命令:
i386-elf-gcc:C编译器(本实验暂不用)i386-elf-as:GNU汇编器,支持.code16/.code32伪指令i386-elf-ld:链接器,需指定脚本控制段布局
实操心得:很多学生卡在“找不到i386-elf-gcc”。不要试图用
apt install gcc-multilib替代——它提供的是x86_64主机上编译32位程序的能力,而非生成16位实模式代码的交叉工具。必须从源码编译,且--target=i386-elf是核心参数,elf后缀表明生成ELF格式(便于链接器处理),而非a.out等旧格式。
4.2 汇编与链接:ld脚本如何决定代码落点?
bootsect.s需输出纯二进制(.bin),而非ELF,因其将被BIOS直接加载。编译命令为:
i386-elf-as --32 -o bootsect.o bootsect.s i386-elf-ld -Ttext 0x0 -m elf_i386 -o bootsect.elf bootsect.o i386-elf-objcopy -O binary bootsect.elf bootsect.bin其中,-Ttext 0x0指定代码段起始虚拟地址为0,-m elf_i386指定目标架构。objcopy将ELF转换为纯二进制,去除所有头部信息,确保首字节即为第一条指令。
setup.s同理,但链接地址不同:
i386-elf-as --32 -o setup.o setup.s i386-elf-ld -Ttext 0x90000 -m elf_i386 -o setup.elf setup.o i386-elf-objcopy -O binary setup.elf setup.bin关键在-Ttext 0x90000,它告诉链接器:所有符号地址(如gdt_start)均以0x90000为基址计算。这样,当setup.bin被bootsect.s读入0x90000,其内部地址引用才能正确解析。
注意:
bootsect.s中jmp指令的目标地址是相对偏移,而非绝对地址。例如jmp setup_start,汇编器会计算setup_start - (当前地址 + 2)得到16位偏移量。因此,bootsect.s无需指定-Ttext,因其运行地址由BIOS固定为0x7c00。
4.3 制作软盘镜像:dd命令背后的扇区对齐哲学
HIT oslab使用软盘镜像(.img)模拟启动介质。标准1.44MB软盘有2880个扇区(80磁道×2面×18扇区),每个扇区512字节。制作镜像命令:
# 创建空镜像(1.44MB = 1474560字节) dd if=/dev/zero of=oslab.img bs=512 count=2880 # 写入bootsect.bin到第0扇区(LBA 0) dd if=bootsect.bin of=oslab.img bs=512 seek=0 conv=notrunc # 写入setup.bin到第1-4扇区(LBA 1-4) dd if=setup.bin of=oslab.img bs=512 seek=1 conv=notruncseek=0表示从镜像文件第0个512字节块开始写入,conv=notrunc确保不截断文件。若遗漏conv=notrunc,dd会清空后续所有数据,导致镜像损坏。
实操心得:
setup.bin大小必须严格≤2048字节(4扇区)。若超限,dd写入时会覆盖后续扇区,导致内核加载失败。可在编译后检查:ls -l setup.bin。若超过2048,需精简代码或调整setup.s中数据区大小。
4.4 QEMU单步调试:用GDB解开每一条指令的谜团
QEMU是调试引导代码的黄金搭档,配合GDB可实现指令级追踪:
# 启动QEMU,监听GDB连接(端口1234) qemu-system-i386 -fda oslab.img -s -S # 在另一终端启动GDB,连接QEMU gdb (gdb) target remote :1234 (gdb) set architecture i8086 # 切换到16位模式 (gdb) b *0x7c00 # 在bootsect入口设断点 (gdb) c # 开始执行此时QEMU暂停在0x7c00,可逐条执行:
(gdb) si # 单步执行一条指令 (gdb) info registers # 查看所有寄存器值 (gdb) x/10i $pc # 查看当前指令附近10条汇编 (gdb) x/20xb 0x7c00 # 查看0x7c00起始的20字节内存关键调试技巧:
- 当执行
int 0x13后,检查%ax寄存器:若%ah=0,表示成功;若%ah=0x01,表示无效命令;%ah=0x02表示扇区未找到。 - 在
setup.s中A20开启后,执行movw $0x10, %ax前,用info registers确认%cr0的PE位是否为1(cr0 & 1 == 1)。 - 进入保护模式后,
set architecture i386切换到32位模式,再用x/10i $pc查看指令,否则GDB会错误解析为16位指令。
我曾帮一位学生解决“GDT加载后黑屏”问题。通过GDB发现,lgdt指令执行后,%cr0的PE位确为1,但ljmp指令后%cs值仍为0x07c0。追查发现,ljmp的目标标号1f定义在.code32段之后,而.code32声明位置错误,导致GDB解析的$1f地址偏移不正确。修正.code32位置后,问题解决。这印证了一点:引导代码调试,80%的问题源于地址计算错误,而非逻辑错误。
5. 常见问题与排查技巧实录:那些年踩过的坑与独门解法
5.1 典型问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| QEMU启动后黑屏,无任何输出 | bootsect.s未正确加载(魔数错误) | `hexdump -C oslab.img | head -n 1检查前512字节末尾是否为aa 55` |
bootsect.s执行后卡死,不读取setup.s | int 0x13调用失败(CF=1) | GDB中执行si至int 0x13后,info registers检查%ah | 检查%dl(驱动器号)是否为0(软驱);%ch/%cl/%dh磁盘参数是否为0;es:bx地址是否有效 |
setup.s加载后立即重启 | A20未开启成功 | GDB中x/wx $cr0,确认bit0是否为1;若为0,检查8042通信 | 严格按顺序执行两次wait_kbd,确保0xd1和0xdf均被8042接收 |
进入保护模式后GDB显示Cannot access memory at address 0x... | GDT基地址或限长错误 | x/10xw gdt查看GDT内容;x/wx $gdtr确认GDTR加载值 | 检查gdt_start定义位置;gdt结构中.word顺序是否正确;lgdt指令前是否已设置%ds指向GDT所在段 |
ljmp后程序跳转到错误地址 | .code32声明位置错误或标号1f未正确定义 | GDB中disassemble查看ljmp指令反汇编,确认目标地址 | 将.code32置于ljmp指令之前;确保1:标号在ljmp同一段内,且无其他指令插入 |