16 | 时间初始化time_init:操作系统怎么获取当前时间?
你好,我是闪客。
上一讲咱们说到,通过初始化控制台的 tty_init 操作,内核代码已经可以很方便地在控制台输出字符啦!
用户也可以通过敲击键盘,或调用诸如 printf 这样的库函数,在屏幕上输出信息,或使用换行和滚屏等功能。这些都得益于 tty_init 这个初始化方法和它对外封装的小功能函数在底层的支撑。
时间初始化 time_init
我们继续看下一个初始化的倒霉鬼,time_init。
// init/main.c
void main(void) {
...
time_init();
...
}
曾经我很好奇,操作系统是怎么获取到当前时间的呢?
当然,现在都联网了,它可以从网络上实时同步时间。但没有网络时,为什么操作系统启动之后可以显示出当前的时间呢?难道操作系统在电脑关机后,依然不停地在某处运行着,勤勤恳恳地数着秒表么?
当然不是,我们今天就打开这个 time_init 函数一探究竟。
打开这个函数后我又是很开心,因为它很短,且没有更深入的方法调用。
// init/main.c
#define CMOS_READ(addr) ({ \
outb_p(0x80|addr,0x70); \
inb_p(0x71); \
})
#define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10)
static void time_init(void) {
struct tm time;
do {
time.tm_sec = CMOS_READ(0);
time.tm_min = CMOS_READ(2);
time.tm_hour = CMOS_READ(4);
time.tm_mday = CMOS_READ(7);
time.tm_mon = CMOS_READ(8);
time.tm_year = CMOS_READ(9);
} while (time.tm_sec != CMOS_READ(0));
BCD_TO_BIN(time.tm_sec);
BCD_TO_BIN(time.tm_min);
BCD_TO_BIN(time.tm_hour);
BCD_TO_BIN(time.tm_mday);
BCD_TO_BIN(time.tm_mon);
BCD_TO_BIN(time.tm_year);
time.tm_mon--;
startup_time = kernel_mktime(&time);
}
太棒了呀!
那我们主要就是看看 CMOS_READ 和 BCD_TO_BIN 都是啥意思,展开讲一下就明白了。
CMOS_READ
先看 CMOS_READ。
#define CMOS_READ(addr) ({ \
outb_p(0x80|addr,0x70); \
inb_p(0x71); \
})
简单来说,这里就是对一个端口先 out 写一下,再 in 读一下。
这是 CPU 与外设交互的一个基本玩法,CPU 与外设打交道基本是通过端口。它往某些端口写值来表示要这个外设干嘛,然后从另一些端口读值来接受外设的反馈。
至于这个外设内部是怎么实现的,对使用它的操作系统而言,这是个黑盒,无需关心。那对于我们程序员来说,就更不用关心了。
如果直接讲 CMOS 这个外设的交互你可能没什么感觉,我们看看Linux 0.11与硬盘的交互。
最常见的就是读硬盘了,我们来看硬盘的端口表。

读硬盘就是,往除了 0x1F0 以外的后面几个端口写数据,告诉要读硬盘的哪个扇区,读多少。然后再从 0x1F0 端口一个字节一个字节地读数据,这就完成了一次硬盘读操作。
如果你觉得这个解释还不够具体,那我们再来个具体的版本。
- 在 0x1F2 写入要读取的扇区数。
- 在 0x1F3 ~ 0x1F6 这四个端口写入计算好的起始 LBA 地址。
- 在 0x1F7 处写入读命令的指令号。
- 不断检测 0x1F7 (此时已成为状态寄存器的含义)的忙位。
- 如果第四步得到的结果为不忙,则开始不断从 0x1F0 处读取数据到内存指定位置,直到读完为止。
是不是对 CPU 最底层是如何与外设打交道的有点感觉了?是不是也不难?其实就是按照人家的操作手册,然后无脑按照要求读写端口就行了。
当然,读取硬盘的这个无脑循环,可以 CPU 直接读取并做写入内存的操作,这样就会占用 CPU 的计算资源。
但我们也可以交给 DMA 设备去读,解放 CPU。Linux 0.11和硬盘的交互,通通都是按照硬件手册上的端口说明来操作的,实际上也是做了一层封装。
好了,我们已经学会了和一个外设打交道的基本玩法了。
那我们的代码中要打交道的是哪个外设呢?就是 CMOS。
它是主板上的一个可读写的 RAM 芯片,你只要在开机时长按某个键就可以进入设置它的页面。

