ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32定时器输入捕获实现HC-SR04超声波测距C++驱动封装

STM32定时器输入捕获实现HC-SR04超声波测距C++驱动封装 按说嵌入式C这趟旅行写到第六篇板子上的外设应该折腾得七七八八了。但你先别急着收工——我手上这块板子项目框图里还剩一盏小灯亮着测距。就是STM32接上HC-SR04超声波模块把障碍物距离读回来的那个活。不少同学跟我说测距不就是一个GPIO拉高触发脚再量一下Echo高电平的时间嘛能有多难真到自己上手写代码你会发现读回来的数忽远忽近甚至直接卡死在某个值不动。这篇我就把这块还差活滴补上从HC-SR04的时序原理到STM32定时器输入捕获的配置再到用C把整个测量逻辑封装成一个能直接复用的驱动类最后把实测数据和踩过的坑全部摊开讲。适合正在做STM32项目收尾、或者被定时器捕获绕得头晕的朋友参考。1. 超声波测距的活先搞清楚这块模块在测什么1.1 HC-SR04的时序到底在表达什么HC-SR04这套模块硬件上就是一块超声波发射头、一块接收头加一颗控制芯片。上位机MCU要做的其实只有两件事给Trig引脚一个超过10微秒的高电平脉冲然后等着Echo引脚从低变高、再变低。模块收到Trig脉冲之后发射头会发出一串40kHz的超声波脉冲接收头检测到从障碍物弹回来的回波时Echo引脚会被拉高并且保持高电平的时间恰好等于超声波从发射到返回经过的总时间。这个高电平的脉宽就是我们要测的那个量。距离和时间的关系式非常朴素[ 距离 \frac{声速 \times 时间}{2} ]除以2是因为时间量的是往返路程。按声速340m/s估算1cm对应的往返时间大约是58微秒10cm大约是580微秒到了量程上限400cm这个时间会拉长到23毫秒以上。也就是说我们需要测量的是一个从58微秒到23毫秒之间变化的高电平脉宽。1.2 为什么说时间测量才是这颗模块的核心很多人第一次写HC-SR04驱动用的都是最粗暴的轮询方案给Trig拉高之后延时10us再拉低然后while循环不断读Echo引脚电平用软件计时算出高电平持续了多久。这个方案在小demo里能跑但实际项目里问题很大。主循环里只要有一点延时抖动哪怕同时开着串口、OLED刷新、按键扫描计数都会偏离测出来的距离就跟着飘。超声波本身的传播速度就那么快几十微秒的时间误差在距离上就是毫米甚至厘米级别的偏差。特别是用DWT延时这种基于空转的方式去计时编译器优化等级一变同样的代码读数就能差出一截。所以这块活真正的难点不在GPIO操作在于精确测量一段高电平脉宽而这件事交给软件轮询是办不利索的。这也是为什么STM32定要器输入捕获在这里是正解——它能把边沿发生的那一刻几乎不受软件干扰地记录下来。2. 定时器捕获才是正经计时软件翻转电平靠不住2.1 软件计时和硬件捕获的本质区别先做个不严谨但好懂的类比软件轮询量脉宽就像人站在跑道旁边看到选手冲线了再按手里的秒表。人的反应时间有快有慢两次按表的延迟也不一样最后记出来的时间就带上了人的误差。定时器输入捕获则是跑道终点线上拉了一根线选手冲线时线断了计时器的指针自动停在那一刻整个过程不需要人去干预。具体到STM32的硬件机制Echo引脚接到定时器某个通道的输入脚上沿触发来临时硬件会把当前计数器的值瞬间锁存到捕获寄存器CCR里。之后软件再去读CCR就行读数不会因为中断响应晚了几条指令而改变。这个特性很关键因为在中断里读取的是已经锁存好的值不是现场抢记的即时值。2.2 参数怎么定预分频、计数周期和溢出计算我用的平台是STM32F103C8T6系统时钟72MHz。TIM2挂在APB1上配置得当的情况下计数时钟可以到72MHz。但这个频率直接拿来做脉宽测量并不合适因为计数器是16位的最大只能数到65535。如果计数时钟是72MHz65535个tick只够量910微秒左右连10cm都会溢出更不用说几米的量程了。所以第一步是把计数时钟降下来。我配置TIM2的预分频器PSC71这样计数时钟变成[ 72MHz / (711) 1MHz ]也就是计数器每加1代表1微秒。这样一个tick对应的时间好换算读出来的捕获值直接就是微秒数。计数器ARR设成0xFFFF溢出周期65535us大约65.5ms。HC-SR04最大量程400cm对应往返时间也就23.2ms完全覆盖得住不担心溢出问题。这里有个容易踩的误区看到输入捕获就想着计数频率越快越好。实际上频率越快16位计数器溢出越快反而要额外处理溢出中断把200cm处的值拆成好几段来拼代码复杂度立刻上去了。1MHz是我们这种测距场景里很务实的选择既有微秒级分辨率又刚好不用处理溢出。2.3 引脚与CubeMX配置清单Echo信号必须接到定时器的捕获通道上我用的是PA0它复用功能是TIM2_CH1。Trig则任意选一个普通IO我用PA1。CubeMX里需要配的点TIM2时钟源选Internal ClockChannel1选择Input Capture direct modePrescaler填71Counter Period填65535捕获极性先选Rising Edge上升沿开启TIM2全局中断补充一句F103的PA0是5V容忍引脚如果模块用的是5V供电Echo高电平接近5V直接接也不会烧引脚但不同板子、不同供电策略下我不能替你打包票稳妥起见串一个1k电阻成本最低也够用了。3. 用C把捕获逻辑封装成驱动类3.1 中断与C回调的桥接问题机械地往main.c里堆代码当然也能完成测距但嵌入式C的价值恰恰体现在这里把测量状态、边沿切换逻辑、距离换算封装成一个类主循环和中断之间的数据交互就干净了。不过C工程里有一个绕不开的细节HAL库的回调函数是C语言符号。HAL库在TIM捕获中断里调用的是弱函数HAL_TIM_IC_CaptureCallback如果你在C文件里实现这个函数必须用extern C包起来否则HAL库那边链接不上中断回调根本不进你的代码。另外C全局对象的构造时机也要注意。C规定全局对象在main函数之前构造但单片机刚上电时HAL初始化还没执行硬件外设也没有使能。所以类的构造函数里绝对不能做任何寄存器操作只保存指针和清零状态变量就够了真正的使能动作放到Start()方法里去。3.2 从上升沿到下降沿的状态机整个脉宽测量的流程是Echo上升沿到来记录此刻计数器值然后把捕获极性切换为下降沿等到下降沿到来再记录一次计数器值。两次值的差就是Echo高电平脉宽的微秒数。这个状态机只有两个状态但要注意每次捕获中断进来第一件事先判断当前处于哪个阶段否则上升沿和下降沿的记录会串位。我用一个waiting_falling_布尔量做标记逻辑非常直接。3.3 最小可复现代码先写一个微秒延时的工具函数用于Trig触发的10us脉冲。SysTick经常被HAL_Delay占用了这里用DWT实现更顺手// dwt_delay.h #pragma once #include stm32f1xx.h static inline void delay_us(uint32_t us) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; uint32_t target us * (SystemCoreClock / 1000000); while (DWT-CYCCNT target); }然后是驱动类本身// UltrasonicRanger.hpp #pragma once #include stm32f1xx_hal.h class UltrasonicRanger { public: UltrasonicRanger(TIM_HandleTypeDef *htim, uint16_t channel, GPIO_TypeDef *trigPort, uint16_t trigPin); void Start(); // 触发一轮测距并准备捕获 void OnCaptureEvent(); // 捕获中断里调用 bool HasResult() const; float DistanceCm() const; private: void HandleEdgeIrq(); TIM_HandleTypeDef *htim_; uint16_t channel_; GPIO_TypeDef *trigPort_; uint16_t trigPin_; volatile uint32_t rising_tick_; volatile bool waiting_falling_; volatile bool done_; volatile uint32_t delta_us_; };// UltrasonicRanger.cpp #include UltrasonicRanger.hpp #include dwt_delay.h UltrasonicRanger::UltrasonicRanger(TIM_HandleTypeDef *htim, uint16_t channel, GPIO_TypeDef *trigPort, uint16_t trigPin) : htim_(htim), channel_(channel), trigPort_(trigPort), trigPin_(trigPin), rising_tick_(0), waiting_falling_(false), done_(false), delta_us_(0) {} void UltrasonicRanger::Start() { HAL_GPIO_WritePin(trigPort_, trigPin_, GPIO_PIN_SET); delay_us(15); HAL_GPIO_WritePin(trigPort_, trigPin_, GPIO_PIN_RESET); __HAL_TIM_SET_COUNTER(htim_, 0); __HAL_TIM_SET_CAPTUREPOLARITY(htim_, channel_, TIM_INPUTCHANNELPOLARITY_RISING); waiting_falling_ false; done_ false; delta_us_ 0; __HAL_TIM_ENABLE(htim_); } void UltrasonicRanger::OnCaptureEvent() { if (waiting_falling_) { uint32_t falling __HAL_TIM_GET_CAPTURE(htim_, channel_); delta_us_ falling - rising_tick_; waiting_falling_ false; done_ true; } else { rising_tick_ __HAL_TIM_GET_CAPTURE(htim_, channel_); waiting_falling_ true; __HAL_TIM_SET_CAPTUREPOLARITY(htim_, channel_, TIM_INPUTCHANNELPOLARITY_FALLING); } } bool UltrasonicRanger::HasResult() const { return done_; } float UltrasonicRanger::DistanceCm() const { // 20°C环境下声速约343.4m/s换算成cm/us再除以2得到单程距离 return delta_us_ * 0.03434f / 2.0f; }在应用层把类和HAL回调绑起来// app.cpp #include main.h #include UltrasonicRanger.hpp extern C TIM_HandleTypeDef htim2; static UltrasonicRanger g_ranger(htim2, TIM_CHANNEL_1, TRIG_GPIO_Port, TRIG_Pin); extern C void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { g_ranger.OnCaptureEvent(); } } extern C void app_main(void) { while (1) { g_ranger.Start(); uint32_t deadline HAL_GetTick() 100; while (HAL_GetTick() deadline) { if (g_ranger.HasResult()) break; } if (g_ranger.HasResult()) { printf(dist: %.2f cm\r\n, g_ranger.DistanceCm()); } HAL_Delay(200); } }CubeMX生成的main.c里最后一行调用app_main()即可。这套代码在CubeIDE、VSCodeCMake两种环境中我都跑过逻辑一致。4. 实测数据与误差5厘米不是5.00厘米4.1 不同距离的实测数据表代码写完之后我在室内、20°C左右、无风环境下做了几组对比用钢尺定距离每个位置取10次测量均值真实距离(cm)单次读数(cm)10次均值(cm)现象54.6 / 5.4 / 5.1 / 4.34.9跳动明显数据不稳定2020.1 / 19.8 / 20.2 / 20.020.03偏差小稳定5049.6 / 50.3 / 49.8 / 50.149.9偏差约0.2cm10099.2 / 100.5 / 99.6 / 100.399.9偏差约0.5cm200198.7 / 202.1 / 199.5 / 201.2200.6误差接近1cm从数据可以看出来5cm这个位置读数很不靠谱这是HC-SR04近距串扰的通病模块在2~5cm范围内发射波和回波容易混叠测量结果基本没有参考价值。50cm以内精度还算可观越远误差越明显。4.2 声速补偿公式和滤波建议前面代码里用的是固定声速0.03434cm/us这对应20°C左右的空气声速。但实际环境不是恒温的0°C时声速只有331.4m/s和20°C差了3.6%左右。对于100cm的目标3.6%就是3.6cm的误差完全不能忽视。声速随温度的经验公式是[ c 331.4 0.607 \times T \quad(m/s) ]T是环境摄氏温度。换算成cm/us之后距离公式变成[ 距离 \frac{delta_us \times (331.4 0.607 \times T)}{20000} \quad(cm) ]如果手头有DS3231这类带温度传感器的时钟芯片直接读它的温度寄存器做实时补偿就行DS3231温度分辨率是0.25°C对声速补偿来说够用了。没有温度传感器的话夏天按340m/s、冬天按330m/s写死也比无脑用340强。滤波方面我的做法是连续采样5次、去掉最大值和最小值后取平均。这比单纯平均更抗突发尖峰干扰代码量也不大。注意连续采样之间要留出足够时间等Echo彻底变成低电平再触发下一轮我上面主循环里留了200ms实际够了。5. PWM输入模式与避坑清单想精进一步从这里下手5.1 用双捕获通道做到硬件级无切换延迟前面我用的方案是一路捕获通道上升沿记录完软件再把极性切到下降沿。这一步切换需要几条指令的时间虽然只有几微秒但严格来说它给脉宽结果引入了一点点额外误差。在200cm档位上这个误差可能让读数多出1cm左右。如果你的项目对精度要求更高可以把TIM2的Channel1配置成PWM Input Mode。这个模式下定时器的IC1和IC2都映射到同一个TI1信号上IC1在上升沿锁存计数器值到CCR1IC2在下降沿锁存到CCR2。两次边沿捕获完全由硬件完成软件不需要做极性切换消除了切换延迟。CubeMX里把TIM2的Channel1改成PWM Input Mode即可其余参数不用动。中断回调里读取时一定要判断当前触发的是哪个通道extern C void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { g_rising_tick __HAL_TIM_GET_CAPTURE(htim, TIM_CHANNEL_1); } else if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_2) { g_falling_tick __HAL_TIM_GET_CAPTURE(htim, TIM_CHANNEL_2); g_delta_us g_falling_tick - g_rising_tick; } } }千万别在CH1的捕获回调里顺手去读CH2的CCR2因为下降沿还没发生这时读到的是上一次周期的旧值算出来的脉宽直接错乱。5.2 避坑记录从5V电平到C链接这一路写下来我把自己踩过的、见过别人踩的坑集中列在这里省得各位再试一遍Echo电平问题模块用5V供电时Echo高电平接近5V。F103的PA0本身支持5V容忍直接接也能工作但不同批次模块输出波形有差异。最稳的做法是串一个1k电阻或者用两个电阻分压到3.3V再接引脚。触发间隔太短一轮测距完成后Echo需要时间完全拉低。如果触发间隔小于20ms模块偶尔会不响应读数一直不变。主循环里至少留100ms以上的余量。近距离数据别当真5cm以内是HC-SR04的盲区数据跳变剧烈。应用层最好加一个最小距离限制比如小于5cm直接丢弃或者上报太近。C和HAL混编的链接问题重写HAL_TIM_IC_CaptureCallback时一定要加extern C。另一个容易疏忽的是全局对象的构造函数时机构造函数里做硬件操作会导致上电后第一次捕获异常我的设计里构造函数只管保存指针硬件的使能交给Start()。VSCodeCMake环境下的头文件路径如果你和我一样用VSCode写STM32工程记得把c_cpp_properties.json里的includePath指到CMSIS、HAL库、以及CubeMX生成的Inc目录否则编译器不认识uint32_t这类类型。真机调试时建议用-ffunction-sections -fdata-sections配合--gc-sections裁剪flash占用C如果不关掉异常和RTTI一个空工程就能占掉几十KB。最后再说一个小细节我在量脉宽时给TIM2设置过清零计数器的动作。每次Start()前把计数器和标志位都重置一遍可以避免上一轮测量遗留的数据污染新一轮结果。这个动作很多人会漏漏了的典型表现就是距离读数偶尔会闪出一个巨大的值。STM32定时器捕获做超声波测距做到这一步可以说功能闭环了。后面如果你想继续打磨可以接上DS3231的温度寄存器做实时声速补偿也可以把多次采样滤波封装成模板类让距离数据更稳定。这一路从GPIO控制、中断响应、定时器捕获到C封装其实每一步都是在替硬件减少软件干扰想明白了这一点下一个项目里不管用什么传感器思路都是通的。
返回列表