引言:接过BIOS的“火种”,开启裸机探索

想象一下,你按下电脑电源键,CPU 醒来的那一刻,它什么都做不了。它不认识硬盘文件,不知道什么是内存管理,甚至不知道屏幕该怎么亮。唯一刻在它骨子里的,是一个极其顽固的“肌肉记忆”——自动跳转到物理内存的 0xFFFF0 地址去执行代码

在这个地址,等待着的是主板上固化好的 BIOS(基本输入输出系统)。BIOS 就像是电脑的“启蒙老师”,它替 CPU 快速巡检了一遍硬件(内存、键盘、显卡等),最后一步,它会找到软盘或硬盘的第 0 磁道,0 磁头,第 1 个扇区,将这 512 个字节 的内容,完整复制到物理内存的 0x7C00 处,然后修改 CPU 的指令指针,让它跳转过去执行。

这 512 个字节,就是 boot/bootsect.s 编译出来的代码。 它虽然短小,却肩负着整个 Linux 操作系统走向内核实体的“第一棒”重任。


第一章:目标与主角——bootsect.s 的三大使命

如果你翻开本书的代码注释,你会发现 bootsect.s 的源码只有不到 140 行汇编指令(包含注释)。但它要完成的任务,却堪比一场极其精密的“接力赛”:

  1. 给自己找个安全的地盘:它不能一直待在 0x7C00。因为稍后,庞大的内核文件 system 模块会被加载到内存的低端。它必须赶紧把 0x7C00 这个位置让出来,并把自己搬到高一点的安全地址 0x90000 去。
  2. 把第二棒选手拉进赛场:它需要把磁盘上的 setup.s 程序(占 4 个扇区,约 2KB)读取到内存的 0x90200 处。
  3. 把主力大部队搬运过来:最后,它要把庞大的 system 内核模块(包含内核所有核心逻辑,约 120KB+)从磁盘搬运到内存的 0x10000 处。

完成这三步后,它的使命终结,用一条长跳转指令,将 CPU 的指挥权移交给 setup.s


第二章:实模式的“地址墙”与搬家魔术

在 16 位的实模式下,CPU 的段寄存器(DS、ES、SS、CS)配合偏移寄存器(SI、DI、SP),只能访问 1MB(2^20 = 1048576 字节)的物理内存。

当你从 BIOS 手里接过控制权时,你正处在物理内存的 0x7C00 处。往前看,从 0x00000x7BFF,这 31KB 的空间是空的;往后看,从 0x7C000x7FFF 以及后面的高地址,也是空的。但这里隐藏着一个致命的安全隐患:内核最终要被移到内存的最低位 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 子程序在书中虽然不到百行,但逻辑极其严密。我们来模拟它是如何运作的:

  1. 扇区的“总账目”:我们知道 bootsect 占了第 1 个扇区,setup 占了紧接着的 4 个扇区。所以最开始,我们已经“读过”了 5 个扇区。
  2. 地址边界对齐read_it 一开始会检查 ES 寄存器(存放内存段地址)是否位于 64KB 的边界。因为 8086 的段寄存器只能跨越 64KB 空间,因此内核必须按段分批加载。
  3. 看菜下饭(扇区计算):它会计算当前磁道上还剩多少扇区。如果一次读完会越过 64KB 的边界,那么它就会少读一些,保住内存边界的完整性。
  4. 调用 0x13 苦力干活:对于每一个“合法的读取请求”,它会再次调用 int 0x13 的 0x02 号功能,将数据读入内存。
  5. 磁头与磁道的切换:如果读取完当前磁头下的所有扇区,还有数据没读,它就切换“磁头号”(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 绘制了一个直观的流程图。这张图浓缩了本节讲的所有物理内存变化:

Stage4 [第四阶段:交接]

Stage3 [第三阶段:加载 setup 与 system]

Stage2 [第二阶段:bootsect 自复制]

Stage1 [第一阶段:BIOS 加载]

复制到内存

CPU 跳转

复制自身512字节

远跳转执行

BIOS int 0x13 读磁盘

BIOS int 0x13 读磁盘

获取磁盘参数与根设备

BIOS 读取软盘第1扇区512字节

物理内存 0x7C00

开始执行 bootsec.s

物理内存 0x90000

在 0x90000 继续执行

加载 setup.s 到 0x90200

加载 system 模块到 0x10000

长跳转至 0x90200

开始执行 setup.s!

Stage1

Stage2

Stage3

Stage4

核心演变链:
BIOS -> 0x7C00 -> 0x90000 -> setup(0x90200) -> system(0x10000) -> setup 接管


第八章:亲手实践——写一个“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

🚀 操作与深度解读

  1. 编译:打开终端,进入包含上述两个文件的文件夹,执行 make clean && make
  2. 运行:输入 ./bootsect_sim 并回车。
  3. 解读输出
    • 你会看到程序打印出 ========== 当前内存状态快照 ==========
    • 第一次快照:0x7C00 处有数据,0x90000 处为空。
    • 第二次快照(执行 simulate_bootsect_copy 后):0x7C00 处数据已被逻辑清除,0x90000 处发现了新的“引导代码”。
    • 这个打印过程,精准重现了书中 bootsect.s 第 47 行至第 56 行 rep movsw 的魔法效果。
    • 最后,你会看到控制台打印出“引导成功,交接给 setup”的提示。这标志着一段 512 字节的“胶水代码”,完成了它守护内核启动的最终使命。

第九章:本章总结与光辉的下一跳

如果你能看懂前面所有关于内存地址 0x7C000x900000x10000 的讨论,那么恭喜你,你已经摸清了操作系统启动时最初的、也是最脆弱的那 1MB 空间是怎么运转的。

bootsect.s 虽然仅有 512 字节,却上演了一场极其精彩的**“自救与搬运大戏”**。它利用 BIOS 中断完成了对磁盘的读取,利用段寄存器与 jmpi 指令完成了对内存的布局。它的执行干净利落,绝不留恋,把所有复杂的环境初始化工作(比如开启 A20 地址线、进入 32 位保护模式),全部交给了刚拉进场的 setup.s 程序。

接下来的章节,我们将离开 16 位实模式的下水道,迎接 32 位保护模式的新世界。我们需要手动构建 GDT(全局描述符表),开启 A20 地址线,将 CPU 推向一个崭新的时代。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