起因

早年学微机课的时候就发现一个规律:班里学得最好的往往不是那些按老师要求一步一步照本学打字的人,反而是那些先偷着玩游戏的人——他们对计算机本身的兴趣,比对打字这件枯燥任务的兴趣高得多。那一代人里,不少人正是因为迷上了 DOS 上的游戏,一路琢磨怎么破解、怎么改内存、怎么写自己的小程序,最后走上了程序员这条路,兴趣领进门这件事从来都不是一句空话。我自己走的也是这么一条路:从折腾 DOS 开始,到研究 Windows 底层,再到后来自己动手写操作系统,一路是这么摸过来的。

入门操作系统开发的时候读的是《30天自制操作系统》和于渊编著的《自己动手写操作系统》,当时也顺手转载留存过一篇讲同样这段原理的读后笔记在博客里,主要是讲从加电到保护模式这一段的原理:BIOS 自检、引导扇区、中断读盘、GDT/LDT/段选择子。市面上大部分操作系统原理资料都偏理论、少实操——纯靠死记硬背 GDT 结构、段选择子位域这些细节,枯燥又记不牢。真正让这些概念在脑子里扎根的,是照着这两本书把引导代码一行行敲出来、在虚拟机里跑起来调试的过程:亲手写过一次,到现在具体的位域排布、寄存器约定还能记得很清楚,比单纯背理论深刻得多。等后面代码里加上图形模式,再结合多任务调度、文件系统这些概念一起动手写驱动,之前死记的那些底层机制一下就串起来了——原来 GDT 里的特权级检查是为了给多任务做隔离,原来文件系统读写最终还是落回引导阶段学的那套磁盘访问接口,理解起来反而顺畅了很多。

自己动手把这些东西一点点拼出来,能在屏幕上画出图形、能跑起多个任务、能读写一个文件系统,那种成就感是看书背理论完全给不了的,这也是当年真正把我留在这个方向上的原因——有图形界面,还能在这个界面上跑起自己写的程序,这才是最直接、最实在的成就感来源。而有了这份成就感,才会真正愿意往底层细节里钻,这也是跟前面学微机课那批人一脉相承的道理:兴趣带出来的钻研劲头,比刻板的教学进度管用得多。

不过话说回来,市面上大多数操作系统原理的书,讲到任务调度、内核这一层基本就封顶了——模仿 Linux 的设计思路、参考 Minix、或者对着 MIT 的教学操作系统(6.828/6.S081 那套 xv6)把内核骨架搭出来,就算是"讲完"了。这些教材几乎都是围绕开源系统或者教学系统展开,很少真正去分析 Windows 这类商业系统内部到底是怎么设计的——一来源码不公开,二来商业系统实际的引导链路和驱动模型往往比教科书里的教学系统复杂得多,也更贴近现在真实硬件的样子,但这部分内容基本没人写透过。图形界面才是把"这是一个操作系统"这个抽象概念落到实处的临门一脚,可教材写到内核调度就收尾,很少有人愿意继续往这条更贴近真实商业系统、更贴近现在硬件的路上走。

回头看,从 DOS 到 Windows 再到自己写操作系统这段经历,对后来做嵌入式开发帮助特别大——嵌入式这一行本质上天天在跟没有操作系统、或者操作系统层很薄的裸机环境打交道,早年这些从零把系统一层层搭起来的经历,让"硬件到底在干什么"“控制权是怎么一步步移交的"这些问题不再是抽象的概念,是真正上手摸过的东西。这也是为什么这几年我一直想找机会把这块知识补一补:当年那篇笔记里配的插图链接全部失效了,干脆借着这个机会重新整理一遍——不照抄旧笔记,只拿这些经典知识点做骨架,重新组织、补充细节写;顺便把当年止步在 32 位保护模式的部分往后补一补,聊聊现在的机器实际是怎么引导的(长模式、GRUB、UEFI),新旧对照着看更清楚。

一、加电到跑起来:BIOS/UEFI 干了什么

按下电源键之后,主板给供电系统一个信号,供电系统起来之后又通知固件(老机器是 BIOS,新机器基本都是 UEFI)可以开始工作了。固件启动的第一件事是自检(POST,Power-On Self Test):检查内存、主板芯片、键盘鼠标、磁盘控制器这些硬件是不是都能正常响应。自检通过后,固件按配置好的引导顺序,依次去检查每个存储设备,找到"能引导"的那个,把控制权交出去。

传统 BIOS 判断一个磁盘"能不能引导"的办法很直接:读这块磁盘的第一个扇区(512 字节)到内存 0x0000:0x7C00 处,检查这 512 字节最后两个字节是不是 0x55AA。是的话,就认为这是一块可引导磁盘,直接跳转过去执行——这 512 字节的内容就是引导程序(Boot Loader 的最初一段,业内叫 MBR,Master Boot Record)。

