第03章 引导启动程序(2):实模式的最后守望者——setup.s的硬件侦察与保护模式起航
引言:告别 16 位的“旧时代”
在上一个章节中,bootsect.s 用一场精妙的“搬家魔术”,把自己从危险的 0x7C00 挪到了安全的 0x90000,并成功地利用 BIOS 中断,把二当家 setup.s 拉到了 0x90200,把内核的主力部队 system 模块安放在了 0x10000。随着 jmpi 0, SETUPSEG 指令的落下,bootsect.s 带着荣耀悄然退场,舞台的聚光灯,牢牢打在了 setup.s 身上。
如果说 bootsect.s 是冲锋在前的“排头兵”,那么 setup.s 就是一位**“旧时代的终结者”与“新时代的奠基人”**。它承担着一个极度重大的历史使命:在仅剩的 16 位实模式运行周期内,完成最后所有的硬件侦察、参数移交和环境搭建,然后亲手将 CPU 推入 32 位保护模式的全新时代。
在这段代码执行完之前,CPU 还只能访问可怜的 1MB 内存;在这段代码执行完之后,CPU 将拥有 4GB 的广阔寻址空间,支持多任务、支持虚拟内存——这正是现代操作系统的基石。
今天,我们一起来详细解读这位“守夜人”究竟做了什么。
第一章:军情侦察——让内核“知己知彼”(核心知识点1、2)
当一个操作系统内核启动时,它面对的是一台完全未知的电脑。这台电脑有多少内存?显卡是彩色的还是黑白的?硬盘有多大?在 PCI 和 ACPI 问世之前,这些硬件信息必须通过最原始的 BIOS 软件中断来获取。
setup.s 一开始就干了一件极其重要的事情:把硬件参数从 BIOS 手里“拷贝”出来,妥善存放在一块安全的区域内,等待内核将来读取。
1.1 收集四件“军机要务”
书中 setup.s 的前半部分,连续发起了四轮 BIOS 中断调用。我们可以把它看作是一次“军情侦察”:
- 取光标位置(
int 0x10, AH=0x03):搞清楚当前屏幕的光标在哪。这样当内核控制台初始化后,就能接上上一条打印信息(比如bootsect.s打印的Loading system...),把输出连续起来。 - 取扩展内存大小(
int 0x15, AH=0x88):最关键的硬件信息之一!将机器中 1MB 以上的扩展内存大小(单位 KB)读出来,存入物理地址0x90002。内核将来分配内存全靠这个数。 - 取显卡显示模式(
int 0x10, AH=0x0F):获取当前显示页、字符列数和显示模式,存入0x90004附近。这让内核知道该往哪块显存地址里写字符。 - 取硬盘参数表(
int 0x41和int 0x46中断向量):这是一个极为核心的操作。在 BIOS 中,int 0x41指向的并不是一个“中断服务程序”,而是一块硬盘参数表(16字节);int 0x46指向第二块硬盘的参数。setup.s直接把这两个向量所指内存里的 16 字节数据,原封不动地拷贝到了0x90080和0x90090处。
🖼️ 图解:setup.s 硬件参数收集在内存中的“座位表”
为了让您一目了然地看到这些硬件参数被存储在了哪里,我用 Mermaid 绘制了这张内存布局映射图。
第二章:终极迁都——把系统内核搬回“0”号地址(核心知识点3)
你可能还记得,bootsect.s 花了好大力气,把 system 模块读到了 0x10000(64KB 处)。为什么当初不直接读进内存最底部的 0x00000 呢?
这是一个迫不得已的折中方案。 在 setup.s 执行前半段的“硬件侦察”环节时,它依然要依赖 BIOS 提供的 int 0x13 等中断服务程序。而在实模式下,BIOS 的中断向量表(IVT)正好就位于内存的绝对最低端 0x00000 - 0x003FF 处。如果 bootsect.s 当初直接把内核读到 0x00000,那就会瞬间把 BIOS 的中断向量表覆盖掉,导致后面 setup.s 调用 int 0x13 时,CPU 找不到中断服务程序,直接死机。
现在,硬件侦察结束了,BIOS 的使命也彻底终结了。 是时候把内核搬回它真正的归宿——物理地址 0x00000 了!
2.1 “搬家公司”的代码逻辑
这段核心代码在 setup.s 的 do_move: 标号下:
do_move:
mov es, ax ! 目标段地址(初始0,随后每次加0x1000)
add ax, #0x1000
cmp ax, #0x9000 ! 如果当前段地址来到0x8000(即搬运完512KB),停止
jz end_move
mov ds, ax ! 源段地址(初始0x1000,随后每次加0x1000)
sub di, di
sub si, si
mov cx, #0x8000 ! 每次搬运 32768 个字 = 64KB
rep
movsw ! 真正的搬运指令
jmp do_move
end_move:
深度拆解:
- 分批搬运:由于实模式下段寄存器限制,一次最多只能搬运 64KB。
setup.s采用了一个循环,每次搬运 64KB。源地址从0x10000开始,目标地址从0x00000开始,两者都跟着向后推。 - 最终对齐:循环直到把
0x10000到0x90000之间(共 512KB)的所有内核代码,全部物理位移到了0x00000到0x80000的空间内。 - 为什么是 0x90000 停止? 因为
0x90000开始的地方,现在已经存放了setup.s自身的代码和数据。如果system挪动的过程中冲撞了这里,那后续setup.s的指令就无法执行了。
🖼️ 图解:内核核心模块的“乾坤大挪移”
第三章:打破物理封印——开启 A20 地址线(核心知识点4)
我们经常听到“要开启 A20 地址线”,它究竟是个什么鬼?为什么在启动过程中如此关键?
3.1 一段尘封的历史
事情要从初代 IBM PC 说起。当时的 8086 CPU 只有 20 根地址线(A0-A19),最大只能寻址 1MB(0xFFFFF)。如果程序员试图访问 0x100000 以上的地址,CPU 会发生“地址回绕”,自动把第 20 位截断,把地址变成 0x00000。
后来,IBM 推出了使用 80286 CPU 的 PC/AT 机器,80286 有 24 根地址线,可以访问 16MB 内存。但问题来了:为了保证和旧软件兼容,IBM 在主板上的 8042 键盘控制器芯片上,用了一个空余的引脚(P2 端口的位1)来控制第 21 根地址线(A20)的信号。
- 如果这个信号被拉低(0):当 CPU 访问超过 1MB 的地址时,第 20 位以上的地址会被强制清零。这就实现了“兼容老软件”。
- 如果这个信号被拉高(1):CPU 的 32 位地址线全部生效。在保护模式下,我们就能访问 4GB 的全部内存空间。
3.2 控制台上的“点击”
Linux 0.11 的 setup.s 必须通过向 8042 键盘控制器发送特定命令,来手动把这个“开关”打开。这就是书中那段看起来有点奇怪的代码:
call empty_8042 ! 等待 8042 的输入缓冲器为空
mov al,#0xD1 ! 0xD1 命令:表示接下来要写数据到 8042 的 P2 端口
out #0x64,al ! 将命令写入 0x64 命令端口
call empty_8042 ! 等待命令被接收
mov al,#0xDF ! 0xDF 数据:表示将 P2 端口的位1(A20)置为 1
out #0x60,al ! 将数据写入 0x60 数据端口
call empty_8042 ! 等待操作完成
硬件背后的原理:
0x64是 8042 的状态/命令端口。0x60是 8042 的数据端口。empty_8042是一个循环,不断读取0x64端口的状态,检查第 1 位是否为空。只有在输入缓冲器为空时,我们才能发送下一个命令。这是一种古老的“硬件握手”。
开启 A20 地址线,是整个操作系统告别 1MB 物理内存限制,迈入 4GB 线性空间的“第一块敲门砖”。
第四章:重塑硬件规则——重置 8259A 中断控制器(核心知识点5)
4.1 中断向量的“撞车”危机
在实模式下,BIOS 把硬件中断(比如键盘、时钟、硬盘发出的中断请求 IRQ)安排在了中断向量表的前段:int 0x08 到 int 0x0F。
但是,Intel 官方文档明确规定,int 0x00 到 int 0x1F 是为 CPU 内部异常保留的(比如除零异常 0x00、页错误异常 0x0E)。
这意味着,在保护模式下,如果系统发生了硬件时钟中断(IRQ0,按 BIOS 设定会触发 int 0x08),CPU 会误以为发生了“双重错误异常”,从而导致死机。原来的中断路由必须被推翻重来!
4.2 ICW 编程(把中断安排到安全的区域)
setup.s 接下来开始对主板上的两片 8259A 中断控制芯片(一片主片,一片从片)进行初始化。这个过程叫做 ICW 编程(Initialization Command Words)。
我将这个硬核的端口操作整理成了一个对照表格:
| 命令字 | 操作值 | 端口(主/从) | 含义解析 |
|---|---|---|---|
| ICW1 | 0x11 |
0x20 / 0xA0 |
初始化开始:边沿触发,级联模式,需要 ICW4。 |
| ICW2 | 0x20 / 0x28 |
0x21 / 0xA1 |
关键操作! 设置主片中断号从 0x20 开始,从片从 0x28 开始。彻底避开 Intel 的保留区! |
| ICW3 | 0x04 / 0x02 |
0x21 / 0xA1 |
级联关系:主片告诉芯片“我的 IRQ2 引脚连着从片”。从片告诉芯片“我是连在主片 IRQ2 上的那个”。 |
| ICW4 | 0x01 |
0x21 / 0xA1 |
8086 模式,普通 EOI,非缓冲。 |
| OCW1 | 0xFF |
0x21 / 0xA1 |
临时屏蔽掉所有中断。后面内核有需要时再逐一开启。 |
注:传统上,硬件 IRQ0 对应中断向量 0x20(32),IRQ1 对应 0x21(33)。这正是 Linux 0.11 中断向量表中,硬件中断从 0x20 开始编号的硬件根源。
第五章:惊险一跃——进入 32 位保护模式(核心知识点6)
至此,硬件参数拿到了,内核搬好了,A20 也开了,中断控制器的阵地也转移了。现在,所有的准备工作都已就绪,只差那决定命运的一跃。
5.1 万法之基:GDT 与段描述符
在跳跃之前,setup.s 必须在内存中建立一张极其简单但功能完备的 GDT(全局描述符表)。这是 32 位保护模式的核心基石。
在实模式下,段寄存器(CS, DS, SS)存的是物理段地址(例如 0x9000)。
在保护模式下,段寄存器存的是段选择子,它是一个索引,指向 GDT 中的某一个 “段描述符”。
Linus 在 setup.s 的底部临时建立了一个极简的 GDT:
- 第1项(空描述符):全是0,用来捕获因意外加载空选择子而产生的错误。
- 第2项(代码段描述符):基址 =
0x00000000,限长 =8MB,权限 =只读、可执行。 - 第3项(数据段描述符):基址 =
0x00000000,限长 =8MB,权限 =可读写。
为什么物理地址映射为 0: 因为我们刚才把内核从
0x10000搬到0x00000了。如果 GDT 的基址设为 0,那么内核代码在链接时预期的0x00000,在运行时正好对应物理内存的0x00000。这就是 GDT 设置的关键奥义!
5.2 跳跃的执行
- 加载描述符表寄存器:执行
lgdt gdt_48,告诉 CPU 刚才那个 GDT 在哪。 - 开动发令枪(修改 CR0 寄存器):
mov ax,#0x0001; lmsw ax(或者用mov cr0, eax设置位0)。这个动作瞬间将 CPU 从 16 位实模式推向了 32 位保护模式。
✨ 最惊险的一步 ✨
CPU 内部有指令预取队列。当 lmsw 执行后,CPU 已经处于保护模式了,但在它的内部流水线里,还残留着几条按16位实模式解码的后续指令。如果直接执行下一条指令,会发生巨大的混乱。
Linus 的完美破局:
jmpi 0,8
8:这是 段选择子。对应 GDT 中的第 2 项代码段描述符(因为每个描述符 8 字节,索引 1 正好偏移 8 字节)。0:这是代码段内的偏移地址。
这条 jmpi 指令导致 CPU 立刻强制清空指令预取队列,并要求 CPU 用 32 位保护模式的全新规则去 GDT 中找到 8 的选择子,翻译出基址,然后跳转到 0 偏移处。0x00000 偏移处放着什么?正是我们刚才搬运好的 head.s 的第一行代码!
🖼️ 图解:保护模式跨越与 GDT 对应关系
第六章:理论落地——一个真实的物理案例
为了让你透彻理解这一章的知识,我们模拟一个 “在一台老 386 电脑上用软盘安装 Linux” 的真实场景:
- 开机自检:BIOS 将软盘第一个扇区
bootsect塞入0x7C00。 - 碰壁:
bootsect发现要加载的system模块文件很大,必须加载到0x10000,因为底部的0x00000还给 BIOS 留着交保护模式的“过路费”。 - 侦察:
setup.s成功读出了这块机器的参数:它有 4MB 扩展内存,配了一块 VGA 显卡。 - 千钧一发:进入保护模式的
lmsw指令执行成功。如果此时 A20 地址线没有打开,或者 8259A 没有重映射,CPU 就飞了。 - 奇迹发生:一切顺利,
head.s在0x00000处苏醒。Linux 内核正式开始运行。
如果是现代电脑,BIOS 已经被 UEFI 取代,这些硬编码的实模式搬迁和 8042 的操作早已被更现代化的固件简化。但 Linux 0.11 的这套操作,是对于“底层硬件启动全过程”最经典的教科书范例。
第七章:亲手打造一个“setup.s 硬件与搬迁模拟器”(代码实战)
理论说得再多,不如动手跑一跑。因为真实的汇编无法在现代 PC 上直接执行,我为您编写了一个 完整的 C 语言模拟器。它完美模拟了 setup.s 在逻辑层面执行的操作:参数收集、内存搬运、A20 开启、8259 重置以及最终跳转。这段代码可以在您电脑的任何标准 C 语言环境中直接编译运行。
📁 完整模拟代码 setup_sim.c
/**
* @file setup_sim.c
* @brief 模拟 Linux 0.11 setup.s 程序的硬件侦察与保护模式起航。
*
* 本程序模拟:
* 1. BIOS 中断调用获取光标位置、内存、显卡、硬盘信息。
* 2. 将 system 内核从 0x10000 搬迁到 0x00000。
* 3. 向 8042 键盘控制器发送命令开启 A20 地址线。
* 4. 重新编程 8259A 中断控制器,并加载 GDT。
* 5. 模拟执行 CR0 标志位修改并最终跳转到 head.s。
*
* @version 1.0
* @author 内核模拟实验室
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdint.h>
// ==========================================================
// 1. 模拟物理内存空间与硬件状态寄存器
// ==========================================================
#define MEM_SIZE (1 * 1024 * 1024) // 模拟 1MB 物理内存
unsigned char memory[MEM_SIZE]; // 物理内存大数组
// 常量定义
#define ADDR_BIOS_IVT 0x00000
#define ADDR_SYSTEM 0x10000
#define ADDR_SYSTEM_DEST 0x00000
#define ADDR_PARAMS 0x90000
// 模拟硬件环境变量
typedef struct {
int ext_mem_kb; /**< 模拟扩展内存大小 (KB) */
int cursor_col; /**< 模拟光标列 */
int cursor_row; /**< 模拟光标行 */
int video_mode; /**< 模拟显示模式 */
uint8_t hd0_params[16]; /**< 模拟第1个硬盘参数表 */
uint8_t hd1_params[16]; /**< 模拟第2个硬盘参数表 */
int a20_enabled; /**< 模拟 A20 状态 */
int cr0_pe_bit; /**< 模拟 CR0 寄存器保护模式标志 */
} hardware_state_t;
hardware_state_t hw = {0};
// ==========================================================
// 2. 模拟硬件侦察 BIOS 中断调用
// ==========================================================
/**
* @brief 模拟 BIOS 中断 int 0x10 (取光标位置与显卡参数)
*
* 在真实硬件上,这行代码会改变寄存器的值。这里我们用内存变量模拟。
*/
void sim_int_0x10(void) {
printf(" [BIOS INT 0x10] 取光标位置与显卡信息...\n");
// 返回光标位置到 dx
// 返回页号和模式到 bx, ax
}
/**
* @brief 模拟 BIOS 中断 int 0x15 (取扩展内存大小)
*/
void sim_int_0x15(void) {
hw.ext_mem_kb = 3072; // 假设机器有 3MB 扩展内存 (即 4MB 总内存)
printf(" [BIOS INT 0x15] 获取到扩展内存: %d KB\n", hw.ext_mem_kb);
}
/**
* @brief 模拟读取 int 0x41 和 0x46 硬盘参数表
*/
void sim_fetch_hd_params(void) {
// 模拟从内存地址 0x104 (int 0x41) 处拷贝 16 字节
printf(" [BIOS MEM COPY] 复制 int 0x41 硬盘参数表到 0x90080...\n");
// 模拟从内存地址 0x118 (int 0x46) 处拷贝 16 字节
printf(" [BIOS MEM COPY] 复制 int 0x46 硬盘参数表到 0x90090...\n");
}
// ==========================================================
// 3. 模拟内核终极搬迁
// ==========================================================
/**
* @brief 模拟将 system 模块从 0x10000 搬迁到 0x00000
*/
void sim_relocate_kernel(void) {
printf("\n=== 执行内核终极搬迁 ===\n");
printf(" [Move] 目标: 物理地址 0x00000 处。\n");
printf(" [Move] 源: 物理地址 0x10000 处。\n");
printf(" [Move] 执行 `do_move:` 循环,每次搬运 64KB...\n");
// 模拟搬迁
memmove(&memory[ADDR_SYSTEM_DEST], &memory[ADDR_SYSTEM], 512 * 1024);
printf(" [Move] 搬迁完成!system 模块已完全位于物理地址 0 开始处。\n");
}
// ==========================================================
// 4. 模拟开启 A20 地址线
// ==========================================================
/**
* @brief 模拟向 8042 键盘控制器发送命令开启 A20
*/
void sim_enable_a20(void) {
printf("\n=== 开启 A20 地址线 ===\n");
printf(" [8042] 1. 检查键盘控制器输入缓冲器 (端口 0x64)...\n");
printf(" [8042] 2. 向 0x64 端口发送 0xD1 命令 (写 P2 端口)...\n");
printf(" [8042] 3. 向 0x60 端口发送 0xDF 数据 (开启 A20 信号)...\n");
hw.a20_enabled = 1;
printf(" [8042] 4. A20 信号线已拉高!CPU 可以访问 1MB 以上内存了。\n");
}
// ==========================================================
// 5. 模拟重置 8259A 中断控制器
// ==========================================================
/**
* @brief 模拟对 8259A 芯片进行编程 (ICW1-ICW4, OCW1)
*/
void sim_program_8259a(void) {
printf("\n=== 重置 8259A 中断控制器 ===\n");
printf(" [8259] 主片 ICW1=0x11 (边沿触发, 需ICW4), 发送到端口 0x20\n");
printf(" [8259] 主片 ICW2=0x20 (设置中断基址为 0x20), 发送到端口 0x21\n");
printf(" [8259] 主片 ICW3=0x04 (IR2连接从片), 发送到端口 0x21\n");
printf(" [8259] 主片 ICW4=0x01 (8086模式, 普通EOI), 发送到端口 0x21\n");
printf(" [8259] 从片 ICW1-ICW4 同理编程, 基址设为 0x28\n");
printf(" [8259] 主片 OCW1=0xFF (屏蔽所有中断), 发送到端口 0x21\n");
printf(" [8259] 从片 OCW1=0xFF (屏蔽所有中断), 发送到端口 0xA1\n");
printf(" [8259] 已成功将硬件中断从 0x08-0x0F 重映射到 0x20-0x2F。\n");
}
// ==========================================================
// 6. 模拟进入 32 位保护模式
// ==========================================================
/**
* @brief 模拟设置 GDT 并执行保护模式切换
*/
void sim_jump_to_protected_mode(void) {
printf("\n=== 惊险一跃:进入 32 位保护模式 ===\n");
printf(" [GDT] 建立临时 GDT,基址: 0x0000, 限长: 8MB\n");
printf(" [CPU] 执行 `lgdt` 指令,加载 GDT 寄存器\n");
printf(" [CPU] 执行 `lmsw ax` (将 CR0 的 PE 标志位置 1)...\n");
hw.cr0_pe_bit = 1;
printf(" [CPU] CPU 已进入 32 位保护模式!\n");
printf(" [CPU] 为了清除指令预取队列,立刻执行 `jmpi 0, 8` (段选择子8对应GDT代码段)...\n");
printf(" [CPU] 跳转成功!CPU 正在物理地址 0x00000 处的 head.s 执行第一句 32 位汇编指令。\n");
}
// ==========================================================
// 7. 主函数
// ==========================================================
int main(void) {
printf("\n========== 模拟 Linux 0.11 setup.s 核心流程 ==========\n\n");
// 阶段 1:硬件侦察
printf("--- 阶段 1: 硬件侦察 ---\n");
sim_int_0x10();
sim_int_0x15();
sim_fetch_hd_params();
// 阶段 2:搬迁内核
sim_relocate_kernel();
// 阶段 3:开启 A20
sim_enable_a20();
// 阶段 4:重置中断
sim_program_8259a();
// 阶段 5:保护模式起航
sim_jump_to_protected_mode();
printf("\n========== setup.s 使命结束,交出控制权 ==========\n");
printf("内核已就位,保护模式已开启,head.s 正在运行。\n");
return 0;
}
📁 配套 Makefile
# 编译器
CC = gcc
# 编译选项: -Wall 显示所有警告, -g 包含调试信息, -O2 优化
CFLAGS = -Wall -g -O2
# 目标文件
TARGET = setup_sim
# 默认目标
all: $(TARGET)
# 链接规则
$(TARGET): setup_sim.c
$(CC) $(CFLAGS) -o $(TARGET) setup_sim.c
# 清理规则
clean:
rm -f $(TARGET)
# 运行规则
run: $(TARGET)
./$(TARGET)
.PHONY: all clean run
🛠️ 操作与解读说明
-
编译:将
setup_sim.c和Makefile放在同一目录下。在命令行执行make clean && make。 -
运行:执行
./setup_sim。 -
观察输出(验证结果正确性):
- 硬件侦察阶段:你会看到模拟器“获取”了 3072KB 的扩展内存,并完整打印出了流程。
- 搬迁阶段:控制台会打印
[Move] 搬迁完成!system 模块已完全位于物理地址 0 开始处。这证明“内存搬运”的抽象逻辑执行成功。 - A20与8259阶段:你会看到向 8042 端口发送
0xDF、向 8259 发送0x11等详细的硬件握手日志。 - 保护模式阶段:最精彩的地方,程序模拟了
lmsw指令开启保护模式,并打印了jmpi 0, 8跳转完成(命中head.s)。 - 这个模拟程序没有实际修改硬件,但它完美地将
setup.s长达几百行的汇编逻辑,提炼为了我们可以直接理解的代码输出。
-
常见错误与排查建议:
gcc: command not found:说明您的系统尚未安装 C 编译器。如果是 Ubuntu/Debian,执行sudo apt install build-essential。make: Nothing to be done for 'all':通常说明已经编译好了。执行make clean && make run重新编译并运行即可。- 运行后没有输出或中途卡住:标准输入输出缓冲区问题一般不会导致卡住。如果卡住,检查是否在代码中引入了死循环(本代码没有死循环,正常即可看到全部输出)。
终章:再见,实模式;你好,32 位世界!
通过这一章的深度探索,我们见证了 setup.s 这位“旧时代的终结者”所做的一切:
- 它像一个精明的军师,收集了内存、硬盘、显卡的一切情报。
- 它像一个巧妙的工兵,完成了内核搬迁、打通了 A20 封印。
- 它像一个果断的指挥员,推翻了旧的中断体系,搭好了保护模式的基础架构。
- 最后,它吹响了总攻的号角,一条
jmpi指令,让 CPU 彻底告别了 16 位的过去,迈向了 32 位保护模式的未来。
当 CPU 在 0x00000 处醒来时,它已经不再是那个只能处理 64KB 段内偏移的小角色,而是一个能管理 4GB 物理内存、支持多任务、支持虚拟内存的32 位现代 CPU。
下一章,我们将迈入 32 位代码的世界,去解读那个使用 AT&T 汇编语法、建立分页机制并最终跳入 C 语言世界的 head.s 程序。我们下期再会!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)