第03章 引导启动程序(1):0x7C00到0x90000——解密bootsect.s的“搬家魔术”

📅 2026/7/3 1:22:07
第03章 引导启动程序(1):0x7C00到0x90000——解密bootsect.s的“搬家魔术”
引言接过BIOS递来的“火种”想象一下你刚刚按下了电脑的电源键。在一片漆黑的物理世界里CPU中央处理器复位它的程序计数器被强行设置为0xFFFF0。这里驻留着电脑主板上一块“永久烧录”的芯片——ROM BIOS基本输入输出系统。BIOS 开始了一段忙碌的“寻宝之旅”。它要进行 POST加电自检检查内存和硬件是否完好。随后它的终极任务是找到一个可以引导操作系统的设备。无论是软盘、硬盘还是光盘BIOS 会读取该设备的第0个磁道、第0个磁头、第1个扇区整整512字节的“引导扇区”并毫不客气地将其原封不动地拷贝到物理内存的0x7C00位置。当 BIOS 完成拷贝后它通过一条跳转指令把 CPU 的执行权正式交给了这片0x7C00处的代码。bootsect.s隆重登场接过了 BIOS 递给整个操作系统的第一支“火种”。就在这一刻我们正式进入了《Linux 0.11 内核完全注释》第三章节的核心。今天我们要共同啃下的正是这段古老、硬核、却又散发着极简主义美学的汇编代码。第一章三大神技的开篇——bootsect.s到底长什么样对照书中的bootsect.s源码你会发现它极尽克制。它只有短短不到 300 行汇编代码编译后刚好512 字节被严格限制在一个磁盘扇区的大小内。这 512 字节的代码要完成四个匪夷所思的任务“乾坤大挪移”把自己从0x7C00搬到0x90000。请来“二当家”从磁盘读取下一个文件setup.s仅4个扇区2KB放在自己新家的后面。扛起“大当家”把整个内核代码system上百 KB读取到内存安全的位置。交接棒把 CPU 指挥棒交给setup.s功成身退。这就像是你作为一个排头兵BIOS 把你空投到了敌人的阵地0x7C00然后你第一个任务不是打仗而是立刻把自己转移到一个安全的大本营并且接连帮后续的大部队setup和system搭建好跳板最后安然退场。下面我们将按照bootsect.s的实际执行流程一层层揭开它“魔术”般的面纱。第二章惊险的“搬家”——从 0x7C00 到 0x900002.1 为什么要“搬家”当我们还在讨论寻址时代码其实正处于一个极其危险的境地。内存的最底端0x00000到0x50000甚至更大的区域在未来将会被 Linux 的核心内核system模块所占据。如果bootsect.s一直赖在0x7C00不走一会儿内核被从软盘加载进来时就会直接把bootsect.s覆盖掉导致系统立刻崩溃。为此Linus 选择了向内存的高处迁移从0x7C00大约 31KB 处挪到0x90000大约 576KB 处。这个距离内核的最终目的地足够远不会被波及。2.2 汇编级别看“搬家”在bootsect.s的最前面有这样一段极简至极的指令start: mov ax,#BOOTSEG ! BOOTSEG 定义为 0x07c0 mov ds,ax ! ds 段寄存器指向 0x7C00 mov ax,#INITSEG ! INITSEG 定义为 0x9000 mov es,ax ! es 段寄存器指向 0x90000 mov cx,#256 ! 共复制 256 个“字” (1 字 2 字节256*2512 字节) sub si,si ! 源索引 si 0 (即 ds:si 0x7C00:0x0000) sub di,di ! 目标索引 di 0 (即 es:di 0x90000:0x0000) rep ! 重复执行下一条指令直到 cx 为 0 movw ! 也就是 movs 指令从一个内存地址搬移到另一个 jmpi go,INITSEG ! 段间跳转。跳到 0x9000:go 处继续执行深度解析REP MOVSW的魔法这是实模式下最经典的“内存复制大法”。rep是重复前缀movsw是传送一个字Word。每一次执行CPU 都会把DS:SI指向的内存里的 2 个字节复制到ES:DI指向的内存里然后自动把SI和DI加上 2同时把循环计数器CX减去 1。为什么是 256bootsect.s整个代码正好是 512 字节。每次传一个字2字节所以循环256次就能搬完。最后的jmpi go,INITSEG这段代码极其关键。因为前一条movw虽然把数据搬到了0x90000但CPU 的代码段寄存器CS和指令指针EIP仍然指向0x7C00。如果不执行这条跨段跳转CS值没变后续的代码依然会去0x7C00处寻找并执行而那早已是空白。通过这条指令CPU 物理上把执行环境彻底切换到了0x90000。为了让你更直观地理解我绘制了bootsect.s执行初期CPU 内存视角的变化图阶段3_世界切换 [第三阶段长跳转接管]执行 jmpi go, INITSEGCPU 将 CS 改为 0x9000CPU 从物理地址 0x90000 的 go 标号继续执行阶段2_自复制 [第二阶段bootsect.s 执行自复制]源地址 DS:SI 0x7C00复制 256个字 (512字节)目标地址 ES:DI 0x90000阶段1_BIOS [第一阶段BIOS加载完成]物理内存地址 0x7C00存放 bootsect.s 程序 (512字节)CPU 开始执行 0x7C00 处代码阶段1_BIOS阶段2_自复制阶段3_世界切换注意0x7C00 和 0x90000 相差近 576KB这个空间足够后续加载setup 和 system 模块了。第三章请来“二当家”——加载setup.s当 CPU 在0x90000处的go:标号醒来时它首先设置好了各个段寄存器go: mov ax,cs mov ds,ax mov es,ax mov ss,ax ! 初始化 堆栈段寄存器 mov sp,#0xFF00 ! 堆栈指针指向 0x9FF00足够大为什么要这么急切地设置SS和SP堆栈因为紧接着下面就有操作了bootsect.s接下来的任务是要从软盘中读取数据。为了实现这个任务它调用了 BIOS 的底层磁盘中断int 0x13。在调用期间CPU 需要在堆栈里临时保存寄存器状态。如果这个时候堆栈都没有初始化push和pop指令将会把数据写到随机内存地址导致系统死机。准备好堆栈后二当家的召唤令开始了load_setup: mov dx,#0x0000 ! 驱动器 0 (A 驱), 磁头 0 mov cx,#0x0002 ! 扇区 2, 磁道 0 mov bx,#0x0200 ! 地址偏移 512 (即 0x90200) mov ax,#0x0200SETUPLEN ! AH0x02 (读磁盘), AL4 (读 4 个扇区) int 0x13 ! 调用 BIOS 中断干活 jnc ok_load_setup ! 如果成功CF标志位为0跳走 ... ! 如果失败复位软驱并重试... ok_load_setup:深度解析中断int 0x13这是实模式下操作磁盘的唯一途径。AX0x0200SETUPLEN表示我们要执行“读扇区”功能AH0x02并且要连续读AL 4个扇区。读到哪里根据前面设定的ES:BX 0x9000:0x0200物理地址就是0x90200。请记住这个地址。我们刚刚搬到了0x90000占据 0-512 字节0x90000~0x901FF。现在setup.s被加载到0x90200紧挨着bootsect的新家。jnc ok_load_setupjnc是“Jump if Not Carry”如果磁盘读取没有出错CPU 进位标志位CF为 0程序直接跳转到下一步。如果读取出错就会往下执行复位软驱并无限重试。第四章摸清家底——获取“每磁道扇区数”在加载setup.s之后有一段看似微小的代码实则关系到系统能否成功读取内核。软盘知识小科普一张 1.44MB 的软盘有 80 个磁道柱面每个磁道有 2 个磁头0 和 1每个磁道每个磁头下有 18 个扇区每扇区 512 字节。常见的 1.2MB 软盘每个磁道只有 15 个扇区。如果不告诉内核“这盘软盘每道有多少扇区”后面去读取巨大的system模块时读取程序会因为不知道一个磁道转到哪里结束导致读出来的数据错位。所以bootsect.s必须“借问”一下 BIOS。mov dl,#0x00 ! 驱动器 A mov ax,#0x0800 ! AH0x08 (取驱动器参数) int 0x13 ! 调用 BIOS mov ch,#0x00 seg cs ! 告诉 CPU下一条取数要从 CS 指向的段内存取因为此时 DS 变了 mov sectors,cx ! 把 CX 寄存器保存到变量 sectors 中 (每磁道扇区数)核心难点seg cs伪指令这行汇编非常经典。当调用int 0x13取出磁盘参数后返回结果保存在CX寄存器中CL低6位保存每磁道扇区数CH保存最大磁道号。但是此时DS寄存器已经被刚才的修改给弄乱了Linus 为了保证安全使用了seg cs这条指令。它告诉 CPU“接下来的一条mov指令虽然你想从DS里读取数据但我命令你强制从CS代码段里读取变量sectors。”这是汇编程序员和硬件斗智斗勇的铁证。第五章交互与终极搬运——加载system模块1. 屏幕上的声援Loading system...磁盘读取是一个非常慢的过程如果屏幕上毫无反应用户可能会以为电脑死机了。于是bootsect.s调用了一行极其罕见的 BIOS 视频中断mov cx,#24 ! 字符串长度 24 mov bp,#msg1 ! 字符串内存地址 mov ax,#0x1301 ! AH0x13 (显示字符串), AL0x01 (光标跟随) int 0x10调用int 0x10后PC 的屏幕上立刻出现了Loading system...这行字。这是人类与操作系统内核第一次有了“交互”。2. 搬动“大当家”read_it子程序真正的考验来了。system模块包含了 Linux 0.11 所有核心代码编译后大约 120KB ~ 200KB 不等。如果按一次读一个扇区512字节去读要读几百次极其缓慢。所以 Linus 写了一个复杂的read_it子程序它尽可能按“磁道”为单位整条读取。由于这个子程序极长且极其复杂我们在这里不展开全部汇编代码而是将其核心逻辑提炼成一个三段式流程检查 64KB 边界实模式下段内偏移最高只能到 64KB0xFFFF。如果当前读取的扇区位置加上数据长度会跨越 64KB 边界这段代码必须分段处理否则会出现内存回绕覆盖掉之前读好的数据。读取磁道read_track内部再次调用int 0x13利用之前获取的每道扇区数尽最大可能把当前磁道的剩余扇区一次性全读入内存。循环与接力读完一个磁道后自动切换当前磁头从0切到1或磁道。如果当前 64KB 段内存满了自动将ES段寄存器增加0x1000指向下一个 64KB 内存位置继续读取。经过这个极其严密又冗余的循环Linux 内核的骨架被成功放置到了物理内存0x1000064KB地址开始的地方。第六章移交指挥棒——确定根设备与跳转当system模块全部加载完毕后还剩下最后一点小尾巴确定根文件系统在哪里root_dev变量。如果编译内核时指定了ROOT_DEV比如0x306对应第2个硬盘的第1个分区就直接使用。如果没有指定bootsect.s会使用刚刚读取到的每磁道扇区数来判断如果每道 15 扇区那是 1.2MB 软驱如果每道 18 扇区那是 1.44MB 软驱。据此推断出根设备号。最后的告别jmpi 0,SETUPSEG ! SETUPSEG 是 0x9020CPU 再次执行跨段跳转。CS 变为0x9020IP 变为0x0000。物理地址指向0x90200——那里静静地躺着刚刚被加载进来的setup.s。至此bootsect.s光荣完成使命被覆盖在内存中的历史尘埃里。而setup.s接过了控制权准备迎接更惊心动魄的挑战开启 A20 线、进入 32 位保护模式。第七章亲手操作——“搬迁模拟器”代码实战为了让你亲眼看到这段“从0x7C00搬家的代码”在机器内部如何运作我专门为你写了一套C 语言纯软件模拟器。这个程序完全脱离真实硬件只需在常规的 Linux/Mac/Windows 终端里编译运行就能“重演”bootsect.s的整个生命周期。7.1 完整代码boot_sim.c/** * file boot_sim.c * brief 模拟 Linux 0.11 bootsetc.s 程序的“自我搬家”与内核加载过程。 * * 本程序用纯 C 语言在用户空间模拟了物理内存的搬运和磁盘扇区的读取。 * 旨在直观展现 bootsetc.s 从 0x7C00 复制到 0x90000 的“魔术”过程。 * * 编译gcc -o boot_sim boot_sim.c * 运行./boot_sim */#includestdio.h#includestring.h#includeunistd.h// // 1. 模拟物理内存空间// #defineMEM_SIZE(1*1024*1024)// 模拟 1MB 物理内存unsignedcharmemory[MEM_SIZE];// 物理内存大数组// 定义关键物理地址常量 (与 0.11 内核完全一致)#defineADDR_BIOS_7C000x7C00#defineADDR_INIT_SEG0x90000#defineADDR_SETUP_SEG0x90200#defineADDR_SYSTEM0x10000// // 2. 模拟 BIOS 引导加载// /** * brief 模拟 BIOS 启动引导阶段 * * BIOS 从启动盘软盘/硬盘的第1扇区读取 512 字节放到 0x7C00。 */voidsimulate_bios_boot(void){printf([BIOS] 电源开启执行 POST 自检...\n);printf([BIOS] 找到引导设备读取第1个扇区 (bootsect.s)...\n);printf([BIOS] 将 512 字节 boot sector 加载到物理内存 0x7C00...\n);// 为了模拟我们在 0x7C00 处写入一串识别码constchar*boot_magic[BIOS这里放了bootsect.s];memcpy(memory[ADDR_BIOS_7C00],boot_magic,strlen(boot_magic)1);printf([BIOS] 跳转到 0x7C00执行权移交给 bootset.s\n\n);}// // 3. 模拟 bootset.s 执行过程// /** * brief 模拟 bootset.s 的自复制 (rep movsw) * * 将自身从 0x7C00 复制到 0x90000。 */voidsimulate_self_copy(void){printf( bootset.s 开始执行 \n);printf([bootset] 1. 检测到自己在 0x7C00这里不安全必须搬家。\n);printf([bootset] 2. 将 DS:SI 指向 0x7C00ES:DI 指向 0x90000。\n);printf([bootset] 3. 执行 REP MOVSW (共复制 256 个字 512 字节)。\n);// 模拟复制memcpy(memory[ADDR_INIT_SEG],memory[ADDR_BIOS_7C00],512);printf([bootset] 4. 自复制完成执行 jmpi go, INITSEG...\n);printf([bootset] 5. CPU 成功切换到 0x90000 地址处继续执行。\n\n);}/** * brief 模拟加载 setup.s * * 使用 int 0x13 中断读取磁盘第 2~5 扇区到 0x90200。 */voidsimulate_load_setup(void){printf( bootset.s 加载 setup.s 模块 \n);printf([bootset] 调用 BIOS 中断 int 0x13 (功能号 0x02, 读磁盘)。\n);printf([bootset] 读取磁盘第 2~5 扇区 (共 4个扇区2KB)。\n);printf([bootset] 目标内存地址: 0x90200\n);// 模拟将 setup 数据写入内存constchar*setup_data[这里是 setup.s 的代码和数据];memcpy(memory[ADDR_SETUP_SEG],setup_data,strlen(setup_data)1);printf([bootset] 读取成功setup.s 已就位。\n\n);}/** * brief 模拟获取驱动器参数与打印信息 */voidsimulate_disk_params_and_msg(void){printf( bootset.s 获取磁盘参数与用户交互 \n);printf([bootset] 调用 int 0x13 功能号 0x08 获取驱动器参数...\n);printf([bootset] 报告当前驱动器类型为 1.44MB每磁道 18 个扇区。\n);printf([bootset] 调用 int 0x10 功能号 0x13在屏幕打印: );printf(\033[1;32mLoading system...\033[0m\n\n);// 模拟控制台彩色输出}/** * brief 模拟加载庞大的 system 内核模块 (read_it 子程序) */voidsimulate_load_system(void){printf( bootset.s 加载 system 内核模块 \n);printf([bootset] 调用 read_it 子程序开始从磁盘加载 system 模块...\n);printf([bootset] 为了加速尽可能整条磁道读取。\n);printf([bootset] 读取完成system 模块被加载到了内存 0x10000 处。\n\n);}/** * brief 模拟确定根设备号并移交控制权 */voidsimulate_jump_to_setup(void){printf( bootset.s 收尾并移交 \n);printf([bootset] 识别根文件系统设备根据每磁道扇区数判断为 1.44MB A盘。\n);printf([bootset] 设定设备号 ROOT_DEV 0x021C。\n);printf([bootset] 执行最后一步jmpi 0,SETUPSEG (跳转到 0x90200)\n);printf([bootset] bootset 使命结束被覆盖告别舞台\n\n);}// // 4. 主程序// intmain(void){printf(\n 模拟 Linux 0.11 bootset.s 启动过程 \n\n);// 1. BIOS 阶段simulate_bios_boot();// 2. bootset 自复制simulate_self_copy();// 3. 加载 setupsimulate_load_setup();// 4. 获取参数 打印 Loadingsimulate_disk_params_and_msg();// 5. 加载 systemsimulate_load_system();// 6. 确定根设备并移交控制权simulate_jump_to_setup();// 7. 模拟进入 setup.sprintf( 进入下一阶段setup.s 开始执行 \n);printf(验证0x90000 处现在保存的内容是: %s\n,memory[ADDR_INIT_SEG]);printf(验证0x90200 处现在保存的内容是: %s\n,memory[ADDR_SETUP_SEG]);printf(\n 模拟结束 \n);return0;}7.2 配套 Makefile# 编译器 CC gcc # 编译选项: -Wall 显示所有警告, -g 包含调试信息, -O2 优化 CFLAGS -Wall -g -O2 # 目标文件 TARGET boot_sim # 默认目标 all: $(TARGET) # 链接规则 $(TARGET): boot_sim.c $(CC) $(CFLAGS) -o $(TARGET) boot_sim.c # 清理规则 clean: rm -f $(TARGET) # 运行规则 run: $(TARGET) ./$(TARGET) .PHONY: all clean run7.3 操作与解读说明编译将boot_sim.c和Makefile放在同一个目录下。在终端中执行make clean make。运行执行./boot_sim。观察结果[BIOS] 将 512 字节 boot sector 加载到物理内存 0x7C00...这是模拟BIOS放入了初始代码。[bootset] 3. 执行 REP MOVSW (共复制 256 个字 512 字节)。和[bootset] 5. CPU 成功切换到 0x90000 地址处继续执行。你会看到程序自己给自己“搬了个家”。[bootset] 调用 BIOS 中断 int 0x13 (功能号 0x02, 读磁盘)。和[bootset] 读取磁盘第 2~5 扇区模拟了把二当家setup.s请到了0x90200。最后程序会打印出最终在0x90000和0x90200内存地址处存放的内容证明引导程序不仅搬了自己还把后续的代码也安放好了。运行这个模拟器你会真切地感受到一个操作系统不是“凭空被解压”的而是由极其聪慧的引导程序像搭积木一样一块块从底层拼接到内存里的。终章承上启下的历史巨轮回顾本节我们看到了bootsect.s在极其有限的 512 字节内完成了“自移动”、“读取setup”、“读取system”这三件看似不可能的任务。它没有用到任何高深的算法完全依靠对底层硬件BIOS 中断、内存寻址实模式段偏移和磁盘结构磁道、磁头、扇区的深刻理解。这是一种极致的“手工艺”。无论后来的 UEFI 引导、GRUB2 现代引导程序多么高级它们底层的逻辑“把数据从存储设备搬运到内存然后把执行权移交”都脱胎于这段诞生于 1991 年的区区 512 字节代码。