定时器
263.2.14定时器
#include <rtthread.h> // 引入 RT-Thread 的定时器、系统节拍、线程延时和内核打印接口
#define DBG_TAG "main" // 设置 rtdbg 日志标签为 main
#define DBG_LVL DBG_LOG // 设置 rtdbg 日志级别
#include <rtdbg.h> // 引入 RT-Thread 调试日志宏
rt_timer_t timer_ptr; // 定义动态定时器句柄,用于保存创建成功后返回的定时器对象
void timer_callback(void *parameter) // 定义定时器超时回调函数;本例没有使用 parameter
{ // timer_callback 函数体开始
rt_kprintf("rt_tick_ is %u \r\n",rt_tick_get()); // 每次超时时打印当前累计 OS tick
} // timer_callback 函数体结束
int main(void) // RT-Thread 用户主线程入口函数
{ // main 函数体开始
int count = 1; // 初始化课程用于维持主线程循环的计数变量
uint8_t timeout_value=400; // 按视频原样保存新周期值;正文会说明这里的类型存在严重问题
timer_ptr = rt_timer_create("timer",timer_callback,RT_NULL,100,RT_TIMER_FLAG_PERIODIC|RT_TIMER_FLAG_SOFT_TIMER); // 动态创建名为 timer、初始周期为 100 tick 的周期软定时器
if(timer_ptr == RT_NULL) // 判断动态定时器是否因内存不足等原因创建失败
{ // 创建失败分支开始
LOG_E("timer_ptr rt_timer_create failed!\r\n"); // 输出定时器创建失败日志
return -RT_ENOMEM; // 返回内存不足错误码并结束主线程入口
} // 创建失败分支结束
rt_timer_start(timer_ptr); // 启动定时器,使其进入活动定时器队列
rt_thread_mdelay(2000); // 挂起主线程约 2 秒,让定时器先按 100 tick 周期运行
rt_timer_control(timer_ptr,RT_TIMER_CTRL_SET_TIME,&timeout_value); // 传入变量地址,尝试把后续周期改为 400 tick
while (count++) // 课程用自增变量维持主线程循环,通常不会主动退出
{ // 主线程循环体开始
//rt_kprintf("rt_tick_ is %u \r\n",rt_tick_get()); // 注释掉上一节主循环中的 tick 打印,避免与定时器回调输出混淆
//LOG_D("Hello RT-Thread!"); // 注释掉原有周期日志
rt_thread_mdelay(1000); // 每轮挂起主线程约 1000 ms
rt_hw_us_delay(100); // 忙等待约 100 μs;这是视频最后用于说明接口的示例调用
} // 主线程循环体结束
return RT_EOK; // 理论上的正常返回值;本例通常不会执行到这里
} // main 函数体结束
源码说明: 上方按视频结束前画面收录
12TIMER/applications/main.c:控制值为400,且rt_hw_us_delay(100)尚未注释。配套压缩包保存的是课后继续改动过的状态,控制值变为600、微秒延时调用被注释;为避免把后续状态倒灌到本节,文首以视频实际展示为准。代码中的uint8_t timeout_value=400确有类型错误,按要求没有擅自改写,具体风险见 1.1.4.1。
一、定时器
本节所讲的是 RT-Thread 内核软件定时器,不是 STM32 的 TIM1、TIM2 等硬件外设。它以 OS Tick 为时间基准,到期后由内核安排回调函数运行,适合周期检查、状态更新和超时处理等不要求微秒级精度的工作。
本工程配置为 RT_TICK_PER_SECOND = 1000,所以 1 tick 名义上等于 1 ms。不过,定时器接口参数的正式单位仍然是 tick:只有在当前 1000 Hz 配置下,100 tick 才近似等于 100 ms。若以后修改系统节拍频率,同一个数值代表的实际时间也会改变。
RT-Thread 定时器有两组彼此独立的属性:
| 属性组 | 选项 | 含义 |
|---|---|---|
| 触发次数 | RT_TIMER_FLAG_ONE_SHOT | 到期回调一次,然后自动停止 |
| 触发次数 | RT_TIMER_FLAG_PERIODIC | 到期后重新装载,持续周期回调,直到被停止或删除 |
| 回调上下文 | RT_TIMER_FLAG_HARD_TIMER | 在 OS tick 中断处理路径中执行回调 |
| 回调上下文 | RT_TIMER_FLAG_SOFT_TIMER | 由系统软件定时器线程执行回调 |
这里的 HARD_TIMER 仍然是 RT-Thread 的内核定时器,名称只表示回调运行在中断上下文;它并不自动占用 STM32 的某个 TIM 外设。SOFT_TIMER 则需要工程启用 RT_USING_TIMER_SOFT。本工程已启用该选项,软件定时器线程优先级为 4、栈为 512 字节。
1.1 创建定时器
课程实际使用的动态创建接口是:
rt_timer_create(name, timeout, parameter, time, flag)
五个参数依次表示:
| 参数 | 本例 | 说明 |
|---|---|---|
name | "timer" | 内核对象名,便于调试和对象管理 |
timeout | timer_callback | 超时回调函数指针,不是普通硬件中断向量 |
parameter | RT_NULL | 内核调用回调时传入的用户参数,本例不用 |
time | 100 | 初始超时 tick 数;本配置下约为 100 ms |
flag | 周期 ` | ` 软定时器 |
两个标志用按位或 | 组合,因为它们分别控制“触发方式”和“回调上下文”。不能在同一属性组中同时选择互斥含义;本例选择 PERIODIC | SOFT_TIMER。
动态创建会从系统堆为定时器控制块分配内存。成功时返回 rt_timer_t 句柄,失败时返回 RT_NULL,所以课程增加了空指针检查和 -RT_ENOMEM 错误返回。创建只完成对象初始化,并不会自动开始计时。
1.1.1 静态创建定时器
原小标题写成“静态创建定时器”,但视频明确说“动态创建、静态创建,我们只用动态创建”,实际代码调用的是 rt_timer_create(),并非静态接口。
两种生命周期应这样区分:
| 方式 | 初始化接口 | 控制块来源 | 释放接口 |
|---|---|---|---|
| 动态 | rt_timer_create() | 内核从堆中分配 | rt_timer_delete() |
| 静态 | rt_timer_init() | 用户预先定义 struct rt_timer | rt_timer_detach() |
不能对静态对象调用 rt_timer_delete(),也不能用 rt_timer_detach() 代替动态对象的释放。本节没有编写静态定时器代码,因此文首不补造静态示例。
回调函数的固定形式为接受一个 void * 参数。创建定时器时传入的 parameter 会原样交给回调,本例传 RT_NULL,所以 timer_callback() 没有使用形参。正式项目若要让同一个回调服务多个对象,可以传入设备句柄或业务结构体指针,但必须保证被指向对象在定时器存活期间一直有效。
软定时器回调运行在线程上下文,比硬定时器回调限制少,但仍应短小、非阻塞。所有软定时器共享系统定时器线程;如果一个回调长时间打印、延时或等待资源,其他已经到期的软定时器也会被拖迟。硬定时器回调则位于 tick ISR 路径,不能调用会阻塞或可能触发线程调度的接口。
1.1.2 应用案例
本节回调调用 rt_tick_get(),再用 rt_kprintf() 打印当前累计 tick。这样可以从相邻输出的差值观察定时器的实际触发周期。
课程第一次只创建而未启动定时器,复位后没有周期输出。这不是创建失败:创建成功的定时器初始状态仍是未激活,尚未进入活动定时器链表。必须再调用 rt_timer_start(timer_ptr)。
创建失败检查也很重要。若 timer_ptr == RT_NULL 后仍继续调用启动或控制接口,内核参数断言可能触发;课程在失败分支立即返回,避免了这个问题。示例没有检查 rt_timer_start() 和 rt_timer_control() 的返回值,正式代码应根据返回值决定是否继续。
1.1.2.1 例题: 定时器回调函数打印
初始周期设为 100 tick,典型输出类似:
rt_tick_ is 101
rt_tick_ is 202
rt_tick_ is 303
...
相邻值常约为 101,而不是严格为 100,原因包括 tick 量化、定时器线程被唤醒和调度的延迟,以及 rt_kprintf() 本身的执行时间。RT-Thread 5.0.2 的周期定时器在回调结束后重新调用启动逻辑,以当时的当前 tick 计算下一次超时点,因此长回调还会进入下一个周期的起算时刻,形成累积漂移。
软定时器提供的是“到期后尽快执行”的调度语义,不是无抖动的硬件波形发生器。若任务需要精确采样、PWM 或严格微秒级边沿,应使用合适的 STM32 硬件定时器、输入捕获、输出比较或 PWM 外设,而不是依靠软件定时器加串口打印。
1.1.3 启动定时器
rt_timer_start(timer_ptr) 会先移除该定时器可能已有的旧链表节点,再计算:
$ timeout_tick=current_tick+init_tick $
然后根据软/硬标志插入相应的活动定时器链表,并设置激活标志。重复启动同一个定时器相当于从当前时刻重新计时,而不是叠加多个独立实例。
课程只实际演示了启动,没有逐一运行停止和删除:
rt_timer_stop():从活动链表移除定时器并清除激活状态,但保留对象,可再次启动。rt_timer_delete():删除动态创建的对象并释放其控制块内存;删除后句柄不再有效。rt_timer_detach():脱离静态初始化对象,不负责释放用户提供的存储。
停止与删除不是同义词。需要暂停后重启时用 stop;确定不再使用动态对象时才 delete。若其他线程也保存了该句柄,删除前还要处理并发访问,避免悬空指针。
1.1.4 控制定时器
控制接口为:
rt_timer_control(timer, cmd, arg)
timer:目标定时器句柄。cmd:控制命令。arg:与命令匹配的参数地址;某些只改标志的命令不使用它。
本工程 RT-Thread 5.0.2 支持的主要命令包括:
| 命令 | arg 的预期类型 | 作用 |
|---|---|---|
RT_TIMER_CTRL_SET_TIME | 指向 rt_tick_t | 设置初始超时 tick 数 init_tick |
RT_TIMER_CTRL_GET_TIME | 指向 rt_tick_t | 读出 init_tick |
RT_TIMER_CTRL_SET_ONESHOT | 不使用 | 改成单次定时器 |
RT_TIMER_CTRL_SET_PERIODIC | 不使用 | 改成周期定时器 |
RT_TIMER_CTRL_GET_STATE | 指向 rt_uint32_t | 读出激活或未激活状态 |
RT_TIMER_CTRL_GET_REMAIN_TIME | 指向 rt_tick_t | 本版本源码实际返回 timeout_tick 字段 |
原稿中的 “set pretty” 是 SET_PERIODIC 的语音识别错误,“active or detective” 应为 active or deactive。
课程先启动 100 tick 周期定时器,再让主线程延时约 2 秒,然后用 SET_TIME 改周期。需要注意:该命令只修改 init_tick,不会重新计算当前已经排入链表的 timeout_tick。因此当前这一轮仍按旧到期点运行;周期定时器回调结束并重新启动时,后续轮次才采用新值。若希望从控制时刻立即按新周期重新计时,应结合接口语义停止并重新启动,而不能假定 SET_TIME 自动重排当前周期。
1.1.4.1 例题: 定时器控制命令传参问题
这是本节最需要纠错的地方。
第一次尝试把整数 400 或 1000 直接作为第三个参数传入。arg 的类型是 void *,整数会被当成地址,例如 400 会变成地址 0x00000190;内核随后把该地址强制解释为 rt_tick_t * 并解引用。这可能触发编译警告、访问非法地址、HardFault 或断言,绝不是传值接口的正确用法。
课程根据断言表达式推测“1000 大于最大 tick 的一半,所以必须小于 500”,这个结论错误。源码检查的是:
要设置的超时 tick < RT_TICK_MAX / 2
本工程 rt_tick_t 是 32 位无符号整数,RT_TICK_MAX 是 0xFFFFFFFF,并不是 RT_TICK_PER_SECOND 的 1000。有效上限约为 2,147,483,646 tick;在 1 kHz 下约为 24.85 天。限制在无符号计数空间的一半以内,是为了让跨越 0xFFFFFFFF → 0 回绕时的模运算先后比较保持无歧义。
第二次尝试改为“变量地址”,方向正确,但视频写成了:
uint8_t timeout_value = 400
这仍然不正确,原因有两层:
uint8_t只能表示 0~255,无法表示 400;赋值会发生窄化,低 8 位为 144。- 内核按
rt_tick_t *读取 4 字节,而该对象只有 1 字节;这会越界读取相邻栈内存,属于未定义行为。
视频中它碰巧得到约 400 tick 的效果,只能说明当时相邻内存字节偶然组合出了预期数值,不能证明写法正确。配套归档后来把常量改成 600,却仍保留 uint8_t,问题同样存在。正确原则是:SET_TIME 的变量对象必须使用接口要求的 rt_tick_t 类型,再把该对象的地址传入;精校版为遵守“课程源码原样保留”的要求,没有直接改写文首代码。
控制后典型输出从约 100 tick 间隔变为约 400 tick:
rt_tick_ is 1919
rt_tick_ is 2020
rt_tick_ is 2421
rt_tick_ is 2822
rt_tick_ is 3223
每次约增加 401 而非绝对 400,仍与回调打印时间、软定时器线程调度及 tick 量化有关。
1.1.5 高精度延时
本 BSP 提供的接口名是 rt_hw_us_delay(),不是原稿 OCR 得到的 rthw_us_delay()。视频加入:
rt_hw_us_delay(100); // 忙等待约 100 μs,调用期间当前线程不会主动挂起或让出 CPU
它与 rt_thread_mdelay() 的行为不同:
| 接口 | 时间单位 | 实现方式 | 当前线程状态 | 适用场景 |
|---|---|---|---|---|
rt_thread_mdelay() | ms | 换算为 OS Tick,启动线程定时器 | 挂起,让调度器运行其他线程 | 普通毫秒级等待 |
rt_hw_us_delay() | μs | 轮询 SysTick 当前计数值 | 一直占用当前 CPU 执行流 | 很短的微秒级时序等待 |
该 BSP 的实现不是另外启用一个 STM32 TIM 外设,而是读取 SysTick->VAL 和 SysTick->LOAD,根据 SystemCoreClock / 1,000,000 换算每微秒的内核时钟数,在循环中等待计数差达到目标。
课程强调参数必须短于一个 OS tick,在这个具体实现中确有依据:差值算法只处理 至多一次 SysTick 回绕。本工程一 tick 约为 1000 μs,所以调用值应严格小于这一周期;目标过长时,差值永远达不到要求,可能陷入死循环。即使参数小于 1000 μs,若期间被高优先级中断或线程抢占并经历多次 SysTick 回绕,测量也会失真。
“微秒级”描述的是计数分辨率,不等于每次误差都小于 1 μs。函数调用、循环指令、编译优化、中断抢占和 SystemCoreClock 配置都会影响结果。它也会浪费 CPU,所以只适合 DHT11、软件模拟总线等确需短忙等待的局部时序;较长延时仍应使用线程挂起或硬件定时器。
1.1.6 软件定时器的检查链路
上一节看到 SysTick ISR 调用 rt_tick_increase()。RT-Thread 5.0.2 在该函数末尾调用 rt_timer_check(),它直接检查 硬定时器链表,所以硬定时器回调位于 tick 中断上下文。
软定时器采用另一条链路:
SysTick 到来
└─ rt_tick_increase()
├─ 推进全局 tick、处理线程时间片
└─ rt_timer_check() 检查硬定时器
软件定时器线程
└─ 等待最近的软定时器到期
└─ rt_soft_timer_check()
└─ 在线程上下文调用 timer_callback()
rt_timer_start() 会根据 RT_TIMER_FLAG_SOFT_TIMER 把对象插入软定时器链表,并在需要时唤醒定时器线程。链表按绝对 timeout_tick 排序;内核利用无符号减法和半范围规则处理 tick 自然回绕。周期定时器回调结束后会重新启动,单次定时器则清除激活标志。
这说明软件定时器仍以 OS Tick 为基础,却不是简单地在每次 SysTick ISR 中直接执行所有软回调。将耗时工作放入独立业务线程、让回调只发送事件或信号,通常比在共享定时器线程里完成整段业务更稳妥。
核对依据: 本节业务代码、调试现象与调用顺序取自课程视频、逐帧画面、转录文本和配套
12TIMER工程;参数类型、半范围上限、软/硬回调上下文、控制命令和重装行为已与 RT-Thread 5.0.2 的timer.c、rtdef.h、rtthread.h及官方内核基础文档交叉核对;微秒延时限制按本工程drivers/drv_common.c的实际实现分析。