我们的代码其实就是与它打交道,获取它的一些数据而已。
我们回过头来看代码。
// init/main.c
static void time_init(void) {
struct tm time;
do {
time.tm_sec = CMOS_READ(0);
time.tm_min = CMOS_READ(2);
time.tm_hour = CMOS_READ(4);
time.tm_mday = CMOS_READ(7);
time.tm_mon = CMOS_READ(8);
time.tm_year = CMOS_READ(9);
} while (time.tm_sec != CMOS_READ(0));
BCD_TO_BIN(time.tm_sec);
BCD_TO_BIN(time.tm_min);
BCD_TO_BIN(time.tm_hour);
BCD_TO_BIN(time.tm_mday);
BCD_TO_BIN(time.tm_mon);
BCD_TO_BIN(time.tm_year);
time.tm_mon--;
startup_time = kernel_mktime(&time);
}
前面几个赋值语句 CMOS_READ 就是通过读写 CMOS 上的指定端口,依次获取年月日时分秒等信息的。具体咋操作代码上也写了,也是按照 CMOS 手册要求的读写指定端口就行了,我们就不展开了。
所以你看,其实操作系统程序,也是要依靠与一个外部设备打交道来获取这些信息的,并不是它自己有什么魔力。操作系统最大的魅力,就在于它借力完成了一项伟大的事情,借 CPU 的力,借硬盘的力,借内存的力,还有现在借 CMOS 的力。
至于 CMOS 又是如何知道时间的,这个就不在我们讨论范围了。
BCD_TO_BIN
我们再来看 BCD_TO_BIN,它的意思就是将 BCD 转换成 BIN。因为从 CMOS 上获取的这些年月日都是 BCD 码值,需要转换成存储在我们变量上的二进制数值,所以需要一个小算法来转换一下,没什么意思。
最后一步 kernel_mktime 也很简单,就是根据刚刚的那些时分秒数据,计算从 1970 年 1 月 1 日 0 时起到开机当时经过的秒数,作为开机时间,存储在 startup_time 这个变量里。
如果你想深入研究可以仔细看看这段代码,不过我觉得这种细节不看也可以。
startup_time = kernel_mktime(&time);
// kernel/mktime.c
long kernel_mktime(struct tm * tm)
{
long res;
int year;
year = tm->tm_year - 70;
res = YEAR*year + DAY*((year+1)/4);
res += month[tm->tm_mon];
if (tm->tm_mon>1 && ((year+2)%4))
res -= DAY;
res += DAY*(tm->tm_mday-1);
res += HOUR*tm->tm_hour;
res += MINUTE*tm->tm_min;
res += tm->tm_sec;
return res;
}
所以这一讲的 time_init 的最终目标,其实就是计算出了一个 startup_time 变量而已,至于这个变量今后会被谁用,怎么用,那就是后话了。
总结时刻
好了,总结一下。
这节课,我们重点学习了时间初始化time_init方法,time_init 方法通过与 CMOS 端口进行读写交互,获取到了年月日时分秒等数据,并借此计算出了开机时间 startup_time 变量。
到这里,相信你逐渐也体会到了,此时操作系统好多地方都是用外设要求的方式去询问的,比如硬盘信息、显示模式,以及今天的开机时间的获取等。
所以至少到目前为止,你还不应该感觉操作系统有多么的“高端”,很多时候它都是很繁琐的,读人家的硬件手册,获取到想要的信息,拿来给自己用,或者对其进行各种设置。
但你一定要耐得住寂寞,真正体现操作系统设计的强大之处的,还在后面。
我是闪客,欲知后事如何,且听下回分解。