ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32微秒延时函数实现:基于硬件定时器的高精度延时方案

2026/8/29 3:05:38 拓冰建站 浏览量
STM32微秒延时函数实现:基于硬件定时器的高精度延时方案 1. 项目概述为什么我们需要一个精准的微秒延时函数在STM32的开发中延时函数几乎是每个项目都绕不开的基础功能。无论是为了等待传感器稳定、控制通信时序、生成精确的PWM脉冲还是实现简单的按键消抖我们都需要让程序“等一等”。最常见的做法是使用HAL_Delay()函数它基于SysTick系统滴答定时器提供毫秒级的延时简单易用。然而当你需要更精细的时间控制时比如驱动WS2812B灯珠需要800ns~1.25us的高电平、读取某些高速接口的应答信号或者进行高精度的超声波测距毫秒级的延时就显得力不从心了你需要的是微秒us级别的精准控制。这时很多开发者会想到使用简单的for循环或while循环进行空转来实现微秒延时也就是所谓的“软件延时”。这种方法在代码里看起来可能像这样void delay_us(uint32_t us) { for(uint32_t i0; ius*N; i) { // N是一个经验值 __NOP(); // 空操作 } }但这种方法存在几个致命问题首先延时精度极差严重依赖编译器优化选项和CPU主频代码稍作改动或优化等级一变延时时间就可能天差地别其次它在延时期间会完全占用CPU导致系统无法响应其他事件效率低下最后这种延时无法在中断服务函数中可靠使用因为中断可能会打断循环。因此一个基于硬件定时器的、不占用CPU的、高精度的微秒延时函数对于追求稳定性和性能的嵌入式项目来说是必不可少的“基础设施”。本项目就是教你如何利用STM32内部无处不在的“通用定时器”如TIM2, TIM3, TIM4等结合STM32CubeMX图形化配置工具和HAL库打造一个可靠、精准、易用的微秒延时模块。这不仅是一个函数更是一种提升代码质量和系统可靠性的设计思想。2. 整体设计与思路拆解为什么选择通用定时器在开始动手之前我们先要理清设计思路。STM32的定时器资源非常丰富有基本定时器、通用定时器、高级定时器等。为什么我们选择通用定时器如TIM2, TIM3来实现微秒延时而不是其他方案呢我们来做一个简单的对比分析。方案一继续使用SysTick。SysTick是Cortex-M内核自带的24位递减计数器通常用于操作系统的心跳。HAL库的HAL_Delay()就是基于它实现的。理论上通过调整重装载值我们可以让它产生更小时基的中断。但为什么不这么做主要原因是SysTick在系统中通常扮演着“系统节拍器”的角色很多中间件如FreeRTOS的时基和HAL库的内部超时检测都依赖于它。如果我们为了微秒延时而去频繁修改它的配置很可能会“牵一发而动全身”导致系统不稳定。因此最佳实践是让SysTick专心负责毫秒级系统时基我们另寻他路。方案二使用基本定时器TIM6, TIM7。基本定时器结构简单只能向上计数没有输入输出通道非常适合做单纯的时基生成。它确实是实现延时的好选择。但它的局限性在于不是所有STM32系列都有基本定时器或者数量有限。而通用定时器几乎在所有型号中都存在且数量更多资源更通用。为了代码在不同型号STM32之间的可移植性选择通用定时器是更稳妥的方案。方案三使用通用定时器TIM2, TIM3, TIM4, TIM5…。这正是我们选择的方案。通用定时器功能强大支持向上、向下、中央对齐计数模式具备输入捕获、输出比较、PWM生成等功能。我们只利用其最基础的“定时计数”功能。其优势非常明显资源丰富且通用几乎每个STM32都有多个通用定时器不容易与其他功能如PWM驱动电机、输入捕获测频率冲突即使冲突也容易更换另一个。精度高定时器的时钟源通常来自高速的APB总线经过可编程的预分频器可以实现非常精细的时间分辨率例如在72MHz系统时钟下不分频时一个计数周期就是13.9ns。不占用CPU通过“查询标志位”或“中断”的方式工作在等待延时结束期间CPU可以执行低功耗模式或处理其他任务对于查询方式虽然CPU在循环查询但理论上可以被打断且结构清晰。可预测和可移植延时时间基于硬件时钟和配置计算得出不随编译器优化而改变只要系统时钟确定延时就是准确的。我们的核心思路是配置一个通用定时器让其以1MHz的频率计数即每计数一次为1微秒。当需要延时N微秒时启动定时器并清零计数器然后等待计数器值累加到N时间到则退出。为了实现1MHz的计数频率我们需要根据系统时钟正确计算定时器的预分频器PSC值。3. 硬件定时器原理与关键参数计算要正确配置定时器必须理解其工作原理。一个通用定时器的主要构成部分和我们的配置关注点如下时钟源定时器的“心脏”。通常是APB1或APB2总线时钟APBx_CLK。在STM32中当APB预分频系数不为1时连接到定时器的实际时钟会是APB时钟的2倍。这一点在CubeMX中会自动计算并显示为“Timer Clock”但我们必须心里有数。预分频器这是一个16位寄存器PSC可以对输入时钟进行分频。如果输入时钟是72MHz我们设置PSC71那么分频后的时钟频率就是72MHz / (711) 1MHz。这里“1”是因为分频器是从0开始计数的设置PSC0表示1分频即不分频。计数器这是定时器的核心一个16位或32位如TIM2/TIM5的寄存器CNT在分频后的时钟驱动下每个时钟周期加1或减1。自动重装载寄存器这是一个16位或32位的寄存器ARR它决定了计数器的周期。当计数器计数到ARR值向上计数或从ARR值减到0向下计数时会产生一个更新事件并可以将计数器重置。对于单纯的延时函数我们通常将ARR设置为最大值如0xFFFF让计数器自由计数我们只关心它从0计数到目标值N的过程。关键参数计算示例假设我们的STM32F103C8T6系统时钟SYSCLK为72MHzAPB1总线时钟APB1_CLK为36MHz。由于APB1预分频系数为2根据STM32设计挂载在APB1下的定时器如TIM2, TIM3, TIM4的时钟TIMx_CLK会是APB1_CLK的2倍即72MHz。 我们的目标是让定时器每1微秒计数一次即计数频率为1MHz。 计算公式为定时器计数频率 TIMx_CLK / (PSC 1)所以PSC TIMx_CLK / 目标计数频率 - 1代入数值PSC 72MHz / 1MHz - 1 72 - 1 71。 因此我们需要将预分频寄存器PSC设置为71。注意这个计算过程是理解定时器配置的基石。在实际使用STM32CubeMX时你只需要输入想要的“Counter Period”和“Prescaler”软件会自动帮你计算并显示最终的定时器频率非常方便。但了解背后的原理能让你在调试和移植时游刃有余。4. 使用STM32CubeMX配置定时器理论清晰后我们进入实战环节。STM32CubeMX极大地简化了配置过程。4.1 创建工程与时钟树配置首先打开STM32CubeMX选择你的芯片型号例如STM32F103C8Tx。在Pinout Configuration界面我们需要做两步关键配置系统时钟配置点击Clock Configuration选项卡。这里确保你的系统时钟SYSCLK被正确设置为目标频率比如72MHz。通常使用外部高速晶振HSE然后通过PLL倍频得到。CubeMX的图形化界面非常直观你只需要在HSE和PLL相关选项上点击选择并最终将SYSCLK拖动到72MHz即可。确认APB1总线的时钟APB1 peripheral clocks也随之正确生成例如36MHz。记住最终的APB1时钟频率它是计算定时器时钟的基础。选择并配置通用定时器回到Pinout Configuration的Timers分类下。你会发现一系列定时器。我们选择一个不计划用于其他功能如PWM、输入捕获的通用定时器例如TIM2。点击TIM2在左侧模式配置中选择Internal Clock内部时钟源。这意味着定时器使用来自APB总线的时钟而不是外部引脚输入。4.2 参数化配置选择Internal Clock后下方会出现Configuration按钮点击进入详细参数设置。Parameter Settings选项卡Prescaler (PSC - 16 bits value): 这是我们计算的关键。输入我们计算好的值71。Counter Mode: 选择Up向上计数模式。对于延时函数向上计数最直观。Counter Period (AutoReload Register - 16 bits value): 设置为最大值65535对于16位定时器。因为我们不依赖ARR产生周期更新只是让计数器自由计数到我们需要的值。设置为最大值可以避免在短延时期间意外触发更新中断。Internal Clock Division (CKD): 保持默认No Division。auto-reload preload: 保持默认Disable即可。这个功能用于在更新事件发生时缓冲ARR的新值对于我们的查询式延时非必需。配置完成后你会看到下方Timer Frequency和Timer Period自动更新。Timer Frequency应该显示为1.000 MHz这验证了我们的配置是正确的1MHz计数频率每个计数1us。Timer Period会显示为65535us即65.535ms这是计数器从0计到65535所需的时间。NVIC Settings选项卡这里非常重要我们实现的是“查询式”延时而不是“中断式”。因此千万不要勾选TIM2 global interrupt使能框。如果使能了中断定时器溢出或达到ARR值时会跳转到中断服务函数这不符合我们“等待特定计数值”的需求并且会引入不必要的中断开销和复杂性。我们的目标是让定时器安静地计数我们通过软件读取它的计数器值CNT来判断时间。配置完成后点击“OK”保存定时器设置。4.3 生成工程代码回到主界面进入Project Manager选项卡设置好工程名称、路径、IDE如MDK-ARM V5等选项。在Code Generator部分建议勾选Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral这样定时器的初始化代码会独立生成在tim.c和tim.h文件中结构更清晰。最后点击右上角的GENERATE CODE生成工程代码。用你熟悉的IDE如Keil MDK打开工程。5. 编写微秒延时函数打开工程后在main.c或你自己的用户代码文件如bsp_delay.c中我们来编写核心的延时函数。首先我们需要一个函数来启动定时器并准备延时环境。通常我们会在系统初始化后调用一次。// 微秒延时初始化函数 void Delay_us_Init(void) { HAL_TIM_Base_Start(htim2); // 启动定时器2的计数功能 __HAL_TIM_SET_COUNTER(htim2, 0); // 启动后立即将计数器清零确保起点一致 }注意HAL_TIM_Base_Start()函数启动了定时器的计数器但它不会自动清零CNT寄存器。计数器会从当前值开始累加。因此在初始化时或每次延时开始前手动将计数器清零是一个好习惯能保证延时基准的准确性。接下来就是最核心的微秒延时函数/** * brief 微秒级延时函数阻塞式查询方式 * param us: 需要延时的微秒数范围 1 ~ 65535 * note 基于TIM2实现定时器时钟配置为1MHz1计数1us。 * 在延时期间CPU会循环查询计数器值。 */ void Delay_us(uint16_t us) { uint16_t start_cnt 0; uint16_t target_cnt 0; uint16_t cur_cnt 0; // 获取定时器当前计数值作为起始点 start_cnt __HAL_TIM_GET_COUNTER(htim2); // 计算目标计数值考虑计数器溢出16位最大值65535 target_cnt start_cnt us; // 如果目标值超过了16位计数器的最大值需要处理溢出 if (target_cnt start_cnt) { // 发生溢出 // 等待计数器溢出从65535翻转到0 while (__HAL_TIM_GET_COUNTER(htim2) start_cnt) { // 空循环等待溢出 } // 溢出后新的起始点是0目标值就是 us start_cnt 0; target_cnt us; } // 等待计数器达到目标值 while (__HAL_TIM_GET_COUNTER(htim2) target_cnt) { // 空循环等待时间到 } }代码逻辑深度解析获取起点start_cnt __HAL_TIM_GET_COUNTER(htim2);记录下函数被调用时定时器的瞬间值。计算目标target_cnt start_cnt us;我们希望计数器从start_cnt开始增加us次因为1计数1us后时间就到了。溢出处理关键这是实现鲁棒性延时函数的核心。定时器CNT是16位变量最大值65535。如果start_cnt是65500需要延时100us那么target_cnt就是65600超过了65535。在16位无符号数运算中65600会溢出变成64因为65600 - 65536 64。此时target_cnt (64)反而小于start_cnt (65500)。我们的if (target_cnt start_cnt)条件就是为了捕获这种情况。当检测到溢出时我们不能直接等待CNT从65500增加到64这永远不会发生因为增加到65535后就归零了。正确的做法是先等待第一次溢出发生。即等待CNT值从start_cnt65500一路增加到65535然后归零。while (__HAL_TIM_GET_COUNTER(htim2) start_cnt)这个循环就是在做这件事。溢出发生后我们将start_cnt重置为0target_cnt重置为us100。此时问题就简化成了从0计数到100的标准情况。等待完成最后while (__HAL_TIM_GET_COUNTER(htim2) target_cnt)这个循环会持续检查当前计数值直到它等于或超过目标值然后退出函数延时结束。实操心得这个溢出处理逻辑是微秒延时函数的“灵魂”。没有它当延时跨越计数器溢出边界时函数可能会陷入死循环。许多初学者自己编写的延时函数不稳定问题就出在这里。务必理解并加上这段处理。6. 进阶优化与高精度技巧上面的基础版本已经可以满足大部分需求。但如果你追求极致的精度和灵活性可以考虑以下优化6.1 使用32位定时器或硬件自动重装载如果你的芯片有32位的通用定时器如STM32F1系列的TIM2和TIM5一定要优先使用它们32位计数器的最大值约42.9亿2^32-1以1MHz计数需要约4295秒超过1小时才会溢出一次。在绝大多数应用场景下你基本可以忽略溢出问题代码可以简化为void Delay_us_TIM5(uint32_t us) { // 使用32位TIM5 uint32_t start_cnt __HAL_TIM_GET_COUNTER(htim5); uint32_t target_cnt start_cnt us; // 对于32位定时器在几十秒内几乎不可能溢出可以省略复杂判断 // 但为了绝对安全可以保留一个简单的溢出判断当us极大时 if(target_cnt start_cnt) { // 发生了罕见的溢出 while(__HAL_TIM_GET_COUNTER(htim5) start_cnt); // 等待溢出 start_cnt 0; target_cnt us; } while((uint32_t)__HAL_TIM_GET_COUNTER(htim5) target_cnt); }代码简洁且由于减少了条件判断在短延时时理论上更精确。6.2 使用输出比较模式实现“非阻塞”延时查询方式阻塞式的延时在延时期间CPU仍然在忙碌地循环检查虽然比纯软件空转好因为可以被中断打断但依然没有充分利用CPU。更高阶的玩法是利用定时器的“输出比较”功能。思路配置定时器的一个通道为输出比较模式不输出到引脚。当需要延时N微秒时设置比较寄存器CCRx的值为当前计数器值CNT N并开启该通道的比较中断。然后CPU就可以去执行其他任务了。当计数器值增加到与CCRx匹配时硬件会自动触发一个比较中断在中断服务函数里你可以设置一个标志位或者调用一个回调函数通知主程序“延时时间到”。这种方法的优点是“非阻塞”CPU利用率高。缺点是实现稍复杂需要编写中断服务函数并且如果多个任务都需要微秒延时可能需要管理多个标志位或使用软件定时器队列增加了系统复杂度。它更适合作为一个小型调度器的基础。6.3 校准与误差分析没有绝对的精确。我们的延时函数存在几个潜在的误差源函数调用开销进入函数、读取计数器、执行判断等指令本身需要消耗CPU周期。对于几个微秒的极短延时这个开销可能占比很大。为了校准你可以用逻辑分析仪或示波器测量一个GPIO引脚在调用Delay_us(10)前后的翻转时间然后反推实际延时微调us参数或修改代码。中断打断如果在Delay_us函数执行过程中发生了高优先级中断并且该中断服务程序执行时间较长那么实际的延时时间就会变长。对于需要严格时序的操作如驱动WS2812需要在操作前关闭全局中断__disable_irq()操作后再开启__enable_irq()但需谨慎使用避免影响系统实时性。系统时钟精度定时器的精度最终依赖于系统时钟的精度。如果使用内部RC振荡器HSI其精度可能只有±1%那么你的微秒延时也会有±1%的误差。对精度要求高的场合务必使用外部晶振HSE。一个简单的校准方法是编写一个测试程序让一个GPIO引脚每延时一定时间就翻转一次然后用示波器测量方波周期。while(1) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0); Delay_us(500); // 理论上产生1kHz方波 }用示波器测量PA0引脚输出的方波频率。如果测得是0.99kHz说明延时偏长你可以尝试将Delay_us函数中的target_cnt start_cnt us;稍微减小一点比如target_cnt start_cnt us - 1;通过实验找到一个最佳偏移值进行补偿。7. 常见问题与排查技巧实录在实际使用中你可能会遇到以下问题问题1延时函数完全不工作或者延时时间长得离谱。排查步骤检查定时器是否启动确认在main()函数的初始化部分调用了Delay_us_Init()。检查定时器时钟在STM32CubeMX的Clock Configuration界面确认你使用的定时器如TIM2的时钟源TIMxCLK频率是否正确。例如TIM2挂在APB1上如果APB1时钟是36MHz且预分频系数为2则TIM2的实际时钟应为72MHz。检查PSC和ARR配置在CubeMX的定时器配置界面确认Prescaler和Counter Period设置正确并且下方的Timer Frequency显示为1MHz或你期望的频率。检查生成的代码打开tim.c文件查看MX_TIM2_Init函数确认htim2.Instance-PSC和htim2.Instance-ARR的值与你设置的一致。问题2延时时间不稳定时快时慢。可能原因及解决中断干扰这是最常见的原因。高优先级中断特别是SysTick中断每1ms一次会打断Delay_us函数中的等待循环。如果你需要绝对稳定的短延时如几微秒可以考虑在调用Delay_us前后临时关闭全局中断。但要注意关闭中断时间不能过长。__disable_irq(); Delay_us(5); // 执行需要精确时序的操作 __enable_irq();编译器优化确保编译器没有对你的延时函数进行“激进”的优化。在Keil中可以尝试将优化等级设置为-O0不优化进行测试。如果优化导致问题可以考虑将start_cnt,target_cnt等变量声明为volatile防止编译器优化掉看似“无用”的循环。volatile uint16_t start_cnt __HAL_TIM_GET_COUNTER(htim2);问题3需要延时超过65.535ms65535us怎么办解决方案对于超过定时器单次计数周期的延时可以进行循环组合。例如需要延时100ms100000usvoid Delay_ms(uint32_t ms) { while(ms--) { Delay_us(1000); // 延时1ms } }注意Delay_us(1000)本身会处理定时器溢出所以循环调用是安全的。你也可以利用HAL库的HAL_Delay()进行毫秒级延时我们的微秒延时函数作为补充。问题4在中断服务函数里能调用Delay_us吗答案可以但必须非常小心。由于Delay_us是阻塞查询式如果在高优先级中断中调用且延时时间较长它会阻塞同级及更低优先级的中断可能影响系统实时性。更严重的是如果Delay_us依赖的定时器如TIM2的中断优先级低于当前中断且Delay_us等待过程中发生了TIM2的更新溢出中断那么这个中断会被延迟处理可能不会影响Delay_us本身的逻辑因为我们没开中断但会影响依赖TIM2中断的其他功能。最佳实践是在中断中尽量避免使用阻塞延时如果必须用尽量缩短延时时间并清楚了解其影响。问题5如何为不同的定时器编写通用的延时函数解决方案使用宏或函数指针来抽象硬件依赖。例如// 在头文件中定义 #define DELAY_TIM_HANDLE (htim2) // 可以方便地改为htim3 // 在函数中使用宏 void Delay_us(uint16_t us) { uint16_t start_cnt __HAL_TIM_GET_COUNTER(DELAY_TIM_HANDLE); // ... 后续代码 }这样要更换使用的定时器只需修改头文件中的一行宏定义即可提高了代码的模块化和可移植性。通过以上从原理到实践从基础到进阶的详细拆解你应该已经掌握了在STM32CubeMX和HAL库环境下打造一个工业级精度微秒延时函数的全套技能。记住嵌入式开发中对时间的精准掌控是区分“能工作”和“稳定可靠”的关键之一。自己动手实现一遍再用示波器验证一下你会对STM32的定时器有更深的理解。