简介:一份基于STM32F103C8T6的超声波测距完整工程,面向单片机初学者与嵌入式开发者,解决HC-SR04测距、OLED显示及蜂鸣器报警联动的入门实践问题。资源包共206个文件,以H/C源码、编译生成的O/AXF/HEX、Keil工程配置(UVPROJX/UVOPTX)以及调试辅助文件(CRF/DEP/MAP)等为主,整体约7.34MB,解压后可直接使用。系统硬件由电源、控制、OLED显示、蜂鸣器报警和HC-SR04测距五大模块组成,软件部分涵盖各模块引脚配置、输入输出方式设置、采集数据处理与传输逻辑,并给出距离低于报警值时的声光报警实现。工程目录分层清晰,驱动与主逻辑分离,便于定位和按需移植。已有594人学习下载,适合课程设计、毕业设计或项目原型参考,能帮助快速掌握STM32外设驱动与传感器数据处理的整体思路。
1. 基于stm32f103c8t6的超声波测距:为什么这组合至今仍是入门首选
做嵌入式开发的人几乎都绕不开超声波测距这个经典课题。基于stm32f103c8t6的超声波测距程序,说到底就是用一颗不到十块钱的Cortex-M3内核单片机,配合HC-SR04这类超声波模块,把「发超声波→等回波→算距离」这三步用代码跑通。它的价值在于:硬件成本极低、逻辑链完整、踩坑点密集,恰好覆盖了从GPIO操作到定时器捕获再到中断优先级这一整条嵌入式基本功链路。对于做课设、毕设或者刚转行嵌入式的新手来说,这是性价比最高的练手项目;对于有经验的工程师,它也是验证板卡时钟配置、GPIO翻转速率和定时器精度的快速冒烟测试。
很多人以为这个项目简单到半小时就能写完,实际上一头扎进去才发现,同样的代码在开发板上跑得好好的,换个自己焊的板子就满屏乱跳。问题往往不在算法,而在供电、共地、引脚映射这些看起来不起眼的地方。这篇文章会把HC-SR04的时序要求、定时器输入捕获的配置参数、多组测量滤波的取舍一次讲透,最后给出几条血泪踩坑记录,让新手少走弯路,让熟手直接照搬关键参数。
2. 硬件选型与接线:为什么HC-SR04是默认选项,供电和引脚映射决定成败
2.1 HC-SR04相比其他测距方案的取舍
市面上常见的超声波测距模块主要是HC-SR04、US-015、JSNS-SR04T这三种。HC-SR04之所以成为默认选项,不是因为精度最好,而是因为它的接口逻辑最简单:一个Trig引脚触发,一个Echo引脚输出回波电平,完全不需要I2C或串口协议解析。US-015的优点是工作电压可以低到2.4V,适合电池供电场景,但市面货源参差不齐,买回来引脚定义不统一的概率较高。JSNS-SR04T是防水探头,适合水箱液位检测,价格贵一倍以上,日常室内测距用不上。
从性价比和资料丰富度来看,HC-SR04是大多数人唯一需要考虑的方案。stm32f103c8t6的GPIO耐受电压是5V,因此HC-SR04的5V供电和5V电平输出可以直接连接,不需要额外加电平转换电路。这是一个很多人没注意到的优势,换做其它3.3V单片机,Echo引脚输出5V高电平是存在烧引脚风险的。
2.2 最小接线方案与供电坑点
接线只需要四根线:VCC接5V,GND接GND,Trig接任意GPIO输出引脚,Echo接一个具有输入捕获能力的定时器通道引脚。这里有个容易被忽略的细节:Echo引脚必须接在定时器的捕获通道上,否则测距只能退化为「阻塞式延时读电平」方案,精度会大幅下降。
以下是固定的接线参考,也是我习惯采用的配置:
| 模块引脚 | 单片机引脚 | 说明 |
|---|---|---|
| VCC | 5V | 必须用稳压5V,不能接3.3V |
| GND | GND | 与单片机共地,不可省略 |
| Trig | PB10 | 普通GPIO推挽输出即可 |
| Echo | PB11 | 必须复用为TIM2_CH4输入捕获 |
选择TIM2是因为它是32位定时器,向上计数不会像16位定时器那样在65535次溢出后回绕,给后续扩展测距量程留了余地。PB10和PB11在最小系统板上相邻,排线短,信号完整性好。如果手头板子的PB11被其它功能占用,也可以换成PA0对应的TIM2_CH1,原理完全一样。
供电是这里最容易翻车的地方。HC-SR04的标称工作电流约15mA,看似不大,但它在发射瞬间的脉冲电流可以冲到50mA级别。如果用电脑USB口直接供电,电压跌落会直接导致Echo高电平时间缩短,测出来的距离系统性偏大。常见做法是给模块单独供电,或者确保开发板的5V稳压芯片有足够的余量。我用某开发板测过,板载AMS1117-5.0带HC-SR04加OLED显示屏就出现轻微跌落,测距值在1.3米处偶尔跳到1.4米,最终是改用外部5V供电解决。靠代码滤波压这个偏差,是典型的用软件补硬件的错误思路。
2.3 Echo引脚为什么必须走定时器捕获而不是外部中断
Echo引脚输出的是高电平持续时长,这个时长与距离成正比,声速约340m/s,往返距离除以声速再乘以一个系数就是实际距离。测量这个高电平持续时间的标准方法是:上升沿启动定时器计数,下降沿停止计数,读计数差值。用外部中断虽然也能实现,但EXTI中断响应本身有几十个时钟周期的延迟,且单片机在忙于其它任务时中断响应时间不可控,测出来的脉冲宽度抖动明显。
定时器输入捕获是硬件行为,计数器在上升沿自动把当前值锁存到捕获寄存器,下降沿再锁存一次,CPU只需要在中断里做一次减法,精度不受程序执行路径影响。让定时器工作在捕获模式,本质上就是用硬件计数器替代软件计时,这个差别在近距离测量时尤其明显——测量10cm的距离,算上中断延迟就有接近5%的误差。
3. 测距时序与代码实现:从时序图到可运行程序的完整拆解
3.1 HC-SR04时序协议的关键时间参数
HC-SR04的触发时序是固定的,需要发送一个至少10us的高电平脉冲到Trig引脚,模块内部会发出8个40kHz的超声波脉冲并等待回波。Echo引脚在发出超声波的同时拉高,收到回波后拉低,高电平时间就是超声波往返的时间。
测距公式为:距离(cm) = Echo高电平时间(us) / 58。这个58是怎么来的?声速340m/s,往返距离是单程的两倍,340m/s换算成cm/us是0.034cm/us,往返就是0.017cm/us,1/0.017约等于58.8。注意有些资料写58.3甚至58,差别来源于声速取值不同。实际用时,如果环境温度比较稳定,自己拿尺子量几次标定一下这个系数会更好。
以下是HAL库版本的核心触发代码:
// 发送10us以上高电平触发脉冲 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, 10); HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(15); // 实测15us比10us更稳妥,模块对最小脉宽有响应余量 HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET);代码说明:通过__HAL_TIM_SET_COMPARE配置TIM2_CH3的PWM占空比,是为了让Trig引脚产生一个精确的脉冲。HAL库直接用GPIO翻转然后delay_us也是可以的,但GPIO翻转加软件延时的误差在几十ns级别,对10us的最小脉宽要求来说足够。变量延迟15us比最低要求的10us多留了50%余量,实测中模块的响应一致性更好。有说法认为脉宽超过20us模块会进入异常状态,实测15us并没有触发异常。
如果用的是标准外设库,delay部分需要注意,在72MHz主频下,简单空循环延时的最小分辨率是1/72us,足够用来凑10us的脉宽。
3.2 TIM2输入捕获配置:通道、分频和采样频率
定时器配置是整个程序的地基。TIM2的时钟来自APB1,在stm32f103c8t6的默认时钟树中APB1频率是36MHz,定时器时钟倍频后为72MHz。如果把PSC设为71,那么计数频率是1MHz,即每计数一次代表1us,这是最常用也最直观的配置。
以下是初始化配置的核心代码:
void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_IC_InitTypeDef sConfigIC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 72MHz / (71+1) = 1MHz,每个计数单位1us htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFFFFFF; // 32位定时器,按最大值装载 htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(&htim2); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig); sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; // 先捕获上升沿 sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler = TIM_ICPSC_DIV1; sConfigIC.ICFilter = 0; HAL_TIM_IC_ConfigChannel(&htim2, &sConfigIC, TIM_CHANNEL_4); }参数说明:Prescaler设为71是固定的做法,它决定测量分辨率是1us,对应距离分辨率约0.017cm,对HC-SR04本身约±3mm的精度来说完全够用。Period设为0xFFFFFFFF,配合32位计数器,最大可以测量约4294秒的高电平,不会溢出。ICFilter设为0表示不启用输入滤波,因为Echo信号来自模块的数字输出,信号边沿干净,不需要滤波。
这里有个不少人犯的错误:ICPolarity同时配置了上升沿捕获,但在后续代码中实际上需要在上升沿和下降沿之间切换捕获极性。正确的做法是在初始化时配置上升沿,然后通过中断回调函数动态切换。
3.3 捕获中断完整逻辑与测距主流程
完整的测距需要配合中断回调函数来记录两个边沿的计数值。
volatile uint32_t rising_time = 0; volatile uint32_t falling_time = 0; volatile uint8_t echo_flag = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_4) { if (echo_flag == 0) // 第一次捕获,是上升沿 { rising_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_4); // 切换为下降沿捕获 TIM2->CCER &= ~TIM_CCER_CC4P; // 清除极性位 TIM2->CCER |= TIM_CCER_CC4NP; // 设置为下降沿 echo_flag = 1; } else // 第二次捕获,是下降沿 { falling_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_4); // 切换回上升沿捕获,准备下一次测量 TIM2->CCER &= ~TIM_CCER_CC4NP; TIM2->CCER |= TIM_CCER_CC4P; echo_flag = 0; echo_done = 1; // 通知主循环可以读取结果 } } }逻辑说明:首次捕获发生在Echo引脚拉高时,记录下当前计数器的值,然后立即将捕获极性切换为下降沿。第二次捕获发生在Echo引脚拉低时,记录下计数器的值,两者差值就是Echo高电平持续us数。一定要在两处捕获之间完整执行读值-切换极性操作,否则边沿到来时会丢失数据。这里直接用寄存器操作CCER而不是用HAL的HAL_TIM_IC_ConfigChannel,是为了保证切换延迟最小化,函数调用开销会影响到边沿切换的窗口期。
主循环触发一次测量到拿到结果的完整流程如下:
while (1) { echo_flag = 0; echo_done = 0; HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(15); HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); uint32_t timeout = 0; while (!echo_done) { if (++timeout > 100000) // 约100ms超时 { distance = -1; // 超出量程或未检测到回波 break; } } if (echo_done) { uint32_t pulse_width = falling_time - rising_time; // 注意无符号减法,自动处理回绕 distance = pulse_width / 58; // 单位cm } HAL_Delay(60); // 两次测量间隔至少60ms,避免余振干扰 }注意falling_time - rising_time使用了无符号减法,即使rising_time在计数回绕后被赋值,差值依然正确,这是32位计数器带来的一个天然优势。超时判断用了一个简单的递增变量,在1MHz计数频率下每次循环约1us,100000次约等于100ms。但注意这个100ms不是精确值,while循环的执行时间取决于代码编译优化级别,所以真正的超时时间应该用调试器实测确认,它存在的意义是防止程序在无回波时卡死。
4. 距离换算、精度分析与数据滤波:从原始值到可信距离的关键步骤
4.1 温度对声速的影响以及补偿公式
声速在空气中不是恒定值,它随温度变化大约每摄氏度0.6m/s。在室内常温下误差不明显,但在温差大的环境中就暴露了。声速公式:v = 331.4 + 0.6 × T(℃),其中T是环境温度。在25℃时声速是346.4m/s,如果代码里固定按340m/s算,在25℃环境下会引入约1.9%的系统性误差。30cm的距离会偏差接近6mm,对精度要求高的场景这已经不可接受了。
补偿的做法有两种。一种简单做法是在换算系数上做文章:把固定的除法值58改成随温度变化的变量,比如在温度25℃时用56.7代替58。另一种做法是用一个温度传感器读取环境温度,实时计算声速。常见做法是把DS18B20挂在同一个I2C或单总线上,每10秒读一次温度即可,超声波的传播速度不会瞬间跳变,过高频率的温度采样没有意义。
4.2 为什么单次测距不可信:用中值滤波代替均值滤波
HC-SR04的测量结果存在明显的随机抖动,原因是超声波在传播过程中会遇到多次反射和衍射,Echo信号本身就不是一个理想的方波边沿。近距离测桌面,结果普遍稳定;远距离测墙角,结果可能反复跳变。应对手段是滤波,但选对滤波算法比滤波本身更重要。
均值滤波会把异常大值和异常小值都算进平均值里,一个偶发的大幅跳变就会拉偏结果。中值滤波则能有效剔除异常点:连续测五次,取排序后的中间值。这种滤波在超声波测距场景中的表现明显优于均值滤波。以下是我常用的滑动中值滤波实现:
#define FILTER_SIZE 5 int32_t median_filter(int32_t raw_value, int32_t *buffer) { int32_t sorted[FILTER_SIZE]; int32_t temp; for (int i = FILTER_SIZE - 1; i > 0; i--) { buffer[i] = buffer[i - 1]; // 滑动窗口推进 } buffer[0] = raw_value; for (int i = 0; i < FILTER_SIZE; i++) { sorted[i] = buffer[i]; } // 冒泡排序,5个元素足够快,不需要优化 for (int i = 0; i < FILTER_SIZE - 1; i++) { for (int j = 0; j < FILTER_SIZE - i - 1; j++) { if (sorted[j] > sorted[j + 1]) { temp = sorted[j]; sorted[j] = sorted[j + 1]; sorted[j + 1] = temp; } } } return sorted[FILTER_SIZE / 2]; // 返回中间值 }这个函数每次调用需要约2.8us的开销(在72MHz主频下),对整体测距周期来说几乎可以忽略。需要注意buffer数组必须由调用方保持静态存储,否则每次调用重新初始化会导致滑动窗口失效。取中间值而非平均值,是因为超声波测距的误差分布不是高斯分布,而是带有明显长尾的脉冲噪声,中值滤波在这种噪声模型下具有理论上的最优性。
4.3 量程、盲区和最大测量距离的实测边界
HC-SR04标称量程是2cm到400cm,但这个边界值在实测中要打折扣。表面对超声波的反射特性差异极大:平坦硬表面反射好,超过300cm还能稳定测量;布料、海绵等软表面反射弱,150cm以上就是碰运气。在2cm以内的盲区,收发探头之间的串扰会导致Echo信号还没被反射回来就已经被接收端误触发,此时测量值不是偏大而是直接乱跳。
如果应用场景必须覆盖近距离,HC-SR04基本无解,需要换带单探头收发分时复用的模块。另一个容易被忽视的边界是测量夹角:超声波探头的波束角约15度,如果被测物体表面与探头不垂直,回波强度按夹角余弦衰减。超过30度角基本测不到稳定回波,这一点在安装结构设计时就要考虑,不要在代码里试图补偿,物理限制无法通过软件解决。
5. 排查与避坑:这五个问题我几乎每次帮人调超声波都会遇到
5.1 Echo引脚误接普通GPIO导致测距值跳变
现象:程序逻辑完全正确,代码看起来没有任何漏洞,但测量值时好时坏,短距离偏大长距离偏小,毫无规律。
原因:回波捕获走的是软件轮询GPIO电平,而不是定时器输入捕获。主循环里执行其它代码时,Echo高电平变化无法被及时采集,高电平持续的us数被系统任务调度切碎。
解决:确认Echo引脚连接的是定时器捕获通道,并在初始化代码中将该引脚复用为AF功能而不是GPIO模式。用STM32CubeMX生成初始化工程时,在GPIO配置页把引脚复用为TIMx_CHx即可。
5.2 供电不足导致远距离系统性偏大
现象:测量距离超过50cm后,读数比实际距离多出2到5cm且误差随距离增大趋势变明显;近距离测量正常。
原因:HC-SR04发射瞬间电流大,电源电压跌落导致模块内部比较器阈值漂移,Echo高电平时间被拉长。这是硬件问题,不是软件算法能修正的。
解决:用万用表实测模块VCC引脚在触发瞬间的电压,如果跌落超过0.2V,说明供电不足。把模块供电改到USB口的5V输出或独立稳压源,避免与单片机共用同一路LDO。我在某开发板上调整供电后,同样的代码测距误差从3cm降到了0.5cm以内。
5.3 中断优先级配置错误导致Echo捕获丢失
现象:程序在单独测距时一切正常,加入OLED刷新、串口打印或电机控制后,测量值偶尔显示-1或跳到极端值。
原因:定时器捕获中断被其它更高优先级中断长时间打断,或者捕获中断自身优先级太低被阻塞。Echo信号的捕获要求实时响应,任何延时都可能导致边沿被跳过。
解决:将定时器捕获中断优先级设为最高(抢占优先级0,子优先级0),并把不需要抢占的中断优先级调低。尤其注意串口中断的优先级不要高过捕获中断,串口接收中断里如果有大量处理逻辑,会对捕获造成难以预测的延迟。
5.4 两次测量间隔过短导致测距值重复
现象:测距值长时间不变,即使物体移动了10cm读数仍然保持旧值,看似是滤波把数据拉平了。
原因:HC-SR04的探头存在余振现象,第二次触发超声波时,上一轮的残余振动还没完全消失,模块默认检测到的是余振的反射回波。测量间隔小于50ms时这种情况出现概率极高。
解决:将两次测量的间隔拉长到60ms以上,这个值要放在触发前的延时逻辑里。如果想提高测距频率,只能换传感器,比如用多路复用分时驱动或改用激光测距模块。
5.5 用HAL_Delay触发Trig导致脉宽不稳定
现象:近距离测量正常,远距离偶尔跳变,用示波器看Trig引脚脉宽发现波动范围在8us到22us之间。
原因:HAL_Delay的实现基于SysTick中断,如果程序正在中断服务函数里执行,HAL_Delay无法完成延时计数,导致Trig高电平时间飘移。HC-SR04对Trig脉宽的响应虽然不严格,但低于10us可能导致模块完全没有触发。
解决:改用定时器计数延时,或者先用其它GPIO翻转的方式保存好Trig拉高瞬间,再用HAL_Delay延时15us后拉低。如果系统中有中断频繁触发,建议使用一个专门的TIM定时器完成微秒级延时,彻底规避SysTick中断竞争。
6. 进阶:把测距程序扩展成带移动平均和越限报警的可复用组件
基础测距程序跑通之后,下一步值得做的是把它封装成一个独立模块,把不稳定的测量值转化为可靠的布尔信号。常见的做法是设计一个双层滤波结构:第一层用中值滤波剔除瞬时噪声,第二层用滑动平均压低慢变波动,然后通过一个滞回比较器输出稳定的越限信号。
滞回比较器解决的是边界抖动问题。如果没有滞回,目标物体在设定的阈值线附近微微移动时,输出信号会在无效和有效之间反复翻转,这在控制设备的开关逻辑中是灾难性的。具体实现方法:设定上限值和下限值,当测量距离小于下限时认为障碍物存在,只有距离大于上限时才认为障碍物消失。上下限之间保持2到3cm的迟滞区间。
在代码结构上,建议把测距逻辑从主循环中抽离,做成一个独立函数,用状态机管理测距周期和超时重发。这样移植到其它项目时,只需要修改引脚映射和定时器实例,不需要改动调用逻辑。我在自己的模拟项目X里就是这么处理的,后来换到某跨平台系统的板子上,整个超声波模块的代码只改了引脚定义就完成了迁移。整套程序调试通过之后,我会习惯性地用万用表量一下模块供电、用示波器看一眼Echo引脚的波形、再用尺子按几个固定距离标定一遍换算系数,这三个动作做完,程序才是真正可信的。希望帮到你。
本文还有配套的精品资源,点击获取