定时器

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

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"内核对象名,便于调试和对象管理
timeouttimer_callback超时回调函数指针,不是普通硬件中断向量
parameterRT_NULL内核调用回调时传入的用户参数,本例不用
time100初始超时 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_timerrt_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

这仍然不正确,原因有两层:

  1. uint8_t 只能表示 0~255,无法表示 400;赋值会发生窄化,低 8 位为 144。
  2. 内核按 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 的实际实现分析。