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/(71+1)=1MHz,即每1微秒计数一次。自动重装载寄存器设为999,则每1000次计数溢出一次,溢出周期为1000微秒=1ms。这就是我们需要的1ms心跳。
对于51单片机,比如STC89C52,用定时器0工作在模式1(16位定时器),晶振12MHz,机器周期1微秒。要产生1ms中断,需要计数1000次,初值为65536-1000=64536=0xFC18。所以TH0=0xFC, TL0=0x18。
注意:硬件定时器的中断周期决定了软件定时器的最小时间粒度。如果你设的是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单片机,配置定时器0:
void vtor_timer_init(void) { TMOD &= 0xF0; TMOD |= 0x01; // 定时器0,模式1(16位) 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,一个字节传输时间约1ms,10ms超时意味着连续10个字节时间内没有新数据就认为帧结束。如果波特率更高,比如115200,一个字节约87微秒,超时时间可以设短一些,比如3~5ms。
4.3 多任务时间片调度
当系统里有多个周期性任务时,可以用软件定时器实现一个简单的时间片调度:
| 任务 | 周期 | 定时器ID | 处理内容 |
|---|---|---|---|
| LED刷新 | 500ms | TIMER_LED | 翻转LED状态 |
| 数码管扫描 | 5ms | TIMER_SEG | 刷新一位数码管 |
| 按键扫描 | 20ms | TIMER_KEY | 检测按键状态 |
| 串口发送 | 100ms | TIMER_UART_TX | 发送心跳包 |
| 传感器采集 | 1000ms | TIMER_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个软件定时器为例,每个定时器控制块占用的空间:
| 字段 | 类型 | 字节数 |
|---|---|---|
| reload | uint32_t | 4 |
| counter | uint32_t | 4 |
| mode | uint8_t | 1 |
| state | uint8_t | 1 |
| callback | 函数指针 | 4(32位机)或2(51单片机) |
| 合计 | 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()堆砌项目,不妨花半天时间把软件定时器搭起来,后面的开发效率会明显不一样。