引言:机器中的“时间之眼”

在个人计算机的日常使用中,一个常被忽视的事实是:当我们完全切断电源,主机内部的一切半导体存储(比如我们熟知的内存条 DRAM)都会因为失去电力供应而被彻底清零。然而,第二天你再次开机时,电脑的系统时间却是准确无误的。它既没有因为断电而倒退到 1970 年,也没有丢失系统的硬件配置信息。

这种“断电不失忆”的魔法,源自于计算机主板上一个不起眼、又极其关键的小芯片——CMOS RAM(互补金属氧化物半导体随机存取存储器)。在 Linux 0.11 内核的初始化流程中,系统最早的一批硬件操作,就是围绕着这颗芯片展开的。

一、 硬件解剖:被纽扣电池呵护的 MC146818

在早期的 IBM PC/AT 及其兼容机上,主板上集成了一颗名为 MC146818 的专用集成电路芯片。这颗芯片的功能非常独特,它被设计成需要由主板上的一颗 3V 纽扣锂电池进行持续供电,哪怕计算机电源被拔掉,这颗电池依然能保证芯片内部的数据不丢失。

这颗 MC146818 芯片内部被划分成两个主要区域:

  1. 实时时钟(RTC)电路:负责独立于 CPU 之外计时,每秒钟更新一次内部的时间寄存器。
  2. CMOS 静态 RAM 存储器:早期的容量仅为 64 字节。这些字节非常重要,它们不再存放程序代码,而是存放着系统最基本的硬件配置参数。

重要注解:书中提到,后来一些机器的 CMOS 内存扩展到了 128 字节,甚至更大,但由于当时 Linux 0.11 开发的时代背景(1991 年),内核默认按照 64 字节的标准布局来操作。

二、 端口魔法:0x70 与 0x71 的对话

为了读取 CMOS RAM 里面的时间或配置信息,CPU 不能像访问普通内存那样直接使用 MOV 指令。因为 CMOS 的特殊物理设计,它的寄存器被映射到了 I/O 端口地址空间中。在 x86 架构下,我们必须使用 INOUT 指令进行端口操作。

具体来说,访问 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 掩码操作:将 addr0x80(二进制 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

如果内核直接把这个 0x35 当成十进制来用,就会错乱。所以,我们必须写一段转换逻辑把它解码回十进制。Linux 0.11 使用了一个非常精妙的宏:

#define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10)

拆解这个宏的执行步骤:

  1. (val) & 15:利用位掩码 00001111,提取出原数据的低 4 位。对于 0x35,低 4 位是 0101,即十进制的 5
  2. (val) >> 4:将原数据向右移 4 位,这样原数据的高 4 位就变成了现在的低 4 位。对于 0x35,右移后变成 0011,即十进制的 3
  3. (val)>>4) * 10:将提取出的十位数乘以 10。即 3 * 10 = 30
  4. 相加得到最终结果: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)

  • 0x0E POST 诊断状态,记录了系统自检过程中是否发生了错误(如内存故障、键盘故障等)。
  • 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.cmem_init 初始化过程中,就会依赖这个数值来决定整个操作系统主内存区(Main Memory Area)的具体范围,从而划分出内存管理(MM)分页机制所需的内存页表。没有这个数值,Linux 0.11 根本不知道自己能管理多大的内存。

5. 保留与校验和(偏移 0x19 - 0x3F)

CMOS 中的大部分空间是保留给 BIOS 存放各类复杂配置信息的(比如硬盘的柱面数、磁头数等)。以及为了确保数据没出错,在 0x2E, 0x2F 位置处存放了校验和(Checksum)。如果电池没电,CMOS 里的值(包括校验和)就会乱掉,导致 BIOS 报 CMOS Checksum Error 错误。

六、 内核关键步骤流程复盘

至此,我们将硬件连接、内核代码读取、数据转换的整个流程串联起来。下面这张 Mermaid 流程图,能非常直观地展示这“硬件 -> 内核 -> 时间”的转化过程。

开启电脑,BIOS完成自检与设置

内核加载,执行init/main.c time_init()函数

准备读取硬件时间

向I/O端口 0x70 写入要读取的地址偏移量

延迟等待I/O同步

从I/O端口 0x71 读回一字节数据

得到BCD码格式字节

调用宏 BCD_TO_BIN

计算出正确的十进制秒、分、时、日、月、年

将这些数值赋值给 struct tm 结构体

调用 kernel_mktime() 计算Unix时间戳

将总秒数存入全局变量 startup_time

内核所有与时间相关的模块(定时器、文件修改时间)在此刻拥有了时间基准

七、 技术终章:从 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 进程是如何“诞生”出来的,敬请期待!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