按说嵌入式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。如果计数时钟是72MHz,65535个tick只够量910微秒左右,连10cm都会溢出,更不用说几米的量程了。
所以第一步是把计数时钟降下来。我配置TIM2的预分频器PSC=71,这样计数时钟变成:
[ 72MHz / (71+1) = 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 Clock
- Channel1选择Input Capture direct mode
- Prescaler填71,Counter 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、VSCode+CMake两种环境中我都跑过,逻辑一致。
4. 实测数据与误差:5厘米不是5.00厘米
4.1 不同距离的实测数据表
代码写完之后,我在室内、20°C左右、无风环境下做了几组对比,用钢尺定距离,每个位置取10次测量均值:
| 真实距离(cm) | 单次读数(cm) | 10次均值(cm) | 现象 |
|---|---|---|---|
| 5 | 4.6 / 5.4 / 5.1 / 4.3 | 4.9 | 跳动明显,数据不稳定 |
| 20 | 20.1 / 19.8 / 20.2 / 20.0 | 20.03 | 偏差小,稳定 |
| 50 | 49.6 / 50.3 / 49.8 / 50.1 | 49.9 | 偏差约0.2cm |
| 100 | 99.2 / 100.5 / 99.6 / 100.3 | 99.9 | 偏差约0.5cm |
| 200 | 198.7 / 202.1 / 199.5 / 201.2 | 200.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在上升沿锁存计数器值到CCR1,IC2在下降沿锁存到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()。
- VSCode+CMake环境下的头文件路径:如果你和我一样用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++封装,其实每一步都是在替硬件减少软件干扰,想明白了这一点,下一个项目里不管用什么传感器,思路都是通的。