嵌入式基础--毫秒级定时模块
大家好,我是惊觉。失踪了三个月,我回来了。给大家带来一个好消息和一个坏消息。坏消息是,我尚未满血复活,Ardupilot第四篇将继续延期。好消息是,公众号恢复更新,先出一系列提升编码能力的文章。
全国电赛在即,昨天母校老师联系我,想让我给学弟们做下赛前培训。我做过很多年的培训,很早就发现了一个问题:同学们在为比赛做准备时,往往只注重去学习使用各种各样的传感器,自动控制算法,各种驱动。同学们只关注如何去实现功能,而忽视了如何把代码写得更好,更健壮,更易扩展和维护。如果在比赛之前,先准备好高质量的代码框架,基础模块,熟练掌握调度技巧,将极大提高赛时的开发和调试效率。
所谓高质量,涉及到很多方面,比如:
函数、变量的命名有统一的规则 基础接口要简单易用 设计模块时需层次分明,高内聚低耦合 尽量避免重复代码
笔者不打算一一讲解这些设计原则,而是介绍一些实际的基础模块,讲解它们的设计思路,注意事项和编程技巧,并在此过程中让大家理解相关的设计原则。
毫秒级定时模块
作为系列开篇,本文先介绍一个非常基础的模块:毫秒级定时模块。
友情提醒,本模块比较基础,可能有的同学对此非常熟悉,不过文末还有一个重要的小技巧噢。有基础的同学,可直接往后翻,跳到“再看comm_delay”一节。
此模块提供基础的定时功能,可细分为两种:
延时功能:毫秒极延时。 定时功能:过一段时间后执行一项操作。
可能有的同学会想,直接用单片机的定时器嘛,一个任务用一个定时器,有啥好讲的。其实不然。定时模块肯定要依赖于硬件定时器,但是一个任务用一个定时器的话,会有如下问题:
浪费资源。硬件定时器不仅仅只有定时功能,还有捕获输入信号,输出PWM,编码器等功能。如果滥用基础定时功能的话,等用到上述功能时,将无资源可用。 某个任务与某个定时器绑定在一起,提高了系统耦合程度,可移植性差。
因此,我们需要一个统一的,可移植性强的定时模块。
我们再回头看下两个基础的功能,延时和定时。
延时示例,1秒打印1次hello。comm_delay实现毫秒极延时。
void comm_delay(uint32_t ms);
while (1)
{
printf('hello\r\n');
comm_mdelay(1000);
}
定时示例,1秒打印1次hello。comm_get_ms返回当前系统时间,即系统从启动到现在经过了多少毫秒。
uint32_t comm_get_ms(void);
while (1){ cur_time = comm_get_ms(); if (cur_time >= timeout) { printf('hello\r\n'); timeout = cur_time + 1000; }}
可能有的同学觉得上述两项功能差不多,而定时比延时的代码要复杂。定时的代码确实多一些,不过它具有并发能力,即支持多个定时任务同时进行。
定时示例,1秒打印1次you,2秒打印1次me。
static void show_you(void)
{
static uint32_t timeout = 0;
uint32_t cur_time = 0;
cur_time = comm_get_ms();
if(cur_time < timeout)
{
return;
}
printf('you\r\n');
timeout = cur_time + 1000;
}
static void show_me(void)
{
static uint32_t timeout = 0;
uint32_t cur_time = 0;
cur_time = comm_get_ms();
if(cur_time < timeout)
{
return;
}
printf('me\r\n');
timeout = cur_time + 2000;
}
int main(int argc, char **argv)
{
while (1)
{
show_you();
show_me();
}
return 0;
}
小结:
延时功能需要提供一个延时函数。 定时功能需要提供获取系统时间的函数。
即:
void comm_delay(uint32_t ms);uint32_t comm_get_ms(void);
其实延时函数很简单,因为它也可以看成是一个定时任务:
void comm_delay(uint32_t ms)
{
uint32_t timeout = comm_get_ms() + ms;
while(comm_get_ms() < timeout);
}
系统时间
实现comm_get_ms,即记录系统时间,自然要靠硬件定时器啦。大家用的单片机,无论是TI,STM32,NXP等,大部分都是cortex-m的内核,该内核有一个专门干这事情的定时器:SysTick timer。其名称为系统滴答定时器,只有简单的定时功能。配置好reload计数并使能后,其由reload值递减至0,触发中断,再从reload递减。如果根据其时钟配置相应的reload值,实现每1ms触发1次中断,那就可以记录毫秒 级的系统时间。
其配置函数位于单片机驱动库的CMSIS组件中,一般的工程都包含了这个组件,比如笔者使用TRUEStudio创建的工程:
下面是stm32的示例。SystemCoreClock为单片机的主频,这也是SysTick的输入时钟。SystemCoreClock / 1000即为1ms的定时计数,将reload配置为此值即可实现每1ms触发1次定时中断。其他单片机方法类似。
#include 'stm32l1xx.h'#include <stdint.h>
static uint32_t sys_tick = 0;
void sys_tick_init(void){ if(SysTick_Config(SystemCoreClock / 1000)); NVIC_SetPriority(SysTick_IRQn, 0);}
uint32_t sys_tick_get(void){ return sys_tick;}
void SysTick_Handler(void){ sys_tick++;}
用法非常简单,单片机启动时调用sys_tick_init配置并使能SysTick,每1ms触发1次SysTick_Handler,其内对当前时间sys_tick进行加1操作。应用层通过sys_tick_get获取当前时间。
SysTick_Handler在中断向量表中指定,大家根据具体的MCU对号入座。
comm_get_ms只需对sys_tick_get进行简单的封装:
uint32_t comm_get_ms(void)
{
return sys_tick_get();
}
再看comm_delay
我们再看一下毫秒极延时的实现,大家觉得它有问题吗?
void comm_delay(uint32_t ms){ uint32_t timeout = comm_get_ms() + ms; while(comm_get_ms() < timeout);}
对于只需要进行几分钟演示的电赛来说,它没有问题。不过,电赛只是同学们实践所学的一条途径。正儿八经的产品,需要具有足够的健壮性,可长期稳定地运行。上述代码能长期运行吗?
static uint32_t sys_tick = 0;
comm_get_ms是对sys_tick_get的简单封装,而sys_tick_get返回的是一个32位无符号整型变量,它记录的是系统从启动到现在所经过的毫秒数。32位无符号整型变量最大能表示多长的时间呢?
2^32 / 1000 / 3600 / 24 = 49.71
其可记录49天。在49天后,sys_tick将会溢出,从零重新开始累加。为了方便描述,要使用到一个宏UINT32_MAX。UINT32_MAX表示32位无符号整型变量的最大值,即0xffffffff。
假设我们要延时1分钟,即60000ms。当前时间为UINT32_MAX - 59999,下面计算timeout。
uint32_t timeout = comm_get_ms() + ms;
timeout发生溢出,计算结果为0。
UINT32_MAX - 59999 + 60000 = UINT32_MAX + 1 = 0
那么下面的等待循环将立刻退出,而不需要等待1分钟。
while(comm_get_ms() < timeout);
在接下来的1分钟内,comm_get_ms(60000)都是失效的,每1分钟执行1次的任务将不停地执行。还有其他溢出的场景,这里不再一一描述。我们只要明确一点就好:comm_delay不能长期运行。
健壮的comm_delay
怎么修改comm_delay以解决溢出问题呢?其实很简单,直接给出答案:
void comm_delay(uint32_t ms){ uint32_t timeout = comm_get_ms() + ms; while(comm_get_ms() - timeout > UINT32_MAX / 2);}
我们简单地验证几个场景:
当前时间为10ms,延时2ms。
先计算timeout:timeout = 10 + 2 = 12
下面看看从现在开始,什么时候while(comm_get_ms() - timeout > UINT32_MAX / 2);
会退出。
第0ms秒时,当前时间为10, 10 - 12 = -2
,请注意,comm_get_ms() - timeout
中的操作数都是uint32_t类型,即32位无符号整型,它们相减的结果还是无符号整型。所以-2 --> UINT32_MAX - 1 > UINT32_MAX / 2
,循环继续。第2秒时,当前时间为12, 12 - 12 = 0 < UINT32_MAX / 2
,循环等待结束。
此种场景,成功实现延时2ms。
当前时间为(UINT32_MAX - 1)ms,延时2ms。
之所以定成UINT32_MAX - 1,是想测试时间溢出的场景,2ms后时间溢出。
先计算timeout,timeout在加2时溢出,最终结果为0。timeout = (UINT32_MAX - 1) + 2 = UINT32_MAX + 1 = 0
下面看看从现在开始,什么时候while(comm_get_ms() - timeout > UINT32_MAX / 2);
会退出。
第0秒时,当前时间为(UINT32_MAX - 1), (UINT32_MAX - 1) - 0 = (UINT32_MAX - 1) > UINT32_MAX / 2
,循环继续。第2秒时,当前时间如timeout一样加2溢出,最终为0。 0 - 0 = 0 < UINT32_MAX / 2
,循环等待结束。
此种场景,成功实现延时2ms。
总结
经过两种情况的测试,我们发现,无论计算过程中时间有无溢出,改进后的comm_delay都圆满完成延时。
这种实现的原理是什么呢?原理很重要噢,否则大家在使用时,可能把大于和小于关系搞反,或者是把被减数与减数的关系搞反。至于原理是什么呢,今天来不及讲了,请待下回分解。剧透一下,下篇的名字叫:张三与李四谁跑的快。