bootsect.s ------ 搬运工(实模式,512字节)
主要功能:把内核从磁盘搬进内存。它是 BIOS 加载的第一个扇区,只能靠自己完成一切加载工作。
| 动作 | 目的 |
|---|---|
| 自我搬移 0x7C00 → 0x90000 | 腾出不被遮挡的位置 |
| 设栈到 0x9FF00 | 有了可用的函数调用栈 |
int 13h 读 setup(2KB)→ 0x90200 |
紧跟在自己后面 |
int 13h 逐磁道读内核 → 0x10000 |
小心绕开 64KB DMA 边界 |
| 关软盘电机、定根设备号 | 留下干净、确定的硬件状态 |
对状态的影响:内存里有了 setup 和完整内核,屏幕打出 "Loading system ..."。但 CPU 仍在实模式、BIOS 仍可用、地址仍受限 1MB------它只是个搬运工,不改变任何"运行规则"。
setup.s ------ 转型师(实模式 → 保护模式)
主要功能:最后一次用 BIOS 抢救硬件参数,然后把系统从 16 位实模式推入 32 位保护模式。
| 动作 | 对状态的影响 |
|---|---|
| 探测并保存光标/内存/显卡/硬盘参数 → 0x90000 | BIOS 即将失效,参数"留档"供内核随时取用 |
| 内核整体下移 0x10000 → 0x00000 | 中断向量表不再需要,物理 0 处就位(head.s 要求在这) |
| 加载临时 GDT(代码 0x08 / 数据 0x10)、空 IDT | 有了合法的段翻译规则;任何中断=崩溃(靠屏蔽保命) |
| 开 A20 地址线 | 突破 1MB 回绕,4GB 空间真正可达 |
| 重编程 8259A:硬件中断 0x080x0F → 0x200x2F | 消除与 CPU 异常号(除零0、缺页14...)的冲突 |
lmsw 置 PE 位 + jmpi 0,8 |
质变:进入 32 位保护模式 |
对状态的影响 :这是一次"换世界"的跳跃------段寄存器含义从裸数字变成选择子、BIOS 从此永久不可用、中断全部关闭(cli + 屏蔽字 0xFF)。但分页未开、IDT 是空壳,系统只是"换了个骨架",还不能接待中断。
head.s ------ 开国元勋(保护模式,物理 0 处)
主要功能 :在保护模式里安家建章------建立正式的 IDT/GDT、开启分页,然后把权力交给第一个 C 函数 main()。
| 动作 | 对状态的影响 |
|---|---|
| 重载段寄存器(0x10)、设内核栈 | 从"临时票据"换到内核自己的正式段描述符 |
setup_idt:256 项全指 ignore_int |
任何中断不再崩溃,只是打印 "Unknown interrupt"(占位,之后 trap_init 换真货) |
setup_gdt:正式 GDT(16MB 代码/数据段) |
段限长从 8MB 扩到 16MB,表也搬进内核自己的内存 |
| 检查 A20、数学协处理器(287/387) | 硬件环境确认:浮点交给 FPU 或软件模拟 |
setup_paging:页目录+4 页表,恒等映射 16MB,置 CR0.PG |
质变:分页开启,虚拟地址机制就位(当前线性=物理) |
压入假参数(0,0,0)+假返回地址,jmp setup_paging + ret |
借 ret 弹出 main 地址------CPU 进入 main(),汇编时代结束 |
对状态的影响:中断/异常有了占位处理、分页机制上线、栈就位------运行规则全部建立。副作用是一个精妙的自毁设计:head.s 开头的代码执行完后被页目录覆盖,因为页目录恰好也必须在物理 0。
系统状态演进总表
| 维度 | BIOS 交权后 | bootsect 后 | setup 后 | head 后 |
|---|---|---|---|---|
| CPU 模式 | 实模式 | 实模式 | 32位保护模式 | 保护模式 + 分页 |
| 地址翻译 | 段×16+偏移 | 同左 | 选择子查GDT | 再经页表(恒等映射) |
| 可寻址空间 | 1MB | 1MB | 8MB(段限长) | 16MB |
| 中断 | BIOS向量表可用 | 同左 | 全屏蔽,IDT为空 | IDT就绪(全指ignore_int),仍关 |
| BIOS 可用? | ✓ | ✓ | ✗ 永久失效 | ✗ |
| 内核位置 | 不在内存 | 0x10000 | 0x00000 | 0x00000(底部被页表占据) |
| 下一步 | --- | 交给 setup | 交给 head.s | 交给 main() → C 世界 |
一句话概括:bootsect 负责"把家当搬进来",setup 负责"换国籍(换模式)",head 负责"立宪(立规则)"------三者接力后,操作系统才有资格开始运行 main()。
一、为什么必须有一个"第一扇区"(硬约束①)
BIOS 的加载协议写死了两件事:
- **只加载启动设备的第一个扇区(512 字节)**到 0x7C00;
- 检查偏移 510 处的
0xAA55魔数,不对就不启动。
这个约束属于 IBM PC 的硬件规范,任何 OS 都绕不开。所以第一个程序被硬性封顶在 512 字节。看看现实尺寸:
bootsect.s 汇编后 = 510 字节 + 0xAA55 ← 已经顶满一个扇区
setup.s 汇编后 ≈ 2KB(4个扇区)
─────────────────────────────────────
合计 ≈ 2.5KB,是 512 字节限制的 5 倍 ------ 装不下,没有商量余地
二、鸡生蛋问题(硬约束②)
有人会想:那我写一个"大程序",让它前 512 字节先把剩余部分从磁盘读进来,不就合并了?
这正是关键:任何超出第一扇区的代码,都必须先由第一扇区里的代码加载才能执行。所以无论怎么组织,运行时结构必然是两级:
第一级(≤512B,自包含):设栈 → 读盘 → 把"下一级"弄进内存 → 跳过去
第二级(任意大): 做真正的复杂工作
把源码写成一个文件,只是把两级"写在同一页纸上"------运行时它们仍是先后加载、先后执行的两段程序。这就是形式合并:源码合了,架构没合。
三、BIOS 依赖的单向门(硬约束③,也是 setup≠head 的原因)
两阶段代码的工作性质有一条不可逾越的分界线:
| 加载阶段 | 转换阶段 | |
|---|---|---|
| 需要 BIOS(int 13h 读盘、int 15h 查内存、int 10h 查显卡) | ✓ 必须用 | ✗ 保护模式下 BIOS 永久失效 |
| CPU 模式 | 实模式 | 必须切换到保护模式 |
所以分工天然是:趁 BIOS 还活着把所有它知道的事做完(查参数、读内核),然后过河拆桥、烧桥进入新世界。setup.s 就是"桥上"的那段代码------查完参数、开 A20、重设 8259A、拉下模式切换的闸门。
有人会问:能不能把 setup 的工作并进 head.s?不能。head.s 是 32 位保护模式代码,执行它之前必须先完成模式切换,而模式切换前又必须先做完 BIOS 查询------这个先后顺序是硬件决定的单向门,切不开。
四、分开的三个正收益(即使能合并也不该合)
1. 生命周期错开,内存回收干净
0x90000: bootsect 的家 → bootsect 一跳走就作废 → setup 立刻征用为参数保存区
0x90200: setup 的家 → head.s 重建 GDT 后作废 → 之后的初始化复用
每级代码"用完即弃",它的地盘马上被下一级回收。若合成一体,这段自举代码反而要在内存里多占地方更久。
2. 职责单一,错误域隔离
bootsect 是磁盘 I/O 专家:读出错就复位重试、死循环(注释原话"手工重启");setup 则假定读盘已成功,专心做模式转换。混在一起后,512 字节里要同时处理两种错误模型,可读性和可靠性都变差。
3. 可替换性------这条界线被现代 Linux 保留了 25 年
最有力的证据:现代 Linux 把 bootsect.s 整个删掉了 (由 GRUB 等引导器接管),而 setup 的职责演化为 arch/x86/boot/ 的 setup 阶段,一直活着。之所以能这样替换,正是因为当年定下了清晰的两级契约(引导器 → 2KB 实模式 setup → 保护模式入口)。如果当年混成一体,今天就没法单独换掉一半。
五、三种假想合并方案盘点
| 方案 | 可行性 | 结论 |
|---|---|---|
| A. 全并入一个 512 字节扇区 | ❌ | 2.5KB 装不进 512B,物理不可能 |
| B. 一个源文件,前 512B 做自加载 | ⭕ 形式可行 | 运行时仍是两阶段,只是书面上合并;自加载代码反而多占宝贵空间,纯负收益 |
| C. setup 并入 head.s | ❌ | 模式单向门:BIOS 查询/A20/8259A 必须在实模式做完,32 位代码做不了 |
| D. 维持现状 | ✅ | 现代内核演化同样保留了这条界线 |
六、类比收尾
这套结构就是多级火箭:
bootsect = 一级助推器 ------ 只负责"离架"(把后续部件送上天),燃料烧完即抛
setup = 二级助推器 ------ 负责"穿出大气层"(跨过模式边界),任务完成即抛
head.s = 入轨 + 点火主引擎 ------ 此后 main() 接管一切
一、int N 这条指令本身只做三件事
首先要破除一个误解:int 13h 指令本身不会"读盘",它只是一次受控跳转。CPU 执行它时:
1. FLAGS、CS、EIP 依次压栈(保存现场)
2. 到中断向量表 IVT(物理 0x000~0x3FF)里取第 N 项:
地址 = N × 4,低字是 IP、高字是 CS
3. 清 IF/TF,跳到 CS:IP ------ 剩下的全是"人"(处理程序)的代码,iret 返回
所以"int 13h 怎么实现读盘"的完整答案是:IVT0x13 里存着 BIOS 写好的一个函数的地址,那个函数里是一段真正的磁盘驱动程序 。实测中 0x4C 处就是 0xF000:0xE3FE------F 段是 BIOS ROM 映射区,那里躺着几百行 SeaBIOS 的汇编/C 代码。
而 IVT 这张表是谁填的?BIOS 自己在 POST(开机自检)时填的 。实测 0xFFFF0(CPU 上电执行的第一条指令)就是一个 ljmp 0xF000:0xE05B------跳进 POST,POST 检测硬件、填写 BDA(0x400~0x4FF 的 BIOS 数据区)和 IVT,最后 int 19h 加载引导扇区。你的内核是从这条链的末端接手的。
二、int 13h(读盘):一段真正的磁盘驱动程序
这是三个里最复杂的。AH=2 读扇区时,BIOS 内部要做的事:
1. CHS 换算:把调用者给的 磁道/磁头/扇区 整理成控制器要求的参数
(bootsect.s 读内核时绕 64KB 边界的计算,就是配合这一层的约束)
2. 软盘路径:
- 编程 DMA 控制器 8237A 通道2(设内存地址、传输字节数)
- 打开软盘电机(端口 0x3F2------head.s 里 kill_motor 关的就是它)
- 向软盘控制器 FDC(uPD765) 端口 0x3F4/0x3F5 发命令字节序列
- 睡眠等待 FDC 完成中断(IRQ6)
- 读回 7 个结果状态字节,判错,AH=错误码
3. 硬盘路径:直接对 IDE 端口 0x1F0~0x1F7 依次写
扇区数/扇区号/柱面/驱动器头号,最后写命令寄存器 0x1F7 = 0x20(READ)
本质 :BIOS 中断就是"厂商替你预写好的设备驱动"。这也解释了为什么 Linux 0.11 一进内核就抛弃 BIOS------因为内核要写自己的驱动:hd.c 直接操作 0x1F0 端口发 ATA 命令(和上面硬盘路径一模一样的事)、floppy.c 自己编程 FDC。BIOS 能用,但太慢太不透明,真内核都自己来。
三、int 15h/88(查内存):最"水"的一个------查的是考试答案
AH=88 返回扩展内存大小(KB)。它的"实现"朴素到令人发笑:
POST 阶段(开机时,一次性干完):
- 通过 CMOS(端口 0x70 写索引、0x71 读写数据)读出固件记录的内存配置
- 或对内存做写-读回测试逐段数出来的实际容量
- 结论存进 BIOS 内部变量
int 15h/88 处理程序:
- 把那个存好的数字放进 AX,iret 返回。完。
所以 Linux 0.11 存到 0x90002 的"15232",不是调用时现测的,而是POST 几秒钟前数好的结论。这也正是它的局限(只到 64MB、数字可能不准),现代内核改用 int 15h 的 0xE820 子功能(返回真实内存映射表)------但那已经是 90 年代中期的事了。
四、int 10h(显卡):半是"数据搬运工",半是真驱动
实测暴露了它的秘密------处理程序第一条实质指令就是把 DS 指向 0x0040(BDA)。因为许多子功能的"实现"就是查表:
| 子功能 | 实际干了什么 |
|---|---|
| AH=03 读光标 | 从 BDA 0x450+页号×2 读出(行列),实测当前值 0x0800 = 第8行第0列 |
| AH=0F 读模式 | 从 BDA 0x449 读模式(实测 0x03=80×25彩色文本)、0x44A 读列数(实测 0x50=80) |
| AH=13h 写字符串 | 把"字符+属性"两个字节一组写进 0xB8000 文本缓冲区,再更新 BDA 光标 |
| AH=12h EGA检测 | 这才真碰硬件:操作显卡寄存器端口 0x3B4~0x3DF |
0xB8000 的原理值得单独说:文本模式下,屏幕上每个字符对应显存里 2 个字节------字符 ASCII 码 + 属性字节 (高4位背景色、低4位前景色)。实测开头 4 个字 0753 0765 0761 0742:属性 0x07(黑底灰字)+ 'S''e''a''B',正是 SeaBIOS 刚打印的开机横幅。BIOS"打印"和 Linux 0.11 的 console.c 打印,干的是完全相同的事:往 0xB8000 搬字节。你在 QEMU 窗口里看到的一切文字,物理上都是那块内存。
五、三张"幕后基础设施"总结
| 数据结构 | 位置 | 谁写的 | 作用 |
|---|---|---|---|
| IVT 中断向量表 | 0x000~0x3FF | POST 填 | int N → 处理程序地址的目录 |
| BDA BIOS数据区 | 0x400~0x4FF | POST/各handler维护 | 光标、模式、列数、键盘缓冲、时间滴答... |
| BIOS ROM/选项ROM | 0xC0000(显卡)、0xE0000~0xFFFFF | 厂商烧录 | 所有 int 处理程序的本体 |
六、在你的调试环境里复现这次实验
gdb 连接后(停在 [1/4] bootsect 时,IVT 还是完整的):
(gdb) x/2wx 0x4c ← 看 int 13h 的处理程序在哪(IP, CS)
(gdb) x/2wx 0x40 ← int 10h 的(你会看到 CS=0xC000,显卡ROM)
(gdb) set $ip=*(unsigned short*)0x4c
(gdb) set $cs=*(unsigned short*)0x4e
(gdb) x/10i $cs*16+$ip ← 反汇编 BIOS 的磁盘驱动!
(gdb) x/1xh 0x449 ← 显卡模式
(gdb) x/1xw 0x450 ← 光标位置(走完引导后再看,会被 bootsect 的 int 10h 改掉)
(gdb) x/40xh 0xb8000 ← 显存里的字符,看 BIOS 给你留了什么话
一个阅读提示:x/i 反汇编 0xF000/0xC000 区的 16 位代码时同样会出现"32位误读"的合并指令怪相(比如实测 jmp 0x10d41a 其实是段内近跳转),以字节视图 x/10bx 对照为准。
一句话总结 :int N 只是"按目录打电话",目录(IVT)是 POST 填的,接线员(处理程序)住在 BIOS ROM 里,他们干活的方式不神秘------读 BDA 里的小本本、往 0x70/0x71/0x1F0/0x3F5 这些硬件端口写字、往 0xB8000 搬字符。Linux 0.11 读完参数就和他们分道扬镳,自己把这些端口活全接管了。