ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32实战:用C++封装HC-SR04超声波测距模块

STM32实战:用C++封装HC-SR04超声波测距模块 STM32、C、嵌入式——这三个词凑在一起很多人的第一反应是“能点个灯就算成功”。但项目稍微复杂一点点灯的逻辑根本不够用。这个系列连载走到第六篇前几篇里我们聊过GPIO、串口、外部中断、定时器这类基础外设零碎的知识攒了一堆可我总感觉缺了点什么。缺的不是新知识点而是把这些东西揉成一个完整小系统的实战体验。所以这期我给自己布置了一个硬核活儿用STM32驱动HC-SR04超声波测距模块从底层信号捕获到上层应用逻辑全部用C来组织代码。这篇内容适合谁看如果你是跟着这个系列一路走下来的朋友看完这期基本就能把定时器、中断、GPIO、串口这些基础串成一条线如果你是有一定C语言基础、正准备往嵌入式项目实战迈步的开发者这篇也能帮你看清楚“嵌入式C编程”到底是怎么落地的。我尽量把每一个设计决策的“为什么”都讲透代码也给全照着抄也能跑起来。1. 这期到底要干什么超声波测距为什么能收尾系列1.1 系列前几篇铺了什么路前几篇我们其实已经把嵌入式开发常用的几个外设覆盖得差不多了GPIO用来操作引脚串口用来看调试信息外部中断用来响应突发事件定时器用来计时和产生PWM。学单个外设的时候什么都很流畅可一进到真实项目里问题就来了——设备上一堆模块要协同工作代码稍微一多就成了一锅粥。超声波测距这个场景有点特殊。它看上去只是一个“测距离”的小功能实际上你需要同时用到GPIO输出触发信号、定时器输入捕获去测量回波脉宽、中断来处理上升沿和下降沿、最后还要把数据从串口或者屏幕上输出来。一个模块就把系列前几篇的内容全部串起来了。所以我一直觉得想把阶段性基础打牢光靠练习单个外设不够必须做一次综合小项目。超声波模块便宜、稳定、见效快拿它来当这个“串起来”的角色再合适不过。另一个比较现实的原因是超声波测距的应用场景实在太常见了智能小车避障、倒车雷达、液位监测、人体感应甚至鱼缸水位提醒都可以用它。做完这个项目你可以很快把它改造成自己手头需要的工具而不是学完就忘。1.2 方案对比为什么不用延时轮询很多人拿到HC-SR04的第一反应是Trig发一个脉冲然后用HAL_Delay之类的延时函数等Echo引脚拉高再循环读引脚直到变低最后用HAL_GetTick()算时间差。这个思路很简单但存在两个明显问题。第一延时轮询会彻底占用CPU。测量期间主循环干不了任何事如果系统里同时要跑屏幕刷新、按键扫描、数据上传整个任务的实时性就崩了。第二HAL_GetTick()的精度是毫秒级超声波模块响应的时间是微秒级算出来的距离误差会让人怀疑人生。所以我最后选择了定时器输入捕获方案让硬件帮忙记录引脚电平变化的时间点精度能到微秒甚至更高而且测量过程中CPU可以去忙别的。这个决策的本质是“让合适的硬件做合适的事”。定时器天生就是干计时的你非要用软件去轮询不仅干得慢还把自己累死。嵌入式开发里这种思路贯穿始终能用外设解决的就别用CPU硬扛。2. HC-SR04的核心时序和参数计算2.1 一条指令测出距离靠的是这串时序HC-SR04模块的工作原理并不复杂它内部有一个超声波发射头和接收头外加一个控制电路。完整的一次测距流程可以拆成四步主控给Trig引脚一个大于10us的高电平脉冲模块收到后自动发射8个40kHz的超声波脉冲。超声波遇到障碍物后反射回来模块的接收头捕捉到回波。模块从发射超声波的同时把Echo引脚拉高。当回波被接收到Echo引脚被拉低。关键点在于Echo引脚高电平持续的时间等于超声波从发射到返回所经历的总时长。这个时间跟距离成正比所以我们只需要精确测出Echo高电平的脉宽就能算出目标距离。声波在空气中的传播速度大约是340m/s也就是0.034cm/us而声波走的是往返路程单程距离就是脉宽乘以声速再除以2。这里我习惯用一个速算口诀脉宽1us对应大约0.017cm也就是0.17mm。也就是说如果Echo高电平持续了1000us距离大约是17cm。这个速算值在常温下足够用来估测但如果要求数据准确下面这个修正公式才是正路。2.2 声速不是固定值温度修正公式我最早用超声波模块的时候踩过一个坑冬天室外测距读数和卷尺量出来的总是差那么一两厘米。一开始我还以为是模块坏了后来查了一下声速的温度特性才反应过来问题出在空气密度变化导致声速变了。声速与温度的关系可以用一个近似公式表达c 331.4 0.607 * T其中c的单位是m/sT是摄氏温度。0℃时声速约331.4m/s25℃时约346.6m/s两者差了约4.5%。如果不修正光温度变化就能产生好几厘米的误差。所以在正式的计算函数里我预留了一个温度输入的接口从温度传感器读取当前环境温度然后用上面的公式计算实时声速。如果你做的是室内项目温度变化不大固定用340m/s问题也不大但涉及户外、或者温差明显的环境最好还是把修正加上。2.3 定时器捕获参数到底怎么算取一个具体的计算过程来说明。假设MCU是STM32F103系列系统主频72MHzAPB1总线上的定时器时钟也是72MHz我们选TIM3作为捕获定时器。目标是让计数频率达到1MHz也就是每一个计数值代表1微秒。定时器预分频值PSC的计算公式是计数频率 定时器时钟 / (PSC 1)代入数值1MHz 72MHz / (PSC 1)得到PSC 71。也就是说CubeMX里预分频填71计数器的步进就是1us。市面常见的HC-SR04标称量程是2cm到400cm。400cm的往返路程是800cm以340m/s声速计算所需时间大约23.5ms即23500us。STM32的定时器计数器是16位最大计数值65535折算下来单次捕获能覆盖约6.5万微秒对应距离超过11米远大于模块标称量程。所以直接使用16位计数器、不分频扩展完全够用。下面是几个常用配置参数方便直接对号入座系统主频定时器时钟预分频值计数频率分辨率理论最大量程340m/s72MHz72MHz711MHz1us约0.17mm11.1m80MHz80MHz791MHz1us约0.17mm11.1m168MHz84MHz831MHz1us约0.17mm11.1m如果你不需要那么高的分辨率可以把计数频率降到500kHz对应2us计数一次分辨率约0.34mm最大量程能翻倍。但我个人建议先用1MHz数据平滑度更好。2.4 盲区、量程与精度先有个数HC-SR04不是万能的它有三个比较尴尬的物理限制。首先是测量盲区模块标称最小量程2cm实际上太近的目标会落入发射与接收之间的串扰区读数会乱跳甚至超时。其次是波束角模块的超声波波束有一个大约15度的锥角面对不平整的反射面时回波信号会叠加干扰造成读数抖。第三是反射面特性柔软织物、海绵这类材质吸收声波太厉害可能导致收不到回波。知道这些限制不是为了劝退你而是让你在排查问题的时候有个方向。很多“模块坏了”的结论最后都被证明是使用环境不合适。3. 用C封装一套能复用的驱动3.1 为什么嵌入式里也用C封装嵌入式领域有一个老生常谈的争论资源这么紧张直接用C不就行了为什么还要用C我的观点是C有C的简单直接但当工程规模超过几千行之后C的全局函数加散落状态变量会慢慢失控。尤其超声波模块这种自带状态、需要和中断打交道的设备用C写出来很容易变成一堆global_flag和global_tick。C在这里最大的价值不是“面向对象”这四个字而是把“数据和操作数据的逻辑”装进一个独立的单元里。这个模块需要哪些引脚、怎么触发、内部处于什么状态、怎么计算距离全部收拢到类里。以后再接一个测距模块不再需要复制粘贴几十行零散的C代码直接创建一个新的类实例就行。但嵌入式C有两条红线我强烈建议你遵守不要用动态内存分配不要依赖异常处理。跑在裸机上的程序new和delete容易造成堆碎片时间一长行为不可预测。小系统里完全可以用静态对象替代动态分配安全又好查。3.2 类的接口设计public和private怎么切类的设计核心是“对外简单对内克制”。外部调用者不应该看到定时器内部寄存器的细节也不需要知道回波信号是怎么被捕获的。我设计的对外接口只有三个初始化、触发并等待测量完成、读取距离值。内部设计则围绕一次测量的生命周期展开。一次完整测距需要经历四个状态空闲、触发中、等待上升沿、等待下降沿。当上升沿到达记录当前计数值当下降沿到达再记录一个值两次记录的差就是Echo脉宽。class Ultrasonic { public: Ultrasonic(TIM_HandleTypeDef* htim, uint32_t channel, GPIO_TypeDef* trigPort, uint16_t trigPin) : m_htim(htim), m_channel(channel), m_trigPort(trigPort), m_trigPin(trigPin), m_riseTick(0), m_fallTick(0), m_measureDone(false), m_temperatureC(25.0f) {} // 初始化启动输入捕获中断 void Init() { HAL_TIM_IC_Start_IT(m_htim, m_channel); } // 触发一次测距并等待完成超时返回false bool TriggerAndWait(uint32_t timeoutMs) { m_measureDone false; m_riseTick 0; m_fallTick 0; HAL_GPIO_WritePin(m_trigPort, m_trigPin, GPIO_PIN_SET); delayMicroseconds(15); HAL_GPIO_WritePin(m_trigPort, m_trigPin, GPIO_PIN_RESET); uint32_t start HAL_GetTick(); while (!m_measureDone (HAL_GetTick() - start) timeoutMs) { } return m_measureDone; } // 读取距离单位cm未完成测量时返回 -1 float GetDistanceCm() const { if (!m_measureDone) return -1.0f; uint32_t pulseWidthUs m_fallTick - m_riseTick; float speedOfSoundMps 331.4f 0.607f * m_temperatureC; return pulseWidthUs * speedOfSoundMps / 20000.0f; } // 供中断回调调用的上升沿/下降沿处理 void OnRise() { m_riseTick __HAL_TIM_GET_COUNTER(m_htim); __HAL_TIM_SET_CAPTUREPOLARITY(m_htim, m_channel, TIM_INPUTCHANNELPOLARITY_FALLING); } void OnFall() { m_fallTick __HAL_TIM_GET_COUNTER(m_htim); m_measureDone true; __HAL_TIM_SET_CAPTUREPOLARITY(m_htim, m_channel, TIM_INPUTCHANNELPOLARITY_RISING); } // 设置当前环境温度用于声速修正 void SetTemperature(float t) { m_temperature t; } private: TIM_HandleTypeDef* m_htim; uint32_t m_channel; GPIO_TypeDef* m_trigPort; uint16_t m_trigPin; volatile uint32_t m_riseTick; volatile uint32_t m_fallTick; volatile bool m_measureDone; float m_temperature; };代码里有两个细节值得说明。一是volatile修饰了中断和主循环共享的变量这是嵌入式C里非常容易忽略的。中断里写主循环里读如果不加volatile编译器可能会把变量值优化进寄存器导致主循环永远读不到更新。二是GetDistanceCm里的声速公式我把所有单位换算得很清楚脉宽单位是us声速单位是m/s除以20000的原因是声波往返的路程要折半同时完成微秒和厘米的换算。3.3 中断回调怎么和类对象连接类写好了但HAL库给的中断回调是全局函数没法直接调用某个对象的成员函数。这里的桥接方案有很多种最直观的是在C文件里定义一个外部对象引用然后在中端回调里转发调用。// 全局对象定义 Ultrasonic g_ultrasonic(htim3, TIM_CHANNEL_1, GPIOA, GPIO_PIN_0); extern C void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef* htim) { if (htim-Instance TIM3) { if (__HAL_TIM_GET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1) TIM_INPUTCHANNELPOLARITY_RISING) { g_ultrasonic.OnRise(); } else { g_ultrasonic.OnFall(); } } }这里判断当前极性有个容易搞反的点回调触发的时候读取到的极性是本次触发沿的极性。上升沿触发回调时读到Rising此时要执行上升沿记录下降沿触发回调时读到Falling这时执行下降沿记录。如果你的工程里同时挂了多个超声波模块每个模块对应一个定时器通道可以用一个静态数组保存多个对象指针在回调里根据htim和channel做索引分发。比如两个探头的避障小车就能用这种方式管理代码不会乱。4. 完整实操从CubeMX到第一行距离数据4.1 引脚分配与GPIO配置我这次用STM32F103C8T6最小系统板属于最常见的那块“蓝丸”。Trig接PA0Echo接PC6原因很简单PC6刚好是TIM3_CH1的复用引脚可以直接作为定时器输入捕获通道不需要额外飞线。GPIO配置里有一个小地方要注意Trig引脚必须是推挽输出而且初始状态输出低电平。很多人模块没反应是因为Trig默认悬空或者开漏触发脉冲根本送不进去。Echo引脚则配置为浮空输入即可因为HC-SR04的Echo输出是推挽结构模块自己会驱动这个引脚。CubeMX里配完后会生成一大段MX_GPIO_Init()和MX_TIM3_Init()。工程语言选择C没问题但我们最终要用C写应用层这里有个很关键的处理步骤CubeMX生成的main.c建议直接改名为main.cpp然后在文件开头用extern C包住HAL库和生成代码的头文件。这样既能使用HAL的C接口又能在同一个文件里直接写C类实例。extern C { #include main.h #include gpio.h #include tim.h #include usart.h } #include ultrasonic.h Ultrasonic g_ultrasonic(htim3, TIM_CHANNEL_1, GPIOA, GPIO_PIN_0);这里不建议你去动HAL库本身的文件也不建议把大量C类塞进中断文件。保持HAL底层的C语言环境稳定只在应用层引入C工程维护成本最低。4.2 定时器输入捕获的CubeMX配置定时器输入捕获的配置比普通PWM输出稍微绕一点。CubeMX里有几个参数容易混淆我一个个过一下。首先在左侧Timers里选中TIM3在Mode下把Channel1设置为Input Capture direct mode。这里注意Slave Mode保持Disabled我们不需要外部触发同步。然后在Parameter Settings里Prescaler填71得到1MHz计数频率。计数器计数周期可以保持默认的65535或者填一个略大于最大测距时间的值也行。接着在Input Capture Channel1那一栏里把Polarity设置为Rising EdgeIC Filter可以根据需要设成4或者8。IC Filter是数字滤波可以滤掉一些窄脉冲毛刺但滤波深度太大会对信号边沿产生延迟。实测下来接线干净的情况下设0就行如果环境有干扰再从小到大试。最后一步是使能NVIC中断在NVIC Settings里勾选TIM3 global interrupt。忘记开中断是初学者最常见的漏配少了这一步整个捕获逻辑都会静默失效。配完之后CubeMX生成的代码里HAL_TIM_IC_Start_IT并不会自动调用需要我们在初始化函数里手动启动。4.3 核心代码实现触发、捕获、距离计算主循环的逻辑非常简洁代码结构大概是这样的int main() { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM3_Init(); MX_USART1_UART_Init(); g_ultrasonic.Init(); char buffer[64]; while (1) { if (g_ultrasonic.TriggerAndWait(100)) { float distance g_ultrasonic.GetDistanceCm(); snprintf(buffer, sizeof(buffer), distance: %.1f cm\r\n, distance); HAL_UART_Transmit(huart1, (uint8_t*)buffer, strlen(buffer), 100); } else { const char* msg timeout\r\n; HAL_UART_Transmit(huart1, (uint8_t*)msg, strlen(msg), 100); } HAL_Delay(100); } }TriggerAndWait(100)里的100ms是超时保护防止回波没回来导致程序卡死在等待循环里。正常测量时最远4m的回波时间约23ms100ms超时足够宽松。循环末尾的HAL_Delay(100)也很重要HC-SR04两次触发之间至少要间隔60ms以上否则发射余波会干扰下一次测量。实测的时候我把模块正对一面距离30cm的墙串口输出的数字在29.6cm到30.3cm之间跳动。看起来有一点点不稳定其实这个现象对超声波模块来说是正常的因为声波在墙面上反射有细微的路径差异。等会儿我们在排查章节会讲怎么用软件手段把这个抖动压下来。4.4 串口输出与屏幕显示怎么选展示方式调试阶段用串口看原始数据是最省事的接一个USB转TTL模块波特率115200数据就一行行往终端上蹦。但如果想把测距结果做成一个看得见的产品原型串口就有点不够直观了这时候可以接一块OLED屏幕。OLED显示这部分本质上就是把距离值转换成字符串再调用显示库画到屏幕上。这里我多说一句别在中断回调里做屏幕刷新或串口打印。中断里应该只做标记和记录时间所有耗时的IO操作放到主循环。否则一旦中断服务函数执行时间过长会影响整个系统的实时性定时器捕获都可能丢事件。这也是为什么我坚持用类来封装驱动的一个原因当主循环里同时存在屏幕刷新、按键扫描、测量任务时类的状态管理能让你清楚地知道当前这几件事之间的关系而不是一个全局变量满天飞。5. 实测长跑遇到的坑与处理方案5.1 回波超时一直测不到距离这是新手遇到最多的一个问题代码逻辑没毛病串口却一直打timeout。排查方向从上到下有这么几层第一检查Trig引脚的GPIO模式。必须确认是推挽输出许多初始化模板默认开漏此时高电平拉不高模块根本发不出超声波。第二检查触发脉冲宽度。模块要求至少10us我给15us留了余量用delayMicroseconds实现时要注意自己的延时函数准不准如果循环体被编译器优化了15us可能缩水到2us。第三检查Echo引脚和定时器通道是否对应。PC6是TIM3_CH1如果你错接到了PC7TIM3_CH2CubeMX和代码里都没配对应通道自然收不到捕获。第四确认中断回调真的进了。可以临时在回调里加一个翻转GPIO的测试代码再用示波器看电平变化能快速验证中断链路是否通畅。5.2 读数乱跳要的是“稳”而不是“快”模块连接没问题之后下一个典型现象是读数不稳定比如摆在固定距离输出却在17cm、19cm、28cm之间随机蹦。这种情况通常有三个原因反射面太粗糙、测量距离落入了盲区、两次测量间隔太短导致超声余波串扰。最有效的处理方案是软件滤波。个人经验里先做窗口滑动滤波再做中值滤波。连续取5次测量结果排序后取中间值能够一次性滤掉大部分瞬时毛刺。因为超声波测距的噪声往往是单次突刺而真实距离值在短时间内是缓慢变化的中值滤波器对这种噪声形态非常有效。float MedianFilter(float* data, int len) { for (int i 0; i len - 1; i) { for (int j i 1; j len; j) { if (data[j] data[i]) { float tmp data[i]; data[i] data[j]; data[j] tmp; } } } return data[len / 2]; }冒泡排序虽然简单但在5个元素的小规模下性能完全够用。取完中值再输出实测数据稳定度肉眼可见地提升了一个档次。5.3 声速和定时器溢出读数系统性偏大或偏小如果读数不是乱跳而是稳定地比真实值大几厘米或小几厘米大概率是“系统性误差”。系统性偏大的原因常见于声速设置偏高。代码里如果固定用344m/s而现场实际只有331m/s每个厘米就会放大百分之三左右测1米就多3厘米。解决办法就是把温度修正加进去读取环境温度后设置到类里。系统性偏小则多半出在定时器溢出上。虽然前面计算过4m量程不会超过65535us但如果模块实际能测更远、或者你换了其他型号量程更大的探头单次脉宽一旦超过65.535ms16位计数器就会回绕直接相减会出现莫名其妙的负数或小值。这时候需要把定时器扩展成32位或者在溢出中断里维护一个溢出计数器把完整时间拼出来。5.4 几个容易被忽略的硬件细节最后说几个硬件层面的细节都是我实际踩过的。HC-SR04模块很多是5V供电而它的Echo引脚输出高电平也是5V。如果你的MCU是3.3V供电直接把Echo接入芯片引脚时间长了可能损伤引脚甚至烧坏IO口。稳妥的做法是在Echo引脚上串一个1k电阻再对地接一个2k电阻组成分压网络把5V降到约3.3V。也有些模块在3.3V供电下也能工作但发射功率会降低测距质量受影响。还有一个容易被忽略的点是电源质量。超声波模块的发射瞬间电流需求比较大如果用电脑USB口或者稳压芯片余量不足的板子供电容易造成电压跌落进而让回波信号幅度变低。给模块单独供电或者在模块电源引脚旁边加一个100uF电解电容往往能解决很多莫名其妙的超时。我个人在实际调试中还有一个体会不要急着把滤波、温度修正这类“看起来很专业”的功能一上来就全加上。先把最原始的测量数据打印出来观察它的规律再根据现象决定加什么处理。很多人在第一步就被“读数乱跳”吓住了结果加了一堆滤波代码真正的问题其实是供电不良。数据驱动调试比经验驱动调试可靠得多。至于这个超声波测距还能往哪里扩展我后来把它做成了鱼缸水位监测超阈值自动提醒也有人用它做小车避障前左右三个方向各装一个探头采集的数据统一上报云平台。硬件部分一点不用改只需要在类外面多包一层业务逻辑。这就是把底层驱动用类封装好的好处——你的注意力可以放在应用上而不是重新查一遍引脚怎么配。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进