引导扇区的三个硬性规定:

  • 大小固定 512 字节,多一个字节都不行
  • 最后两个字节必须是 0x55AA(小端序存储,即偏移 510 处是 0x55,511 处是 0xAA),这是"合法引导扇区"的签名
  • 位置固定在磁盘的 0 磁头 0 磁道 1 扇区(CHS 编址下的起始位置)

因为 BIOS 只肯读一个扇区,512 字节干不了什么大事——现在随便一个内核镜像都是几百 KB 到几十 MB,装不进 512 字节。所以引导扇区里的代码通常只做一件事:想办法把磁盘上更大的一段代码(第二阶段引导程序,或者直接是内核)读进内存,然后跳过去执行。这也是为什么几乎所有实际的引导方案都是"多级火箭"结构——第一级只负责点火,把第二级从磁盘捞出来。

二、不靠操作系统怎么读磁盘:BIOS 中断

引导阶段还没有操作系统,读磁盘只有两条路:直接操作磁盘控制器的 I/O 端口(麻烦,还要区分具体的控制器型号),或者借助 BIOS 提供的中断服务(更常用,本质上还是 BIOS 帮你封装好了端口操作)。

中断机制的核心是中断向量表:内存最开始的 1KB(0x00000x03FF)存放着 256 个中断向量,每个向量指向一段处理程序的入口。触发一次中断(比如执行 int 0x13),CPU 会查这张表,找到对应向量,跳转执行对应的处理程序——中断号相当于查表用的索引,处理程序需要的参数则是提前放在约定好的寄存器里。

用 BIOS 13 号中断(int 0x13)读磁盘扇区是引导代码里最常见的操作,调用前要按约定把参数塞进对应寄存器:

1
2
3
4
5
6
7
8
AH = 0x02        ; 功能号,2 表示"读磁盘扇区"
DL = 驱动器号     ; 指定读哪个驱动器(0x00=第一块软盘,0x80=第一块硬盘)
CH = 磁头号
CL = 起始扇区号
AL = 扇区数量     ; 要读多少个扇区
ES:BX = 目标内存地址  ; 读到的数据放哪儿

int 0x13          ; 触发中断,执行读盘

这几个寄存器凑成的组合,本质上就是 CHS(Cylinder-Head-Sector)编址方式下定位一块磁盘数据的完整坐标。现代磁盘容量早就超出 CHS 能表达的范围,实际引导代码大多切换成了 LBA(Logical Block Address,线性块地址)编址,通过 int 0x13 的扩展功能号(0x42)用一个"磁盘地址包"结构体传参,原理是一样的,只是坐标系统换了。

三、实模式与保护模式:内存访问的两套规则

CPU 刚加电时工作在"实模式”(Real Mode),这是兼容早年 8086 处理器留下的模式:地址总线只有 20 位,能访问的内存上限是 1MB。判断 CPU 当前处于哪种模式,看的是 CR0 寄存器(0 号控制寄存器)的最低位,这一位叫 PM(Protected Mode)位:清零表示实模式,置 1 表示保护模式。加电瞬间这一位是清零的,所以 CPU 总是从实模式起步。

实模式的地址怎么算

实模式下,一个内存地址写成"段:偏移"的形式,比如 0x0000:0x7C00。冒号前面是段寄存器里的值,后面是偏移量,实际物理地址的计算规则是:

1
物理地址 = 段寄存器值 × 16 + 偏移量

拿引导扇区加载地址 0x0000:0x7C00 举例:0x0000 × 16 + 0x7C00 = 0x7C00,正好对上 BIOS 把引导扇区放进内存的固定地址。这套算法简单粗暴,但也决定了实模式下单个"段"最大只能表达 64KB(16 位偏移量的上限),这是它注定要被淘汰的先天限制。

保护模式:从段寄存器到段选择子

保护模式下,内存管理方式发生了根本变化。段寄存器不再直接存段的基址,而是存一个叫"段选择子"(Segment Selector)的 16 位数值,它的构成是:

1
| 高 13 位:索引值 | 1 位:TI | 2 位:RPL |
  • 索引值:指出要用描述符表里的第几个描述符,13 位最多能表示 8192(2^13)个表项
  • TI(Table Indicator):0 表示查全局描述符表(GDT),1 表示查局部描述符表(LDT)
  • RPL(Requested Privilege Level):请求的特权级,0~3 共四级,操作系统内核通常跑在 0 级(最高权限)

真正描述一个内存段"在哪、多大、能不能访问"的信息,放在"段描述符"里,8 个字节一个,一堆描述符连续排列就构成了描述符表。全局描述符表(GDT)整个系统共享一份,由 GDTR 寄存器指向它的基址;局部描述符表(LDT)是每个任务私有的一份,由 LDTR 指向。段描述符里比较关键的几个字段:

  • 基址(Base):段在物理内存里的起始地址
  • 段限(Limit):段的大小上限,配合 G 位(粒度位)决定单位是字节还是 4KB
  • DPL(Descriptor Privilege Level):这个段本身要求的最低访问特权级,配合段选择子的 RPL 一起做权限检查——访问一个段时,请求方特权级必须"够高"(数值上更小或相等)才被允许
  • P(Present)位:这个描述符当前是否有效,配合分页/换出机制用
  • TYPE:区分这是代码段、数据段还是系统段(比如 TSS 任务状态段),细分位还能表示可读/可写/向下扩展等属性

