引言:接过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 字节的代码,要完成四个匪夷所思的任务:

  1. “乾坤大挪移”:把自己从 0x7C00 搬到 0x90000
  2. 请来“二当家”:从磁盘读取下一个文件 setup.s(仅4个扇区,2KB),放在自己新家的后面。
  3. 扛起“大当家”:把整个内核代码 system(上百 KB)读取到内存安全的位置。
  4. 交接棒:把 CPU 指挥棒交给 setup.s,功成身退。

这就像是你作为一个排头兵,BIOS 把你空投到了敌人的阵地(0x7C00),然后你第一个任务不是打仗,而是立刻把自己转移到一个安全的大本营,并且接连帮后续的大部队(setupsystem)搭建好跳板,最后安然退场。

下面,我们将按照 bootsect.s 的实际执行流程,一层层揭开它“魔术”般的面纱。

第二章:惊险的“搬家”——从 0x7C00 到 0x90000

2.1 为什么要“搬家”?

当我们还在讨论寻址时,代码其实正处于一个极其危险的境地。内存的最底端 0x000000x50000 甚至更大的区域,在未来将会被 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*2=512 字节)
    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 指向的内存里,然后自动把 SIDI 加上 2,同时把循环计数器 CX 减去 1。
  • 为什么是 256? bootsect.s 整个代码正好是 512 字节。每次传一个字(2字节),所以循环 256 次就能搬完。
  • 最后的 jmpi go,INITSEG:这段代码极其关键。因为前一条 movw 虽然把数据搬到了 0x90000,但CPU 的代码段寄存器(CS)和指令指针(EIP)仍然指向 0x7C00。如果不执行这条跨段跳转,CS 值没变,后续的代码依然会去 0x7C00 处寻找并执行(而那早已是空白)。通过这条指令,CPU 物理上把执行环境彻底切换到了 0x90000

为了让你更直观地理解,我绘制了 bootsect.s 执行初期,CPU 内存视角的变化图:

阶段3_世界切换 [第三阶段:长跳转接管]

执行 jmpi go, INITSEG

CPU 将 CS 改为 0x9000

CPU 从物理地址 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(足够大)

为什么要这么急切地设置 SSSP 堆栈?
因为紧接着下面就有操作了!bootsect.s 接下来的任务是要从软盘中读取数据。为了实现这个任务,它调用了 BIOS 的底层磁盘中断 int 0x13。在调用期间,CPU 需要在堆栈里临时保存寄存器状态。如果这个时候堆栈都没有初始化,pushpop 指令将会把数据写到随机内存地址,导致系统死机。

准备好堆栈后,二当家的召唤令开始了:

load_setup:
    mov dx,#0x0000          ! 驱动器 0 (A 驱), 磁头 0
    mov cx,#0x0002          ! 扇区 2, 磁道 0
    mov bx,#0x0200          ! 地址偏移 = 512 (即 0x90200)
    mov ax,#0x0200+SETUPLEN ! AH=0x02 (读磁盘), AL=4 (读 4 个扇区)
    int 0x13                ! 调用 BIOS 中断,干活!
    jnc ok_load_setup       ! 如果成功(CF标志位为0),跳走
    ...                     ! 如果失败,复位软驱并重试...
ok_load_setup:

深度解析:

  1. 中断 int 0x13:这是实模式下操作磁盘的唯一途径。AX=0x0200+SETUPLEN 表示我们要执行“读扇区”功能(AH=0x02),并且要连续读 AL = 4 个扇区。
  2. 读到哪里? 根据前面设定的 ES:BX = 0x9000:0x0200,物理地址就是 0x90200
    • 请记住这个地址。我们刚刚搬到了 0x90000,占据 0-512 字节(0x90000~0x901FF)。现在 setup.s 被加载到 0x90200,紧挨着 bootsect 的新家。
  3. 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        ! AH=0x08 (取驱动器参数)
    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      ! AH=0x13 (显示字符串), AL=0x01 (光标跟随)
    int 0x10

调用 int 0x10 后,PC 的屏幕上立刻出现了 Loading system... 这行字。这是人类与操作系统内核第一次有了“交互”。

2. 搬动“大当家”:read_it 子程序
真正的考验来了。system 模块包含了 Linux 0.11 所有核心代码(编译后大约 120KB ~ 200KB 不等)。如果按一次读一个扇区(512字节)去读,要读几百次,极其缓慢。所以 Linus 写了一个复杂的 read_it 子程序,它尽可能按“磁道”为单位整条读取

由于这个子程序极长且极其复杂,我们在这里不展开全部汇编代码,而是将其核心逻辑提炼成一个三段式流程:

  1. 检查 64KB 边界:实模式下,段内偏移最高只能到 64KB(0xFFFF)。如果当前读取的扇区位置加上数据长度会跨越 64KB 边界,这段代码必须分段处理,否则会出现内存回绕,覆盖掉之前读好的数据。
  2. 读取磁道read_track 内部再次调用 int 0x13,利用之前获取的每道扇区数,尽最大可能把当前磁道的剩余扇区一次性全读入内存。
  3. 循环与接力:读完一个磁道后,自动切换当前磁头(从0切到1)或磁道。如果当前 64KB 段内存满了,自动将 ES 段寄存器增加 0x1000(指向下一个 64KB 内存位置),继续读取。

经过这个极其严密又冗余的循环,Linux 内核的骨架被成功放置到了物理内存 0x10000(64KB)地址开始的地方

