ARTICLE DETAIL

资讯详情

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

单片机软件定时器VtorTimer:从裸机延时到多任务时间管理

单片机软件定时器VtorTimer:从裸机延时到多任务时间管理 1. 从裸机延时到软件定时器为什么需要VtorTimer做单片机开发的人几乎都经历过这样的阶段写一个LED闪烁用delay_ms(500)写一个按键消抖用delay_ms(20)写一个串口发送等待还是delay_ms()。代码能跑功能也对但一旦项目里同时有两三个需要不同时间节拍的任务整个程序就开始卡顿——按键响应迟钝、串口丢数据、屏幕刷新一顿一顿的。这个问题的根源在于阻塞式延时。delay_ms()的本质是让CPU在原地空转这段时间里CPU什么也干不了。对于只有一个任务的场景这没问题但只要有第二个任务矛盾就出现了。解决思路有两条路一是上RTOS用任务调度器来管理多个任务二是自己写一个软件定时器用硬件定时器的周期性中断作为心跳在中断里维护多个软件计时器主循环里轮询这些计时器是否到期。RTOS功能强大但资源占用高对于RAM只有几KB的51单片机或者资源紧张的小容量STM32来说往往杀鸡用牛刀。而软件定时器方案轻量、可控、移植性好是裸机开发中最实用的多任务时间管理手段。VtorTimer就是这样一个面向单片机的软件定时器模块。它的核心思路很朴素用一个硬件定时器产生固定周期的中断比如1ms一次在中断服务函数里对一个全局tick计数同时递减每个已注册软件定时器的剩余时间当某个定时器的剩余时间减到0时置位它的到期标志。主循环里通过查询到期标志来决定是否执行对应的任务。这套机制听起来简单但真正写好、用好里面有不少门道。下面我从设计思路、代码实现、实际使用中的坑、以及进阶扩展几个方面把VtorTimer这类软件定时器的完整面貌讲清楚。2. VtorTimer的核心机制拆解2.1 硬件定时器中断是整个系统的心跳软件定时器本身没有计时能力它必须依赖一个硬件定时器来提供时间基准。这个硬件定时器的中断周期就是整个软件定时器系统的时间分辨率。假设我们用STM32F103的TIM2系统时钟72MHz预分频器设为71则计数频率为72MHz/(711)1MHz即每1微秒计数一次。自动重装载寄存器设为999则每1000次计数溢出一次溢出周期为1000微秒1ms。这就是我们需要的1ms心跳。对于51单片机比如STC89C52用定时器0工作在模式116位定时器晶振12MHz机器周期1微秒。要产生1ms中断需要计数1000次初值为65536-1000645360xFC18。所以TH00xFC, TL00x18。注意硬件定时器的中断周期决定了软件定时器的最小时间粒度。如果你设的是10ms中断那所有软件定时器的定时值只能是10ms的整数倍无法实现5ms这样的定时。2.2 tick计数与定时器链表在中断服务函数里我们需要做两件事第一维护一个全局的tick计数器。这个计数器记录系统启动以来经过了多少个时间单位比如多少毫秒。它的作用是为需要获取当前时间的场景提供支持比如计算两个事件之间的时间差。第二遍历所有已注册的软件定时器对每个处于运行状态的定时器执行减计数操作。当计数值减到0时将该定时器标记为到期并根据其工作模式决定是否自动重装。这里有一个关键的设计选择用数组还是用链表来管理软件定时器数组方案简单直接定义一个固定大小的定时器数组每个元素包含定时器ID、当前计数值、重装值、回调函数指针、状态标志等字段。优点是访问速度快、内存连续、无需动态分配缺点是定时器数量固定不够灵活。链表方案支持动态创建和删除定时器数量不受限受限于可用内存但需要动态内存分配在资源紧张的单片机上容易产生内存碎片。VtorTimer这类轻量级模块通常采用静态数组位图管理的方案预分配一定数量的定时器槽位比如8个或16个用一个位图或状态数组标记每个槽位是否被占用。这样既避免了动态内存分配的风险又保持了较好的灵活性。2.3 到期标志与主循环轮询中断服务函数里不应该执行耗时操作这是单片机编程的铁律。所以软件定时器到期后中断里只做一件事置位到期标志。真正的任务处理放在主循环里。主循环的结构大致如下while (1) { if (vtor_timer_expired(TIMER_ID_LED)) { vtor_timer_clear(TIMER_ID_LED); LED_Toggle(); } if (vtor_timer_expired(TIMER_ID_KEY)) { vtor_timer_clear(TIMER_ID_KEY); Key_Scan(); } // 其他任务... }这种中断置标志、主循环查标志的模式本质上是一种前后台系统Foreground/Background System。中断是前台负责响应时间事件主循环是后台负责处理具体任务。它的实时性取决于主循环一轮的执行时间——如果主循环里某个任务耗时过长其他任务的响应就会被延迟。2.4 单次模式与周期模式软件定时器通常支持两种工作模式单次模式One-Shot定时器到期后停止运行需要手动重新启动才能再次计时。适合延时执行某个动作一次的场景比如按键消抖后延时20ms再确认。周期模式Periodic定时器到期后自动重装计数值继续下一轮计时。适合每隔固定时间执行一次的场景比如每500ms刷新一次数码管。在VtorTimer的实现中这两种模式通过一个标志位来区分。中断服务函数在减计数到0时检查这个标志位如果是周期模式就把重装值重新赋给当前计数值如果是单次模式就把定时器状态设为停止。3. 手把手实现一个可用的软件定时器模块3.1 数据结构设计先定义定时器控制块的结构体typedef struct { uint32_t reload; // 重装值单位tick uint32_t counter; // 当前计数值 uint8_t mode; // 0单次, 1周期 uint8_t state; // 0停止, 1运行, 2到期 void (*callback)(void); // 回调函数可选 } vtor_timer_t;这里我加了callback字段支持两种使用方式一种是在主循环里查询到期标志后手动处理另一种是直接在中断里调用回调函数。后者适合执行非常短的操作比如翻转一个IO口但要注意回调函数的执行时间不能太长。3.2 初始化与硬件定时器配置以STM32F103为例配置TIM2产生1ms中断void vtor_timer_init(void) { // 使能TIM2时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef TIM_InitStruct; TIM_InitStruct.TIM_Prescaler 71; // 72MHz/72 1MHz TIM_InitStruct.TIM_Period 999; // 1MHz/1000 1kHz 1ms 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_InitTypeDef NVIC_InitStruct; NVIC_InitStruct.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStruct.NVIC_IRQChannelSubPriority 1; NVIC_InitStruct.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStruct); TIM_Cmd(TIM2, ENABLE); }对于51单片机配置定时器0void vtor_timer_init(void) { TMOD 0xF0; TMOD | 0x01; // 定时器0模式116位 TH0 0xFC; // 12MHz晶振1ms定时 TL0 0x18; ET0 1; // 使能定时器0中断 EA 1; // 开总中断 TR0 1; // 启动定时器0 }3.3 中断服务函数的实现volatile uint32_t g_tick 0; volatile uint8_t g_timer_expired_flags 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_tick; for (uint8_t i 0; i VTOR_TIMER_MAX; i) { if (timer_pool[i].state 1) { // 运行中 if (timer_pool[i].counter 0) { timer_pool[i].counter--; } if (timer_pool[i].counter 0) { g_timer_expired_flags | (1 i); if (timer_pool[i].mode 1) { // 周期模式 timer_pool[i].counter timer_pool[i].reload; } else { timer_pool[i].state 0; // 单次模式停止 } if (timer_pool[i].callback ! NULL) { timer_pool[i].callback(); } } } } } }这段代码有几个细节值得注意第一g_tick和g_timer_expired_flags必须用volatile修饰。因为它们在中断里被修改在主循环里被读取编译器如果不加volatile可能会把它们优化到寄存器里导致主循环读到的永远是旧值。这是单片机开发中最经典的坑之一。第二中断里的循环遍历所有定时器槽位即使大部分槽位是空的。如果定时器数量多这会浪费一些时间。优化方案是用一个活跃位图只遍历活跃的定时器或者用链表只遍历已注册的节点。但对于8~16个定时器的规模直接遍历完全够用。第三回调函数在中断里执行。如果回调函数里有耗时操作比如串口发送一帧数据会阻塞其他中断的响应。所以回调函数只适合做极短的操作比如置标志、翻转IO。复杂任务还是应该通过到期标志在主循环里处理。3.4 对外接口设计一个易用的软件定时器模块应该提供以下接口// 创建定时器返回定时器ID-1表示失败 int8_t vtor_timer_create(uint32_t reload, uint8_t mode, void (*callback)(void)); // 启动定时器 void vtor_timer_start(int8_t id); // 停止定时器 void vtor_timer_stop(int8_t id); // 重新设置定时值并启动 void vtor_timer_reset(int8_t id, uint32_t reload); // 查询定时器是否到期 uint8_t vtor_timer_expired(int8_t id); // 清除到期标志 void vtor_timer_clear(int8_t id); // 获取系统tick uint32_t vtor_timer_get_tick(void);这些接口覆盖了绝大多数使用场景。创建时指定定时值和模式启动后定时器开始运行到期后通过expired查询、clear清除。reset用于动态修改定时值比如在通信协议中根据不同的超时时间动态调整。4. 实际项目中的典型用法与踩坑记录4.1 按键消抖软件定时器的经典场景按键消抖是软件定时器最典型的应用之一。传统做法是在检测到按键按下后调用delay_ms(20)这20ms里CPU什么也干不了。用软件定时器改造后// 主循环中 if (Key_Read() 0 key_state KEY_IDLE) { key_state KEY_DEBOUNCE; vtor_timer_reset(TIMER_KEY, 20); vtor_timer_start(TIMER_KEY); } if (vtor_timer_expired(TIMER_KEY)) { vtor_timer_clear(TIMER_KEY); if (Key_Read() 0) { key_state KEY_PRESSED; // 处理按键按下 } else { key_state KEY_IDLE; } }这样在20ms的消抖等待期间CPU可以继续处理其他任务按键响应不会阻塞整个系统。踩坑记录我曾经在一个项目里把消抖时间设成20ms但主循环里有一个刷屏任务耗时约30ms。结果按键按下后20ms的定时器早就到期了但主循环还在刷屏等刷完屏才去查到期标志此时按键可能已经松开了导致按键丢失。解决办法是把刷屏任务拆分成多次小批量刷新每次只刷一部分保证主循环一轮的时间远小于消抖时间。4.2 串口超时接收动态调整定时值串口接收不定长数据时常用空闲超时来判断一帧结束。做法是每收到一个字节就重置超时定时器如果超过一定时间没有新字节到来就认为一帧接收完毕。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); rx_buffer[rx_index] data; vtor_timer_reset(TIMER_UART, 10); // 10ms空闲超时 vtor_timer_start(TIMER_UART); } } // 主循环中 if (vtor_timer_expired(TIMER_UART)) { vtor_timer_clear(TIMER_UART); // 一帧接收完毕处理数据 Process_Frame(rx_buffer, rx_index); rx_index 0; }这里的超时时间需要根据波特率来算。比如波特率9600一个字节传输时间约1ms10ms超时意味着连续10个字节时间内没有新数据就认为帧结束。如果波特率更高比如115200一个字节约87微秒超时时间可以设短一些比如3~5ms。4.3 多任务时间片调度当系统里有多个周期性任务时可以用软件定时器实现一个简单的时间片调度任务周期定时器ID处理内容LED刷新500msTIMER_LED翻转LED状态数码管扫描5msTIMER_SEG刷新一位数码管按键扫描20msTIMER_KEY检测按键状态串口发送100msTIMER_UART_TX发送心跳包传感器采集1000msTIMER_SENSOR读取温湿度这种表格化的任务管理方式清晰直观每个任务的周期一目了然。在实际编码时把每个任务的定时器ID和处理逻辑对应起来主循环里依次查询即可。注意数码管扫描的5ms周期要求比较高如果主循环一轮的时间超过5ms数码管就会闪烁。这时候要么优化主循环要么把数码管扫描放到中断里直接处理用回调函数但回调函数里不能做耗时操作所以扫描一位数码管、立即返回是可以接受的。4.4 软件定时器的精度问题软件定时器的精度受两个因素影响一是硬件定时器中断的响应延迟。如果系统里有更高优先级的中断正在执行硬件定时器的中断会被延迟响应导致tick计数不准。对于1ms的tick通常延迟在几微秒到几十微秒级别对大多数应用来说可以接受。二是主循环的轮询延迟。定时器到期后主循环可能正在执行其他任务要等当前任务执行完才会去查到期标志。这个延迟取决于主循环一轮的最长执行时间。如果某个任务耗时10ms那所有软件定时器的实际响应都可能延迟最多10ms。如果需要更高精度的定时可以考虑把关键定时任务放到中断回调里直接执行前提是操作足够短提高硬件定时器中断的优先级优化主循环把耗时任务拆分4.5 中断里的volatile陷阱前面提到了volatile的重要性这里再展开说一下。在STM32的HAL库开发中经常看到这样的代码volatile uint32_t g_tick 0; void SysTick_Handler(void) { g_tick; } uint32_t Get_Tick(void) { return g_tick; }如果g_tick不加volatile编译器在优化时可能认为g_tick在主循环里没有被修改于是把它缓存到寄存器里导致Get_Tick()永远返回同一个值。这个bug非常隐蔽因为代码逻辑看起来完全正确但实际运行就是不对。类似的所有在中断和主循环之间共享的变量都必须加volatile。包括标志位、计数器、缓冲区索引等。5. 资源占用与性能优化5.1 RAM和ROM占用分析以8个软件定时器为例每个定时器控制块占用的空间字段类型字节数reloaduint32_t4counteruint32_t4modeuint8_t1stateuint8_t1callback函数指针432位机或251单片机合计14~16字节8个定时器共占用约112~128字节RAM。对于STM32F103C8T6的20KB RAM来说这完全可以接受。对于51单片机的128字节RAM来说8个定时器就占用了大部分RAM需要减少定时器数量或者精简结构体字段。精简方案如果不需要回调函数去掉callback字段如果定时值不超过65535把reload和counter改成uint16_t如果模式固定去掉mode字段。这样每个定时器可以压缩到6字节8个定时器只占48字节。5.2 中断执行时间优化中断服务函数里遍历所有定时器槽位即使大部分是空的。如果定时器数量多可以优化为只遍历活跃的定时器。一种简单的优化是用一个uint16_t active_bitmap每个bit对应一个定时器槽位。中断里只遍历active_bitmap中为1的位uint16_t bitmap active_bitmap; while (bitmap) { uint8_t i __builtin_ctz(bitmap); // 获取最低位的1的位置 // 处理timer_pool[i] bitmap bitmap - 1; // 清除最低位的1 }__builtin_ctz是GCC的内置函数用于计算尾部零的个数。在ARM Cortex-M上可以用__CLZ指令实现类似功能。对于51单片机没有这类指令直接遍历反而更快。5.3 用硬件定时器直接驱动IO有些场景下软件定时器的精度不够比如需要产生精确的PWM波形或者精确的脉冲序列。这时候应该直接用硬件定时器的PWM输出功能而不是用软件定时器去翻转IO。软件定时器适合的是时间管理场景即多个任务需要不同的时间节拍但对精度要求不是极高毫秒级。如果精度要求到微秒级或者需要硬件级别的精确控制应该用硬件外设。6. 从VtorTimer到更通用的时间管理框架6.1 支持定时器动态创建和删除静态数组方案的局限是定时器数量固定。如果项目里有时需要更多定时器有时只需要少量可以考虑用内存池来动态管理。预先分配一块内存作为定时器池用链表连接空闲节点和活跃节点。创建定时器时从空闲链表取一个节点删除时归还。这种方案比malloc/free更可控不会产生内存碎片同时支持动态数量。代价是需要额外的链表指针字段每个定时器多占用4~8字节。6.2 支持定时器优先级当多个定时器同时到期时主循环里的处理顺序决定了它们的优先级。如果某个任务比较紧急可以在主循环里先查询它的到期标志。更进一步的方案是在定时器控制块里加一个priority字段中断里把到期的定时器按优先级插入一个就绪队列主循环从队列头部依次处理。6.3 与状态机结合软件定时器经常和状态机一起使用。比如一个通信协议的状态机不同状态下有不同的超时时间switch (comm_state) { case STATE_IDLE: // 等待起始字节超时100ms vtor_timer_reset(TIMER_COMM, 100); break; case STATE_RECEIVING: // 接收数据中超时10ms vtor_timer_reset(TIMER_COMM, 10); break; case STATE_PROCESSING: // 处理数据超时50ms vtor_timer_reset(TIMER_COMM, 50); break; }每次状态切换时重置定时器的定时值实现动态超时管理。这种模式在Modbus、自定义串口协议等场景中非常实用。6.4 低功耗场景下的注意事项在电池供电的低功耗应用中软件定时器的使用需要特别注意。如果系统大部分时间处于睡眠模式硬件定时器中断可能会频繁唤醒CPU增加功耗。解决方案是在进入睡眠前计算最近的定时器到期时间把硬件定时器的中断周期调整为该时间或者使用硬件定时器的单次触发模式在到期时间点唤醒CPU。醒来后重新配置定时器继续下一轮。另外如果软件定时器的tick在睡眠期间不计数醒来后需要补偿这段时间。具体做法是在进入睡眠前记录tick值醒来后根据实际睡眠时间可以通过RTC或硬件定时器计数获得补偿tick。7. 移植到不同平台的要点7.1 从STM32移植到51单片机STM32的TIM2配置和51的定时器0配置差异很大但软件定时器的核心逻辑tick计数、减计数、到期标志是完全一样的。移植时只需要修改硬件定时器的初始化代码和中断服务函数的入口。需要注意的是51单片机的RAM非常有限定时器控制块要尽量精简。另外51的中断响应速度比STM32慢如果tick周期设得太短比如100微秒中断服务函数本身的开销就会占用大量CPU时间。建议51上的tick周期不低于1ms。7.2 从裸机移植到RTOS如果项目后期升级到RTOS软件定时器模块可以保留但使用方式要调整。RTOS通常自带软件定时器功能比如FreeRTOS的xTimerCreate可以直接用RTOS的定时器替代。如果坚持用自己的软件定时器需要注意中断优先级配置确保硬件定时器中断的优先级不高于RTOS的系统中断优先级否则会影响RTOS的调度。7.3 在GD32、ESP32等平台上的适配GD32的定时器和STM32高度兼容基本可以直接复用STM32的代码只需要修改寄存器名称和库函数。ESP32的定时器架构不同用的是ESP-IDF的esp_timer或者硬件定时器组需要重新编写硬件层代码但软件定时器的核心逻辑不变。8. 几个容易被忽略的细节8.1 定时器ID的边界检查对外接口函数里一定要做ID的边界检查。如果传入的ID超出范围或者指向一个未创建的定时器槽位直接操作数组会导致越界访问可能改写其他变量的值产生难以排查的bug。void vtor_timer_start(int8_t id) { if (id 0 || id VTOR_TIMER_MAX) return; if (timer_pool[id].state 0 timer_pool[id].reload 0) return; timer_pool[id].counter timer_pool[id].reload; timer_pool[id].state 1; }8.2 中断中的原子操作如果主循环里在修改某个定时器的同时中断也可能修改它就需要考虑原子性。比如主循环调用vtor_timer_reset修改counter和reload而中断正在对counter做减计数。如果中断在reset执行到一半时发生可能导致counter被减到一个错误的值。解决办法是在reset等关键操作前后关中断void vtor_timer_reset(int8_t id, uint32_t reload) { if (id 0 || id VTOR_TIMER_MAX) return; __disable_irq(); timer_pool[id].reload reload; timer_pool[id].counter reload; __enable_irq(); }对于51单片机用EA 0和EA 1来关开总中断。关中断的时间要尽可能短只包裹必要的几条语句。8.3 tick溢出的处理g_tick是uint32_t类型在1ms的tick下大约49.7天会溢出归零。对于大多数项目来说这个时间足够长不需要特别处理。但如果需要计算两个时间点之间的差值直接用减法即可因为无符号数的减法在溢出时仍然正确uint32_t start vtor_timer_get_tick(); // ... 一段时间后 uint32_t elapsed vtor_timer_get_tick() - start;即使g_tick在中间溢出了elapsed仍然是对的。这是无符号数运算的特性不需要额外处理。8.4 调试时的观察手段软件定时器出问题时最有效的调试手段是观察tick值和定时器的counter值。可以通过串口定期打印这些值或者用调试器实时查看。如果发现tick不增长说明硬件定时器中断没有正常触发如果tick正常但counter不减说明定时器的state不对或者中断里没有遍历到它。另一个实用技巧是在中断服务函数里翻转一个空闲的IO口用示波器观察波形。如果波形周期是1ms说明中断正常如果周期不对或者没有波形说明硬件定时器配置有问题。9. 实际项目中的选型建议9.1 什么时候用软件定时器什么时候用RTOS如果项目满足以下条件软件定时器是更好的选择任务数量少3~5个任务间没有复杂的同步和通信需求RAM和ROM资源紧张跑不起RTOS对实时性要求不高毫秒级响应即可开发者对RTOS不熟悉不想引入额外的复杂度如果项目需要任务间通信信号量、消息队列、需要动态创建删除任务、需要优先级抢占调度那RTOS更合适。9.2 定时器数量的估算在项目规划阶段先列出所有需要定时功能的任务统计它们的周期和数量。然后在此基础上留出20%~30%的余量作为定时器槽位的数量。比如项目里有5个周期性任务那分配8个定时器槽位比较合适。9.3 tick周期的选择tick周期决定了软件定时器的最小分辨率。选择原则是tick周期 ≤ 所有任务周期中最小值的1/2。比如最小任务周期是5ms那tick周期应该≤2.5ms通常取1ms。如果最小任务周期是100ms那tick周期取10ms也够用这样可以减少中断频率降低CPU开销。我在实际项目中用过1ms、5ms、10ms三种tick周期。1ms适合有数码管扫描、按键消抖等需求的项目5ms适合只有LED刷新和串口通信的项目10ms适合纯传感器采集和显示刷新的低功耗项目。10. 写在最后VtorTimer这类软件定时器模块代码量不大核心逻辑也就几十行但它解决的是单片机裸机开发中最常见的一类问题多个任务的时间管理。把这套机制吃透不仅能用在定时任务上还能延伸到状态机超时、通信协议帧间隔、按键长短按判断等场景。我自己在多个量产项目中用过这套方案从51单片机到STM32再到GD32核心代码几乎没变过只是硬件层适配不同。它的稳定性经过了实际验证值得作为裸机开发的标配组件之一。如果你正在用delay_ms()堆砌项目不妨花半天时间把软件定时器搭起来后面的开发效率会明显不一样。
返回列表