
简介面向STM32开发者这份工程提供基于HAL库的多路HC-SR04超声波测距方案核心是用一个定时器完成多个模块的触发与回波计时显著节省定时器资源适合需要多路测距的嵌入式项目。压缩包共987个文件以558个C源码和242个头文件为主另含汇编启动文件、链接脚本、配置文件及文本说明等整体23.26MB工程结构完整便于直接查阅和迁移。已有2040人学习下载。通过该工程可学习定时器输入捕获/比较输出在超声波测距中的实际用法理解多传感器轮询时序、距离换算及HAL库外设初始化流程也可作为模板扩展更多路数对嵌入式初学者和进阶开发者均有参考价值。1. 一个定时器为什么能管住多个超声波模块把四五个 HC-SR04 摆在同一块 STM32 上新手第一反应是每个模块配一个定时器触发、一个捕获通道回波结果发现 TIM 资源根本不够分。反直觉的做法是整个系统只用一个定时器就能完成对多路超声波模块的触发与回波脉宽测量。核心思路是——HC-SR04 的测距本质上只要求两件事给 Trig 引脚一个不低于 10us 的高电平脉冲测量 Echo 引脚高电平持续的时间。前者可以用 PWM 输出比较通道实现后者可以用定时器的输入捕获加轮询状态机完成。只要把各路触发与回波测量在时间上错开单定时器就够用而且不需要引入外部中断代码量比标准库版本更短。这套方案适合手头 TIM 资源紧张的裸机工程也适合把测距节点嵌入到已有 HAL 库任务里下面把原理到关键实现完整拆开。2. HC-SR04 与定时器工作机制先弄清三个时间窗口2.1 Echo 脉宽测量为什么天然适合输入捕获HC-SR04 的测距公式是distance echo_us / 58单位 cm本质上就是测量高电平持续时间。常见的误用是把 Echo 直接接到外部中断引脚在上升沿和下降沿各进一次中断用HAL_GetTick()求差值。这个方案在系统负载高时误差会到厘米级因为HAL_GetTick()的分辨率是 1ms而声波在空气中每厘米约需 58us1ms 的抖动意味着约 17cm 的误差。用定时器的输入捕获通道则完全不同捕获寄存器会在边沿到来时自动锁存计数器的值整个过程不需要 CPU 介入分辨率取决于定时器时钟频率。在 STM32 上做输入捕获首选 TIM2、TIM3、TIM4、TIM5 这类通用定时器它们的时钟来自 APB1 定时器时钟通常是 84MHz 或 72MHz。如果直接让计数器跑满速那么 16 位计数器在 72MHz 下 0.91ms 就会溢出一次而 HC-SR04 最大测距约 4m对应 Echo 高电平约 23ms远超计数器溢出周期。因此必须开启定时器的更新中断在溢出时递增一个软件变量把计数范围扩展为 32 位甚至更长这就是所谓的定时器溢出扩展计数法。2.2 触发脉冲用 PWM 还是软件翻转各有什么代价多路超声波最省事的触发方式是对每一路 Trig 引脚配置一个定时器的输出比较通道让它输出一路周期 60ms、脉宽 20us 的 PWM。这样硬件自动产生触发脉冲CPU 完全不用管。代价是每个超声波模块都需要占用一个输出比较通道而通用定时器一般只有 4 个通道四路模块就会把通道占满留给 Echo 捕获的通道就没有了。另一个被广泛采用的是软件触发在主循环或定时器中断里把 Trig 引脚拉高delay_us(20)再拉低。软件触发的好处是几乎不占用定时器通道所有通道都可以用于 Echo 捕获。缺点是延时期间 CPU 被占用如果系统里还有 PID 控制、OLED 刷新等实时任务20us 的忙等还可以接受但若多次触发就会累积阻塞。我的建议是混合策略如果每路模块的 Echo 必须同时测量比如做阵列测距就用 PWM 触发 多通道输入捕获但要确认触发通道与捕获通道不冲突如果是普通的多点避障间隔几十毫秒轮询各路那么软件触发 输入捕获是最省资源的组合这也是本文接下来要展开的实现方式。2.3 分时轮询 vs 并发捕获的临界场景判断用单个定时器管理多个超声波有两种拓扑需要区分。分时轮询是最容易理解的定时器只负责产生一个时基在某个时刻只触发一路模块然后立刻把捕获通道切换到该模块的 Echo 引脚测量完这一路再触发下一路。这种方式的时序完全可控互不干扰缺点是同时刻只能测一路。并发捕获则是各路 Echo 引脚分别接到定时器的不同捕获通道上升沿到来时每个通道独立记录计数值。这种方式的延迟最小特别适合需要同时测量多个方向的场景但对定时器通道数量和代码复杂度都有要求。本文实现选择分时轮询作为主逻辑因为绝大多数博客提问和工程需求都是「34 路超声波避障」轮询周期控制在 50ms 以内完全够用代码也好维护。3. 基于 HAL 库的单定时器多路测距实现从 CubeMX 到状态机3.1 CubeMX 配置一个 TIM2 同时承担时基、触发和捕获先打开 STM32CubeMX选择你手头的芯片这里以 STM32F103C8T6 为例72MHz 主频。在 Timers 菜单里使能 TIM2将 Clock Source 选为 Internal Clock。Prescaler 设为 71Counter Period 设为 0xFFFF这样定时器时钟为 72MHz / (711) 1MHz计数周期 1us 一递增16 位计数器溢出周期为 65.535ms足以覆盖 HC-SR04 的最大回波时间。static 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; // 1MHz计数频率1us分辨率 htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; // 65535溢出周期65.535ms htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_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_1); }这段配置里比较关键的是捕获极性先设为上升沿后面在中断服务函数里会动态切换为下降沿。ICFilter 设为 0 表示不滤波因为 HC-SR04 的 Echo 信号边沿比较干净滤波反而会引入几百纳秒的延迟。在 GPIO 配置中将 Echo 引脚设为输入模式Trig 引脚设为推挽输出。注意 CubeMX 生成的初始化函数默认只使能了基本定时器输入捕获通道需要在主程序里单独调用HAL_TIM_IC_Start_IT()才会开启。3.2 捕获扩展32 位时间戳的完整实现因为 16 位计数器在 1MHz 计数频率下 65.535ms 溢出一次而 Echo 高电平时长最大可到 23ms对应 4m 测距理论上一路测量不会跨越两个溢出周期。但考虑到分时轮询中需要知道捕获发生时的绝对时刻我会在每次捕获中断里把htim-Instance-CNT和溢出次数组合成 32 位时间戳这样后续做多路时间同步、连续测距滤波都更方便。volatile uint32_t timer_overflow_cnt 0; volatile uint32_t echo_rise_tick 0; volatile uint32_t echo_fall_tick 0; volatile uint8_t echo_capture_flag 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { timer_overflow_cnt; // 每次溢出加1扩展计数范围 } } void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { if (__HAL_TIM_GET_IT_SOURCE(htim, TIM_IT_CC1) SET) { uint32_t cnt __HAL_TIM_GET_COUNTER(htim); if (HAL_GPIO_ReadPin(ECHO_GPIO_Port, ECHO_Pin) GPIO_PIN_SET) { // 上升沿记录起始时刻并将捕获极性改为下降沿 echo_rise_tick (timer_overflow_cnt 16) | cnt; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } else { // 下降沿计算脉宽并将捕获极性改回上升沿 echo_fall_tick (timer_overflow_cnt 16) | cnt; echo_capture_flag 1; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); } } } } }这段代码的核心是先读取引脚电平判断当前捕获到的是上升沿还是下降沿再用溢出次数高 16 位与计数器值低 16 位拼出 32 位时间戳。有一个容易被忽略的细节判断沿类型时不能依赖捕获极性寄存器的当前值因为读取CNT之后、执行__HAL_TIM_SET_CAPTUREPOLARITY之前可能又有新的边沿到来导致状态错乱。因此直接读引脚电平是更稳健的做法。__HAL_TIM_GET_IT_SOURCE的检查则是为了防止更新事件误触发捕获回调。3.3 触发时序与轮询状态机触发脉冲的生成放在主循环里用一个简单的状态机来避免阻塞式延时。状态机的核心是三个状态空闲、等待回波、处理完成。typedef enum { US_IDLE, US_WAIT_ECHO, US_DONE } US_State; typedef struct { GPIO_TypeDef *trig_port; uint16_t trig_pin; GPIO_TypeDef *echo_port; uint16_t echo_pin; float distance_cm; US_State state; uint32_t start_tick; } US_Sensor; void US_Process(US_Sensor *sensor, uint32_t now_tick) { switch (sensor-state) { case US_IDLE: // 拉高Trig20us后拉低产生触发脉冲 HAL_GPIO_WritePin(sensor-trig_port, sensor-trig_pin, GPIO_PIN_SET); sensor-start_tick now_tick; sensor-state US_WAIT_ECHO; break; case US_WAIT_ECHO: if (now_tick - sensor-start_tick 1) { // 20us延时结束后拉低Trig HAL_GPIO_WritePin(sensor-trig_port, sensor-trig_pin, GPIO_PIN_RESET); sensor-state US_DONE; } break; case US_DONE: if (echo_capture_flag) { uint32_t pulse_us echo_fall_tick - echo_rise_tick; sensor-distance_cm pulse_us / 58.0f; echo_capture_flag 0; sensor-state US_IDLE; } break; } }这里用系统 tick1ms 分辨率来做 20us 延时不准确所以实际工程中我会把now_tick换成 TIM2 的 1MHz 计数值即__HAL_TIM_GET_COUNTER(htim2)与溢出次数组成的 32 位时间戳。上面的代码仅展示状态迁移逻辑。US_Process在每路传感器的结构体上独立运行主循环里依次调用各路传感器即可实现分时轮询。轮询间隔由外层循环速度决定一般控制在 30ms 以上避免前一波回波未结束就开始下一波触发。4. 多路测量中的通道切换、时序避让与数据滤波4.1 Echo 引脚与定时器通道的自动重映射分时轮询方案中硬件上每路 Echo 引脚需要能连接到同一个捕获通道。有三种做法第一是硬件上把多个 Echo 通过跳线或模拟开关切换到同一个引脚但这样电路复杂且不灵活第二是用定时器的多个通道每路独占一个输入捕获通道但通道管理复杂第三是把所有 Echo 接在同一个引脚上靠外部逻辑门或三态缓冲隔离。工程上最常用的是第二种变体复用同一个捕获通道在触发不同传感器之前通过 GPIO 重映射函数切换引脚复用。STM32F103 上部分定时器通道支持引脚重映射AFIO但运行时切换重映射需要先关闭定时器再修改 AFIO 寄存器最后重新开启会丢失当前计数状态。因此我一般不建议运行时切换。更实用的做法是给每个传感器分配一个固定的 GPIO 引脚作为 Echo然后在调用HAL_TIM_IC_Start_IT时只使能一路通道其余传感器先不接入捕获通道。通过判断当前轮询到哪个传感器在触发之前调用下面的切换函数void US_SelectEchoChannel(uint8_t sensor_id) { // 关闭捕获中断切换GPIO复用再重新开启 HAL_TIM_IC_Stop_IT(htim2, TIM_CHANNEL_1); if (sensor_id 0) { HAL_GPIO_DeInit(ECHO1_GPIO_Port, ECHO1_Pin); GPIO_InitTypeDef gpio {0}; gpio.Pin ECHO1_Pin; gpio.Mode GPIO_MODE_AF_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(ECHO1_GPIO_Port, gpio); } // 类似处理其他传感器 HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1); }注意HAL_GPIO_DeInit会先把引脚恢复到默认状态然后重新按复用功能初始化。整个过程需要大约几个微秒由于在触发之前执行不会影响测量时序。这里有个容易踩的坑HAL_TIM_IC_Stop_IT不只是停止中断它会把捕获通道也关闭所以每次切换之后必须重新Start_IT。另外如果多个 Echo 引脚分布在不同 GPIO 端口切换时还要注意开启对应的 GPIO 时钟否则复用功能配置无效。4.2 触发间隔与回波重影的处理分时轮询最常见的异常是上一路触发产生的回波还没下降到低电平下一路触发就开始了导致捕获通道把上一路的下降沿误判为本路回波结束。HC-SR04 在 4m 量程内回波时间最长约 23ms因此触发间隔必须大于 25ms。如果系统里有多路传感器轮询每路触发并等待完成后至少在延时 5ms 再触发下一路。回波重影的另一个来源是声波余振模块在 Trig 下降沿之后Echo 会立即拉高但如果在拉高之前 Echo 引脚存在一个小于 10us 的毛刺捕获通道可能误触发。忽略方法是在初始化时把 ICFilter 设为 0x0F即开启 8 个时钟周期的采样滤波1MHz 下对应 8us 的毛刺过滤。这个值不会明显影响测距精度但对抑制 PCB 走线串扰很有用。4.3 测量结果的滑动平均与异常剔除单次超声波测距受温度、湿度、反射面角度影响很大直接拿单次值控制小车避障会出现抖晃。我习惯在输出距离前做一次窗口长度为 5 的滑动平均同时剔除超出合理范围的突变值。#define MEDIAN_WINDOW 5 float US_GetStableDistance(US_Sensor *sensor) { static float history[4][MEDIAN_WINDOW]; static uint8_t idx 0; float raw sensor-distance_cm; // 剔除明显跳变与上次差值超过50cm则沿用上次值 static float last_valid[4] {0}; if (last_valid[sensor-id] 0 fabsf(raw - last_valid[sensor-id]) 50.0f) { return last_valid[sensor-id]; } history[sensor-id][idx % MEDIAN_WINDOW] raw; idx; // 简单中值滤波 float tmp[MEDIAN_WINDOW]; for (int i 0; i MEDIAN_WINDOW; i) tmp[i] history[sensor-id][i]; for (int i 0; i MEDIAN_WINDOW - 1; i) { for (int j i 1; j MEDIAN_WINDOW; j) { if (tmp[i] tmp[j]) { float t tmp[i]; tmp[i] tmp[j]; tmp[j] t; } } } last_valid[sensor-id] tmp[MEDIAN_WINDOW / 2]; return last_valid[sensor-id]; }这段代码里先用fabsf做突变剔除防止前方突然有手挥过时测距值从 30cm 跳到 200cm 带来的误判然后在中值滤波里取 5 次数据的中间值。中值滤波相比平均值的好处是能过滤掉突发的多径反射尖峰——比如超声波打到墙角再反射回来的伪回波。处理完后每路传感器在 50ms 轮询周期内能得到一个稳定值不会每次都输出跳动剧烈的数据。上一轮的测量结果在下一轮触发前读取保持数据连贯性。5. 标定补偿与多模块一致性校准同一批 HC-SR04 模块之间声波换能器的启振延迟存在个体差异直接导致同样距离下各路模块输出的脉宽不同。标准的 58us/cm 换算公式在空气中的声速为 340m/s 时成立但温度变化会带来明显误差。实际工程里我通常会做两级校准先对每个模块单独测一组固定距离的数据拟合出该模块的distance_us系数再在系统初始化时对其中一个模块做温度补偿。5.1 单模块线性校准typedef struct { float k; // 每厘米对应的us数 float b; // 零点偏移us } US_Calib; void US_Calibrate(US_Sensor *sensor, float known_dist_cm, US_Calib *calib) { // 触发一次测量读取脉宽 US_TriggerSingle(sensor); uint32_t pulse_us US_GetPulseUs(sensor); // 已知距离为 known_dist_cm暂不考虑零点偏移 calib-k (float)pulse_us / known_dist_cm; }把模块放在 20cm、50cm、100cm 三个已知距离下分别测量用最小二乘拟合出 k 和 b然后测距时distance (pulse_us - b) / k。实验数据表明HC-SR04 的 k 一般在 5660 之间b 在 -3050us 之间。不校准的话最大偏差能到 23cm对于精度要求不高的避障已经够用但做测距小车的自主循迹就偏大了。值得说明的是这个校准过程只需在模块初次接入系统时做一次结果存入 Flash以后开机直接读取。5.2 温度对声速影响的补偿公式声速与温度的关系近似为c 331.4 0.6 * TT 为摄氏温度。在 20℃ 下声速 343.4m/s对应 k58.2us/cm而 0℃ 时声速 331.4m/sk60.3us/cm。如果系统里没有温度传感器可以假设室温 25℃ 直接用 k58如果有 DS18B20 或 NTC那么在每次测距前补偿一次系数float US_GetSpeedCompensatedK(float temperature_c) { float speed 331.4f 0.6f * temperature_c; return 1000000.0f / speed; // 每厘米对应的us }注意这个公式在 -10℃ 到 50℃ 范围内线性近似效果良好超出范围误差会增大但 HC-SR04 的工作温度范围本来就是 -20℃ 到 70℃所以基本够用。在 40℃ 的环境里不做补偿会造成约 3.5% 的误差1m 处就是 3.5cm对精度敏感的应用不可忽略。把补偿系数叠加到每路模块的 k 中即可得到k_final calib.k * (343.4 / speed)。6. 用逻辑分析仪验证触发与回波时序写完全部代码后验证环节往往被忽略但时序错误在超声波测距里是最难排查的。最直接的手段是用逻辑分析仪同时抓取 Trig 和 Echo 两条线观察触发脉冲宽度、回波高电平持续时间以及触发间隔。采样率不需要太高10MHz 足够分辨微秒级波形。# 假设逻辑分析仪是 Saleae命令行导出CSV后可用Python分析 # pulse_us.py 解析回波脉宽 import csv import sys rows [] with open(sys.argv[1], r) as f: reader csv.reader(f) next(reader) # 跳过表头 for row in reader: rows.append((float(row[0]), int(float(row[1])))) # 找出Echo通道的所有上升沿和下降沿 echo_idx 1 rising None for t, v in rows: if rising is None and v 1: rising t elif rising is not None and v 0: print(fpulse_us{int((t - rising) * 1e6)}) rising None这段 Python 脚本把逻辑分析仪导出的 CSV 按时间戳解析输出每个回波的高电平时间。比对串口打印的distance_cm如果串口值在 40cm而逻辑分析仪显示 2320us约 40 * 58说明测量链路正确。如果串口值与捕获值不一致优先检查中断回调里echo_rise_tick和echo_fall_tick是否被其他中断打断——比如串口中断优先级更高且耗时过长可能造成极短的回波脉宽小于中断响应时间被漏捕获。解决办法是把定时器中断优先级设为最高抢占优先级 0串口中断设为 1 或 2。捕获通道切换的验证也很直观逻辑分析仪上观察相邻两次触发之间的通道映射如果切换函数执行时有超过 1ms 的间隔说明HAL_GPIO_DeInit和重新初始化的耗时拖慢了轮询节奏可以改为直接操作 GPIO 寄存器来提高切换速度。例如用GPIOA-CRL直接修改引脚模式从输入切换到复用功能耗时从几十微秒降到两三微秒。所有时序都确认无误后把轮询周期固定为 50ms声波余振和回波重影问题都会在这个验证环节暴露这也是这套单定时器方案能否稳定跑满三个月不重启的关键检查点。本文还有配套的精品资源点击获取