前言
前面如果只从概念上理解计算机启动,大致会记住这样一条链:
Power On
↓
Firmware
↓
Bootloader
↓
Kernel
↓
Operating System
但只记住这几个名词,还会留下很多问题:
CPU 刚通电时到底执行哪里的代码?
BIOS 怎么知道要从哪个磁盘启动?
磁盘上的第一段代码为什么会跑起来?
为什么老资料总出现 0x7C00?
org 0x7C00 是不是把程序加载到 0x7C00?
为什么启动扇区正好是 512 Byte?
0xAA55 又是什么?
没有操作系统的时候,汇编程序怎么把字符显示到屏幕上?
512 Byte 的 Boot Sector 又怎么继续把真正的 Kernel 拉起来?
这篇就不再停留在“BIOS 会加载 Bootloader”这一句话,而是亲手写一个最小的 Legacy BIOS Boot Sector,让虚拟机从我们自己的 512 Byte 程序开始执行。
整个实验最终要验证的是:
不过开始之前必须先澄清一件事:
这一套
0x7C00 + 512 Byte Boot Sector + BIOS INT 10H是 Legacy BIOS 启动模型,不是现代 UEFI 启动流程的通用规则。
一、先搞清启动模型:Legacy BIOS 与 UEFI
今天的 PC 大量使用 UEFI。
但学习操作系统启动时,Legacy BIOS 仍然非常适合作为第一套模型,因为它的启动链足够直接:
BIOS
↓
读取一个扇区
↓
把它放进 RAM
↓
直接跳过去执行
这让我们能非常清楚地看到:
磁盘上的数据,是怎么真正变成 CPU 正在执行的 Machine Code 的。
1. Legacy BIOS 的经典路径
简化以后:
2. UEFI 的路径不同
UEFI 不要求按照:
第一个 512 Byte 扇区
→ 0x7C00
→ Real Mode BIOS Interrupt
这套方式启动。
典型 UEFI 会从 EFI System Partition 中找到 EFI Executable,然后由 UEFI Firmware 加载执行。
因此后面所有:
0x7C00
INT 10H
Boot Signature 55 AA
都应该放在:
Legacy BIOS Boot
这个前提下理解。
二、Boot Sector 到底是怎么被执行的?
很多入门资料会把下面几个词混着说:
Boot Sector
MBR
磁盘第一个扇区
它们有重叠,但不能永远画等号。
1. Boot Sector、MBR 和磁盘第一个扇区
Boot Sector 是一个更宽泛的概念:
Firmware / Boot Chain 会读取并执行的启动扇区。
在最简单的软盘实验里:
Floppy Image
┌──────────────────────────────┐
│ Sector 0:Boot Sector │
├──────────────────────────────┤
│ Sector 1 │
├──────────────────────────────┤
│ Sector 2 │
├──────────────────────────────┤
│ ... │
└──────────────────────────────┘
这个第一个 Sector 直接就是我们的启动代码。
1) MBR 是更具体的磁盘结构
MBR 是 Master Boot Record。
在经典 MBR Partitioned Disk 上,第一个 512 Byte Sector 不只有启动代码,还包含 Partition Table 和 Boot Signature。
因此:
MBR
⊂
某类 Boot Sector
但软盘没有传统硬盘那种 MBR Partition Table,所以我们这次实验更准确的说法是:
编写一个 512 Byte Legacy BIOS Boot Sector。
虽然很多课堂和教程会直接把它简称成 MBR Program,但从概念层级上最好区分清楚。
2. BIOS 为什么把启动代码加载到 0x7C00?
在 Legacy BIOS 模型里,一个经典约定是:
Boot Sector
↓
Physical Address 0x7C00
↓
开始执行
这就是为什么 OS 入门汇编里经常看到:
org 0x7C00
但这里最容易误解的一点是:
不是
org 0x7C00把程序加载到了 0x7C00。
真正决定加载位置的是 BIOS。
1) BIOS 负责“搬到哪里”
启动时:
Boot Device Sector 0
↓ BIOS 读取
RAM Physical 0x7C00
然后 CPU 才开始执行那里已经存在的机器指令。
2) org 0x7C00 只负责地址计算
org 是 Assembler Directive,不是 CPU Machine Instruction。
它不会在运行时执行一次:
move program to 0x7C00
它做的是让 Assembler 在计算 Label Address 时,以:
0x7C00
作为代码起点。
例如:
org 0x7C00
message db 'hello'
Assembler 计算 message 地址时,会把整个程序将来运行在 0x7C00 这个事实考虑进去。
所以正确关系是:
3. 0x7C00 前后的低端内存里有什么?
在经典 Real Mode PC 模型下,低端内存里有一些历史结构。
最常见的两个是:
0x00000 ~ 0x003FF
Interrupt Vector Table
1 KiB
0x00400 ~ 0x004FF
BIOS Data Area
256 Byte
注意这里有一个很容易算错的地方:
0x00400 ~ 0x004FF
一共是:
0x100 Byte = 256 Byte
不是 512 Byte。
Boot Sector 被 Legacy BIOS 放到:
0x07C00 ~ 0x07DFF
因为:
0x200 = 512 Byte
所以可以画成:
Low Memory
0x00000 ┌──────────────────────────┐
│ Interrupt Vector Table │ 1 KiB
0x00400 ├──────────────────────────┤
│ BIOS Data Area │ 256 B
0x00500 ├──────────────────────────┤
│ Other low-memory area │
│ ... │
0x07C00 ├──────────────────────────┤
│ Boot Sector │ 512 B
0x07E00 ├──────────────────────────┤
│ Other memory │
│ ... │
└──────────────────────────┘
1) 为什么恰好是 0x7C00?
最稳妥的理解是:
这是 IBM PC 兼容 Legacy BIOS 延续下来的历史 Boot Convention。
8086 的 Real Mode、Segment Register、20-bit Physical Addressing 能解释这个时代的地址体系,但它们并不能单独推导出:
为什么一定恰好选择 0x7C00
所以不要把:
8086 是 16-bit Register + 20-bit Address Bus
直接写成:
因此 Boot Sector 必然只能放 0x7C00
前者解释的是 Real Mode Addressing,后者是历史兼容约定。
4. Real Mode 为什么总会看到 Segment:Offset?
8086 的 General-purpose Register 主要是 16-bit,但它需要形成更大的 Physical Address。
于是 Real Mode 使用:
Segment : Offset
计算方式是:
Physical Address
=
Segment × 16 + Offset
例如:
0000:7C00
得到:
0x0000 × 16 + 0x7C00
=
0x7C00
而:
07C0:0000
同样得到:
0x07C0 × 16 + 0
=
0x7C00
这也说明:
同一个 Physical Address,在 Real Mode 下可以存在不同的 Segment:Offset 表达。
因此 Boot Code 不应该随便假定 BIOS 留下的所有 Segment Register 都正好是自己想要的值。
最小实验里最好主动初始化自己要使用的:
DS
ES
SS
SP
三、亲手写一个 512 Byte Boot Sector
下面使用 NASM Syntax 写一个最小程序。
目标非常简单:
Legacy BIOS 启动
↓
加载我们的 Boot Sector
↓
调用 BIOS Video Service
↓
屏幕显示 hello world, welcome to os!
1. 完整 boot.asm
bits 16
org 0x7C00
start:
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
sti
mov ax, 0x1301
mov bx, 0x000F
mov cx, message_len
mov dx, 0x0000
mov bp, message
int 0x10
hang:
cli
hlt
jmp hang
message db 'hello world, welcome to os!'
message_len equ $ - message
times 510 - ($ - $$) db 0
dw 0xAA55
这段代码没有加载 Linux,也没有进入 Protected Mode。
它只是完成最关键的第一步:
证明 Firmware 已经把 CPU Execution Control 交给了我们自己写的代码。
四、逐行拆解:Segment、Stack 与 INT 10H
1. bits 16:现在还是 16-bit Real Mode
bits 16
这是告诉 NASM:
按 16-bit Code 生成 Machine Code
Legacy BIOS 交给 Boot Sector 时,我们仍处于经典 x86 Real Mode 环境。
2. org 0x7C00:不是 Load,而是地址基准
org 0x7C00
前面已经解释过:
BIOS
→ 真正负责把代码放进 RAM 0x7C00
org
→ 负责让 Assembler 正确计算 Runtime Label Address
3. 为什么先初始化 Segment 和 Stack?
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
sti
先:
cli
临时关闭 Maskable Interrupt,避免我们修改 SS:SP 的过程中发生普通 Hardware Interrupt。
然后把:
DS = 0
ES = 0
SS = 0
并让 Stack 从:
0x7C00
向低地址增长。
最后:
sti
重新允许 Interrupt。
为什么要主动设置这些寄存器?
因为 Bootloader 不应该把正确性建立在:
“BIOS 大概会帮我把所有 Segment Register 留成我想要的状态。”
这种假设上。
4. 没有操作系统,怎么把字符串显示出来?
现在最有意思的问题来了。
我们还没有:
Linux
Windows
printf
System.out.println
GPU Driver
那怎么显示字符串?
答案是:
调用 Legacy BIOS 提供的 Firmware Service。
这里使用:
int 0x10
BIOS Interrupt 0x10 提供 Video Service。
我们选择:
AH = 0x13
也就是 Write String Function。
1) INT 10H 的参数放在哪里?
这就是前面“System Call 为什么需要 Register 传参”那类思想的另一个历史实例。
代码:
mov ax, 0x1301
mov bx, 0x000F
mov cx, message_len
mov dx, 0x0000
mov bp, message
int 0x10
可以拆成:
AH = 0x13
→ BIOS Video Function:Write String
AL = 0x01
→ 更新 Cursor
→ String 本身只放 Character,Attribute 从 BL 取
BH = 0x00
→ Display Page 0
BL = 0x0F
→ Character Attribute
CX = message_len
→ String Length
DH = 0x00
DL = 0x00
→ 从第 0 行第 0 列开始
ES:BP
→ String Address
我们前面已经把:
ES = 0
所以:
mov bp, message
最终让 ES:BP 指向 Boot Sector 内的 message。
2) 为什么是 ES:BP,不是 DS:BP?
因为 BIOS INT 10H / AH=13H 这个 Function 的接口约定就是从:
ES:BP
取得字符串地址。
这和 C Function、Linux System Call 一样:
调用者和被调用者必须先约定“参数放哪里”。
区别只是这里的 ABI 来自 Legacy BIOS Service。
3) INT 10H 和 INT 80H 是不是一回事?
它们都利用 x86 Software Interrupt 机制,但调用对象不同。
INT 10H
→ Legacy BIOS Video Service
→ Firmware Environment
INT 80H
→ 经典 Linux x86 System Call Entry
→ OS Kernel
所以不能因为都写成:
int xx
就认为它们属于同一层 API。
五、512 Byte 如何变成一个可启动镜像?
1. 为什么 Boot Sector 必须填到 512 Byte?
传统软盘一个 Sector 是:
512 Byte
Legacy BIOS 读取的就是这个启动 Sector。
我们的 Boot Binary 需要让最后两个 Byte 落在固定位置:
Offset 510
Offset 511
因此前面不足的空间必须补 0。
NASM 里:
times 510 - ($ - $$) db 0
这里几个符号的意义:
$
→ 当前地址
$$
→ 当前 Section 起始地址
$ - $$
→ 当前已经生成了多少 Byte
所以:
510 - 已生成长度
就是还要补多少个 0。
2. 0xAA55 为什么最后落盘看到的是 55 AA?
代码最后:
dw 0xAA55
dw 写入一个 16-bit Word。
x86 使用 Little-endian,所以:
0xAA55
写入 Memory / File 时低 Byte 在前:
55 AA
因此从 Raw Boot Sector 的最后两个 Byte 看,经典 Boot Signature 是:
55 AA
这两个说法并不矛盾:
Assembler Word Value
0xAA55
Raw Byte Order
55 AA
这是一个非常典型的 Endianness 例子。
3. 编译以后先验证 Binary
需要 NASM:
nasm -f bin boot.asm -o boot.bin
这里:
-f bin
非常关键。
我们不是要生成:
ELF
Mach-O
PE
而是直接生成没有 File Header 的 Raw Binary。
1) 验证必须正好 512 Byte
使用:
wc -c < boot.bin
预期应该是:
512
如果不是 512,先不要做启动镜像。
2) 验证最后两个 Byte
Linux / macOS 都可以使用:
od -An -tx1 -j510 -N2 boot.bin
预期应看到:
55 aa
这个实验验证了两件事:
Binary Size = 512 Byte
+
Boot Signature 位于最后两个 Byte
4. 为什么用 1.44 MB Floppy Image?
不是因为现代操作系统还需要软盘。
恰恰相反,是因为软盘模型足够简单。
经典 1.44 MB Floppy:
512 Byte / Sector
×
2880 Sector
=
1,474,560 Byte
也就是常说的:
1.44 MB Floppy
对于 Bootloader 入门来说,它比直接研究现代硬盘分区、GPT、ESP、Filesystem 更容易把注意力集中在:
Firmware
→ Sector
→ RAM
→ CPU
这条最核心链路上。
5. 用 dd 制作启动镜像
先创建一个全 0 的 1.44 MB Image:
dd if=/dev/zero of=floppy.img bs=512 count=2880
关键参数:
if=/dev/zero
→ 输入全 0 数据
of=floppy.img
→ 输出到镜像文件
bs=512
→ 每块 512 Byte
count=2880
→ 一共 2880 块
然后把我们的 Boot Sector 写到第一个 Sector:
dd if=boot.bin of=floppy.img bs=512 count=1 conv=notrunc
这里:
conv=notrunc
表示不要把已经存在的 floppy.img 截短成 512 Byte。
最终镜像结构:
floppy.img
Sector 0
┌──────────────────────────┐
│ boot.bin │ 512 B
├──────────────────────────┤
│ Sector 1 │
├──────────────────────────┤
│ Sector 2 │
├──────────────────────────┤
│ ... │
├──────────────────────────┤
│ Sector 2879 │
└──────────────────────────┘
六、让虚拟机真正执行这 512 Byte
可以使用支持 Legacy BIOS Floppy Boot 的虚拟机。
例如 QEMU:
qemu-system-i386 \
-drive file=floppy.img,format=raw,if=floppy
如果 Firmware 正确识别这个 Boot Sector,预期屏幕上会出现:
hello world, welcome to os!
这个结果验证的不是“我们已经写出了完整操作系统”。
它真正证明的是:
到这一刻以后,控制权已经进入我们自己写的 Boot Code。
七、Boot Sector 怎么继续加载 Kernel?
1. 从第一阶段引导到 Kernel Entry
这个实验只完成 First Stage。
如果真正继续写 OS,Bootloader 还要承担更多工作。
最核心的一步是:
从后续磁盘 Sector
找到 Kernel Image
↓
把 Kernel Load 到 RAM
↓
准备 Kernel 需要的 CPU / Memory Environment
↓
Jump 到 Kernel Entry
一个现代 x86 Kernel Loader 往往还会继续处理:
Memory Map
A20
Protected Mode / Long Mode
GDT
Paging
Kernel Image Format
Hardware Information
但这些已经超出了这个最小 Boot Sector 实验。
这里最重要的是理解:
Bootloader 的使命不是自己变成 Kernel,而是把 Kernel 送到它应该在的位置,并把 CPU Control Flow 交给 Kernel Entry。
2. 把启动实验和“程序执行”连接起来
现在再回头看,Boot Sector 其实和普通程序执行有一个共同点:
Storage 中的 Machine Code
↓
进入 RAM
↓
CPU 的 Instruction Pointer 指向它
↓
CPU 开始 Fetch / Decode / Execute
区别在于普通 Java / C Application 有:
Operating System
Loader
Virtual Memory
Process
System Call
帮我们完成大量工作。
而 Boot Sector 执行时:
OS 还没有启动
所以我们必须自己面对:
Physical / Real Mode Address
Register
Firmware Interrupt
Disk Sector
CPU Entry
这也是为什么写 20 多行 Boot Assembly,反而能把前面很多底层知识一下串起来。
八、几个特别容易混淆的说法
1. “org 0x7C00 把程序加载到 0x7C00”
错误。
BIOS 负责加载;org 只是 Assembler 的地址计算基准。
2. “任何现代 PC 都会从磁盘第一个 512 Byte 加载到 0x7C00”
错误。
这是 Legacy BIOS 启动模型。UEFI 使用不同的 Boot Flow。
3. “Boot Sector 就一定等于 MBR”
错误。
MBR 是特定磁盘布局里的 Master Boot Record;软盘第一个可启动 Sector 更适合直接叫 Boot Sector。
4. “0x00400 ~ 0x004FF 是 512 Byte”
错误。
它是:
0x100 = 256 Byte
5. “8086 使用 20-bit 地址,所以 Boot Sector 必须放 0x7C00”
错误。
Real Mode Segment:Offset 解释了地址形成方式,但 0x7C00 是 Legacy PC BIOS 的历史启动约定,不是由 20-bit Addressing 数学推导出来的唯一答案。
6. “BIOS 一定把 DS、ES、SS 都初始化成我们想要的值”
不能依赖。
Boot Code 应主动初始化自己依赖的 Segment Register 和 Stack。
7. “INT 10H 的字符串地址放 DS:BP”
对于本文使用的 AH=13H Write String Function,应使用:
ES:BP
8. “dw 0xAA55 最后文件里也一定显示 AA 55”
错误。
x86 Little-endian 下 Raw Byte 是:
55 AA
9. “能显示 Hello World 就已经写完操作系统了”
当然不是。
我们只完成了:
Boot Control 被自己的代码接管
真正的 Kernel 还在后面。
九、总结
计算机启动最神奇的地方,其实不是 BIOS、Bootloader、Kernel 这些名字本身。
真正关键的是:
CPU 必须最终得到一个确定的 Machine Code Address,然后从那里开始执行。
Legacy BIOS 解决这件事的经典方式是:
Power On
↓
CPU 进入 Firmware
↓
BIOS 初始化硬件
↓
选择 Boot Device
↓
读取第一个 Boot Sector
↓
放到 Physical Address 0x7C00
↓
CPU 开始执行这 512 Byte
我们自己的汇编程序又继续:
初始化 Segment / Stack
↓
准备 Register 参数
↓
INT 10H
↓
BIOS 帮我们输出字符串
Binary 最后:
前 510 Byte
→ Code + Data + Padding
最后 2 Byte
→ 55 AA Boot Signature
把它写进 1.44 MB Floppy Image 后:
512 Byte × 2880 Sector
=
1,474,560 Byte
虚拟机就可以通过 Legacy BIOS 把我们的代码真正执行起来。
以后如果继续向下写:
Boot Sector
↓
读取更多 Sector
↓
加载 Kernel
↓
切换 CPU Mode
↓
Jump Kernel Entry
↓
Kernel 接管机器
那么这 512 Byte 就成为一个真正 OS Boot Chain 的起点。
所谓“启动操作系统”,本质上就是一层程序负责找到下一层程序,把它放进正确的内存环境,再把 CPU 的执行控制权交过去。Legacy BIOS 把控制权交给 Boot Sector,Bootloader 再把控制权交给 Kernel。
参考资料
- NASM Documentation:Assembler directives and raw binary output
- Intel x86 Architecture:Real Mode and Segment:Offset addressing
- UEFI Specification:UEFI Boot Manager and EFI System Partition
- QEMU System Emulator Documentation:Raw disk / floppy image boot
- IBM PC Compatible Legacy BIOS Boot Convention
评论区