十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

多路HC-SR04测距的STM32定时器与外部中断统一时间基准方案

多路HC-SR04测距的STM32定时器与外部中断统一时间基准方案 简介这套基于 STM32 HAL 库的多路超声波测距实现方案利用单个定时器完成多路 HC-SR04 的触发与回波计时主要面向希望减少定时器占用、快速完成多路测距开发的嵌入式工程师。代码中既有 HAL 库初始化和 GPIO 配置也包含输入捕获、输出比较以及多通道轮询计时逻辑能够有效降低多模块同时工作时的外设冲突。工程内附完整可编译的 Keil 工程、链接脚本和编译输出文件并保留 CubeMX 生成的 .ioc 工程方便根据不同 STM32 型号重新生成底层配置后直接移植。整个资源包共 987 个文件以 C 源码、头文件为主另有汇编启动文件、.ioc 初始化配置、Keil 工程文件及 map/hex 烧录文件等压缩包大小约 23.26MB目录结构清晰。目前已有 2040 人学习下载可作为智能小车避障、多区域测距等项目的参考模板帮助开发者节省定时器资源并降低调试成本。1. 用HAL库定时器为多路HC-SR04建立统一时间基准多路超声波测距在避障小车、仓储料位监测和停车辅助里很常见但教程往往只演示一路 HC-SR04还喜欢用 HAL_Delay 去读 ECHO 电平稍微增加到两路以上测距结果就开始抖距离数值来回跳。根因不是传感器精度而是时间基准太粗。HC-SR04 的 ECHO 高电平宽度就是超声波往返时间这个时间差往往只有几百到两万微秒HAL_Delay 的 1ms 粒度根本喂不饱。更实际的做法是让一个通用定时器以 1us 为间隔做自由计数把每个回波引脚接到外部中断上在中断中读取计数器快照这样所有传感器共享同一个“秒表”。距离换算关系仍是脉宽乘声速除以二但时间来源从软件延时的阻塞等待变成了硬件计数。这个方案在 STM32CubeMX 上从配置到跑通大概需要二十分钟适合已经能点灯、正准备给项目加多路测距的开发者。2. HC-SR04的测距原理与两种定时器测时方案选型2.1 回波脉宽与距离计算的对应关系HC-SR04 的触发过程非常固定主机在 TRIG 引脚上拉高至少 10us模块收到触发后会发射一串 40kHz 的超声脉冲同时把 ECHO 引脚拉高。当模块检测到障碍物反射回来的信号后再把 ECHO 拉低。这中间高电平的持续时间 T就是超声波从发射到返回的完整时间。空气中的声速 v 约等于 340m/s也就是 0.034cm/us那么传感器到障碍物的单程距离等于 v 乘以 T 再除以 2。换成分步公式看就是distance_cm T * 0.034 / 2 T * 0.017。也就是说每 1us 的回波脉宽大约对应 0.017cm 的距离。这个系数在 15°C 常温下误差很小。但要注意声速随温度变化温度每升高 1°C声速大约增加 0.6m/s在 5m 测量距离上会引入 5mm 左右的偏差。如果应用场景有空调风道或户外温度变化建议把声速改成按温度计算后文会给出公式。对大多数室内机器人避障应用来说cm 级精度已经足够直接乘 0.017 完全没有问题。HC-SR04 标称量程通常是 2cm 到 400cm。对应到脉宽最远 400cm 时 ECHO 高电平时间约 23.5ms最近 2cm 时约 1.2ms。这个量程范围决定了定时器计数周期至少需要超过 23.5ms后面配置 ARR 时以这个为锚点。2.2 方案A输入捕获单通道逐路切换输入捕获是定时器测量脉冲宽度的经典方式。把 ECHO 引脚接到定时器的一个捕获通道上上升沿到达时硬件自动锁存当前计数值下降沿再锁存一次两次锁存值之差就是高电平时间。HAL 库上加这个功能并不难HAL_TIM_IC_Start_IT(htim, TIM_CHANNEL_x)打开对应通道然后在HAL_TIM_IC_CaptureCallback里取htim-Instance-CCR就行。这个方案单路时精度很高因为脉宽的计时完全由硬件完成不依赖中断里读寄存器的延迟。但题目里明确说“多个超声波”输入捕获的缺点就暴露了STM32F103 的通用定时器最多有 4 个捕获通道想接 4 路就需要这 4 个通道同时占用而通道 1/2/3/4 往往映射到固定的引脚组合排布受限。想接 6 路就得动用第二个定时器时钟树和中断回调都要跟着加。还需要在回调里判断到底是哪个通道的哪一次捕获代码分支变多维护成本上升。另一个容易被忽略的问题是输入捕获会同进来的毛刺信号发生误触发。如果 ECHO 线路上有电机驱动带来的干扰捕获通道会把毛刺也当成一个沿CCR 数值被污染距离直接错掉。所以这个方案更适合固定少量通道、硬件上已经处理好信号质量的场景。2.3 方案B外部中断自由运行定时器我一般在多路 HC-SR04 工程里用的是方案B一个定时器做自由运行计数器预分频到 1MHz计数器每 1us 自增一次每个传感器的 ECHO 引脚单独接一个普通 GPIO使能外部中断。上升沿触发时在回调里读一次CNT保存为时间起点下降沿触发时再读一次CNT保存为时间终点。终点减起点就是脉宽。这个方案的好处是一个定时器可以覆盖所有传感器传感器数量只受外部中断引脚数量限制不占用定时器通道。CubeMX 中把 ECHO 引脚配置为 EXTI 即可代码上用结构体数组管理每个传感器的状态所有传感器共用同一个中断回调通过GPIO_Pin判断当前是哪一个。缺点是回波引脚需要额外的 GPIO而且外部中断回调里读取寄存器会有几个时钟周期的延迟但 HC-SR04 本身精度是 ±3mm 左右几十纳秒的中断延迟可以忽略不计。两种方案对比如下对比维度输入捕获方案A外部中断自由计数方案B定时器资源占用每路需要一个捕获通道一个定时器服务全部传感器数量限制受定时器通道数量约束受外部中断引脚数量约束信号质量要求捕获通道易受毛刺影响可用状态机过滤无效脉冲距离计算硬件 CCR 直接给出时间戳中断里读 CNT引脚选择灵活性受复用功能映射限制普通 GPIO 即可方案B不是“精确到指令周期”的测量但它把复杂度和可扩展性平衡得最好下面的实现全部围绕这个方案展开。3. 基于CubeMX初始化定时器与中断GPIO3.1 定时器时钟配置与预分频计算以 STM32F103C8T6 为例系统时钟 72MHz。要让 TIM3 计数频率为 1MHz预分频值需要满足72MHz / (PSC 1) 1MHz所以PSC 71。在 STM32CubeMX 的 TIM3 配置界面里Prescaler 这一栏直接填 71Counter Period 填 65535Counter Mode 保持 Up。参数面板里没有体现减一的操作因为 HAL 库生成代码时会自动把这个值减去 1 再写入 PSC 寄存器但我们心里要清楚实际分频数是 72。Counter Period填 65535 意味着 ARR 寄存器值是 65535计数器从 0 加到 65535 后溢出回 0。因为计数频率是 1MHz一次完整溢出周期是 65535us约 65.5ms。前面说过 HC-SR04 最大量程对应的回波脉宽是 23.5ms完全落在溢出周期内单次测量不会撞上计数器回绕。CubeMX 生成的初始化代码基本是固定结构static void MX_TIM3_Init(void) { htim3.Instance TIM3; htim3.Init.Prescaler 71; // 72MHz / 72 1MHz htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 65535; // 溢出周期 65.535ms htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Init(htim3); }这段代码里的Prescaler、Period和后面的实际运行直接相关。如果开发板时钟不是 72MHz比如外部晶振是 8MHz 但 PLL 配到了 64MHz那 Prescaler 要改成 63 才能得到 1MHz 计数频率。先把SystemCoreClock打出来确认再填参数。3.2 触发引脚与回波引脚配置TRIG 引脚很简单CubeMX 中配置为 GPIO_Output初始输出低电平推挽模式即可。ECHO 引脚需要配置为外部中断输入。在 Pinout Configuration 视图里选中 ECHO 所在引脚GPIO mode 下拉选择External Interrupt Mode with Rising/Falling edge trigger detection这一项会自动开启上升沿和下降沿中断两个沿都必须保留一个用来记起点一个用来记终点。真正容易踩坑的是 EXTI 中断线的复用关系。STM32F103 上PA0 和 PB0 共用 EXTI0PA1 和 PB1 共用 EXTI1以此类推。如果第一个传感器的 ECHO 接 PA0第二个传感器又接 PB0那么两个信号会同时出现在 EXTI0 中断线上HAL 回调会收到GPIO_PIN_0但没法区分是哪个端口。所以同一套系统里ECHO 引脚必须避开相同编号的不同端口。推荐的做法是 4 路传感器分别接 PA0、PA1、PA2、PA3对应 EXTI0 到 EXTI3中断服务函数从 EXTI0_IRQHandler 到 EXTI3_IRQHandler 各自独立不互相干扰。还有一类问题是引脚上拉电阻。ECHO 引脚默认建议配置成浮空输入或者带上拉输入。HC-SR04 的 ECHO 输出是推挽信号空闲时是低电平所以上拉下拉都无所谓但上拉能增强抗干扰能力。如果工程里需要用长杜邦线连接建议在 CubeMX 的 GPIO Pull-up 里选 Pull-up。3.3 生成工程后的用户代码组织生成工程后在main()里先启动定时器HAL_TIM_Base_Start_IT(htim3);这里我用Start_IT而不是HAL_TIM_Base_Start目的是打开 TIM3 的更新中断。回波脉宽最长 23.5ms理论上不需要溢出计数但为了防呆我会在回调里累计一个cnt_overflow变量后面合成完整时间戳用。如果不想开中断也可以用HAL_TIM_Base_Start代码更简单但无法应对可能出现的溢出场景。日志打印建议放到主循环不要放到中断回调里。用串口直接输出距离值时数据量很小每秒刷新 20 次足够调试。把所有传感器相关的定义独立放到sonar.h和sonar.c中避免 main.c 被挤爆。下面是一个简单的初始化列表sonar_t sonars[SONAR_NUM] { {GPIOB, GPIO_PIN_0, GPIOA, GPIO_PIN_0, SONAR_IDLE, 0, 0, -1.0f}, {GPIOB, GPIO_PIN_1, GPIOA, GPIO_PIN_1, SONAR_IDLE, 0, 0, -1.0f}, {GPIOB, GPIO_PIN_2, GPIOA, GPIO_PIN_2, SONAR_IDLE, 0, 0, -1.0f}, {GPIOB, GPIO_PIN_3, GPIOA, GPIO_PIN_3, SONAR_IDLE, 0, 0, -1.0f}, };TRIG 引脚集中在 PB0 到 PB3ECHO 集中在 PA0 到 PA3。这个排列在 PCB 和杜邦线上都很好走而且 EXTI 线不冲突。实际工程中引脚分配以板子丝印为准但结构体里的端口和引脚必须和 CubeMX 配置保持一致。4. 实现多路测距状态机与HAL回调联动4.1 定义传感器实例与状态多路测距的核心是状态机不能靠简单的中断标志位凑数。每个传感器在任意时刻要么处于空闲、要么在等待回波。触发成功后状态从SONAR_IDLE变为SONAR_WAIT_ECHO收到上升沿后记录时间起点但状态保持不变收到下降沿后记录时间终点并计算距离状态回到SONAR_IDLE。如果一直没等到下降沿主循环里的超时逻辑负责强制复位。结构体设计如下#define SONAR_NUM 4 typedef enum { SONAR_IDLE 0, SONAR_WAIT_ECHO } sonar_status_t; typedef struct { GPIO_TypeDef *trig_port; uint16_t trig_pin; GPIO_TypeDef *echo_port; uint16_t echo_pin; sonar_status_t status; uint32_t tick_start; uint32_t tick_stop; float distance_cm; uint32_t trig_tick_ms; // 用于超时判断 } sonar_t;trig_tick_ms保存触发时的HAL_GetTick()值主循环每隔一段时间检查它是否超时。tick_start和tick_stop是定时器时间戳单位是 us。distance_cm用浮点数保存最后串口发送时用%d或者自己封装一个格式化函数都行。4.2 触发与ECHO中断回调实现触发函数负责拉高 TRIG 保持 10us 再拉低。这个 10us 延时不能使用 HAL_Delay可以用 TIM3 的计数器做忙等。因为我们在触发阶段还没有开始回波计时直接清空计数器是可接受的static void sonar_trigger(sonar_t *s) { __disable_irq(); s-status SONAR_WAIT_ECHO; s-tick_start 0; s-tick_stop 0; s-distance_cm -1.0f; s-trig_tick_ms HAL_GetTick(); __enable_irq(); HAL_GPIO_WritePin(s-trig_port, s-trig_pin, GPIO_PIN_SET); __HAL_TIM_SET_COUNTER(htim3, 0); while (__HAL_TIM_GET_COUNTER(htim3) 10) {} HAL_GPIO_WritePin(s-trig_port, s-trig_pin, GPIO_PIN_RESET); }注意这段代码假设 TIM3 计数器处于自由运行状态并且尚未开启更新中断因此清空计数器不会影响其他测量。但在实际工程中如果同一时刻还有一个传感器在等待回波清 CNT 会破坏它的时间基准。所以多路测距必须避免并行触发应该采用串行轮询触发完一路等回波超时后再触发下一路sonar_trigger中的清 CNT 才安全。ECHO 外部中断回调本身只应该在中断上下文做最少的处理读取时间戳、更新结构体状态、释放中断。回调代码如下void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { for (uint8_t i 0; i SONAR_NUM; i) { if (GPIO_Pin ! sonars[i].echo_pin) { continue; } if (HAL_GPIO_ReadPin(sonars[i].echo_port, sonars[i].echo_pin) GPIO_PIN_SET) { sonars[i].tick_start __HAL_TIM_GET_COUNTER(htim3); } else { sonars[i].tick_stop __HAL_TIM_GET_COUNTER(htim3); uint32_t pulse_us sonars[i].tick_stop - sonars[i].tick_start; if (pulse_us 100 pulse_us 30000) { sonars[i].distance_cm pulse_us * 0.017f; } else { sonars[i].distance_cm -1.0f; } sonars[i].status SONAR_IDLE; } break; } }这段代码里最关键的行是pulse_us 100 pulse_us 30000。下限 100us 滤掉明显不可能出现的 1.7cm 以内的显著干扰上限 30000us 对应约 5.1m 的越界距离一旦超过就认为无效。如果不加这两个边界一个延迟很大的下降沿就能让距离跳成一个无意义的大数。中断回调中只保存tick_start和tick_stop不做除法因为浮点运算留在主循环完全来得及。4.3 距离换算与测量结果的读取如果使用 16 位定时器且没有开溢出中断tick_stop - tick_start在正常情况下是正的因为 HC-SR04 的最大脉宽远小于溢出周期。但为了安全更好的做法是打开 TIM3 更新中断维护一个溢出计数变量合成 32 位时间戳volatile uint16_t cnt_overflow 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { cnt_overflow; } } uint32_t get_timestamp(void) { return ((uint32_t)cnt_overflow 16) | (uint32_t)__HAL_TIM_GET_COUNTER(htim3); }这里有一个经典的边界问题读取cnt_overflow和读取CNT之间可能发生溢出导致时间戳跳变。更可靠一点的做法是先读 CNT再读溢出计数再读 CNT如果两次 CNT 不同或者溢出计数的读取位置不对就重试。实际工程中外部中断回调里的延迟本来就不可控多读一次 CNT 的成本也极低。使用get_timestamp()后外部中断回调里保存的起飞和降落时间戳都变成 32 位差值可以覆盖近 4300 秒不需要再考虑回绕。主循环负责串行触发和管理超时uint8_t current_index 0; uint32_t last_trigger_ms 0; while (1) { if (HAL_GetTick() - last_trigger_ms 30) { sonar_trigger(sonars[current_index]); last_trigger_ms HAL_GetTick(); current_index (current_index 1) % SONAR_NUM; } for (uint8_t i 0; i SONAR_NUM; i) { if (sonars[i].status ! SONAR_IDLE HAL_GetTick() - sonars[i].trig_tick_ms 30) { sonars[i].status SONAR_IDLE; sonars[i].distance_cm -1.0f; } } }触发间隔设为 30ms原因是 4m 量程下最远回波时间是 23.5ms加上传感器模块内部的余震收敛时间30ms 足够串行扫描 4 路刷一遍所有传感器大约 120ms。如果只测 2m 以内这个间隔可以缩减到 18ms 左右但不要低于最大回波时间加 2ms 余量否则上一路声波还没消退下一路触发会串扰。5. 多路测量的时序优化与误差排查技巧5.1 计算最短触发间隔串行触发时理论最小间隔是当前传感器的最远回波时间加上模块内部回波释放时间。回波时间的计算方法是传感器最大测量距离乘以 2 再除以声速。以 4m 量程为例回波时间是2 * 400cm / 0.034 cm/us ≈ 23529us也就是约 23.5ms。所以每路触发间隔 25ms 是临界值30ms 是稳妥值。如果把量程缩短到 2m间隔可以降到 15ms4 路扫描周期从 120ms 变成 60ms刷新率翻倍。建议在代码中用宏定义量程再算出最小间隔不要拍脑袋填。5.2 用温度补偿修正声速声速随温度变化的现象在超声波测距里影响不可忽略。公式是float speed_cm_per_us 0.03314f 0.0000607f * temperature_c;其中 0.03314cm/us 是 0°C 时声速 331.4m/s 换算过来的值温度每升高 1°C声速增加 0.0000607cm/us。在 25°C 时声速大约是 0.0348cm/us和常温假设的 0.034 有 2% 的差异。换算距离时使用float distance_cm pulse_us * speed_cm_per_us / 2.0f;实践证明加上温度补偿之后同位置的一路 HC-SR04 在空调风口气流变化时的漂移明显减小。如果板子上已经有 DHT22 或 DS18B20直接在主循环里定时读取温度并更新这个系数成本几乎为零。5.3 去掉最大最小值再平均HC-SR04 对障碍物边缘、细小柱状物和斜面的回波很不稳定常见表现是连续测量中偶尔冒出来一个特别大或特别小的值。遇到这种情况最简单的滤波是连续采集 N 次去掉最小值、最大值剩下值取平均。N 通常取 5采集完排序后直接取中间三个值求平均。这个算法对系统的动态响应影响小也不会把边缘来回移动的障碍物真实位置全部抹平。5.4 把 DWT 计数器和 TIM3 剥离开触发 TRIG 所需的 10us 延时如果用 TIM3 的计数器忙等虽然触发期间没有同时测量回波但一旦未来加入更复杂的状态机风险就会显现。一个更干净的做法是使用 Cortex-M3 的 DWT CYCCNT 寄存器做微秒延时TIM3 完全留给回波时间戳CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; DWT-CYCCNT 0; static void dwt_delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks) {} }SystemCoreClock在系统启动时由 HAL 库初始化值等于当前内核时钟72MHz 时每个 us 对应 72 个 cycle。DWT 延时不占用任何片上定时器也不会被定时器中断打断触发函数只负责控制 GPIO 电平测距时间戳的参考系始终保持完整。配合前面用get_timestamp()读出的长时间戳这个方案在中断压得很紧时也表现稳定。多路超声波的工程排错基本流程可以固定为先量 TRIG 高电平是否够 10us再量 ECHO 上有没有回波再看中断回调里的tick_start和tick_stop是否都正确更新最后检查差值是否落在合理区间。本文还有配套的精品资源点击获取
返回列表