尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32CubeMX实战:基于通用定时器实现高精度微秒延时函数

STM32CubeMX实战:基于通用定时器实现高精度微秒延时函数 1. 为什么我们需要一个精准的微秒延时函数在STM32的项目开发里延时函数就像空气一样无处不在。无论是驱动一个需要精确时序的传感器比如DHT11温湿度模块它要求主机拉低至少18毫秒再拉高20-40微秒还是控制步进电机的脉冲宽度甚至是实现软件模拟的通信协议如单总线、I2C都离不开对时间的精确把控。很多新手朋友一开始会习惯性地使用HAL库自带的HAL_Delay()函数。这个函数确实方便它基于系统滴答定时器SysTick提供毫秒级的延时。但当你需要更精细的控制时HAL_Delay()就显得力不从心了。首先它最小单位是1毫秒对于几十、几百微秒的需求无能为力。其次它是一个阻塞函数在延时期间CPU什么也干不了这在一些实时性要求高的场景是致命的。最后它的精度受系统时钟和中断影响在复杂的中断环境中可能产生不可预知的抖动。于是我们很自然地会想到用循环空指令NOP来实现微秒延时比如写一个for(i0; i100; i) __NOP();。这种方法简单粗暴但问题很大它的延时时间严重依赖于CPU的主频和编译器优化等级。换一个型号的STM32或者调整一下编译器的优化选项比如从-O0调到-O2延时时间就可能天差地别项目移植和调试会变成噩梦。因此一个基于硬件定时器的、与CPU频率解耦的、高精度的微秒延时函数就成了嵌入式开发中的“硬通货”。今天我们就基于STM32CubeMX这个强大的图形化配置工具手把手教你用通用定时器TIM打造一个稳定可靠的微秒延时函数并深入探讨其中的原理、配置细节和那些容易踩的坑。2. 核心工具选型为什么是通用定时器STM32的定时器资源非常丰富从基本定时器TIM6, TIM7、通用定时器TIM2-TIM5, TIM9-TIM14等到高级定时器TIM1, TIM8。为微秒延时函数选择一个合适的“心脏”是关键的第一步。基本定时器功能最简单只能向上计数通常用于产生基础的时基比如DAC触发。但它没有输入捕获和输出比较通道虽然也能用于延时但功能扩展性较弱。高级定时器功能最强大带死区互补输出等常用于电机控制和电源应用用它来做简单的延时有点“杀鸡用牛刀”而且高级定时器数量稀少是宝贵的战略资源。通用定时器恰恰是平衡点上的最佳选择。它具备向上、向下、中央对齐的计数模式有独立的预分频器和自动重装载寄存器最关键的是它通常有多个独立的输入捕获/输出比较通道。这意味着我们不仅可以利用它实现高精度延时未来还可以轻松扩展出PWM输出、输入捕获测频等高级功能一举多得。在资源上通用定时器数量较多例如STM32F103C8T6就有TIM2、TIM3、TIM4三个分配一个来做延时不会对项目整体资源造成太大压力。另一个常被提及的选项是SysTick。SysTick是ARM Cortex-M内核自带的24位递减定时器专为操作系统提供时基。HAL库的HAL_Delay()和HAL_GetTick()都基于它。理论上我们也可以直接操作SysTick的寄存器来实现微秒延时。但我不推荐这么做原因有三第一SysTick通常已被HAL库或操作系统占用直接修改可能破坏整个系统的时基。第二SysTick是24位的在168MHz等高主频下计满整个周期的时间很短需要频繁处理溢出逻辑变复杂。第三将SysTick用于阻塞延时会与操作系统或其他依赖SysTick的模块产生冲突。所以结论很清晰使用一个独立的通用定时器来实现微秒延时是最专业、最可靠、最灵活的方案。它与其他系统模块解耦精度有保障资源独立是嵌入式工程师的常规操作。3. STM32CubeMX 环境配置与定时器参数计算接下来我们进入实战环节。假设我们使用的芯片是STM32F103C8T6蓝色战舰核心板目标是通过TIM2实现一个delay_us(uint32_t us)函数。3.1 项目创建与时钟树配置首先打开STM32CubeMX新建工程选择对应的芯片型号。第一步也是至关重要的一步是配置系统时钟Clock Configuration。很多延时不准的问题根源都出在时钟配置上。在“RCC”配置中如果你的板子有外部高速晶振通常8MHz就在“High Speed Clock (HSE)”选择“Crystal/Ceramic Resonator”。接着转到“Clock Configuration”标签页。对于STM32F103常见的配置是PLL源选择HSEPLL倍频因子设为9这样系统时钟SYSCLK就是 8MHz * 9 72MHz。APB1总线时钟PCLK1最高36MHzAPB2总线时钟PCLK2最高72MHz。TIM2挂载在APB1总线上。这里有一个极易被忽略的细节定时器的实际时钟频率。在STM32中如果APB总线预分频系数不为1那么挂载在该总线上的定时器会得到一个倍频的时钟。具体规则是当APBx预分频器为1时定时器时钟CK_INT等于APBx总线时钟PCLKx否则CK_INT PCLKx * 2。在我们的配置中系统时钟72MHzAPB1预分频器通常设为2因此PCLK1 36MHz。由于预分频系数是2不等于1所以TIM2的时钟源CK_INT PCLK1 * 2 72MHz。这一点必须确认清楚因为它是我们计算定时器计数值的基准。你可以在CubeMX的时钟树图上直观地看到最终流向TIM2的时钟频率。3.2 TIM2 定时器参数化配置时钟配置好后我们开始配置TIM2。在左侧“Pinout Configuration”中找到“Timers”点击“TIM2”。时钟源Clock Source选择“Internal Clock”内部时钟。这意味着TIM2使用我们刚才在时钟树里配置的CK_INT时钟即72MHz。参数配置Parameter SettingsPrescaler预分频器这是实现微秒延时的关键。我们的目标是让定时器计数一次代表1微秒。已知时钟源频率为72MHz即每秒振动72,000,000次。要让每微秒百万分之一秒计数N次那么72,000,000 / 1,000,000 72。所以我们需要将72MHz进行72分频得到1MHz的计数频率。因此Prescaler应设置为 71。这里要注意写入寄存器的预分频值等于实际分频系数减一。所以72分频就填71。Counter Mode计数模式选择“Up”向上计数模式。这是最常用的模式计数器从0开始计到重装载值后溢出产生更新事件。Counter Period计数周期/自动重装载值这个值决定了定时器溢出一次的时间。我们先不关心溢出因为我们的延时函数是短时间的。为了最大化利用定时器的计数范围我们可以先设一个最大值。对于16位定时器最大值是65535。但这里有一个技巧如果我们设置Period为65535那么定时器从0计到65535需要65536 / 1MHz 65.536ms。这对于微秒延时来说周期太长了而且我们计算延时时需要处理uint32_t类型的us参数可能会超过65ms。一个更实用的做法是将Period设置为一个较大的值比如 0xFFFF65535然后在延时函数内部处理计数器溢出。另一种更简洁的思路是将Period设置为1000-1这样定时器每1ms1000us溢出一次我们在中断里更新一个全局的毫秒计数可以同时实现毫秒和微秒计时但这需要开启更新中断。为了保持延时函数的简洁和高效我们选择第一种不依赖中断的方案即处理溢出。auto-reload preload自动重装载预装载选择“Enable”。这个配置的意思是对自动重装载寄存器ARR的修改将在下一次更新事件时才生效而不是立即生效。这可以防止在修改ARR的瞬间产生意外的更新事件保证波形或计时的连续性。对于我们这个应用开启它是个好习惯。NVIC 设置因为我们计划实现一个不依赖中断的阻塞延时函数所以不需要开启TIM2的全局中断。这能节省中断资源避免不必要的上下文切换开销。配置完成后点击“Generate Code”CubeMX会为你生成完整的初始化代码。关键的函数是MX_TIM2_Init()它完成了上述所有寄存器的配置。4. 微秒延时函数的实现与溢出处理策略代码生成后我们打开工程开始编写核心的延时函数。这个函数将放在一个独立的文件如bsp_delay.c中并配套头文件。4.1 基础延时函数实现我们先实现一个最基础的版本暂时不考虑计数器溢出的问题。假设我们只延时小于65535微秒约65.5ms的时间。// bsp_delay.c #include bsp_delay.h #include tim.h // 包含CubeMX生成的tim.h里面定义了htim2 /** * brief 微秒级延时函数基础版延时时间需小于65.5ms * param us: 延时的微秒数范围 0 ~ 65535 * retval None */ void delay_us_basic(uint32_t us) { uint32_t ticks; uint32_t told, tnow, tcnt 0; // 计算需要计数的节拍数。时钟分频后为1MHz即1us计数1次。 ticks us; // 因为1us对应1个计数所以数值上相等。如果分频不同这里需要换算。 // 清除可能的更新中断标志并获取当前计数值 __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_UPDATE); told __HAL_TIM_GET_COUNTER(htim2); // 开始计数 HAL_TIM_Base_Start(htim2); while(1) { tnow __HAL_TIM_GET_COUNTER(htim2); // 计算自上次采样以来走过的节拍数 if(tnow told) { tcnt (tnow - told); // 正常情况未溢出 } else { // 发生了一次溢出计数器从ARR值回到0 tcnt (0xFFFF - told tnow 1); // 注意ARR我们设为65535即0xFFFF } // 如果累计节拍数已经达到目标则退出循环 if(tcnt ticks) { break; } // 更新“上一次”的计数值用于下次计算差值 told tnow; } // 停止定时器如果后续还需要用可以不停止 HAL_TIM_Base_Stop(htim2); }这个版本已经可以处理计数器在延时期间发生一次溢出的情况if(tnow told)的判断逻辑。但它有一个致命的限制入参us不能太大。因为tcnt和ticks是32位变量理论上可以很大但我们的while循环逻辑在遇到长时间延时时可能会因为频繁的told tnow赋值和判断在极端情况下如us接近32位最大值导致循环时间极长且tcnt有溢出的风险。4.2 优化版高效处理任意时长延时一个更健壮、更高效的思路是利用定时器的自动重装载特性将长延时分解为多个“周期”和最后一个“零头”。我们重新设定定时器的参数。在CubeMX中将TIM2的PeriodARR设置为1000 - 1。这样定时器每计数1000次对应1000us即1ms就会产生一次溢出更新事件。同时我们开启定时器的更新中断在中断服务函数里对一个32位的全局变量tim2_update_cnt进行递增。这样硬件定时器就变成了一个1ms一次的“心跳”而tim2_update_cnt则记录了从开始到现在经过了多少个1ms。我们的微秒延时函数可以借助这个毫秒计数和当前的计数器值CNT来合成高精度的延时。// bsp_delay.c (优化版) #include bsp_delay.h #include tim.h volatile uint32_t tim2_update_cnt 0; // 在中断中递增必须加volatile // TIM2更新中断服务函数 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim-Instance TIM2) { tim2_update_cnt; } } /** * brief 微秒级延时函数优化版支持任意时长 * param us: 延时的微秒数 * retval None */ void delay_us(uint32_t us) { uint32_t start_ms, start_tick; uint32_t wait_ms, wait_tick; uint32_t elapsed_ms, elapsed_tick; // 计算需要等待的毫秒数和剩余的微秒数 wait_ms us / 1000; wait_tick us % 1000; // 剩余的微秒数直接对应需要的计数节拍数因为1us1tick // 获取起始时刻的毫秒计数和定时器当前值 start_ms tim2_update_cnt; start_tick __HAL_TIM_GET_COUNTER(htim2); while(1) { // 获取当前时刻的毫秒计数和定时器值 uint32_t now_ms tim2_update_cnt; uint32_t now_tick __HAL_TIM_GET_COUNTER(htim2); // 计算经过的毫秒数处理毫秒计数溢出 if(now_ms start_ms) { elapsed_ms now_ms - start_ms; } else { // 处理32位毫秒计数变量的回绕大约49.7天一次 elapsed_ms (0xFFFFFFFF - start_ms now_ms 1); } // 如果已经过去的毫秒数大于需要等待的毫秒数再判断微秒部分 if(elapsed_ms wait_ms) { break; // 早已超过设定的毫秒延时 } else if(elapsed_ms wait_ms) { // 毫秒数相等需要比较微秒节拍部分 if(now_tick start_tick) { elapsed_tick now_tick - start_tick; } else { // 定时器CNT在本次毫秒周期内发生了回绕从999回到了0 elapsed_tick (1000 - start_tick now_tick); } // 如果经过的节拍数已经达到或超过需要等待的节拍数则退出 if(elapsed_tick wait_tick) { break; } } // 如果 elapsed_ms wait_ms则继续等待 } }这个版本的逻辑非常清晰和健壮分解任务将总延时us分解为整数毫秒部分wait_ms和剩余微秒部分wait_tick。记录起点记录延时开始时的毫秒基准start_ms和定时器当前值start_tick。循环检查在循环中不断获取当前时间(now_ms, now_tick)并计算与起点的差值(elapsed_ms, elapsed_tick)。分级判断先比较毫秒数毫秒数不够就继续等毫秒数相等时再精细比较微秒节拍部分。处理溢出同时处理了32位毫秒计数变量的溢出虽然概率极低和16位定时器计数器在每个1ms周期内的溢出。这个函数可以安全地延时任意时长从1微秒到数秒甚至更长精度在1微秒级别误差主要来源于中断响应延迟和函数调用开销通常在几个微秒以内。注意使用这个优化版必须在main函数中在调用delay_us之前先启动TIM2并开启更新中断HAL_TIM_Base_Start_IT(htim2); // 启动定时器并开启中断5. 精度测试、误差分析与优化技巧函数写好了但它到底准不准我们需要验证。最直接的方法是用一个GPIO引脚来测试。5.1 利用PWM输出模式进行“傻瓜式”验证一个非常巧妙的验证方法是不写测试代码而是利用CubeMX和定时器本身的PWM功能。在CubeMX中除了配置TIM2为内部时钟基础定时还可以同时配置其中一个通道如CH1为“PWM Generation CH1”。参数设置中将Pulse脉冲宽度设置为 50。因为我们的计数器周期是10001ms所以50个计数就对应50us的高电平时间。生成代码后在main函数中启动PWMHAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1);。将对应引脚如PA0连接到示波器或者逻辑分析仪。你应该能看到一个周期为1ms频率1kHz占空比为5%50us/1000us的方波。测量高电平的宽度它应该非常接近50us。这直接证明了我们的定时器基础时钟分频是准确的。5.2 使用延时函数翻转GPIO进行测试如果没有示波器我们可以用延时函数本身来产生一个已知频率的方波然后用简单的延时来估算。// 测试代码 while (1) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0); // 翻转PA0 delay_us(500); // 延时500us }理论上这段代码会产生一个周期为1000us500us高 500us低即1kHz的方波。你可以用手机上的音频频率计APP通过一个简单的电阻分压接到耳机孔来粗略检测频率是否在1kHz附近。更准确的方法是用另一个定时器如TIM3的输入捕获功能来测量这个GPIO方波的周期。5.3 主要误差来源与优化中断延迟对于优化版函数tim2_update_cnt在中断中更新。中断响应、现场保护/恢复需要时间这会导致tim2_update_cnt的递增时刻比实际的物理时间点稍晚几个时钟周期。这是微秒级误差的主要来源。在72MHz下中断延迟通常在几十到几百个时钟周期即不到10微秒。对于大多数应用如传感器时序、软件串口这个误差是可以接受的。函数调用开销进入delay_us函数、读取变量、进行计算等操作本身需要时间。这部分时间虽然短但在延时极短如几个微秒时会带来可观的相对误差。例如调用一次函数可能就消耗了0.5us那么delay_us(1)的实际延时可能是1.5us。优化建议对于极短延时10us可以考虑使用经过校准的NOP循环或一个专门的“硬件延时”定时器配置为单脉冲模式。或者在delay_us函数内部如果检测到us值很小比如小于5则直接减去一个固定的补偿值如us - 2;这个补偿值需要通过实际测量来确定。提高系统时钟精度使用外部晶振而非内部RC振荡器能显著提高定时器的基准时钟精度。关闭全局中断在需要极高精度的一段代码前后可以使用__disable_irq()和__enable_irq()来临时关闭所有中断避免被其他中断打扰。但这种方法风险很高会破坏系统的实时性只适用于对时序极其苛刻且短暂的场景并且要非常清楚自己在做什么。使用DWT时钟周期计数器对于Cortex-M3/M4/M7内核的STM32有一个调试用的数据观察点跟踪单元DWT其中有一个时钟周期计数器CYCCNT。这个计数器随着内核时钟递增可以用于测量非常精确的时钟周期数。用它实现的延时函数精度最高可达单个时钟周期且不占用定时器资源。但需要注意这个计数器是32位的在72MHz下大约59秒就会溢出一次需要处理溢出逻辑。同时在调试模式下该计数器可能被禁用代码的移植性稍差。6. 进阶应用将延时函数集成到模块与项目实战要点一个成熟的延时模块不应该只是一个函数。我们应该把它封装得更好并考虑其在真实项目中的使用。6.1 模块化封装与头文件设计创建一个bsp_delay.h和bsp_delay.c。// bsp_delay.h #ifndef __BSP_DELAY_H #define __BSP_DELAY_H #ifdef __cplusplus extern C { #endif #include stdint.h void delay_init(void); // 初始化函数启动定时器等 void delay_us(uint32_t us); void delay_ms(uint32_t ms); // 基于delay_us实现的毫秒延时避免使用HAL_Delay #ifdef __cplusplus } #endif #endif /* __BSP_DELAY_H */// bsp_delay.c #include bsp_delay.h #include tim.h // 依赖CubeMX生成的定时器句柄 volatile uint32_t g_tim2_update_cnt 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim-Instance TIM2) { g_tim2_update_cnt; } } void delay_init(void) { HAL_TIM_Base_Start_IT(htim2); // 启动定时器中断 } void delay_us(uint32_t us) { // ... 上述优化版实现代码 ... } void delay_ms(uint32_t ms) { while(ms--) { delay_us(1000); } }这样在其他文件中只需要包含#include bsp_delay.h并在main初始化阶段调用一次delay_init()就可以随意使用delay_us和delay_ms了。6.2 在复杂项目中的使用注意事项资源冲突你选择的通用定时器如TIM2不能再用于其他用途比如PWM输出或输入捕获除非你非常清楚地在时间上错开使用。最好在项目规划初期就分配好定时器资源。中断优先级TIM2的更新中断优先级不宜设置过高。因为它每1ms触发一次如果优先级最高可能会阻塞其他重要中断如串口接收。通常设置为一个中等或较低的优先级。与RTOS的协作如果你在RTOS如FreeRTOS中使用这个延时函数需要特别注意。delay_us是阻塞的它会占用CPU。在RTOS的任务中长时间调用delay_us会阻塞整个任务影响其他任务的调度。对于毫秒级以上的延时应该使用RTOS提供的vTaskDelay()它会让出CPU控制权。我们的delay_us更适用于任务中需要极短时间精确等待的场景。功耗考虑在低功耗应用中如果进入睡眠或停止模式定时器可能会停止工作。唤醒后g_tim2_update_cnt和定时器CNT值可能已经不连续导致后续的延时计算错误。在这种情况下要么在进入低功耗前停止延时模块唤醒后重新初始化要么使用在低功耗模式下依然运行的时钟源如LSI来驱动定时器。6.3 一个实战案例驱动DHT11温湿度传感器DHT11的典型时序要求主机MCU先拉低总线至少18ms然后拉高20-40us随后等待从机响应。用我们编写的delay_us函数可以轻松实现。// 模拟DHT11起始信号 void DHT11_Start(void) { SET_DHT11_OUTPUT(); // 设置GPIO为输出模式 DHT11_LOW(); // 拉低总线 delay_ms(20); // 拉低至少18ms这里给20ms留有余量 DHT11_HIGH(); // 拉高总线 delay_us(30); // 拉高20-40us取中间值30us SET_DHT11_INPUT(); // 切换为输入模式准备读取从机响应 // ... 后续读取响应和数据位的代码 }在这个例子中delay_ms(20)和delay_us(30)的精度直接决定了通信能否成功。使用基于硬件定时器的延时函数确保了时序的严格性大大提高了驱动代码的可靠性和可移植性。通过以上从原理到配置从实现到优化再到实战封装的完整梳理你应该已经掌握了在STM32CubeMX环境下构建一个工业级精度微秒延时函数的全套技能。记住理解时钟树、正确处理溢出、进行误差分析和模块化封装是写出稳健底层驱动代码的关键。下次当你的项目需要精确控制时间时不妨试试自己动手打造这个利器。
返回列表