难题:在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;}}
}
注意,我们上面的伪代码为了清晰忽略了模块化和很多细节,比如物理硬件中的电平抖动,这并不是重点。
在上面的代码中,核心就是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 %dspush %espush %fspushl %edxpushl %ecxpushl %ebxpushl %eaxmovl $0x10,%eaxmov %ax,%dsmov %ax,%esmovl $0x17,%eaxmov %ax,%fs# 递增jiffiesincl jiffiesmovb $0x20,%al outb %al,$0x20 movl CS(%esp),%eaxandl $3,%eax pushl %eaxcall do_timer # 'do_timer(long CPL)' does everything fromaddl $4,%espjmp ret_from_sys_call
我们可以在此处看到一些关键信息:
- 递增了jiffies
- 调用了do_timer
void do_timer(long cpl)
{// 如果当前特权级(cpl)为-1,则将内核代码运行时间stime递增;if (cpl)current->utime++;elsecurrent->stime++;if (next_timer) { // 如果有定时器链表next_timer->jiffies--; // 定时器链表的jiffies递减while (next_timer && next_timer->jiffies <= 0) { // 如果当前定时器的jiffies已经为0void (*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的更新
- 内核内部定时器函数调度
- 应用程序线程上下文切换
未完...