第04章 初始化程序(3):不会忘记时间的黑色方块 —— 探秘CMOS 0x80掩码与重启之谜
引言:机器中的“时间之眼”
在个人计算机的日常使用中,一个常被忽视的事实是:当我们完全切断电源,主机内部的一切半导体存储(比如我们熟知的内存条 DRAM)都会因为失去电力供应而被彻底清零。然而,第二天你再次开机时,电脑的系统时间却是准确无误的。它既没有因为断电而倒退到 1970 年,也没有丢失系统的硬件配置信息。
这种“断电不失忆”的魔法,源自于计算机主板上一个不起眼、又极其关键的小芯片——CMOS RAM(互补金属氧化物半导体随机存取存储器)。在 Linux 0.11 内核的初始化流程中,系统最早的一批硬件操作,就是围绕着这颗芯片展开的。
一、 硬件解剖:被纽扣电池呵护的 MC146818
在早期的 IBM PC/AT 及其兼容机上,主板上集成了一颗名为 MC146818 的专用集成电路芯片。这颗芯片的功能非常独特,它被设计成需要由主板上的一颗 3V 纽扣锂电池进行持续供电,哪怕计算机电源被拔掉,这颗电池依然能保证芯片内部的数据不丢失。
这颗 MC146818 芯片内部被划分成两个主要区域:
- 实时时钟(RTC)电路:负责独立于 CPU 之外计时,每秒钟更新一次内部的时间寄存器。
- CMOS 静态 RAM 存储器:早期的容量仅为 64 字节。这些字节非常重要,它们不再存放程序代码,而是存放着系统最基本的硬件配置参数。
重要注解:书中提到,后来一些机器的 CMOS 内存扩展到了 128 字节,甚至更大,但由于当时 Linux 0.11 开发的时代背景(1991 年),内核默认按照 64 字节的标准布局来操作。
二、 端口魔法:0x70 与 0x71 的对话
为了读取 CMOS RAM 里面的时间或配置信息,CPU 不能像访问普通内存那样直接使用 MOV 指令。因为 CMOS 的特殊物理设计,它的寄存器被映射到了 I/O 端口地址空间中。在 x86 架构下,我们必须使用 IN 和 OUT 指令进行端口操作。
具体来说,访问 CMOS 需要两个特定的 I/O 端口:
- 0x70(地址端口,Write-only):CPU 将要读取的 CMOS 内部偏移地址(比如想读“分钟”,就发送偏移量
0x02)写在这个端口。 - 0x71(数据端口,Read/Write):地址设定好后,CPU 从
0x71端口读取到的字节,就是对应 CMOS 内部指定偏移地址所保存的数值。
在 init/main.c 中,Linux 0.11 内核原原本本地用 C 语言(通过内嵌汇编)实现了这个协议。我们来看代码:
// 原文 4.2 main.c 程序
#define CMOS_READ(addr) ({ \
outb_p(0x80|addr, 0x70); \
inb_p(0x71); \
})
代码深度剖析:
outb_p(val, port):这是内核自带的向指定端口输出一个字节的函数(封装了汇编指令OUT)。后缀_p的意思是在输出操作后,加入一个微小的 I/O 延迟。在早期 PC 架构中,硬件的反应速度比 CPU 的指令执行速度慢得多。如果不加延时,连续对 I/O 端口进行读写操作,可能导致硬件来不及响应而产生错误数据。在这段代码中,outb_p先写地址,inb_p再读数据。| 0x80掩码操作:将addr与0x80(二进制1000 0000)进行按位或操作。这是一个非常有趣的细节,下面我将为您详细解释这段“考古级”的代码逻辑。
三、 历史迷案:0x80 与 Linus 的探索之路
“之所以会有这样的操作是因为当时 Linus 手头缺乏有关 CMOS 方面的资料,CMOS 中时钟和日期的偏移地址都是他逐步实验出来的,也许在他实验中将偏移地址与 0x80 进行或操作(并且还修改了其他地方)后正好取得了所有正确的结果,因此他的代码中也就有了这步不必要的操作。不过从 1.0 版本之后,该操作就被去除了。”
0x80 到底是什么?
在早期的 PC 架构中,向 0x70 端口写入数据时,最高位(Bit 7)控制的是 NMI(非屏蔽中断)的开启与关闭。通常情况下,在读取 CMOS 时,我们需要先禁用 NMI 以防止中断干扰读取过程,其标准操作是将 0x80(即高位为 1)写入。
然而,当时的 Linus Torvalds 并没有这篇硬件手册。他可能是在屡次的实验尝试中,偶然发现加上 | 0x80 这段代码后,系统能够成功读取到时间。他以为这是必须的初始化步骤,于是这段本属于“手忙脚乱”的代码就留在了 Linux 0.11 的正式内核中。直到后来,随着内核的完善和资料齐全,在 Linux 1.0 版本中,drivers/block/hd.c 中的类似代码才被去掉这个掩码。
这不仅仅是一次“错误”,更是 Linux 内核诞生之初,一个人单枪匹马在没有手册的情况下攻克硬件的英雄史诗见证。
四、 时间的密码:解密 BCD 码与转换宏
经过上面的硬件握手,我们从端口读到了一个字节。但要注意,这个字节并不是我们习惯的十进制数字!为了简化 RTC 集成电路的内部电路设计,CMOS 芯片内部的时间(秒、分、时、月、日等)是以 BCD 码(二-十进制编码,Binary-Coded Decimal) 的格式保存的。
什么是 BCD 码?
举个例子:当前时间大概是 14 点 35 分。
- 如果我们直接用二进制存储 35,对应的十六进制表示是
0x23(即十进制的 35)。 - 但在 BCD 码中,它是这样存储的:
- 高 4 位:存储十位数字
3(二进制0011) - 低 4 位:存储个位数字
5(二进制0101) - 合起来,CMOS 中保存的字节是
0011 0101,也就是 十六进制的 0x35。
- 高 4 位:存储十位数字
如果内核直接把这个 0x35 当成十进制来用,就会错乱。所以,我们必须写一段转换逻辑把它解码回十进制。Linux 0.11 使用了一个非常精妙的宏:
#define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10)
拆解这个宏的执行步骤:
(val) & 15:利用位掩码00001111,提取出原数据的低 4 位。对于0x35,低 4 位是0101,即十进制的 5。(val) >> 4:将原数据向右移 4 位,这样原数据的高 4 位就变成了现在的低 4 位。对于0x35,右移后变成0011,即十进制的 3。(val)>>4) * 10:将提取出的十位数乘以 10。即3 * 10 = 30。- 相加得到最终结果:
30 + 5 = 35。完美转换为十进制数!
五、 深入 CMOS 64 字节地图(解读表 4-1)
您提供的图片中列出了 表 4-1 CMOS 64 字节信息简表。这张简表堪称计算机硬件配置的“灵魂”。其中存储的数据,不仅给了内核开机的时间,还告诉了内核自己到底有多少内存。我们把这张表格对应的 64 个字节分块来解读:
1. 实时时钟与警报区(偏移 0x00 - 0x09)
这部分存储了系统当前的时间。
0x00秒,0x02分,0x04时0x06星期,0x07日,0x08月,0x09年0x01, 0x03, 0x05是报警用的秒、分、时。如果不需要定时唤醒电脑,这些位置通常都是 0。
2. RTC 状态寄存器区(偏移 0x0A - 0x0D)
0x0A(A),0x0B(B),0x0C©,0x0D(D)
这 4 个字节控制 RTC 芯片的工作模式。例如,寄存器 A 用来设置时钟更新的频率(比如 1024Hz);寄存器 B 决定是否使用 12 小时制还是 24 小时制,是否开启周期性中断等。在 Linux 0.11 中,这些寄存器并没有被直接操作,而是保留由早期 BIOS 初始化。
3. 系统配置与诊断区(偏移 0x0E - 0x11)
0x0EPOST 诊断状态,记录了系统自检过程中是否发生了错误(如内存故障、键盘故障等)。0x0F停机状态字节。0x10磁盘驱动器类型:用来标注软盘驱动器 A 和 B 的规格(是 1.2MB 还是 1.44MB,或者 360KB 等)。
4. 关键系统信息:内存大小(偏移 0x15 - 0x18)
这 4 个字节(16 位)对内核来说是最重要的!
0x15, 0x16存放的是 基本内存大小(Base Memory),单位是 KB。通常标准值是0x0280,即 640KB。0x17, 0x18存放的是 扩展内存大小(Extended Memory),单位也是 KB。
在 Linux 0.11 的
setup.s汇编程序中,内核会通过 BIOS 中断INT 0x15功能号AH=0x88读取扩展内存大小,把结果保存到物理地址0x90002处。随后,在init/main.c的mem_init初始化过程中,就会依赖这个数值来决定整个操作系统主内存区(Main Memory Area)的具体范围,从而划分出内存管理(MM)分页机制所需的内存页表。没有这个数值,Linux 0.11 根本不知道自己能管理多大的内存。
5. 保留与校验和(偏移 0x19 - 0x3F)
CMOS 中的大部分空间是保留给 BIOS 存放各类复杂配置信息的(比如硬盘的柱面数、磁头数等)。以及为了确保数据没出错,在 0x2E, 0x2F 位置处存放了校验和(Checksum)。如果电池没电,CMOS 里的值(包括校验和)就会乱掉,导致 BIOS 报 CMOS Checksum Error 错误。
六、 内核关键步骤流程复盘
至此,我们将硬件连接、内核代码读取、数据转换的整个流程串联起来。下面这张 Mermaid 流程图,能非常直观地展示这“硬件 -> 内核 -> 时间”的转化过程。
七、 技术终章:从 CMOS 到对内存管理的启蒙
除了为系统提供时间,CMOS 芯片所保存的扩展内存(0x17, 0x18)信息,可以说是 Linux 0.11 内核正式开启物理内存分页管理的前置条件。
在 init/main.c 的 118 行,你会看到这样的代码:memory_end = (1<<20) + (EXT_MEM_K<<10);memory_end = 0xfffff000;
系统根据读取的 EXT_MEM_K 和基本内存计算出总物理内存大小后,才会去调用 mem_init(main_memory_start, memory_end); 并将内存分成内核区、高速缓冲区、虚拟盘区、主内存区。
如果说启动引导程序 bootsect.s 是帮内核“把身体”搬进物理内存,那 CMOS 就是为内核指明了自己身体(物理内存)到底有多大的那个“度量衡”。
下篇预告:
在一系列硬件探测与内存初始化之后,Linux 0.11 终于迈出了它作为“多进程操作系统”的关键一步。在第 4 篇解析中,我们将聚焦于if (!fork())这行神奇的代码,揭开进程复制(Clone)的奥秘,并探讨 init 进程是如何“诞生”出来的,敬请期待!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)