第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-onlyCPU 将要读取的 CMOS 内部偏移地址比如想读“分钟”就发送偏移量0x02写在这个端口。0x71数据端口Read/Write地址设定好后CPU 从0x71端口读取到的字节就是对应 CMOS 内部指定偏移地址所保存的数值。在init/main.c中Linux 0.11 内核原原本本地用 C 语言通过内嵌汇编实现了这个协议。我们来看代码// 原文 4.2 main.c 程序#defineCMOS_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。如果内核直接把这个0x35当成十进制来用就会错乱。所以我们必须写一段转换逻辑把它解码回十进制。Linux 0.11 使用了一个非常精妙的宏#defineBCD_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 - 0x0D0x0A(A),0x0B(B),0x0C©,0x0D(D)这 4 个字节控制 RTC 芯片的工作模式。例如寄存器 A 用来设置时钟更新的频率比如 1024Hz寄存器 B 决定是否使用 12 小时制还是 24 小时制是否开启周期性中断等。在 Linux 0.11 中这些寄存器并没有被直接操作而是保留由早期 BIOS 初始化。3. 系统配置与诊断区偏移 0x0E - 0x110x0EPOST 诊断状态记录了系统自检过程中是否发生了错误如内存故障、键盘故障等。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功能号AH0x88读取扩展内存大小把结果保存到物理地址0x90002处。随后在init/main.c的mem_init初始化过程中就会依赖这个数值来决定整个操作系统主内存区Main Memory Area的具体范围从而划分出内存管理MM分页机制所需的内存页表。没有这个数值Linux 0.11 根本不知道自己能管理多大的内存。5. 保留与校验和偏移 0x19 - 0x3FCMOS 中的大部分空间是保留给 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 (120) (EXT_MEM_K10);memory_end 0xfffff000;系统根据读取的EXT_MEM_K和基本内存计算出总物理内存大小后才会去调用mem_init(main_memory_start, memory_end);并将内存分成内核区、高速缓冲区、虚拟盘区、主内存区。如果说启动引导程序bootsect.s是帮内核“把身体”搬进物理内存那CMOS 就是为内核指明了自己身体物理内存到底有多大的那个“度量衡”。下篇预告在一系列硬件探测与内存初始化之后Linux 0.11 终于迈出了它作为“多进程操作系统”的关键一步。在第 4 篇解析中我们将聚焦于if (!fork())这行神奇的代码揭开进程复制Clone的奥秘并探讨 init 进程是如何“诞生”出来的敬请期待