delay.c:
#include "delay.h"/*** @brief 微秒级延时* @param nus 延时时长,范围:0~233015* @retval 无*/
void delay_us(uint32_t nus)
{uint32_t ticks;uint32_t tcnt = 0, told, tnow;uint32_t reload = SysTick->LOAD; //重装载值ticks = nus * 72; //需要计的节拍数told = SysTick->VAL; //刚进入while循环时计数器的值while(1){tnow = SysTick->VAL;if(tnow != told){if(tnow < told)tcnt += told - tnow;elsetcnt += reload - (tnow -told);told = tnow; //下次进入while循环时,当前VAL的值作为toldif(tcnt >= ticks) //已计的数超过/等于需要计的数时,退出循环break;}}
}/*** @brief 毫秒级延时* @param nms 延时时长,范围:0~4294967295* @retval 无*/
void delay_ms(uint32_t nms)
{while(nms--)delay_us(1000);
}/*** @brief 秒级延时* @param ns 延时时长,范围:0~4294967295* @retval 无*/
void delay_s(uint32_t ns)
{while(ns--)delay_ms(1000);
}/*** @brief 重写HAL_Delay函数* @param nms 延时时长,范围:0~4294967295* @retval 无*/
void HAL_Delay(uint32_t nms)
{delay_ms(nms);
}
delay_us()
的思路是:不去改动 SysTick 的配置,而是把 SysTick 当作一个已在运行的“全局倒计时器”,通过读取当前计数值 VAL
并累计消逝的时钟拍数,直到达到目标“微秒对应的时钟拍数”为止。
关键硬件背景(STM32F103C8T6 的 SysTick)
SysTick 是一个 24 位 向下计数定时器:
LOAD
:重装载值(计数到 0 后会重新装入)。VAL
:当前计数值(从LOAD
往 0 递减)。CTRL
:控制/状态,包含是否用 AHB(HCLK)还是 AHB/8 作时钟源。
常见配置(比如 HAL 里)会把 SysTick 配成 1ms 节拍,例如在 72 MHz 下:
若时钟源为 AHB,
LOAD = 72,000 - 1
;若时钟源为 AHB/8,
LOAD = 9,000 - 1
。
本函数不设置 SysTick,只读取它的 LOAD
和 VAL
。
代码逐步做了什么
读出
reload = SysTick->LOAD
该值用于在检测到VAL
回卷(underflow)时,计算跨回卷的增量。把“需要的微秒”换算成“需要的时钟拍数”
ticks = nus * 72;
这里假设 SysTick 的时钟源是 AHB=72 MHz(即每微秒 72 拍)。
如果你的工程把 SysTick 时钟设为 AHB/8,那应改为
ticks = nus * 9;
。
3.拍下进入循环时的 VAL
told = SysTick->VAL;
之后在循环里一直比较“这次的 VAL
”与“上次的 VAL
”,推算出经过了多少拍,累计到 tcnt
。
4.循环内:根据 VAL
的变化,计算经过的拍数
先读本次的
VAL
:tnow = SysTick->VAL
如果发生变化(
tnow != told
),分两种情况:未回卷(
tnow < told
):tcnt += told - tnow;
发生回卷(
tnow > told
,因为计数器是向下数,回卷后会从LOAD
重新开始):tcnt += reload - (tnow - told);
等价于tcnt += (told + (reload - tnow))
—— 即把“从 told 数到 0 的拍数”和“从 reload 数到 tnow 的拍数”相加。
更新基准:
told = tnow
当累计的
tcnt >= ticks
时,退出循环。
注意:该算法假设两次读取
VAL
之间至多回卷一次。因为循环很紧,这在 72 MHz 下是可靠的(一次回卷对应最多LOAD+1
个拍,1 ms 基础节拍时LOAD≈72000
,循环体远小于这个量级)。
为什么上限写成 0 ~ 233015 us
计算:
233015 × 72 = 16,777,080 ≈ 2^24 - 136
。
24 位计数器的上限是2^24 = 16,777,216
拍。把延时限制在此范围内,意味着需要的总拍数不超过 24 位计数器最大值,这在很多示例里是一个保守的安全上限。
实际上,由于本函数是按增量累计
tcnt
(uint32_t
),并不严格只能延时到 24 位上限;之所以给出这个上限,是为了:规避
ticks = nus * 72
的乘法溢出风险;保证在不同优化级/不同 SysTick 配置下有稳定行为的“推荐范围”。
如果你需要更长的阻塞延时,建议外层用毫秒/秒延时分段循环,或使用定时器/RTOS 延时。
实现原理总结
用 SysTick 当前值 VAL
的变化来“量时间”,每次根据是否回卷来计算经过的拍数并累加,直到达到想要的“微秒 × 每微秒拍数”的总拍数。
优点
无需重配 SysTick:与 HAL/RTOS 的 1 ms 系统节拍共存,不破坏全局时基。
分辨率高:在 72 MHz 下理想分辨率可达 1/72 µs(实际受循环开销影响,一般微秒级精度是可达的)。
处理回卷:显式考虑了
VAL
回卷,不会在回卷点产生大误差或卡住。实现简单、移植性好:只依赖 CMSIS 寄存器。
使用与精度注意事项
确保时钟源匹配
如果
CTRL.CLKSOURCE
选择的是 AHB:用ticks = nus * (HCLK_MHz)
;如果是 AHB/8:用
ticks = nus * (HCLK_MHz/8)
。
上面示例用 72,等价于 72 MHz AHB 且 SysTick 用 AHB 作时钟。
SysTick 要在运行
如果 SysTick 没开或被停用,VAL
不会变化,函数会阻塞不返回。忙等占用 CPU
这是阻塞式延时。在 RTOS 或需省电场景下,不宜用于长延时;长延时应使用vTaskDelay
/定时器/外设定时器中断等方案。误差来源
循环体执行开销(与编译优化等级、总线等待状态有关);
HCLK 频率与注释假设不一致;
中断打断(若中断较多将拉大实际延时)。
若要更稳定的微秒延时,可考虑 DWT->CYCCNT(数据观测单元周期计数器)方案。
总结
这段 delay_us()
的精髓是:利用 SysTick 的当前值做“时间刻度”,按差分累加经过的拍数并处理回卷,直到凑够目标拍数。它的优势是不破坏系统节拍、实现简单,并且能在 STM32F103 上提供可靠的微秒级延时;只要确保时钟源和乘数(72 或 9)匹配、避免用它做长时间阻塞即可。
为什么这种实现方式可以带操作系统?
其实关键点就在于它不去重新配置 SysTick,而是只利用 SysTick 的当前值 VAL
来做时间基准。
传统写法 vs 本写法的差别
传统写法(常见 HAL 的 delay_us
)
会重新配置
SysTick->LOAD
,然后开定时器,等计满退出。这种方式会破坏 RTOS 的时基,因为大多数 RTOS(比如 FreeRTOS)就是用 SysTick 作为心跳节拍(1ms Tick)。
如果你在任务里调用这样的延时函数,就会导致系统的任务调度失效(心跳丢失),进而“带不动 RTOS”。
本写法(上面的代码)
它假定 SysTick 已经在跑(系统节拍已经配置好,比如 1ms Tick),不去改
LOAD
和CTRL
。仅仅是每次读取
VAL
值,看它减少了多少,自己在软件里累加消逝的“拍数”。这样 RTOS 的
SysTick
中断依然可以正常产生,任务调度不受影响。
实现原理回顾
VAL
是一个递减的计数器,RTOS 每 1ms 会重装载。代码通过
told - tnow
(或处理回卷)得到经过的拍数。累加到
tcnt
,直到达到目标“微秒 × 每微秒的时钟拍数”为止。整个过程里,不改
LOAD
、不关中断,RTOS 的 Tick 中断仍然照常运行。
优点总结(为什么能“带操作系统”)
不会破坏 RTOS 的时基
SysTick 继续为 FreeRTOS 提供 1ms Tick,调度器能正常工作。延时和任务调度兼容
延时过程只是忙等,占用当前任务 CPU 时间片,但不会影响中断。
→ 高优先级的中断(比如 SysTick 中断)仍能进来,调度照常。粒度更细
FreeRTOS 默认只能延时到毫秒级(vTaskDelay()
),而这种方法能做微秒级延时,适合某些需要精准时序的外设操作。简单通用
不依赖额外定时器,直接用系统 SysTick 的“当前值”寄存器做时间戳。
结论:
这种延时方法 不会重配置 SysTick,所以不会破坏操作系统的节拍机制;它仅仅是读 VAL
值计算经过的时钟周期。这样就能在 FreeRTOS 等 RTOS 中使用,同时实现微秒级延时。