这套机制真正实现了"保护":一个段的最大可访问地址由基址和段限共同决定,一旦程序试图访问的偏移量超出段限,CPU 直接触发异常中断,阻止程序越界读写别的段的内存——这是"保护模式"这个名字的由来,也是多任务系统能互相隔离、不至于一个进程写坏另一个进程内存的硬件基础。

从逻辑地址到线性地址的映射,就是"拿段选择子去描述符表里查出对应描述符,取出基址,加上偏移量"这个过程。如果没有开启分页,线性地址就等同于物理地址;开启分页之后,线性地址还要再经过页表转换才是真正的物理地址(这属于"页模式"的范畴,和这里的"段模式"是内存管理的两套独立机制,可以叠加使用,现代操作系统基本都是段+页一起上)。

四、从保护模式到长模式:这些年多出来的部分

前面这些内容对应的是 32 位 x86 保护模式时代的机制,这也是早年那些"从零写 OS"教程主要覆盖的范围。现在的机器普遍是 64 位,实际引导链路比这个更长,多出来几段现代才有的东西:

  • 长模式(Long Mode):64 位 CPU 在保护模式基础上多了一层,进入长模式前必须先经过 32 位保护模式做中转,且必须开启分页——64 位模式下不再支持传统的"段基址+段限"式内存保护,段寄存器基本形同虚设(除了 FS/GS 还留了点特殊用途),内存保护完全交给分页机制的页表来做。这也是为什么现代 OS 内核的引导代码,哪怕最终目标是 64 位内核,也得先经过实模式→32 位保护模式→64 位长模式这三段式跳转,一步都不能跳过。
  • A20 线:实模式下地址总线理论上只有 20 位,但早期为了兼容性,第 21 根地址线(A20)默认被"屏蔽"了,导致实模式下超过 1MB 的地址会环绕回 0。进入保护模式之前,引导代码必须先想办法"打开 A20 线"(通过键盘控制器、系统控制端口或者 BIOS 中断几种手段之一),否则后续访问高位内存会出现地址回绕的诡异 bug——这是写引导代码时一个经典的坑,前面那篇笔记里没提到。
  • GRUB/Bootloader 分层:现代 Linux 系统几乎不会自己在 MBR 里塞完整的内核加载逻辑,而是交给 GRUB 这类通用引导器。GRUB 自己也分阶段:第一阶段(512 字节内塞不下太多逻辑)负责找到并加载更大的第二阶段代码,第二阶段才真正有能力读文件系统、解析配置、加载内核镜像、切换到保护模式再跳到内核入口——这本质上是前面说的"多级火箭"思路的工程化实现,只是级数更多、每一级能力更强。
  • UEFI 取代传统 BIOS:新机器基本都用 UEFI(Unified Extensible Firmware Interface)替代了传统 BIOS。UEFI 引导不再依赖"读 512 字节看 0x55AA 签名"这套老规则,而是直接从 EFI 系统分区(一个 FAT32 格式的分区)里找特定路径下的 .efi 可执行文件来加载——引导器本身就是一个符合 PE/COFF 格式的应用程序,固件内置了识别和加载这类文件的能力,不再需要引导扇区那种"塞汇编代码进 512 字节"的手法。传统 MBR 引导方式在 UEFI 体系里被保留下来主要是为了兼容老系统(CSM/Legacy 兼容模式),新装的系统基本都是纯 UEFI+GPT 分区表的组合了。

五、这些原理对应到今天的意义

回头看这套从 BIOS 时代传下来的知识,放到现在还有没有用:

  • 写嵌入式 Bootloader(比如给一个自制的小型系统或者定制固件写引导程序),实模式→保护模式→长模式这条链路、A20 打开、GDT 构造这些细节依然要亲手过一遍,这块知识没有过时
  • 排查一些底层的系统启动异常(比如引导修复、双系统引导冲突),理解 MBR/GPT、CHS/LBA、传统 BIOS/UEFI 这几对概念的区别,能少走很多冤枉路
  • 理解现代操作系统的内存保护、多任务隔离是怎么从硬件层面被保证的,段描述符的权限检查机制是这套安全模型最底层的一块拼图

这块内容本身变化不大——从 1985 年 386 引入保护模式到现在,段描述符、GDT/LDT 这套骨架基本没换过,真正在往前走的是引导链路的"上层建筑":GRUB 取代了手写引导扇区,UEFI 取代了传统 BIOS 签名检测,长模式在保护模式基础上叠了一层。想真正搞懂现代系统怎么启动的,这套老底子知识仍然是绕不开的起点。

参考链接