延时设计需要先回答:是在等待一个最短时间、按固定周期执行任务,还是到某个硬件事件才继续?这三种需求分别涉及计时精度、调度和事件同步,不能用同一个 delay() 隐藏所有条件。
本页面向 STM32F103 的裸机定时器例子,并说明 FreeRTOS 中的时间处理。涉及其他器件时,寄存器位宽、时钟源和低功耗行为需重新核对。
分辨率、准确度与响应时间
跳转到“分辨率、准确度与响应时间”“计数器每微秒加一”描述的是分辨率;时钟快慢决定准确度;中断或任务何时真正执行又决定响应时间。软件调用、总线访问、抢占以及输出引脚翻转都有额外开销。
| 方法 | CPU 与精度特点 | 适用条件 |
|---|---|---|
| 空循环/NOP | CPU 忙等待;耗时受编译器、指令、流水线、缓存和抢占影响 | 经过实测、变化范围可控的短等待;不能直接认为一个循环等于一个周期 |
| SysTick 轮询 | 仍是忙等待;计数器有明确时基但位宽有限 | SysTick 未被 HAL/RTOS 等占用,且接受独占修改 |
| 通用定时器轮询 | 时基本身由硬件计数;CPU 等待期间仍占用执行资源 | 短等待,定时器作为稳定的共享只读时基或专属资源 |
| 定时器比较/中断/DMA | CPU 可执行其他工作;硬件边沿比软件回调更确定 | 输出波形、采样触发、事件完成通知 |
| FreeRTOS 延时 | 当前任务进入 Blocked,其他任务或 Idle 可运行 | tick 精度足够的任务调度;恢复运行可能晚于到期时间 |
| RTC/低功耗定时器 | 可配合低功耗状态;准确度依时钟源 | 长时间计时与唤醒,必须配置实际唤醒源 |
不能给这些方法统一贴上“±1%”“0% CPU”或“90% CPU 利用率”的标签。即使当前任务不消耗执行时间,其他任务、中断和调度器仍在工作;低功耗也取决于系统是否真的进入合适的睡眠模式。
纳秒级引脚时序通常应交给硬件输出比较、专用接口或外部电路。微秒级等待应检查计数分辨率和最坏抢占;毫秒以上任务通常优先使用非阻塞时基或 RTOS 阻塞机制。
SysTick 的几个容易遗漏的条件
跳转到“SysTick 的几个容易遗漏的条件”SysTick 的重装字段只有 24 位。采用计数时钟 时,一次周期为:
F103 可以选择 HCLK 或 HCLK/8。原来的示例用 SystemCoreClock 计算 ticks,却只向 CTRL 写入 ENABLE,这会清掉 CLKSOURCE,不能继续按 HCLK 解释计时。在 F1 上,可能得到约八倍的延时。Arm 的 SysTick_Config() 会检查重装范围并显式设置 CLKSOURCE;它同时启用 SysTick 中断,因此也不是可随时插入任意工程的微秒延时函数。CMSIS Cortex-M3 源码、ST HAL SysTick 时钟选择
还需要处理:
us=0时ticks-1下溢;大输入乘法溢出;非整数 MHz 时直接整除造成误差。- HCLK 改变后
SystemCoreClock是否更新;LOAD/VAL/CTRL 是否已经被其他代码使用。 - COUNTFLAG 是计数到零状态,不是排队保存每一次到零事件的计数器;读取 CTRL 还会清除 COUNTFLAG。
- 改写、关闭 SysTick 会影响以它为基础的 HAL 时基或 RTOS。不能为了一个短等待把它们的系统时间一起停止。
因此下面使用专门配置的通用定时器说明裸机短延时。
F103 的自由运行微秒时基
跳转到“F103 的自由运行微秒时基”以下代码拥有 TIM3,要求调用方传入已核实的 TIM3 内核时钟,不是默认把 CPU 主频传进去。初始化会复位 TIM3;不要与 PWM、输入捕获、其他驱动或 HAL 时基复用同一个实例。计时函数只读 CNT,多个调用者不会互相清零计数器,但忙等待仍会占用当前执行上下文。
#include <stdbool.h>#include <stdint.h>#include "stm32f103xb.h"
bool tim3_timebase_init(uint32_t timer_hz){ if (timer_hz < 1000000u || timer_hz % 1000000u != 0u) { return false; } uint32_t divider = timer_hz / 1000000u; if (divider > 65536u) { return false; }
RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; (void)RCC->APB1ENR; RCC->APB1RSTR |= RCC_APB1RSTR_TIM3RST; RCC->APB1RSTR &= ~RCC_APB1RSTR_TIM3RST;
TIM3->CR1 = TIM_CR1_URS; TIM3->PSC = divider - 1u; TIM3->ARR = 0xffffu; TIM3->EGR = TIM_EGR_UG; TIM3->SR = 0u; /* stopped, exclusive initialization: discard all old flags */ TIM3->CR1 |= TIM_CR1_CEN; return true;}
void delay_us_poll(uint32_t us){ while (us != 0u) { uint16_t part = (us > 60000u) ? 60000u : (uint16_t)us; uint16_t start = (uint16_t)TIM3->CNT; uint16_t ticks = (uint16_t)(part + 1u); while ((uint16_t)((uint16_t)TIM3->CNT - start) < ticks) { /* busy wait; interrupts remain enabled */ } us -= part; }}在已配置成 1 MHz 的条件下,16 位 CNT 每 65.536 ms 回绕。减法再转换为 uint16_t,得到模 65536 的差值,不需要用 if (now < start) 手动拼接。每一段不超过 60000 µs,另加一个 tick 以覆盖读取起点所处的计数相位;时钟准确度仍取决于真实硬件。
us=0 直接返回。若中断或高优先级任务持续很久,计数器仍在走,返回时间会延后;若期间经过整圈回绕,还可能额外等待。这个函数没有给出最大响应时间保证,也不适合在高频 ISR 内使用。对精确输出脉宽,直接配置硬件比较/PWM 更合适。
原来“先 CNT=0、ARR=us−1,然后等 UIF”的写法还要处理旧 UIF、PSC 的缓冲装载、ARR 预装载、上一次是否停止,以及并发使用者。独占定时器的一次性等待可以这样设计,但不能省略这些状态管理。
非阻塞软件定时器
跳转到“非阻塞软件定时器”用稳定递增的毫秒时基记录起点,主循环随时检查是否到期,就能在等待期间继续处理串口、采样和状态机。若工程已经使用 HAL 时基,直接读取 HAL_GetTick(),不要再另写一个覆盖原有行为的 SysTick_Handler()。
#include <stdbool.h>#include <stdint.h>
typedef struct { uint32_t anchor_ms; uint32_t period_ms;} periodic_timer_t;
bool periodic_timer_take(periodic_timer_t *timer, uint32_t now_ms){ if (timer == 0 || timer->period_ms == 0u || timer->period_ms > UINT32_C(0x7fffffff)) { return false; } uint32_t elapsed = now_ms - timer->anchor_ms; if (elapsed < timer->period_ms) { return false; } uint32_t periods = elapsed / timer->period_ms; timer->anchor_ms += periods * timer->period_ms; return true;}每个调用者保存自己的 periodic_timer_t。初始化时令 anchor_ms=HAL_GetTick()、period_ms=500;主循环调用上述函数,返回 true 时翻转 LED,然后继续处理其他工作。
这个版本保持原来的周期相位,落后多个周期时只报告一次并跳过已错过的周期。如果应用要求补做每次采样,应另行定义补做策略;补做历史采样也不能恢复当时没有取得的真实数据。
无符号差值可以跨一次数值回绕,但不能凭一个 32 位计数值推断已经过去任意多圈。示例约定定期检查、两次有效观察之间的实际时间不跨越完整 32 位毫秒周期,并将 period 限为半范围以内。毫秒 tick 自身因低功耗停止或中断长期被屏蔽而丢时,也不在模减法能够修复的范围内。
位宽决定的是条件下的范围
跳转到“位宽决定的是条件下的范围”F103 的 TIM2 并不是 32 位;RM0008 的 TIM2~TIM5 通用计数器为 16 位。部分其他系列的 TIM2/5 才是 32 位实现。STM32F103x8/xB 数据手册 Table 4
对于边沿向上计数、16 位 PSC 的一次周期:
| 条件 | 一次周期最大值 |
|---|---|
| MHz,PSC/ARR 都取16位最大值 | 约 59.6523236 s |
| 假设某个实际支持32位 ARR 的定时器,仍用72 MHz和16位最大 PSC | 约 45.2473921 d |
| 1 MHz 计数时钟,16位 ARR 最大值 | 65.536 ms |
这些数值都带有时钟条件。降低输入时钟、使用外部计数或软件累计后可以得到其他范围;“16 位定时器最大约 60 秒”不是对所有配置的绝对限制。
参数自动选择应向上取整
跳转到“参数自动选择应向上取整”想用单次 16 位周期等待至少给定微秒数时,可以先计算参数,再由独占硬件的初始化过程装载。下面只计算寄存器参数,不访问硬件;它拒绝 0 输入和超出单次可表示范围的请求。
#include <stdbool.h>#include <stdint.h>
static uint64_t ceil_div_u64(uint64_t value, uint64_t divisor){ return value / divisor + (value % divisor != 0u);}
bool timer16_delay_parameters(uint32_t timer_hz, uint32_t us, uint16_t *psc, uint16_t *arr){ if (timer_hz == 0u || us == 0u || psc == 0 || arr == 0) { return false; } uint64_t scaled_cycles = (uint64_t)timer_hz * us; uint64_t divider = ceil_div_u64(scaled_cycles, UINT64_C(1000000) * 65536u); if (divider == 0u || divider > 65536u) { return false; } uint64_t counts = ceil_div_u64(scaled_cycles, divider * UINT64_C(1000000)); if (counts == 0u || counts > 65536u) { return false; } *psc = (uint16_t)(divider - 1u); *arr = (uint16_t)(counts - 1u); return true;}64 位乘法保留 timer_hz × us;向上取整避免像 us/10、us/100 那样直接截短请求。返回参数仍需按硬件更新时序装入,并检查当前计数器已经停止。它给出可行的离散周期,不保证每个请求都能得到零误差。
超长等待可以分段,但每一段必须符合当前时基范围。原来的“每段 60000 ms”不能放进只能表示 59.652 s 的单周期配置。长期计时优先考虑软件累计、RTC 或低功耗定时器。
FreeRTOS 中怎样延时
跳转到“FreeRTOS 中怎样延时”vTaskDelay() 的参数单位是 tick,不是毫秒。vTaskDelay(500) 只有在 1 kHz tick 下才约等于 500 ms。应使用 pdMS_TO_TICKS() 并检查较短时间是否被转换成 0;零 tick 不能满足“至少等待一段时间”的要求。
任务请求 N 个 tick 后,从调用时刻到解除阻塞的时间通常位于约 N−1 到 N 个 tick 周期之间;调用发生在相邻 tick 的哪个位置会改变这个时间。解除阻塞只说明任务变为 Ready,真正获得 CPU 还受其他任务和中断影响。FreeRTOS tick 分辨率
周期采样使用 DelayUntil 系列可以避免“工作耗时 + 相对延时”不断累计漂移。下面针对提供 xTaskDelayUntil() 的 FreeRTOS 版本;sample_sensor() 是应用提供的函数。
#include "FreeRTOS.h"#include "task.h"
extern void sample_sensor(void);
void sensor_task(void *argument){ (void)argument; TickType_t anchor = xTaskGetTickCount(); const TickType_t period = pdMS_TO_TICKS(1000u); configASSERT(period > 0u); for (;;) { sample_sensor(); (void)xTaskDelayUntil(&anchor, period); }}如果任务一次工作已经超出周期,DelayUntil 不会把已经错过的时间倒回去。应记录超期并决定跳过、降载还是报错。需要在确定硬件时刻取样时,应由定时器触发 ADC/DMA,再通知任务处理数据。
微秒等待与临界区
跳转到“微秒等待与临界区”RTOS tick 常用于毫秒量级调度,不能依靠 vTaskDelay(1) 得到固定的一微秒或固定一个完整 tick 的最短延时。短等待可以使用前面的自由运行计数器,但应承认抢占带来的延后。
没有通用的“低于 100 µs 就可以关中断”门槛。临界区要按系统的最大中断延迟、通信时限和控制周期决定;在使用 BASEPRI 的端口中,FreeRTOS 临界区也可能只屏蔽规定优先级范围。若某个精确边沿必须不受调度影响,优先交给硬件产生。FreeRTOS 临界区说明
多任务共享延时资源
跳转到“多任务共享延时资源”volatile 不能把共享的 delay_counter 变成每个任务独立的计时器,也不能保证一组字段的发布顺序与一致快照。任务 A 写 100、任务 B 写 200,仍会互相覆盖。
原稿的自建延时管理器用 mutex 保护任务侧的表,却由 ISR 同时递减该表。任务 mutex 不会自动屏蔽 ISR,因此这种保护不完整。此外还存在:0 ms 递减下溢、分配/active 发布竞争、已积累通知造成提前返回、通知通道与其他功能混用,以及任务删除后的句柄生命周期。FreeRTOS mutex 语义
普通任务延时直接交给内核;每个任务的等待状态由内核管理。若等待的是专属硬件定时器事件,可以使用任务通知/队列等 FromISR 接口,但必须定义通知归属、清旧事件顺序、超时/取消、对象生命周期和允许调用内核 API 的中断优先级。需要保护 ISR 共享数据时,采用与该端口和优先级配置匹配的短临界区或明确的无锁发布协议,不能仅在任务侧加一个 mutex。
长时间等待与低功耗
跳转到“长时间等待与低功耗”RTC 适合长时间计时,但调用“进入 Stop/Standby”本身不会自动在目标时刻唤醒。需要先配置正确的 RTC 时钟、绝对闹钟/日历值、清旧标志、路由唤醒事件,再按该芯片要求进入低功耗状态。
F1 的 RTC 与较新系列的日历 RTC 不同。Stop 返回后可能要恢复时钟;Standby 唤醒通常从复位启动过程重新执行,不能写成一个普通函数稍后接着返回。要保存恢复所需状态,并验证目标模式下 SRAM、计数器、时基和外设是否保留。STM32F1 HAL RTC 的限制与唤醒说明
怎样验证实际时间
跳转到“怎样验证实际时间”- 用示波器观察进入/退出处翻转的 GPIO,记录包含函数调用、寄存器访问和测量操作的总时间,并在不同中断负载下观察最大值。
- 使用 DWT CYCCNT 前确认处理器实现、使能/解锁条件、实际 CPU 频率及32位回绕;停止时钟、低功耗和调试器可能影响计数。
- I2C/SPI 时序应按协议定义的最短建立/保持时间与最高频率检查,优先使用硬件接口,不能仅凭一个名叫
delay_us()的函数证明时序合格。 - “上次使用定时器的是另一个任务”只能说明资源被不同调用者访问,不能证明它们同时冲突。验证资源所有权和访问区间,比保存一个
last_user指针更可靠。