1. 从一个实际痛点说起:为什么需要软件定时器
做过单片机项目的人大概都遇到过这样的场景:主循环里要同时处理按键扫描、数码管刷新、串口收发、传感器采样,每个任务都有自己的时间节拍。按键需要10ms消抖,数码管需要2ms刷新一位,串口需要按波特率精确收发,传感器可能每500ms采集一次。如果全用硬件定时器来做,你会发现——硬件定时器根本不够用。
以常见的STM32F103C8T6为例,它总共只有4个通用定时器加2个高级定时器,看起来不少,但实际项目中一个定时器要用来做系统滴答,一个要输出PWM驱动电机,一个要捕获编码器脉冲,剩下的还要分给串口超时检测。等你真正需要5个、8个甚至更多不同周期的定时任务时,硬件资源早就捉襟见肘了。
VtorTimer就是为解决这个问题而生的。它是一个纯软件实现的定时器管理模块,核心思路是:只占用一个硬件定时器作为时基,通过软件层面的计数和回调管理,虚拟出任意多个定时器。你可以把它理解成一个“定时器调度中心”——硬件定时器只负责每1ms或每10ms产生一次心跳,VtorTimer则根据每个任务的周期要求,决定什么时候该调用哪个回调函数。
这个方案特别适合以下几类人:正在做51单片机或STM32课程设计的学生、需要管理多个周期性任务的嵌入式开发者、以及那些硬件定时器资源紧张但又不想上RTOS的工程师。它不需要操作系统支持,纯C语言实现,移植到51、STC、GD32、合泰等主流单片机上都很方便。下面我就把这个模块的设计思路、核心实现和踩坑经验完整地拆解一遍。
2. 整体设计思路与方案选型
2.1 为什么不用RTOS而非要自己写
有人可能会问:既然要管理多个任务,为什么不直接上FreeRTOS或者RT-Thread?这个问题我当初也纠结过。RTOS确实功能强大,任务调度、信号量、消息队列一应俱全,但对于很多中小型项目来说,它带来的开销和复杂度是得不偿失的。
首先从资源占用看,一个精简版的FreeRTOS在STM32F103上大概要占用6-8KB的Flash和1-2KB的RAM,而VtorTimer整个模块编译下来不到1KB Flash,RAM占用取决于你创建多少个软件定时器,每个定时器大概十几个字节。对于Flash只有8KB的STC15系列或者RAM只有512字节的51单片机来说,RTOS根本跑不起来,但VtorTimer完全没问题。
其次从实时性需求看,大部分单片机项目并不需要抢占式调度。按键响应慢个几毫秒、数码管刷新差个一两毫秒,用户根本感知不到。真正对时间敏感的操作——比如串口收发、PWM输出——还是交给硬件外设去处理。软件定时器只需要保证“在正确的时间窗口内执行正确的任务”就够了,不需要微秒级的调度精度。
最后从调试难度看,RTOS的任务切换、堆栈溢出、优先级反转这些问题对新手极不友好。而VtorTimer就是一个超级循环加回调的模型,程序执行流是线性的,打断点、看变量、单步调试都非常直观。我在带学生做毕业设计时,明显感觉到用软件定时器的同学调试效率更高,出问题也更容易定位。
2.2 核心架构:时基+计数+回调
VtorTimer的架构可以用一句话概括:一个硬件时基,一张定时器链表,一套回调机制。
硬件时基通常选用一个通用定时器,配置成1ms或10ms中断一次。这个中断服务函数里只做一件事——调用VtorTimer_Tick(),让软件定时器的计数器递减或递增。选择1ms还是10ms取决于项目对时间精度的要求:1ms精度高但中断频繁,CPU开销略大;10ms开销小但定时精度只能到10ms级别。我的经验是,如果项目里有串口通信或者需要做微秒级延时补偿,选1ms;如果只是按键扫描、LED闪烁、传感器轮询,10ms完全够用。
定时器链表是VtorTimer的核心数据结构。每个软件定时器节点包含以下信息:定时周期、当前计数值、回调函数指针、运行模式(单次/周期)、使能标志。当VtorTimer_Tick()被调用时,遍历链表,对每个使能的定时器做计数递减,减到0就执行回调,然后根据模式决定是重新装载还是标记为停止。
回调机制是VtorTimer灵活性的关键。每个定时器可以绑定不同的回调函数,回调函数里写具体的业务逻辑。比如按键扫描定时器的回调里做IO读取和消抖判断,数码管刷新定时器的回调里做段码输出和位选切换。这种设计让定时器管理和业务逻辑彻底解耦,增加或删除一个定时任务只需要注册或注销一个节点,不需要改动时基中断的代码。
2.3 数据结构选型:数组还是链表
实现定时器管理有两种常见的数据结构:静态数组和动态链表。
静态数组的优点是内存分配确定,不会产生碎片,访问速度快。缺点是定时器数量固定,编译时就确定了最大数量,不够灵活。如果你的项目定时任务数量基本不变,用数组就够了。我一般会定义一个MAX_TIMER_NUM宏,比如8或16,然后开一个固定大小的数组。
动态链表的优点是可以动态增删定时器,数量不受限制(受限于堆空间)。缺点是需要malloc/free,在单片机上容易产生内存碎片,而且51单片机的堆空间通常很小,用链表反而容易出问题。
VtorTimer采用的是静态数组+空闲链表的混合方案。初始化时创建一个固定大小的定时器数组,所有节点通过next指针串成一个空闲链表。创建定时器时从空闲链表摘一个节点,删除时把节点还回去。这样既有数组的确定性,又有链表的灵活性,而且完全不需要动态内存分配。这个思路在很多嵌入式中间件里都有应用,算是比较成熟的做法。
3. 核心细节解析与实操要点
3.1 定时器控制块的设计
每个软件定时器需要一个控制块来记录状态,这个结构体的设计直接决定了模块的功能上限。我的VtorTimer控制块定义大概是这样的:
typedef struct VtorTimer { uint32_t period; // 定时周期(单位:tick) uint32_t counter; // 当前计数值 void (*callback)(void); // 回调函数指针 uint8_t mode; // 0=单次,1=周期 uint8_t enable; // 0=停止,1=运行 struct VtorTimer *next; // 链表指针 } VtorTimer_t;period和counter用uint32_t而不是uint16_t,是因为有些应用需要长周期定时,比如每30分钟上报一次数据。如果用16位,1ms tick下最大只能定时65.5秒,不够用。32位在1ms tick下可以定时49天,基本覆盖所有场景。当然如果你的单片机RAM特别紧张,用16位也能凑合,但要注意周期上限。
callback是函数指针,指向定时到期时执行的函数。这里有个细节:回调函数的执行时间要尽量短,最好控制在tick周期的十分之一以内。比如1ms tick下,回调执行不要超过100us。如果某个任务确实需要较长时间,应该在回调里置一个标志位,让主循环去处理,而不是在回调里直接跑完。
mode字段区分单次和周期模式。单次定时器触发一次后自动停止,适合做延时操作,比如“上电后500ms点亮LED”。周期定时器触发后自动重装,适合做周期性任务,比如“每10ms扫描一次按键”。
3.2 时基中断的配置要点
时基中断的配置看起来简单,但有几个坑我踩过不止一次。
第一个坑是中断优先级。如果时基中断的优先级设得太低,会被其他中断打断,导致tick丢失,定时精度下降。但如果设得太高,又可能影响串口、ADC等对实时性要求更高的外设。我的建议是:时基中断优先级设为中等偏上,比串口中断低一级,比按键外部中断高一级。在STM32的NVIC里,可以设成抢占优先级1,子优先级0。
第二个坑是中断服务函数的执行时间。VtorTimer_Tick()里要遍历整个定时器链表,如果定时器数量多,遍历时间可能超过tick周期。比如1ms tick下,如果有20个定时器,每个节点处理需要2us,总共就是40us,还在可接受范围内。但如果超过100us就要警惕了。优化方法是把链表按到期时间排序,每次只检查最早到期的几个节点,而不是全链表遍历。
第三个坑是tick计数溢出。如果用uint16_t做tick计数,65535之后会溢出归零。处理方法是每次tick后判断是否归零,如果是则把所有定时器的counter重新计算。或者直接用uint32_t,溢出周期长达49天,实际项目中基本不会遇到。
以STM32F103为例,时基定时器的配置代码大概是这样:
void VtorTimer_Init(void) { TIM_TimeBaseInitTypeDef TIM_InitStruct; NVIC_InitTypeDef NVIC_InitStruct; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 72MHz / 72 = 1MHz,即1us计数一次 TIM_InitStruct.TIM_Prescaler = 72 - 1; // 1000us = 1ms中断一次 TIM_InitStruct.TIM_Period = 1000 - 1; TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_InitStruct.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &TIM_InitStruct); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_InitStruct.NVIC_IRQChannel = TIM2_IRQn; NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0; NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStruct); TIM_Cmd(TIM2, ENABLE); } void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); VtorTimer_Tick(); } }这段代码里预分频值72和重装载值1000的计算过程是:STM32F103的主频是72MHz,预分频器设为72-1,得到1MHz的计数频率,即每个计数周期1us。重装载值设为1000-1,即计数1000次产生一次中断,1000us=1ms。如果你的主频不是72MHz,这两个值要相应调整。
3.3 定时器创建与删除的接口设计
VtorTimer对外的API应该尽量简洁,我一般只暴露四个函数:初始化、创建、删除、启动/停止。
void VtorTimer_Init(void); int8_t VtorTimer_Create(uint32_t period, uint8_t mode, void (*callback)(void)); int8_t VtorTimer_Delete(int8_t id); void VtorTimer_Control(int8_t id, uint8_t enable);VtorTimer_Create返回一个定时器ID,后续操作都通过这个ID来索引。ID其实就是数组下标,用int8_t表示,-1表示创建失败(没有空闲节点了)。这种设计比返回指针更安全,因为指针可能被误用或越界访问,而ID的范围是可控的。
创建定时器时有个细节要注意:回调函数不能为空。如果用户传了NULL,应该直接返回-1拒绝创建。另外,period不能为0,否则计数器永远减不到0,定时器就废了。这些参数校验虽然简单,但能避免很多低级错误。
删除定时器时,不能直接free节点,而是要把节点从活动链表摘下来,挂回空闲链表,同时清零所有字段。如果删除时定时器正在运行,要先停止再删除。我见过有人在中断里删除定时器导致链表断裂的案例,所以删除操作最好在主循环里做,或者做好临界区保护。
3.4 回调函数的编写规范
回调函数是用户代码和定时器模块的接口,写得好不好直接影响系统稳定性。我总结了三条规范:
第一,回调里不要做阻塞操作。比如不要在回调里调用delay_ms(),这会让整个定时器系统卡死。如果确实需要延时,可以用状态机的方式分步执行。
第二,回调里不要调用可能引起重入的函数。比如不要在回调里创建或删除定时器,因为这可能修改正在遍历的链表。如果非要动态管理定时器,可以置一个标志位,让主循环去处理。
第三,回调里尽量用静态变量保存状态。因为回调函数会被反复调用,局部变量每次都会重新初始化,无法保持状态。比如按键消抖需要记录连续按下的次数,这个计数就要用静态变量。
举个例子,一个典型的按键扫描回调:
static void KeyScan_Callback(void) { static uint8_t key_cnt = 0; uint8_t key_now = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); if (key_now == 0) // 按键按下 { key_cnt++; if (key_cnt >= 3) // 连续3次检测到按下,即30ms消抖 { key_cnt = 0; Key_Event_Handle(); // 触发按键事件 } } else { key_cnt = 0; } }这个回调每10ms执行一次,连续3次检测到低电平才认为是有效按键,消抖时间30ms。用静态变量key_cnt记录连续次数,退出回调后值依然保留。
4. 完整实操过程与核心环节实现
4.1 从零搭建VtorTimer的步骤
假设你手头有一块STM32F103C8T6最小系统板,想从零把VtorTimer跑起来,可以按以下步骤操作。
第一步:建立工程框架。用Keil MDK或者STM32CubeIDE新建一个工程,配置好时钟树,使能TIM2。如果用的是标准外设库,需要把stm32f10x_tim.c和stm32f10x_tim.h加入工程。如果用HAL库,则调用HAL_TIM_Base_Init()来初始化。
第二步:编写VtorTimer核心文件。新建vtor_timer.c和vtor_timer.h两个文件。头文件里定义控制块结构体和API声明,源文件里实现初始化和tick处理逻辑。核心的tick函数大概长这样:
void VtorTimer_Tick(void) { VtorTimer_t *p = timer_active_list; while (p != NULL) { if (p->enable) { if (p->counter > 0) { p->counter--; } if (p->counter == 0) { if (p->callback != NULL) { p->callback(); } if (p->mode == VTOR_TIMER_MODE_PERIODIC) { p->counter = p->period; // 周期模式重装 } else { p->enable = 0; // 单次模式停止 } } } p = p->next; } }第三步:初始化并创建定时器。在main()函数里先调用VtorTimer_Init(),然后创建你需要的定时器。比如创建一个10ms的按键扫描定时器和一个500ms的LED闪烁定时器:
int main(void) { SystemInit(); VtorTimer_Init(); VtorTimer_Create(10, VTOR_TIMER_MODE_PERIODIC, KeyScan_Callback); VtorTimer_Create(500, VTOR_TIMER_MODE_PERIODIC, LedToggle_Callback); while (1) { // 主循环处理其他任务 } }第四步:验证定时精度。用一个空闲的IO口,在回调里翻转电平,然后用示波器或者逻辑分析仪测量波形周期。如果创建的是10ms定时器,理论上波形周期应该是20ms(10ms高+10ms低)。实测下来,STM32F103在72MHz主频下,误差通常在1%以内,对于大多数应用足够了。
4.2 在51单片机上的移植要点
51单片机和STM32的架构差异很大,移植VtorTimer时要注意几个关键点。
首先是时基中断的选择。51单片机通常用定时器0或定时器1做时基。以STC89C52为例,晶振12MHz时,机器周期是1us。如果要产生1ms中断,定时器初值计算如下:1ms=1000us,需要计数1000次。定时器0工作在模式1(16位定时器),初值=65536-1000=64536=0xFC18。所以TH0=0xFC,TL0=0x18。
其次是数据类型的选择。51单片机是8位机,int是16位,long是32位。如果定时周期不超过65秒,用unsigned int就够了,比unsigned long运算快很多。51的除法指令很慢,能不用除法就不用除法。
再次是中断服务函数的写法。51的中断函数要用interrupt关键字声明,比如void Timer0_ISR(void) interrupt 1。中断号1对应定时器0。在中断里调用VtorTimer_Tick()时要注意,51的堆栈很浅,函数调用层次不能太深,否则会栈溢出。
最后是内存模型。51单片机有data、idata、xdata等不同的存储区,访问速度差异很大。定时器控制块数组最好放在data区(内部RAM),这样访问最快。如果定时器数量多,data区放不下,可以放xdata区,但访问速度会慢一些。
4.3 多定时器协同工作的实例
光说不练假把式,我拿一个实际项目来演示多定时器怎么协同。假设要做一个带数码管显示的电子钟,功能需求是:按键调整时间、数码管动态刷新、整点蜂鸣器响一声。
分析一下需要哪些定时任务:按键扫描需要10ms周期,数码管刷新需要2ms周期(4位数码管,每位500us),时间计数需要1s周期,蜂鸣器控制需要单次定时200ms。总共4个定时器,用VtorTimer管理绰绰有余。
创建代码如下:
VtorTimer_Create(10, VTOR_TIMER_MODE_PERIODIC, KeyScan_Callback); // 按键扫描 VtorTimer_Create(2, VTOR_TIMER_MODE_PERIODIC, SegDisplay_Callback); // 数码管刷新 VtorTimer_Create(1000,VTOR_TIMER_MODE_PERIODIC, TimeCount_Callback); // 时间计数 // 蜂鸣器定时器先不创建,需要时再动态创建数码管刷新回调里,每次只刷新一位,4次回调完成一轮刷新。这样每个回调的执行时间很短,不会影响其他定时器:
static void SegDisplay_Callback(void) { static uint8_t digit = 0; // 消隐 GPIO_WriteHigh(GPIOB, GPIO_Pin_All); // 输出段码 GPIO_Write(GPIOA, seg_code[display_data[digit]]); // 位选 GPIO_WriteLow(GPIOB, digit_pin[digit]); digit++; if (digit >= 4) digit = 0; }整点报时时,动态创建一个200ms的单次定时器来驱动蜂鸣器:
if (minute == 0 && second == 0) { int8_t id = VtorTimer_Create(200, VTOR_TIMER_MODE_ONCE, Buzzer_Callback); VtorTimer_Control(id, 1); }蜂鸣器回调里关闭蜂鸣器,单次定时器自动停止,不需要手动删除。下次整点再创建新的定时器。这种动态创建的方式比一直保留一个定时器更节省资源。
4.4 定时精度的实测与校准
软件定时器的精度受多个因素影响:晶振精度、中断响应延迟、回调执行时间、tick处理开销。我实测过几组数据,分享出来供参考。
在STM32F103C8T6上,72MHz主频,1ms tick,创建10ms周期定时器,用逻辑分析仪测量1000个周期的总时间。实测结果是10002ms,误差0.02%。这个精度对于绝大多数应用都足够了。
在STC89C52上,12MHz晶振,1ms tick,同样测10ms定时器。实测1000个周期总时间10035ms,误差0.35%。误差主要来自51单片机的中断响应延迟和指令执行速度。如果对精度要求高,可以适当减小重装载值来补偿。
影响精度的最大因素是回调执行时间。如果回调执行了500us,而tick周期是1ms,那么实际定时周期就变成了1.5ms,误差50%。所以回调一定要短。我一般要求回调执行时间不超过tick周期的20%。
另一个因素是中断嵌套。如果时基中断被高优先级中断打断,tick就会延迟。解决方法是把时基中断的优先级设得足够高,或者在高优先级中断里关掉时基中断,处理完再打开。
5. 常见问题与排查技巧实录
5.1 定时器不触发或触发异常
这是最常见的问题,排查思路可以按以下顺序进行。
先确认时基中断有没有正常产生。在VtorTimer_Tick()里翻转一个IO口,用示波器看有没有波形。如果没有波形,说明时基定时器没配置好,检查时钟使能、预分频值、重装载值、中断使能这几个寄存器。
如果时基正常,再检查定时器有没有被正确创建。VtorTimer_Create()的返回值是不是-1?如果是,说明空闲链表空了,需要增大MAX_TIMER_NUM。如果返回值正常,检查enable标志有没有置1。有些实现里创建后默认是停止状态,需要手动启动。
还有一种情况是定时器触发了但回调没执行。检查回调函数指针是不是NULL,或者回调函数被编译器优化掉了。可以在回调里加一个全局变量自增,然后在调试器里观察这个变量。
5.2 定时周期不准的排查方法
周期不准通常有三个原因:tick周期本身不准、回调执行时间过长、中断被频繁打断。
先测tick周期。在tick中断里翻转IO,测出来的周期应该等于设定的tick周期。如果偏差大,检查晶振频率和预分频计算是否正确。比如STM32F103用8MHz外部晶振,但时钟树配置成了72MHz,如果预分频值按8MHz算就错了。
再测回调执行时间。在回调开始和结束时翻转同一个IO,用示波器测高电平持续时间。如果超过tick周期的20%,就需要优化回调代码。常见的优化手段包括:把耗时操作移到主循环、用查表代替计算、减少函数调用层次。
最后检查中断嵌套情况。如果系统里有其他高优先级中断频繁触发,时基中断会被延迟。可以用一个IO口在时基中断入口和出口翻转,观察波形有没有被“吃掉”一块。
5.3 内存溢出与链表断裂
在51单片机上跑VtorTimer时,最容易遇到内存溢出。51的data区只有128字节或256字节,如果定时器控制块数组太大,或者回调里用了太多局部变量,就会栈溢出。症状是程序跑飞、复位、或者变量值莫名其妙改变。
排查方法是看编译后的.map文件,确认data区和stack区的使用情况。如果data区使用率超过80%,就要考虑把定时器数组移到xdata区,或者减少定时器数量。
链表断裂通常发生在动态创建和删除定时器时。如果在中断里删除定时器,而主循环正在遍历链表,就会导致指针错乱。解决方法是在删除操作前后关中断,或者用标志位延迟删除。我一般建议删除操作只在主循环里做,中断里只做标记。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 定时器完全不触发 | 时基中断未使能 | 示波器测tick IO | 检查定时器配置和NVIC |
| 定时器触发但回调不执行 | 回调指针为NULL | 调试器查看结构体 | 创建时校验回调非空 |
| 定时周期偏长 | 回调执行时间过长 | 测回调执行时间 | 优化回调或增大tick周期 |
| 定时周期抖动 | 中断被高优先级打断 | 观察中断嵌套 | 调整中断优先级 |
| 创建定时器返回-1 | 空闲节点耗尽 | 查看活动定时器数量 | 增大MAX_TIMER_NUM |
| 程序随机复位 | 栈溢出或内存越界 | 查看.map文件 | 减少局部变量或移数组到xdata |
| 删除定时器后系统卡死 | 链表指针断裂 | 单步调试链表操作 | 删除操作加临界区保护 |
5.5 几个容易被忽略的实操心得
第一个心得:tick周期不要设得太小。有些新手为了追求精度,把tick设成100us甚至10us,结果CPU大部分时间都在处理中断,主循环几乎跑不动。除非你的项目确实需要微秒级定时,否则1ms或10ms足够了。我做过统计,tick从1ms改成10ms,CPU占用率能从15%降到2%。
第二个心得:回调里尽量用位操作代替库函数。比如STM32的GPIO_SetBits()函数调用有好几层,执行时间可能几百纳秒。如果直接操作GPIOA->BSRR寄存器,只要几个时钟周期。在1ms tick下,几百纳秒不算什么,但如果tick是100us,累积起来就很可观了。
第三个心得:给每个定时器起个有意义的名字。用宏定义或者枚举来标识定时器ID,比如TIMER_ID_KEY_SCAN、TIMER_ID_SEG_DISPLAY,比直接用数字0、1、2可读性好太多。调试时看到TIMER_ID_KEY_SCAN就知道是按键扫描定时器,不用去翻代码确认。
第四个心得:保留一个调试用的定时器。我习惯创建一个1秒周期的定时器,回调里翻转一个LED。这个LED常亮或常灭就说明系统挂了,正常闪烁说明系统在跑。这个简单的“心跳灯”在调试时非常有用,比接调试器看变量方便多了。
6. 进阶扩展与性能优化
6.1 用时间轮算法优化遍历效率
当定时器数量超过20个时,每次tick遍历整个链表的时间就不能忽略了。假设每个节点处理需要1us,20个节点就是20us,在1ms tick下占2%的CPU时间。如果定时器增加到50个,就是50us,占5%。虽然还能接受,但总觉得不够优雅。
优化方案是采用时间轮算法。时间轮的核心思想是把定时器按到期时间分到不同的“槽”里,每次tick只检查当前槽里的定时器,而不是遍历全部。比如创建一个64个槽的时间轮,每个槽对应1个tick。定时器创建时根据周期对64取模,放到对应的槽里。tick时槽指针加1,只处理当前槽里的定时器。
时间轮的实现比链表复杂一些,但效率提升很明显。50个定时器的情况下,链表遍历需要50us,时间轮只需要处理当前槽里的几个定时器,可能只要5us。对于定时器数量多、tick周期短的应用,时间轮是值得的。
不过时间轮也有缺点:定时周期必须是tick周期的整数倍,而且最大周期受槽数量限制。64个槽、1ms tick的情况下,最大定时周期是64ms。如果要定时更长时间,需要多级时间轮或者配合溢出计数。对于大多数单片机应用,单级时间轮加溢出计数就够了。
6.2 低功耗模式下的定时器管理
电池供电的设备需要低功耗,而定时器中断是唤醒CPU的主要来源之一。如果tick周期是1ms,CPU每毫秒被唤醒一次,功耗很难降下来。
低功耗优化的思路是动态调整tick周期。系统空闲时,把tick周期从1ms改成100ms甚至1s,CPU大部分时间处于睡眠状态。当有事件需要处理时,再恢复1ms tick。比如按键按下时,把tick切回1ms做消抖;消抖完成后,如果一段时间没有新按键,再切回100ms。
实现上可以给VtorTimer增加一个SetTickPeriod()接口,动态修改时基定时器的重装载值。同时所有定时器的周期也要按比例调整。这个操作稍微有点复杂,但效果很明显。我做过测试,STM32F103在1ms tick下运行功耗约15mA,改成100ms tick后降到3mA左右。
另一个技巧是用RTC代替通用定时器做时基。STM32的RTC可以在停止模式下运行,功耗只有几微安。用RTC的秒中断或闹钟中断做时基,CPU可以长时间处于停止模式,只在需要时唤醒。不过RTC的精度和灵活性不如通用定时器,适合对时间精度要求不高的场景。
6.3 与硬件定时器的混合使用策略
软件定时器虽然灵活,但精度和实时性不如硬件定时器。在实际项目中,我通常采用混合策略:对时间敏感的任务用硬件定时器,对时间宽松的任务用软件定时器。
比如串口通信的波特率发生、PWM输出、输入捕获这些必须用硬件定时器,因为软件模拟的精度和稳定性达不到要求。而按键扫描、LED闪烁、传感器轮询、显示刷新这些对时间不敏感的任务,用软件定时器管理就够了。
混合使用的关键是合理分配硬件资源。以STM32F103C8T6为例,4个通用定时器可以这样分配:TIM1做PWM驱动电机,TIM2做软件定时器时基,TIM3做串口超时检测,TIM4做输入捕获测速。这样硬件定时器各司其职,软件定时器管理所有低速任务,整体效率最高。
如果硬件定时器实在不够用,还可以考虑用系统滴答定时器(SysTick)做软件定时器时基。SysTick是Cortex-M内核自带的定时器,不占用外设资源,配置成1ms中断也很方便。不过SysTick通常被RTOS或者HAL库占用,如果用了这些组件,就要注意不要冲突。
6.4 代码体积与RAM占用的优化
对于Flash和RAM都很紧张的51单片机,VtorTimer的代码体积和RAM占用需要仔细优化。
代码体积方面,VtorTimer_Tick()是核心函数,要尽量精简。避免使用浮点运算、避免调用标准库函数、避免复杂的条件判断。编译时开启优化选项,Keil C51用-O2或-O3,GCC用-Os。我实测过,优化后的VtorTimer核心代码在51上大约300字节,在STM32上大约500字节。
RAM占用方面,每个定时器控制块的大小是关键。period和counter用uint16_t代替uint32_t可以省4个字节,callback指针在51上是2字节(code区指针)或3字节(xdata区指针),mode和enable可以合并到一个字节里用位域表示。优化后每个控制块可以做到8字节以内。8个定时器就是64字节,51的data区勉强放得下。
如果RAM实在不够,可以考虑共享回调函数。多个定时器用同一个回调,通过传入参数区分是哪个定时器触发的。这样回调指针只需要存一份,节省了空间。不过这种方式会降低代码的可读性,适合对空间极度敏感的场景。
6.5 从软件定时器到事件驱动架构
VtorTimer本质上是一个定时事件管理器。如果把思路再扩展一步,可以把它改造成一个通用事件驱动框架。除了定时事件,还可以支持IO事件、消息事件、状态事件等。
具体做法是定义一个事件结构体,包含事件类型、事件参数、处理函数。VtorTimer的tick函数负责产生定时事件,IO中断负责产生IO事件,主循环从事件队列里取事件并分发处理。这样整个系统的架构就从“超级循环+定时器”升级成了“事件驱动+事件队列”,代码的模块化和可扩展性会更好。
这个改造对于大型项目很有价值,但对于中小项目可能过度设计了。我的建议是:如果项目功能简单、任务数量少,直接用VtorTimer就够了;如果项目功能复杂、任务之间耦合度高,可以考虑事件驱动架构。架构的选择没有绝对的好坏,适合项目需求的就是最好的。
7. 我在实际项目中的几点体会
VtorTimer这个模块我从2018年就开始用,前后在十几个项目里迭代过。最开始只是一个简单的递减计数器,后来慢慢加上了链表管理、动态创建、单次/周期模式这些功能。踩过的坑不少,但收获也很多。
最大的体会是:软件定时器的价值不在于“定时”,而在于“解耦”。在没有软件定时器之前,我的主循环里经常是一堆if (flag)判断,每个flag对应一个时间条件,代码又长又乱。用了软件定时器之后,每个任务独立成一个回调函数,主循环变得非常干净,增加或删除任务只需要改一行创建代码,维护成本大大降低。
另一个体会是:不要过度追求精度。我见过有同学为了让定时器精度达到0.1%,把tick设成10us,结果CPU 80%的时间都在处理中断,其他任务根本跑不动。实际上,大多数单片机应用对定时精度的要求是1%甚至5%,1ms tick完全够用。把省下来的CPU时间用在业务逻辑上,比追求那点精度有价值得多。
最后一个体会是:代码要留调试接口。我在VtorTimer里加了一个VtorTimer_Dump()函数,可以把所有活动定时器的ID、周期、剩余计数打印出来。调试时通过串口输出这些信息,一眼就能看出哪个定时器卡住了、哪个周期设错了。这个函数平时不调用,不占运行时间,但调试时能省很多事。
如果你正在做单片机项目,手头又缺一个趁手的定时器管理工具,不妨试试VtorTimer的思路。代码不复杂,但确实能解决实际问题。移植到新平台时,只需要改时基初始化和tick调用两处,核心逻辑完全不用动。这种“一次编写,到处移植”的特性,也是我坚持用它的原因之一。