1. 从毫秒到微秒为什么需要更精确的延时在嵌入式开发里延时函数就像呼吸一样基础。无论是驱动一个需要精确时序的传感器还是实现一个简单的PWM波形都离不开它。对于STM32这类MCUHAL库提供的HAL_Delay()函数几乎是每个开发者最先接触的延时工具它基于SysTick系统滴答定时器默认提供毫秒ms级别的延时。这个函数简单好用对于LED闪烁、按键消抖这类对时间精度要求不高的场景完全够用。但当你开始接触更精细的外设时毫秒级的“粗粒度”延时就显得力不从心了。比如你想通过软件模拟一个I2C或SPI的时序其时钟周期通常在几百千赫兹到几兆赫兹对应的单个时钟周期可能只有几百纳秒到几微秒。用HAL_Delay(1)来等待一个时钟沿那简直是“用牛刀杀鸡”不仅误差巨大整个通信过程也会慢得无法忍受。再比如驱动某些高速ADC的采样保持信号或者为特定型号的OLED屏幕发送初始化命令序列这些时序图里标注的时间单位常常是微秒us甚至纳秒ns。这时一个稳定、可靠的微秒级延时函数就成了必需品。所以我们今天要聊的就是如何绕过HAL_Delay()的“舒适区”深入到STM32的定时器核心亲手打造一个精准的微秒延时工具。这不仅仅是调用一个API那么简单而是理解定时器如何工作、如何配置、以及如何规避其中陷阱的过程。我会结合我实际在多个项目中的使用经验从原理到代码从配置到调试把这件事彻底讲透。2. 定时器延时原理时钟树与计数器的交响曲要理解如何用定时器实现微秒延时首先得明白STM32的定时器是怎么“数时间”的。这背后是STM32时钟树和定时器单元协同工作的结果。2.1 核心时钟源一切计时的起点STM32的定时器除了独立看门狗和窗口看门狗其时钟源都来自于系统时钟SYSCLK经过预分频后的APB总线时钟。以常见的STM32F1系列为例假设我们使用内部8MHz RC振荡器HSI经过PLL倍频到72MHz作为系统时钟。定时器1、8挂在APB2总线上如果APB2的预分频器系数不为1那么定时器的实际时钟频率会是APB2时钟频率的2倍。这是很多初学者容易忽略的一点直接关系到延时计算的准确性。例如系统时钟72MHzAPB2预分频器设为不分频即APB2时钟72MHz那么挂载在APB2上的高级定时器如TIM1的时钟就是72MHz。如果APB2预分频器设为2分频APB2时钟36MHz那么定时器的时钟会自动倍频到72MHz。这个机制保证了定时器始终有较高的时钟源。对于通用定时器如TIM2、3、4它们挂在APB1上规则类似但APB1的最大频率通常较低在F1上为36MHz其定时器时钟最高也为72MHz当APB1预分频系数不为1时自动倍频。所以第一步也是最重要的一步你必须弄清楚你使用的具体STM32型号以及当前系统时钟的配置从而确定你将要使用的那个定时器的实际输入时钟频率CK_INT。这个频率是我们计算延时的基础。你可以通过读取SystemCoreClock全局变量在HAL库中它代表了系统核心时钟频率单位Hz来推算但更准确的是查看RCC相关的配置代码或使用STM32CubeMX生成的时钟树图。2.2 定时器的工作模式向上计数与自动重载我们实现延时最常用的是定时器的“向上计数”模式。在这种模式下定时器内部有一个计数器寄存器CNT它会从0开始在每个时钟周期加1一直计数到一个叫做“自动重装载值”ARR的预设值。当CNT的值等于ARR时就会产生一个“更新事件”UEV并且CNT被硬件自动清零然后重新开始向上计数如此循环往复。那么一个计数周期的时间是多少呢很简单T (ARR 1) / F。其中F是定时器的时钟频率ARR是自动重装载值。1是因为计数器从0计数到ARR总共是ARR1个时钟周期。例如定时器时钟F72MHz如果我们设置ARR71那么一个计数周期的时间T (711) / 72,000,000 1us。这就为我们实现1微秒的延时提供了理论基础我们只需要让定时器计数72次从0到71。但是直接这样用有两个问题第一ARR的值有限16位定时器最大65535如果我们想要延时更长的时间比如100msARR值会非常大可能超出范围。第二我们如何知道计数器“数”够了我们想要的微秒数这就需要引入“溢出次数”的概念和定时器的中断功能。更常见的做法是我们设置一个固定的、较小的ARR值比如999对应1ms的周期如果时钟是1MHz的话然后通过一个软件变量来记录“产生了多少次溢出中断”。每次溢出中断我们就将这个变量加1。当这个变量达到我们需要的延时毫秒数时延时结束。对于微秒延时我们可以把ARR设得更小让一个计数周期就是1us或几us然后通过循环查询CNT寄存器的值而不是用中断来获得更精细的时间控制。这就是我们下面要实现的“阻塞式查询延时”的核心思想。3. 实战配置一个通用定时器实现us延时理论铺垫完毕我们进入实战环节。我将以STM32F103C8T6蓝色药丸板的通用定时器TIM2为例演示如何配置一个微秒延时函数delay_us(u32 us)。我们假设系统时钟配置为72MHzAPB1预分频系数为2即APB1时钟36MHz根据自动倍频规则TIM2的时钟CK_INT 36MHz * 2 72MHz。3.1 定时器初始化分频与周期设定我们的目标是让定时器每计数一次代表一个固定的、很短的时间基准。为了实现1us的延时我们希望计数器每1us计一个数。但定时器时钟是72MHz周期是1/72us ≈ 13.89ns。计数器加1只需要13.89ns这太快了我们很难直接操作。因此我们需要对时钟进行“分频”。定时器有一个预分频器寄存器PSC。输入时钟CK_INT会先经过(PSC1)分频才成为驱动计数器CNT的时钟CK_CNT。即CK_CNT CK_INT / (PSC 1)。为了让CK_CNT 1MHz这样计数器每加1时间就过去1us我们可以计算PSC CK_INT / 1MHz - 1 72 - 1 71。接下来是自动重装载值ARR。在查询模式下实现延时我们通常让定时器自由运行在一个较大的周期然后通过计算两次读取CNT的差值来得到时间差。但更简单粗暴且稳定的方法是将ARR设置为最大值对于16位定时器是65535让定时器在0~65535之间循环向上计数。我们只需要在延时开始时记录当前的CNT值作为起点然后循环等待直到CNT值与起点的差值超过我们计算出的所需计数值。为什么用最大值因为这样可以获得最大的计时范围避免在延时过程中频繁处理计数器溢出带来的逻辑复杂度。对于1us的计数时钟65535个计数对应约65.535ms的循环周期。这意味着我们的delay_us函数单次延时不能超过65ms否则逻辑会出错因为计数器可能已经溢出了一圈以上。对于大多数微秒级延时需求几十us到几ms这完全够用。初始化代码如下基于HAL库TIM_HandleTypeDef htim2; void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig {0}; TIM_MasterConfigTypeDef sMasterConfig {0}; htim2.Instance TIM2; htim2.Init.Prescaler 71; // 预分频值72MHz / (711) 1MHz htim2.Init.CounterMode TIM_COUNTERMODE_UP; // 向上计数模式 htim2.Init.Period 65535; // 自动重装载值设为最大 htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; // 不预装载修改ARR立即生效 if (HAL_TIM_Base_Init(htim2) ! HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource TIM_CLOCKSOURCE_INTERNAL; // 使用内部时钟 if (HAL_TIM_ConfigClockSource(htim2, sClockSourceConfig) ! HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(htim2, sMasterConfig) ! HAL_OK) { Error_Handler(); } }初始化完成后调用HAL_TIM_Base_Start(htim2);启动定时器。定时器就会以1MHz的频率每微秒计数一次在0~65535之间循环计数。3.2 微秒延时函数实现与边界处理有了一个自由运行的1MHz定时器我们就可以编写延时函数了。思路如下获取当前计数器值cnt_now。计算要延时的微秒数对应的计数值cnt_delay us。因为1us 1个计数计算目标计数器值cnt_target cnt_now cnt_delay。由于计数器是16位且会溢出cnt_target可能会超过65535。我们需要处理这个溢出。循环等待直到当前的计数器值cnt_cur“经过”了cnt_target。关键和难点在于第4和第5步即如何正确地处理计数器的溢出并判断时间到达。下面是一个经过实践验证的稳健实现/** * brief 微秒级延时函数阻塞式 * param us: 需要延时的微秒数范围建议在1~65535之间即65.535ms以内 * note 基于TIM2实现时钟1MHz。延时期间会占用CPU。 */ void delay_us(uint32_t us) { uint32_t cnt_now, cnt_target, cnt_cur; uint32_t cnt_delay us; // 需要延时的计数值 // 关中断防止在读取CNT和计算目标值时被中断打断造成计算错误 __disable_irq(); cnt_now __HAL_TIM_GET_COUNTER(htim2); // 获取当前计数值 cnt_target cnt_now cnt_delay; // 处理计数器溢出如果目标值超过了ARR最大值65535则减去一个周期65536 // 因为计数器是循环的所以物理上cnt_target 和 cnt_target - 65536 是等价的。 // 我们选择将目标值映射到 [0, 65535] 区间内方便比较。 if (cnt_target 65535) { cnt_target - 65536; } // 重新开中断。后续的循环判断不要求原子性可以响应中断。 __enable_irq(); // 循环等待直到“当前值”越过“目标值” // 这里必须考虑计数器溢出的情况。我们判断的条件是 // 如果起点(cnt_now) 目标点(cnt_target)那么正常情况等待当前值(cnt_cur) 目标值即可。 // 如果起点(cnt_now) 目标点(cnt_target)说明目标点已经因为我们的“映射”到了下一圈数值上更小。 // 此时只有当当前值(cnt_cur)也溢出并绕回到小于起点值时它才会大于目标值。 // 所以等待条件是(cnt_cur cnt_target) (cnt_cur cnt_now) // 但更通用的方法是计算 (cnt_cur - cnt_now) 0xFFFF 的差值判断其是否 cnt_delay。 // 下面采用一种更直观的循环判断方法 do { cnt_cur __HAL_TIM_GET_COUNTER(htim2); // 获取当前计数值 // 判断是否超时计算从cnt_now到cnt_cur经历的计数值考虑溢出 // 将16位无符号数的减法结果强制转换为32位避免负数问题。 uint32_t elapsed; if (cnt_cur cnt_now) { elapsed cnt_cur - cnt_now; } else { // 当前值溢出后变小了elapsed (65536 - cnt_now) cnt_cur elapsed (65536 - cnt_now) cnt_cur; } if (elapsed cnt_delay) { break; // 延时时间到退出循环 } } while(1); }注意函数开头和结尾的__disable_irq()和__enable_irq()是为了保护cnt_now和cnt_target计算的原子性。想象一下如果在读取cnt_now之后、计算cnt_target之前发生了一个耗时较长的中断并且在这个中断里计数器溢出了那么我们计算出的cnt_target基准就是错的。关中断是最简单有效的保护方式。虽然这会短暂影响系统中断响应但对于微秒级的操作时间极短通常可以接受。关于延时范围这个函数理论上单次延时不能超过定时器的一个完整计数周期65.535ms。如果需要更长的延时可以组合使用delay_us和HAL_Delay或者用另一个定时器中断来累计更长时间。一个重要的实践经验是尽量避免在中断服务函数中调用delay_us尤其是延时时间较长时。因为关中断操作会屏蔽其他所有中断可能引发系统实时性问题。如果必须在中断中进行短延时可以考虑使用纯查询循环NOP指令来实现纳秒到微秒级的等待但这需要精确计算指令周期。4. 精度验证与误差分析你的延时到底准不准写完了代码我们怎么知道它准不准不能光凭感觉。验证延时精度是嵌入式开发中必不可少的一步。4.1 使用示波器进行验证最直接、最可靠的方法就是使用示波器。我们写一个简单的测试程序while (1) { GPIO_PIN_SET(GPIOA, GPIO_PIN_1); // 将PA1引脚拉高 delay_us(10); // 延时10微秒 GPIO_PIN_RESET(GPIOA, GPIO_PIN_1); // 将PA1引脚拉低 delay_us(10); // 延时10微秒 }用示波器的探头连接到PA1引脚触发方式设为边沿触发。然后观察波形。你应该能看到一个周期约为20us高电平10us 低电平10us占空比50%的方波。通过示波器的测量功能如光标测量或自动测量可以精确读出高电平的脉宽。实测结果分析理想情况测量值应非常接近10.00us。实际情况你可能会看到9.98us或10.05us。这个误差来自哪里函数调用开销进入delay_us函数、执行关中断、读取计数器、计算等操作本身需要消耗CPU周期。这部分时间是固定的系统误差。例如假设这些前置操作花了0.5us那么即使你请求延时10us实际从函数开始执行到引脚电平翻转可能已经过去了10.5us。这就是为什么在要求极端精确的时序如模拟通信协议时需要校准这个开销。校准方法用示波器测量一个已知延时如100us的实际值计算误差然后在cnt_delay中减去这个误差值。循环判断开销do-while循环中每次都要读取计数器、计算差值、进行比较。这部分时间相对于延时本身通常可以忽略尤其是在延时大于几微秒时。时钟源误差如果你的系统时钟来源于内部RC振荡器HSI其精度可能只有±1%这意味着你的1MHz计时基准本身就有±1%的误差。对于精度要求高的应用必须使用外部晶振HSE。中断干扰虽然我们保护了起始计算的原子性但如果在do-while循环等待期间发生了中断并且该中断服务程序执行时间较长那么实际的等待时间就会变长。这就是为什么高精度延时通常需要在临界区或高优先级任务中执行。4.2 使用系统滴答定时器SysTick进行交叉验证如果没有示波器也可以用SysTick进行粗略的交叉验证。SysTick通常被配置为1ms中断一次为HAL_Delay提供时基。我们可以修改SysTick的中断服务程序用一个变量累加微秒数需要根据系统时钟频率精确计算然后在delay_us函数前后读取这个微秒计数器计算差值。这种方法本身精度取决于SysTick的配置和中断响应延迟但可以用来进行量级上的验证比如检查delay_us(1000)是否大致等于1ms。一个更实用的调试技巧利用delay_us本身来测量一段代码的执行时间。在代码段开始前读取定时器CNT值结束后再读取一次计算差值注意处理溢出这个差值就是代码执行的微秒数。这对于优化代码性能非常有用。5. 进阶话题与避坑指南掌握了基础实现后我们来看看一些更深入的问题和常见的“坑”。5.1 多定时器协作与资源冲突一个项目里往往不止需要一个定时器。TIM2被用来做微秒延时了TIM3可能用来做PWM输出TIM4用来做输入捕获。这里要注意的是总线带宽和中断优先级。所有的定时器都挂载在APB总线上当CPU频繁通过总线读取多个定时器的CNT寄存器时如果总线负载已经很重比如还有DMA在搬运数据可能会引入轻微的访问延迟。对于纳秒级精度的应用需要考虑对于微秒级应用通常影响甚微。更需要注意的是中断优先级。我们的delay_us函数中有关中断操作。如果此时有一个更高优先级的中断发生它会被立即响应但我们的关中断操作不会影响它。然而如果delay_us函数本身是在一个低优先级中断中被调用那么关中断操作会阻止同级和低优先级中断可能导致系统响应异常。最佳实践是将delay_us设计为仅在主循环或高优先级任务/中断中使用避免在低优先级、实时性要求高的中断中使用。5.2 低功耗模式下的定时器行为如果你的产品需要进入低功耗模式如Stop模式那么所有由HCLK驱动的外设包括通用定时器都会停止工作。这意味着你的delay_us函数将完全失效因为计数器不走了。在进入低功耗前必须停止定时器HAL_TIM_Base_Stop(htim2)在唤醒后需要重新初始化并启动定时器。而且从低功耗模式唤醒后系统时钟可能需要一段时间才能稳定此时立即使用delay_us会导致时间严重不准。通常需要在唤醒后等待时钟稳定标志位或者重新配置系统时钟和定时器。5.3 替代方案使用DWT内核调试单元对于Cortex-M3/M4/M7内核的STM32还有一个更高精度、零额外外设占用的方案使用数据观察点与跟踪DWT单元中的周期计数器CYCCNT。这个计数器是内核级别的随内核时钟通常就是系统时钟递增精度极高在72MHz下约13.89ns而且是32位或64位的范围很大。使用DWT实现延时的示例#define DWT_CYCCNT *(volatile uint32_t *)0xE0001004 #define DWT_CR *(volatile uint32_t *)0xE0001000 #define DEM_CR *(volatile uint32_t *)0xE000EDFC #define DEM_CR_TRCENA (1 24) #define DWT_CR_CYCCNTENA (1 0) void DWT_Init(void) { DEM_CR | DEM_CR_TRCENA; // 启用跟踪调试模块 DWT_CYCCNT 0; // 清零周期计数器 DWT_CR | DWT_CR_CYCCNTENA; // 启用周期计数器 } void delay_us_dwt(uint32_t us) { uint32_t start_tick DWT_CYCCNT; // 计算需要等待的时钟周期数。SystemCoreClock是系统核心时钟频率Hz。 uint32_t delay_ticks us * (SystemCoreClock / 1000000); // 等待经过的周期数达到目标 while ((DWT_CYCCNT - start_tick) delay_ticks); }DWT方案的优缺点优点精度极高不占用任何外设定时器资源代码简洁。缺点依赖调试功能如果芯片的调试模块被禁用比如在某些产品发布配置中DWT计数器可能无法工作。无中断能力DWT只有计数器没有中断功能只能用于查询式延时。内核依赖性不是所有ARM内核都有DWT代码可移植性稍差。选择建议如果项目对精度要求极高且确认调试功能可用DWT是绝佳选择。如果项目需要更稳定的、与外设定时器功能兼容的方案比如未来可能还需要该定时器做PWM或者需要中断来累计长时间那么使用通用定时器方案更稳妥。5.4 一个常见的坑优化等级导致的延时错误这是最隐蔽的坑之一。你测试好的代码一旦将编译器的优化等级从-O0无优化提高到-O1或-O2延时时间就可能严重缩水或增长。为什么因为编译器优化会重新安排指令顺序甚至删除它认为无用的循环。在我们的delay_us函数中do-while循环等待的cnt_cur变量是在循环内被读取的。如果编译器认为这个变量在循环内没有变化它看不到__HAL_TIM_GET_COUNTER这个宏实际上是在读取硬件寄存器它可能会将读取操作提到循环之外导致死循环。或者它可能将整个循环判断优化掉。解决方法将循环内读取的硬件寄存器变量声明为volatile。幸运的是HAL库的__HAL_TIM_GET_COUNTER宏内部已经使用了volatile指针确保了每次都会从内存映射的寄存器地址读取。但为了万无一失我们自己定义的用于存储读回值的变量如cnt_cur也最好加上volatile限定符告诉编译器这个变量可能被硬件或其他线程改变不要对它做激进的优化。volatile uint32_t cnt_cur;在发布版本高优化等级中务必用示波器重新验证延时函数的准确性这是保证可靠性的铁律。从依赖现成的HAL_Delay到深入定时器寄存器实现微秒延时再到考虑优化、中断、低功耗等各种边界情况这个过程本身就是嵌入式工程师能力成长的缩影。它要求你不仅会调用API更要理解硬件如何工作软件如何与硬件精确交互。我个人的习惯是在项目初期就根据时序精度要求选择并验证好延时方案。对于多数应用一个基于通用定时器的delay_us函数足以应对对于极端精度或资源紧张的情况DWT方案值得一试。最后无论用哪种方法用仪器测量是验证代码行为的唯一金标准千万不要想当然。