第六章:移交指挥棒——确定根设备与跳转

system 模块全部加载完毕后,还剩下最后一点小尾巴:

  • 确定根文件系统在哪里root_dev 变量。如果编译内核时指定了 ROOT_DEV(比如 0x306 对应第2个硬盘的第1个分区),就直接使用。如果没有指定,bootsect.s 会使用刚刚读取到的每磁道扇区数来判断:如果每道 15 扇区,那是 1.2MB 软驱;如果每道 18 扇区,那是 1.44MB 软驱。据此推断出根设备号。
  • 最后的告别
    jmpi 0,SETUPSEG       ! SETUPSEG 是 0x9020

CPU 再次执行跨段跳转。CS 变为 0x9020,IP 变为 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
 */

#include <stdio.h>
#include <string.h>
#include <unistd.h>

// ==========================================================
// 1. 模拟物理内存空间
// ==========================================================
#define MEM_SIZE (1 * 1024 * 1024) // 模拟 1MB 物理内存
unsigned char memory[MEM_SIZE];   // 物理内存大数组

// 定义关键物理地址常量 (与 0.11 内核完全一致)
#define ADDR_BIOS_7C00  0x7C00
#define ADDR_INIT_SEG   0x90000
#define ADDR_SETUP_SEG  0x90200
#define ADDR_SYSTEM     0x10000

// ==========================================================
// 2. 模拟 BIOS 引导加载
// ==========================================================

/**
 * @brief 模拟 BIOS 启动引导阶段
 * 
 * BIOS 从启动盘(软盘/硬盘)的第1扇区读取 512 字节,放到 0x7C00。
 */
void simulate_bios_boot(void) {
    printf("[BIOS] 电源开启,执行 POST 自检...\n");
    printf("[BIOS] 找到引导设备,读取第1个扇区 (bootsect.s)...\n");
    printf("[BIOS] 将 512 字节 boot sector 加载到物理内存 0x7C00...\n");
    
    // 为了模拟,我们在 0x7C00 处写入一串识别码
    const char *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。
 */
void simulate_self_copy(void) {
    printf("=== bootset.s 开始执行 ===\n");
    printf("[bootset] 1. 检测到自己在 0x7C00,这里不安全!必须搬家。\n");
    printf("[bootset] 2. 将 DS:SI 指向 0x7C00,ES: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。
 */
void simulate_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 数据写入内存
    const char *setup_data = "[这里是 setup.s 的代码和数据]";
    memcpy(&memory[ADDR_SETUP_SEG], setup_data, strlen(setup_data) + 1);
    
    printf("[bootset] 读取成功!setup.s 已就位。\n\n");
}

/**
 * @brief 模拟获取驱动器参数与打印信息
 */
void simulate_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 子程序)
 */
void simulate_load_system(void) {
    printf("=== bootset.s 加载 system 内核模块 ===\n");
    printf("[bootset] 调用 read_it 子程序,开始从磁盘加载 system 模块...\n");
    printf("[bootset] 为了加速,尽可能整条磁道读取。\n");
    printf("[bootset] 读取完成!system 模块被加载到了内存 0x10000 处。\n\n");
}

/**
 * @brief 模拟确定根设备号并移交控制权
 */
void simulate_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. 主程序
// ==========================================================

int main(void) {
    printf("\n========== 模拟 Linux 0.11 bootset.s 启动过程 ==========\n\n");

    // 1. BIOS 阶段
    simulate_bios_boot();

    // 2. bootset 自复制
    simulate_self_copy();

    // 3. 加载 setup
    simulate_load_setup();

    // 4. 获取参数 & 打印 Loading
    simulate_disk_params_and_msg();

    // 5. 加载 system
    simulate_load_system();

    // 6. 确定根设备并移交控制权
    simulate_jump_to_setup();

    // 7. 模拟进入 setup.s
    printf("========== 进入下一阶段:setup.s 开始执行 ==========\n");
    printf("验证:0x90000 处现在保存的内容是: '%s'\n", &memory[ADDR_INIT_SEG]);
    printf("验证:0x90200 处现在保存的内容是: '%s'\n", &memory[ADDR_SETUP_SEG]);

    printf("\n========== 模拟结束 ==========\n");
    return 0;
}

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 run

7.3 操作与解读说明

  1. 编译:将 boot_sim.cMakefile 放在同一个目录下。在终端中执行 make clean && make
  2. 运行:执行 ./boot_sim
  3. 观察结果
    • [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
    • 最后,程序会打印出最终在 0x900000x90200 内存地址处存放的内容,证明引导程序不仅搬了自己,还把后续的代码也安放好了。

运行这个模拟器,你会真切地感受到,一个操作系统不是“凭空被解压”的,而是由极其聪慧的引导程序,像搭积木一样,一块块从底层拼接到内存里的。

终章:承上启下的历史巨轮

回顾本节,我们看到了 bootsect.s 在极其有限的 512 字节内,完成了“自移动”、“读取 setup”、“读取 system”这三件看似不可能的任务。

它没有用到任何高深的算法,完全依靠对底层硬件(BIOS 中断)、内存寻址(实模式段偏移)和磁盘结构(磁道、磁头、扇区)的深刻理解。这是一种极致的“手工艺”。无论后来的 UEFI 引导、GRUB2 现代引导程序多么高级,它们底层的逻辑(“把数据从存储设备搬运到内存,然后把执行权移交”)都脱胎于这段诞生于 1991 年的区区 512 字节代码。

Logo

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

更多推荐