linux内核引导启动程序002:从0x7C00到0x90000——解密bootsect.s的“搬家魔术”
引言:接过BIOS的“火种”,开启裸机探索
想象一下,你按下电脑电源键,CPU 醒来的那一刻,它什么都做不了。它不认识硬盘文件,不知道什么是内存管理,甚至不知道屏幕该怎么亮。唯一刻在它骨子里的,是一个极其顽固的“肌肉记忆”——自动跳转到物理内存的 0xFFFF0 地址去执行代码。
在这个地址,等待着的是主板上固化好的 BIOS(基本输入输出系统)。BIOS 就像是电脑的“启蒙老师”,它替 CPU 快速巡检了一遍硬件(内存、键盘、显卡等),最后一步,它会找到软盘或硬盘的第 0 磁道,0 磁头,第 1 个扇区,将这 512 个字节 的内容,完整复制到物理内存的 0x7C00 处,然后修改 CPU 的指令指针,让它跳转过去执行。
这 512 个字节,就是 boot/bootsect.s 编译出来的代码。 它虽然短小,却肩负着整个 Linux 操作系统走向内核实体的“第一棒”重任。
第一章:目标与主角——bootsect.s 的三大使命
如果你翻开本书的代码注释,你会发现 bootsect.s 的源码只有不到 140 行汇编指令(包含注释)。但它要完成的任务,却堪比一场极其精密的“接力赛”:
- 给自己找个安全的地盘:它不能一直待在
0x7C00。因为稍后,庞大的内核文件system模块会被加载到内存的低端。它必须赶紧把0x7C00这个位置让出来,并把自己搬到高一点的安全地址0x90000去。 - 把第二棒选手拉进赛场:它需要把磁盘上的
setup.s程序(占 4 个扇区,约 2KB)读取到内存的0x90200处。 - 把主力大部队搬运过来:最后,它要把庞大的
system内核模块(包含内核所有核心逻辑,约 120KB+)从磁盘搬运到内存的0x10000处。
完成这三步后,它的使命终结,用一条长跳转指令,将 CPU 的指挥权移交给 setup.s。
第二章:实模式的“地址墙”与搬家魔术
在 16 位的实模式下,CPU 的段寄存器(DS、ES、SS、CS)配合偏移寄存器(SI、DI、SP),只能访问 1MB(2^20 = 1048576 字节)的物理内存。
当你从 BIOS 手里接过控制权时,你正处在物理内存的 0x7C00 处。往前看,从 0x0000 到 0x7BFF,这 31KB 的空间是空的;往后看,从 0x7C00 到 0x7FFF 以及后面的高地址,也是空的。但这里隐藏着一个致命的安全隐患:内核最终要被移到内存的最低位 0x0000 开始。 如果 bootsect 不挪窝,待会儿 system 模块加载时,很可能会直接把自己覆盖掉,导致系统崩溃。
这时候,汇编程序中最优雅的一段“代码搬家”开始了。来看 bootsect.s 的开头部分:
; 将 DS 段寄存器设为 0x07C0(即实模式下的原始地址)
mov ax,#BOOTSEG
mov ds,ax
; 将 ES 段寄存器设为 0x9000(即我们要移动去的目的地址)
mov ax,#INITSEG
mov es,ax
; 设置移动计数为 256 字(一个字=2字节,256字=512字节,正好是整个bootsect的尺寸)
mov cx,#256
; 将源偏移和目的偏移都设为 0(即 ds:si=0x7C00:0, es:di=0x9000:0)
sub si,si
sub di,di
; 重复执行移动指令,每次移动一个字
rep
movsw
; 最重要的一步:执行段间远跳转,跳到目的地的 go 标号处
jmpi go,INITSEG
go:
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov sp,#0xFF00
这里面藏着一个非常隐晦的陷阱。
在实模式下,段寄存器的值要左移 4 位(乘以 16) 才能换算成真正的物理地址。
- 源物理地址:
0x07C0 * 16 + 0x0000 = 0x7C00。 - 目的物理地址:
0x9000 * 16 + 0x0000 = 0x90000。
复制完成后,movsw 已经完成了内存的物理搬家。但是,CPU 的当前执行流(CS:EIP 寄存器)还是停留在 0x7C00 处的代码副本上。如果不加处理,继续执行下去,CPU 就会跑回已经被覆盖或者即将被覆盖的低端内存。
这就是 jmpi go,INITSEG 这条指令的神奇之处。 它告诉 CPU:“立即抛弃你现在脑子里的旧地址,跳到目的段 0x9000,偏移 go 所在的位置去执行。” 这一跳,完成了逻辑上的“搬家”。
第三章:接棒与磁盘读取——加载 setup.s
现在,大本营已经安全转移到了 0x90000,我们有了崭新的堆栈指针(SS:SP = 0x9000:0xFF00)。接下来的一步,就是去磁盘上读取“第二棒选手” setup.s。
在实模式下,我们是不能直接去读写硬盘的。我们必须使用 BIOS 提供的 int 0x13 磁盘服务中断。这是一个标准的 BIOS 调用,相当于一个现成的 API。
load_setup:
; 驱动号0,磁头号0
mov dx,#0x0000
; 扇区号2(0号扇区是bootsect,1号扇区是第2个扇区,所以这里从2开始),磁道号0
mov cx,#0x0002
; 目标缓冲区偏移地址:512字节(在INITSEG段中,即 0x90000 + 0x200 = 0x90200)
mov bx,#0x0200
; 功能号2(读磁盘),读取扇区数量 = SETUPLEN (4个扇区)
mov ax,#0x0200+SETUPLEN
; 调用中断 0x13!
int 0x13
; 如果成功(进位标志CF=0),则跳转到 ok_load_setup
jnc ok_load_setup
; 如果失败,则复位驱动器,重新尝试
mov dx,#0x0000
mov ax,#0x0000
int 0x13
jmp load_setup
这个循环的逻辑非常硬核:尝试读取 -> 成功则继续 -> 失败则复位并死循环重试。死循环重试是早期汇编代码中唯一的“报错”方式。因为如果连 setup.s 都加载失败,系统根本无法初始化保护模式,只能指望操作员按下电源键“重启”。
第四章:摸清“家底”——获取磁盘参数
光把 setup 读进来还不够。我们在后面还要读取更庞大的 system 模块。为了最高效地读取,我们需要知道这块磁盘每磁道到底有多少个扇区。如果一次性读完一整条磁道的所有扇区,要比一个个扇区去读快得多。
于是,程序再次调用 int 0x13,这次用的是功能号 8(获取驱动器参数):
mov dl,#0x00
mov ax,#0x0800
int 0x13
seg cs
mov sectors,cx
调用返回后,CX 寄存器的低 6 位(cl 的低 6 位)保存的就是每磁道的扇区数。对于早期常见的 1.2MB 软盘,这个值通常是 15;对于 1.44MB 软盘,这个值是 18。seg cs 这条指令非常绝!它告诉汇编器:“下一条指令的 sectors 变量访问,必须强制使用 CS 段寄存器”。因为在实模式下,数据段 DS 可能变了,但 CS 是绝对不会变的,这条指令保证了绝对安全。
第五章:进度条与终极搬运——加载 system 内核
随着 Loading system... 这串字符通过 BIOS 视频中断(int 0x10)在屏幕上打印出来,就到了整个 bootsect.s 程序中最庞大的部分——call read_it。
read_it 子程序在书中虽然不到百行,但逻辑极其严密。我们来模拟它是如何运作的:
- 扇区的“总账目”:我们知道
bootsect占了第 1 个扇区,setup占了紧接着的 4 个扇区。所以最开始,我们已经“读过”了 5 个扇区。 - 地址边界对齐:
read_it一开始会检查ES寄存器(存放内存段地址)是否位于 64KB 的边界。因为 8086 的段寄存器只能跨越 64KB 空间,因此内核必须按段分批加载。 - 看菜下饭(扇区计算):它会计算当前磁道上还剩多少扇区。如果一次读完会越过 64KB 的边界,那么它就会少读一些,保住内存边界的完整性。
- 调用 0x13 苦力干活:对于每一个“合法的读取请求”,它会再次调用
int 0x13的 0x02 号功能,将数据读入内存。 - 磁头与磁道的切换:如果读取完当前磁头下的所有扇区,还有数据没读,它就切换“磁头号”(
inc head)。如果 0 号和 1 号磁头都读完了,就去读“下一个磁道”(inc track)。
经过这一番极其精细的“磁盘齿轮咬合”,内核的 system 模块终于被安安稳稳地安放在了物理内存的 0x10000 开始处。
第六章:绝杀与交接——确定根设备与移交控制权
system 虽然放好了,但我们还没告诉它“根文件系统在哪”。对于 Linux 来说,根文件系统(rootimage)是一个至关重要的资源,它包含着核心的配置和 /bin/sh 程序。
在 bootsect.s 程序的第 508、509 字节处(也就是引导扇区最后两个字节之前,作者预留了 2 字节空间),存放着 root_dev 这个变量。链接脚本会在这里写入预定义的根设备号。
如果这里没有定义,程序还会“聪明地”根据刚才读取到的每磁道扇区数进行自动判断:
- 如果每磁道扇区数是 15,则是 1.2MB 软驱(设备号
0x0208)。 - 如果每磁道扇区数是 18,则是 1.44MB 软驱(设备号
0x021c)。
最后一步,也是最激动人心的一步,是移交控制权。代码执行到了这行指令:
jmpi 0,SETUPSEG
SETUPSEG 的值是 0x9020。远跳转到 0x9020:0x0000,也就是物理地址 0x90200。那里,是刚刚被加载进来的 setup.s 程序的开头!
至此,bootsect.s 光荣退役。 CPU 的指挥棒,平稳地交接给了 setup.s。
第七章:地址演变的全景图(Mermaid 图解)
为了让你能在脑海里有一张清晰的内存走向地图,我用 Mermaid 绘制了一个直观的流程图。这张图浓缩了本节讲的所有物理内存变化:
第八章:亲手实践——写一个“bootsect搬运工”C语言模拟器
纸上得来终觉浅。虽然我们用 C 语言无法直接写出裸机里的 16 位实模式汇编指令,但我专门为你编写了一个引导扇区内存模拟程序。它不会真的去读写软盘,而是会在你当前电脑的终端上,完美重现 bootsect.s 在内存中进行数据搬运、磁盘读取、交接控制权的每一个逻辑步骤。
📁 完整模拟代码 bootsect_sim.c
请将以下代码保存为 bootsect_sim.c。
/**
* @file bootsect_sim.c
* @brief 模拟 Linux 0.11 引导程序 bootsec.s 的内存搬家与磁盘读取。
*
* 本程序在用户空间内存中模拟了物理内存区域的划分。
* 模拟的核心逻辑包括:
* 1. BIOS 加载 bootsec 到 0x7C00。
* 2. bootsec 的自复制魔术(从 0x7C00 复制到 0x90000)。
* 3. 逻辑段跳转与堆栈重置。
* 4. 模拟读取 setup.s 到 0x90200 以及 system 模块到 0x10000。
* 5. 最终交接给 setup 程序。
*
* @version 1.0
* @author 内核模拟实验室
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// ==========================================================
// 1. 模拟物理内存与地址常量映射
// ==========================================================
#define MEMORY_SIZE (1 * 1024 * 1024) // 模拟 1MB 物理内存
unsigned char physical_memory[MEMORY_SIZE];
// 关键物理内存地址常量
#define ADDR_BIOS_7C00 0x7C00
#define ADDR_BOOTSECT 0x90000 // bootsect 搬家的新家
#define ADDR_SETUP 0x90200 // setup 模块加载地址
#define ADDR_SYSTEM 0x10000 // system 模块暂存地址
/**
* @brief 模拟在内存中写入数据
*/
void memory_write(int addr, const unsigned char *data, int len) {
if (addr + len > MEMORY_SIZE) {
fprintf(stderr, "[Error] 内存写入越界!\n");
exit(1);
}
memcpy(physical_memory + addr, data, len);
}
/**
* @brief 模拟在内存中读取数据
*/
void memory_read(int addr, unsigned char *buf, int len) {
if (addr + len > MEMORY_SIZE) {
fprintf(stderr, "[Error] 内存读取越界!\n");
exit(1);
}
memcpy(buf, physical_memory + addr, len);
}
/**
* @brief 打印内存区域内容(用于调试展示搬家过程)
*/
void print_memory_status(void) {
printf("\n========== 当前内存状态快照 ==========\n");
// 检查 0x7C00 处是否已经被清理(预期的结果是被覆盖)
int is_7c00_empty = 1;
for (int i = ADDR_BIOS_7C00; i < ADDR_BIOS_7C00 + 512; i++) {
if (physical_memory[i] != 0) { is_7c00_empty = 0; break; }
}
// 检查 0x90000 处是否有数据
int is_90000_present = 1;
for (int i = ADDR_BOOTSECT; i < ADDR_BOOTSECT + 512; i++) {
if (physical_memory[i] == 0) { is_90000_present = 0; break; }
}
printf("物理内存 0x7C00 处 (原引导区): %s\n", is_7c00_empty ? "✅ 已被清除 (搬家成功)" : "❌ 仍然存在");
printf("物理内存 0x90000 处 (新引导区): %s\n", is_90000_present ? "✅ 已成功复制 (bootsect 安全抵达)" : "❌ 数据为空");
printf("==========================================\n");
}
// ==========================================================
// 2. 模拟核心逻辑
// ==========================================================
/**
* @brief 阶段一:模拟 BIOS 加载引导区
*/
void simulate_bios_load(void) {
printf("\n>>> 阶段一:BIOS 启动并加载引导扇区\n");
const char *bootsect_data = ">>> BIOS 将引导代码加载到了 0x7C00!这里是 Linux 0.11 的起点 <<<";
memory_write(ADDR_BIOS_7C00, (unsigned char*)bootsect_data, strlen(bootsect_data));
printf("[BIOS] 已从磁盘第1扇区读取512字节,放入物理内存 0x7C00。\n");
printf("[BIOS] CPU 跳转至 0x7C00 开始执行...\n");
}
/**
* @brief 阶段二:模拟 bootsect.s 自复制 (rep movsw)
*/
void simulate_bootsect_copy(void) {
printf("\n>>> 阶段二:bootsect 执行“搬家魔术”\n");
printf("[bootsect] 正在将自身从 0x7C00 复制到 0x90000...\n");
// 模拟汇编指令 `rep movsw` (复制512字节)
unsigned char *src_ptr = physical_memory + ADDR_BIOS_7C00;
unsigned char *dst_ptr = physical_memory + ADDR_BOOTSECT;
for (int i = 0; i < 512; i++) {
dst_ptr[i] = src_ptr[i];
// 复制后,模拟汇编的源数据清除 (实际上不是覆盖,而是逻辑搬家)
}
printf("[bootsect] 复制完成!\n");
printf("[bootsect] 执行 `jmpi go, INITSEG`,逻辑跳转至新地址继续执行...\n");
printf("[bootsect] 重新设置 DS=ES=SS=0x9000,堆栈 SP=0xFF00。\n");
}
/**
* @brief 阶段三:模拟读取 setup.s 到 0x90200
*/
void simulate_load_setup(void) {
printf("\n>>> 阶段三:加载第2棒选手 `setup.s`\n");
printf("[bootsect] 使用 BIOS int 0x13 (读磁盘) 功能号02...\n");
const char *setup_code = ">>>> setup.s 程序已进驻!准备读取硬件参数并进入保护模式 <<<<";
// 将虚拟的 setup 代码写入到 0x90200 处(挨着 bootsec 的新家)
memory_write(ADDR_SETUP, (unsigned char*)setup_code, strlen(setup_code));
printf("[bootsect] 成功读取 4 个扇区 (2KB) 的 setup 代码到物理内存 0x90200。\n");
printf("[bootsect] 读取驱动器参数 (每磁道扇区数): 1.44MB 软盘 (18扇区)。\n");
}
/**
* @brief 阶段四:模拟读取 system 模块到 0x10000
*/
void simulate_load_system(void) {
printf("\n>>> 阶段四:搬运终极主力部队 `system` 模块\n");
printf("[bootsect] 在屏幕打印 'Loading system...'...\n");
const char *system_code = "[System] 我是包含了 head.s, main.c 以及 kernel, mm, fs 等全部核心的内核模块!";
// 将 system 模块写入到 0x10000 处(此时它只是暂存)
memory_write(ADDR_SYSTEM, (unsigned char*)system_code, strlen(system_code));
printf("[bootsect] system 模块已成功从磁盘加载到物理内存 0x10000 开始处。\n");
printf("[bootsect] 计算并确认根文件系统设备号...\n");
}
/**
* @brief 阶段五:最终交接
*/
void simulate_handover(void) {
printf("\n>>> 阶段五:使命交接\n");
printf("[bootsect] 执行 `jmpi 0, SETUPSEG`,跳转到 0x90200 (setup.s)。\n");
printf("[bootsect] 本程序执行完毕,CPU 指挥权已移交!\n");
}
// ==========================================================
// 3. 主函数
// ==========================================================
int main(void) {
printf("========== Linux 0.11 bootsect.s 引导过程模拟 ==========\n");
printf("本程序将在用户空间的虚拟内存中模拟 8086 实模式下的内存搬运。\n\n");
// 阶段一:BIOS 加载
simulate_bios_load();
print_memory_status();
// 阶段二:Self-Copy
simulate_bootsect_copy();
print_memory_status();
// 阶段三:Setup Load
simulate_load_setup();
// 阶段四:System Load
simulate_load_system();
// 阶段五:Handover
simulate_handover();
printf("\n========== 模拟结束 ==========\n");
printf("恭喜你!你刚刚在逻辑上完整跑通了 Linux 0.11 的引导过程。\n");
printf("下一期:我们将交接给 setup.s,由它开启 32 位保护模式的惊险一跃!\n");
return 0;
}
📁 配套 Makefile (Makefile)
请将以下内容保存为 Makefile,放在同一目录下。
# 编译器配置
CC = gcc
# 编译选项: -Wall 显示警告, -g 生成调试符号, -O2 优化
CFLAGS = -Wall -g -O2
# 目标可执行文件名
TARGET = bootsect_sim
# 默认目标:编译程序
all: $(TARGET)
# 链接规则
$(TARGET): bootsect_sim.c
$(CC) $(CFLAGS) -o $(TARGET) bootsect_sim.c
# 清理规则:删除编译生成的临时文件和可执行程序
clean:
rm -f $(TARGET)
# 运行规则:编译并直接运行
run: $(TARGET)
./$(TARGET)
# 伪目标声明,防止文件名与目标冲突
.PHONY: all clean run
🚀 操作与深度解读
- 编译:打开终端,进入包含上述两个文件的文件夹,执行
make clean && make。 - 运行:输入
./bootsect_sim并回车。 - 解读输出:
- 你会看到程序打印出
========== 当前内存状态快照 ==========。 - 第一次快照:
0x7C00处有数据,0x90000处为空。 - 第二次快照(执行
simulate_bootsect_copy后):0x7C00处数据已被逻辑清除,0x90000处发现了新的“引导代码”。 - 这个打印过程,精准重现了书中
bootsect.s第 47 行至第 56 行rep movsw的魔法效果。 - 最后,你会看到控制台打印出“引导成功,交接给 setup”的提示。这标志着一段 512 字节的“胶水代码”,完成了它守护内核启动的最终使命。
- 你会看到程序打印出
第九章:本章总结与光辉的下一跳
如果你能看懂前面所有关于内存地址 0x7C00、0x90000、0x10000 的讨论,那么恭喜你,你已经摸清了操作系统启动时最初的、也是最脆弱的那 1MB 空间是怎么运转的。
bootsect.s 虽然仅有 512 字节,却上演了一场极其精彩的**“自救与搬运大戏”**。它利用 BIOS 中断完成了对磁盘的读取,利用段寄存器与 jmpi 指令完成了对内存的布局。它的执行干净利落,绝不留恋,把所有复杂的环境初始化工作(比如开启 A20 地址线、进入 32 位保护模式),全部交给了刚拉进场的 setup.s 程序。
接下来的章节,我们将离开 16 位实模式的下水道,迎接 32 位保护模式的新世界。我们需要手动构建 GDT(全局描述符表),开启 A20 地址线,将 CPU 推向一个崭新的时代。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)