1. 前言:为什么我们需要软件定时器
在嵌入式裸机开发中,硬件定时器资源稀缺是非常普遍的痛点。 一个常规单片机往往只有 2~4 个通用硬件定时器,却要同时承担 PWM 输出、输入捕获、延时、周期性任务调度等多种工作,资源分配捉襟见肘。
软件定时器(Software Timer)就是解决这个问题的最优方案之一:
- 只占用 1 个硬件时基(通常是 SysTick 滴答定时器),就能虚拟出多路独立定时器
- 纯 C 实现,零硬件依赖,所有带 SysTick 的 MCU(STM32、TI MSPM0、GD32 等)均可直接移植
- 轻量无阻塞,适合 LED 闪烁、按键扫描、通信超时检测、状态轮询等绝大多数慢速定时场景
本文从零拆解一套工程级软件定时器的实现思路,包含SysTick 时基封装、定时器数组管理、weak 弱函数解耦三大核心模块,源码可直接复制进项目使用。
2. 核心原理:软件定时器是怎么工作的
2.1 硬件定时器 vs 软件定时器
| 对比维度 | 硬件定时器 | 软件定时器 |
|---|---|---|
| 实现方式 | 芯片内部硬件电路 | 纯软件逻辑模拟 |
| 数量 | 稀缺,固定几个 | 理论上可无限扩展 |
| 精度 | 极高,可达 us 级 | 取决于时基,通常 ms 级 |
| CPU 占用 | 几乎不占用 | 中断内需遍历计数,占用少量 CPU |
| 适用场景 | 高精度时序、PWM、捕获 | 周期任务、超时检测、状态轮询 |
2.2 核心工作机制
软件定时器的本质可以用一句话概括:
一个统一硬件心跳 + 多路独立计数器 + 到期回调执行
- 底层由SysTick 1ms 中断提供固定时基,作为全局心跳
- 每一路软件定时器维护独立的计数值和周期值
- 每进一次 SysTick 中断,所有已启用的定时器计数 + 1
- 当计数值达到设定周期时,触发对应回调函数,重置计数,实现循环定时
2.3 整体架构分层
本文实现采用三层架构,职责清晰、耦合度低:
- 时基层:SysTick 驱动,提供 1ms 心跳、HAL_Delay 延时、统一 Tick 接口
- 核心层:软件定时器管理,负责定时器注册、停止、计数、到期判断
- 业务层:用户回调函数,通过弱符号实现驱动与业务解耦
3. 完整源码与逐行解析
3.1 时基层:SysTick 驱动封装
systick.h头文件:对外暴露初始化、获取 Tick、延时函数接口。
#ifndef __SYSTICK__ #define __SYSTICK__ #include "ti_msp_dl_config.h" //这里修改为对应芯片头文件包即可 #define HAL_MAX_DELAY 0xFFFFFFFFU /** * @brief 获取当前系统滴答计数(单位ms) * @retval 32位滴答值 */ uint32_t HAL_GetTick(void); /** * @brief 毫秒级阻塞延时 * @param[in] Delay 延时时长,单位ms */ void HAL_Delay(uint32_t Delay); /** * @brief SysTick中断初始化 */ void systick_init(void); #endif // __SYSTICK__systick.c源文件:核心是 SysTick 中断服务函数,同时驱动软件定时器心跳。
#include "systick.h" static __IO uint32_t uwTick = 0; static const uint32_t uwTickFreq = 1U; // 声明软件定时器滴答处理函数 extern void softTimer_TickHandler(void); /** * @brief SysTick中断服务函数,1ms触发一次 * @note 软件定时器的心跳源头,必须保证1ms固定周期 */ void SysTick_Handler(void) { uwTick++; softTimer_TickHandler(); // 驱动所有软件定时器计数 } /** * @brief 获取当前系统滴答值 */ uint32_t HAL_GetTick(void) { return uwTick; } /** * @brief 初始化SysTick中断 */ void systick_init(void){ NVIC_ClearPendingIRQ(SysTick_IRQn); NVIC_EnableIRQ(SysTick_IRQn); } /** * @brief 毫秒级阻塞延时 * @note 基于SysTick实现,精度1ms */ void HAL_Delay(uint32_t Delay) { uint32_t tickstart = HAL_GetTick(); uint32_t wait = Delay; if (wait < HAL_MAX_DELAY) { wait += (uint32_t)(uwTickFreq); } while ((HAL_GetTick() - tickstart) < wait) { } }设计要点:
- 全局 Tick 变量加
__IO修饰,防止编译器优化导致读取异常 - 软件定时器的 Tick 处理直接放在 SysTick 中断内,保证计数精准
- 兼容 HAL 库风格的 API 命名,熟悉 STM32 的开发者可以无缝上手
3.2 核心层:软件定时器实现
soft_timer.h头文件:对外 API、弱回调声明。
#ifndef __SOFT_TIMER_H #define __SOFT_TIMER_H #include <stdint.h> #include <stdbool.h> #include <__stddef_null.h> /* 对外API声明 */ void softTimer_init(void); bool softTimer_Register(uint8_t id, uint32_t period_ms, void (*callback)(void)); void softTimer_Stop(uint8_t id); /* 弱回调函数声明(用户可重写) */ void softTimer_0_Callback(void); void softTimer_1_Callback(void); void softTimer_2_Callback(void); void softTimer_3_Callback(void); void softTimer_4_Callback(void); void softTimer_5_Callback(void); void softTimer_6_Callback(void); void softTimer_7_Callback(void); #endif /* __SOFT_TIMER_H */soft_timer.c源文件:结构体定义和核心逻辑全部实现。
#include "soft_timer.h" #define MAX_SOFT_TIMER 8 // 最大支持8路软件定时器 /** * @brief 软件定时器对象结构体 * @note 每一路定时器独立保存所有状态 */ typedef struct { uint8_t id; // 定时器ID uint8_t enable; // 使能标志:1启用 0停止 uint32_t period_ms; // 定时周期,单位ms uint32_t tick_cnt; // 当前计数值 void (*callback)(void); // 到期回调函数指针 }SoftTimer_TypeDef; // 定时器数组,static限定仅本文件访问,封装性更强 static SoftTimer_TypeDef soft_timer[MAX_SOFT_TIMER] = {0}; /** * @brief 软件定时器模块初始化 * @note 上电调用一次,清空所有定时器状态 */ void softTimer_init(void) { for(uint8_t i = 0; i < MAX_SOFT_TIMER; i++) { soft_timer[i].id = i; soft_timer[i].enable = 0; soft_timer[i].period_ms = 0; soft_timer[i].tick_cnt = 0; soft_timer[i].callback = NULL; } } /** * @brief 注册并启动一个软件定时器,重复调用相当于重新计时 * @param[in] id 定时器ID 0~7 * @param[in] period_ms 定时周期,单位ms,不可为0 * @param[in] callback 到期回调函数,不可为NULL * @retval true成功 false参数非法 */ bool softTimer_Register(uint8_t id, uint32_t period_ms, void (*callback)(void)) { // 参数合法性校验,防止越界和空指针 if(id >= MAX_SOFT_TIMER || period_ms == 0 || callback == NULL) { return false; } // 临时指针缓存地址,减少重复寻址计算 SoftTimer_TypeDef *p_timer = &soft_timer[id]; p_timer->enable = 1; p_timer->tick_cnt = 0; p_timer->period_ms = period_ms; p_timer->callback = callback; return true; } /** * @brief 停止指定ID的定时器,并清零计数 * @param[in] id 目标定时器ID */ void softTimer_Stop(uint8_t id) { if (id < MAX_SOFT_TIMER) { SoftTimer_TypeDef *p_timer = &soft_timer[id]; p_timer->enable = 0; p_timer->tick_cnt = 0; } } /** * @brief 软件定时器滴答处理函数 * @note 必须在SysTick 1ms中断中调用,是整个模块的心脏 */ void softTimer_TickHandler(void) { for( uint8_t i = 0; i < MAX_SOFT_TIMER; i++) { SoftTimer_TypeDef *p_timer = &soft_timer[i]; // 只处理已启用的定时器 if(p_timer->enable) { p_timer->tick_cnt++; // 计数达到周期,执行回调并重置 if(p_timer->tick_cnt >= p_timer->period_ms) { p_timer->callback(); p_timer->tick_cnt = 0; } } } } /** * @brief 定时器0~7 默认弱回调 * @note __attribute__((weak)) 弱符号,用户可在业务层重写覆盖 */ __attribute__((weak)) void softTimer_0_Callback(void) {} __attribute__((weak)) void softTimer_1_Callback(void) {} __attribute__((weak)) void softTimer_2_Callback(void) {} __attribute__((weak)) void softTimer_3_Callback(void) {} __attribute__((weak)) void softTimer_4_Callback(void) {} __attribute__((weak)) void softTimer_5_Callback(void) {} __attribute__((weak)) void softTimer_6_Callback(void) {} __attribute__((weak)) void softTimer_7_Callback(void) {}核心设计细节讲解:
static 数组封装定时器数组加 static 修饰,仅本文件可访问,避免外部非法篡改内部状态,符合高内聚低耦合的工程规范。
参数前置校验注册函数先判断 ID 越界、周期为 0、空指针,从源头杜绝数组越界、野指针等致命问题,提升代码健壮性。
临时指针优化
SoftTimer_TypeDef *p_timer = &soft_timer[id];
- 直接数组下标访问,每一行都要执行「基地址 + 下标 × 结构体大小」的地址计算
- 临时指针只计算 1 次地址,后续成员访问直接解引用,减少重复运算
- 在 1ms 高频中断的 TickHandler 中,能有效降低 CPU 开销,中断快进快出
4.弱函数工作原理:
- 弱符号(Weak Symbol)是链接阶段属性:驱动层提供默认空实现
- 如果用户在业务代码中定义了同名普通函数(强符号),链接器会自动用强符号覆盖弱符号
- 用户不重写也不会报错,默认执行空函数,不影响程序运行
优势:驱动与业务完全解耦,新增定时器业务逻辑不需要修改驱动源码,只需要在 main.c 或业务文件中重写回调即可,移植性和可维护性大幅提升,也是 STM32 HAL 库的通用设计思路。
注意:
__attribute__((weak))是 GCC/CCS 编译器语法,Keil MDK 可替换为__weak,读者可自行封装跨平台宏提升兼容性。
4. 三步快速上手使用
步骤 1:移植文件到工程
将systick.h、systick.c、soft_timer.h、soft_timer.c加入工程,确保 SysTick 已配置为 1ms 中断。
步骤 2:初始化与注册
在 main 函数中完成初始化,注册需要的定时器:
#include "systick.h" #include "soft_timer.h" int main(void) { // 系统时钟、外设初始化... systick_init(); softTimer_init(); // 注册定时器0:500ms周期定时 softTimer_Register(0, 500, softTimer_0_Callback); // 注册定时器1:1000ms周期定时 softTimer_Register(1, 1000, softTimer_1_Callback); while(1) { // 主循环业务逻辑 } }步骤 3:重写回调执行业务
在业务代码中重写对应 ID 的回调函数,推荐只在回调中置标志位,业务逻辑放主循环执行,避免阻塞中断。
// 全局标志位 volatile uint8_t led_flip_flag = 0; volatile uint8_t key_scan_flag = 0; // 重写定时器0回调:500ms翻转LED void softTimer_0_Callback(void) { led_flip_flag = 1; } // 重写定时器1回调:1s按键扫描 void softTimer_1_Callback(void) { key_scan_flag = 1; // 如需单次定时,在这里调用 softTimer_Stop(1); 即可 }5. 避坑指南:
回调函数禁止耗时操作TickHandler 运行在中断上下文,回调里不能写 HAL_Delay、串口打印、大量运算,否则会阻塞 SysTick 中断,导致所有定时器精度下降,甚至系统崩溃。 正确做法:回调只置标志位,业务逻辑放主循环。
定时器数量并非越多越好每增加一路定时器,中断内就多一次判断和计数。8 路以内对系统几乎无影响,超过 32 路建议优化为时间轮算法,降低遍历开销。
弱函数的常见错误
- ❌ 头文件中加 weak 修饰:导致每个包含头文件的.c 都生成弱符号,多重定义
- ❌ 用户重写也加 weak:两个弱符号,链接器随机选择,逻辑异常
- ✅ 正确做法:weak 只加在.c 的默认实现上,用户重写不加任何修
4.精度误差说明软件定时器最小精度为 1ms,且存在 ±1tick 的误差,不适合高精度时序场景;us 级精准时序请使用硬件定时器。
5. 思考: 如果想让颗粒度变成 秒(s)级, 只需把RTC配置为1s中断周期,并把软件定时器TickHandler放入RTC中断回调函数即可
6. 总结
这套软件定时器是裸机开发中性价比极高的通用组件,代码量小、移植简单、解耦规范,能够解决绝大多数项目中硬件定时器不足的问题。 从 SysTick 时基到弱回调设计,完整遵循了嵌入式驱动的工程化规范,建议收藏备用,新项目直接复制复用即可。
如果有优化建议或使用问题,欢迎在评论区交流讨论。