从硬件时钟到Linux内核中的时间服务实现
难题:在baremetal上实现按钮点击、长按事件
起因是因为我想用stm32加几个按钮只做一个我自己的控制器,我可以通过按钮执行一些功能。
硬件是如何和CPU通信的呢?CPU上支出几个GPIO引脚,这些引脚可以配置为输入模式和输出模式,并且都有两种状态——高电平和低电平。硬件连接这些引脚,你编写在CPU上运行的程序,去给这些引脚写电平值或从这些引脚中读电平值,就可以实现和硬件的交互。
对于按钮来说,我们假设它连接某一个被我们配置成输入模式的引脚,我们的程序不断的读这个引脚的电平,若为低电平,则认为按钮处于按下状态,若为高电平,则按钮处于放开状态,则我们得到了如下的伪代码:
bool is_pressed() {
return readbit(button_pin) == 0;
}
void main() {
while (1) {
if (is_pressed()) {
// do something...
}
}
}
但是我们要的是点击事件和长按事件,代码中的按下,是一个持续的事件,用户不松手就一直是按下。而点击和长按事件是一个瞬时触发的事件,用户点击、松开,我们需要判断这中间的间隔时间长度,若大于多少,则认为是长按,否则是点击。于是我们有了下面的伪代码:
// 最后一次按下时间
uint64_t last_pressed_time = NULL;
void main() {
while (1) {
// 如果按下,且最后一次按下时间没设置
if (is_pressed() && last_pressed_time == NULL) {
// 最后一次按下时间 = 当前时间
last_pressed_time = get_time();
} else {
// 计算当前时间和最后一次按下时间的差值
uint64_t diff = get_time() - last_pressed_time;
if (diff > LONG_CLK_SPAN) {
// 长按
} else {
// 点击
}
last_pressed_time = NULL;
}
}
}
注意,我们上面的伪代码为了清晰忽略了模块化和很多细节,比如物理硬件中的电平抖动,以及事件FIFO队列等,这并不是重点。
在上面的代码中,核心就是get_time
,它是一个时间值,无论是什么样的时间,只要它具有以下性质:
- 随物理时间单调增长:顺序的两次调用
get_time
,后一次一定大于等于前一次 - 基本均匀:即若我们多次调用
get_time
,每次之间隔了x秒,对于每一个返回值依次和前一次调用得到的时间相减,得到一组y,代表每两次调用之间的时间差,每一个y都不会相差太大,最起码是可被程序参考的。
熟悉了写运行在操作系统上的程序的同学们会想,这有什么值得思考的?我用Java的System.currentTimeMillis
,我用Linux的time()
都能获取到时间,但是你忽略了那些是平台和OS给你提供了服务,在baremetal上,你什么都没有,你要自己考虑如何提供这样一个服务。
墙上时钟和单调时钟
软件世界的时钟分为两种:
- 墙上时钟:顾名思义,即和现实世界时间有关的时钟。比如linux的
time
、java的System.currentTimeMillis
。其特性是可回拨,如果你在程序中第一次调用这些功能和第二次之间将系统时钟回拨,则可能出现第二次获得的时间在第一次之前的情况。所以严格来说它不适合我们说的按钮点击事件的实现。 - 单调时钟:一般是系统启动开始到现在的一个逻辑时间值,和物理世界无关,不会回拨。也是本篇讨论的重点。
baremetal上如何实现时钟(stm32f103c8t6)
构建时钟树
时钟源:硬件时钟/晶振
时钟源通常是一个可以以固定频率震荡的硬件,也就可以以固定频率生成数字脉冲信号发给下游系统。在STM32中,有四个时钟源:
- HSI:内部高速时钟(8MHz),不稳定
- HSE:外部高速时钟,外部晶振电路提供
- LSI:内部低速时钟(40KHz)
- LSE:外部低速时钟
时钟源为整个系统提供计时功能,包括我们刚刚提到的需要时间服务的软件、各种需要以周期性频率协同步调的硬件等。
倍频器/分频器
时钟源是固定频率的,而不同的使用场景可能需要不同的频率,此时,倍频器/分频器电路可以做到将原始频率乘以一个系数或除以一个系数,再分给下游。
定时器电路
定时器电路被设计成这样:上游提供的时钟脉冲(源自于时钟源,经过多次倍频/分频)发生多少次(装载值),发送一次时钟中断给CPU。
定时器通常具有可配置的分频器和可配置的装载值,这让我们可以通过软件灵活控制我们接到时钟中断的频率。
假设上游提供的时钟脉冲频率是10KHz,则你可以配置定时器的装载值为9999,此时每当脉冲发生一次,定时器的装载值-1,最后当它变成0,发送时钟中断给CPU。此时,我们的中断函数就会在每1ms被CPU调用。
图片来自b站keysking,需要详细了解这其中的硬件细节的可以去看它的视频。
构建逻辑时钟
// clock.c
uint64_t __LOGIC_TIME = 0;
void init_logic_clock() {
// 初始化timer电路,配置成1ms一次中断
}
// 假设这个是我们的时钟中断函数
// 1ms会被调用一次
void tim_irqhandler() {
__LOGIC_TIME++;
}
uint64_t get_time() {
return __LOGIC_TIME;
}
Linux时间相关服务
通过单片机对硬件实现时钟服务有一个基本了解之后,我们就又有了疑问。对于Linux这样的通用系统,它是如何利用硬件时钟的,又是向应用提供了怎样的服务?
调度器和jiffies
jiffies和我们刚刚的__LOGIC_TIME
差不多,其作用是记录系统启动以来发生的时钟中断次数,也是一个逻辑时钟。
在Linux中,可以使用如下指令查看配置的时钟中断频率:
~ -> cat /boot/config-xxxx | grep 'CONFIG_HZ='
CONFIG_HZ=1000
在linux2.6开始被设置为1000,之前都是100。
内核代码分析:
在Linux0.11内核代码的kernel/system_call.s
中,使用汇编语言定义了时钟中断的处理函数:
.align 2
timer_interrupt:
push %ds
push %es
push %fs
pushl %edx
pushl %ecx
pushl %ebx
pushl %eax
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
movl $0x17,%eax
mov %ax,%fs
# 递增jiffies
incl jiffies
movb $0x20,%al
outb %al,$0x20
movl CS(%esp),%eax
andl $3,%eax
pushl %eax
call do_timer # 'do_timer(long CPL)' does everything from
addl $4,%esp
jmp ret_from_sys_call
我们可以在此处看到一些关键信息:
- 递增了jiffies
- 调用了do_timer
void do_timer(long cpl)
{
// 如果当前特权级(cpl)为-1,则将内核代码运行时间stime递增;
if (cpl)
current->utime++;
else
current->stime++;
if (next_timer) { // 如果有定时器链表
next_timer->jiffies--; // 定时器链表的jiffies递减
while (next_timer && next_timer->jiffies <= 0) { // 如果当前定时器的jiffies已经为0
void (*fn)(void);
fn = next_timer->fn;
next_timer->fn = NULL;
next_timer = next_timer->next;
(fn)(); // 调用定时器函数
}
}
if (current_DOR & 0xf0)
do_floppy_timer();
if ((--current->counter)>0) return;
current->counter=0;
if (!cpl) return;
// 执行调度
schedule();
}
从上面的代码中,我们可以看到linux中维护了一个定时器功能,它将全部的定时器组装成为一个链表,定时器的jiffies
属性代表多少个时钟中断后它将执行。
在时钟中断的C语言代码最后,执行了schedule
函数,它是Linux进行线程调度的核心函数,即执行线程的上下文切换,用于实现并发执行。
此处的定时器供内核内部类似在未来某个时间点执行的任务或驱动程序定时轮询等使用,不给应用层使用。
总结,linux使用时钟中断进行:
- jiffies的更新
- 内核内部定时器函数调度
- 应用程序线程上下文切换
sys_time实现
sys_time是Linux给用户提供的系统调用,用于返回unix时间戳(秒),注意,其返回的是一个墙上时间。该函数的实现只是将CURRENT_TIME
放到了用户传入的结构体中,put_fs_long
用于在内核空间向用户空间写入数据。问题的关键在于,谁来更新CURRENT_TIME
了。
// sys.c
int sys_time(long * tloc)
{
int i;
i = CURRENT_TIME;
if (tloc) {
verify_area(tloc,4); // 验证内存容量是否足够
put_fs_long(i,(unsigned long *)tloc); // 也放入用户数据段tloc处
}
return i;
}
CURRENT_TIME
在sched.h
中被定义,其为启动时间startup_time
+ jiffies/HZ
,即开始时间 + 开始后过了多少秒。
// sched.h
#define CURRENT_TIME (startup_time+jiffies/HZ)
至于startup_time
怎么来的,是计算机启动时根据CMOS中取到的时间计算的。
所以我们看到sys_time
这一核心系统调用,也是基于jiffies
来做的,我们已经看到基于硬件的时钟脉冲和定时器,向上层提供时间概念的系统服务了。
alarm
alarm是一个用户级的定时器。简单来说,用户可以调用alarm(second)
传递一个秒数,内核会在过了指定秒之后给进程发送SIGALARM
信号。
现实中的alarm,如果在上一次定时任务没结束时调用,会返回上一次任务还差多久执行,这里为了简单把这部分逻辑去除,sys_alarm
的实现如下:
int sys_alarm(long seconds)
{
current->alarm = (seconds>0)?(jiffies+HZ*seconds):0;
return 0;
}
其给当前进程设置了一个alarm
属性,其值是当前的jiffies
加上HZ*seconds
(jiffies递增多少次到达指定秒),实际上也就是要发动SIGALARM
信号的目标jiffies
。
在schedule
调度中,内核会检查每一个进程(实际上没有明确的进程概念,都被看成任务),如果它设置了alarm
,并且当前jiffies
已经大于其设置的alarm
,便给SYSALARM
通知标志位设置1。
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
/* check alarm, wake up any interruptible tasks that have got a signal */
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p) {
if ((*p)->alarm && (*p)->alarm < jiffies) {
(*p)->signal |= (1<<(SIGALRM-1));
(*p)->alarm = 0;
}
if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
(*p)->state==TASK_INTERRUPTIBLE)
(*p)->state=TASK_RUNNING;
}
// ........
}
总结
本篇文章我们从一个如何在baremetal上实现按钮按下功能开始,发现我们需要一个逻辑时钟。
然后,我们从时钟源、定时器电路开始,通过baremetal实现了一个简单的逻辑时钟。
最后,我们分析了Linux0.11内核中如何利用时钟给上层提供服务,我们发现,至少在0.11时,内核做的事情和我们的逻辑时钟十分相似,jiffies
就是经历了多少次时钟中断,而时钟中断具有固定频率,所以可以当成距离现在有了多少ms。
Linux使用jiffies
提供内核timer,sys_time
以及alarm
系统调用。
同时,Linux0.11的代码真的很简单,可以作为学习操作系统的起点,我感觉甚至可能比xv6这个教学系统简单。