ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

VtorTimer:纯软件定时器管理模块,解决单片机硬件定时器不足的痛点

VtorTimer:纯软件定时器管理模块,解决单片机硬件定时器不足的痛点 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 FlashRAM占用取决于你创建多少个软件定时器每个定时器大概十几个字节。对于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次产生一次中断1000us1ms。如果你的主频不是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定时器理论上波形周期应该是20ms10ms高10ms低。实测下来STM32F103在72MHz主频下误差通常在1%以内对于大多数应用足够了。4.2 在51单片机上的移植要点51单片机和STM32的架构差异很大移植VtorTimer时要注意几个关键点。首先是时基中断的选择。51单片机通常用定时器0或定时器1做时基。以STC89C52为例晶振12MHz时机器周期是1us。如果要产生1ms中断定时器初值计算如下1ms1000us需要计数1000次。定时器0工作在模式116位定时器初值65536-1000645360xFC18。所以TH00xFCTL00x18。其次是数据类型的选择。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改成10msCPU占用率能从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遍历整个链表的时间就不能忽略了。假设每个节点处理需要1us20个节点就是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周期是1msCPU每毫秒被唤醒一次功耗很难降下来。低功耗优化的思路是动态调整tick周期。系统空闲时把tick周期从1ms改成100ms甚至1sCPU大部分时间处于睡眠状态。当有事件需要处理时再恢复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或-O3GCC用-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调用两处核心逻辑完全不用动。这种“一次编写到处移植”的特性也是我坚持用它的原因之一。
返回列表