ARTICLE DETAIL

资讯详情

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

STM32+Python超声波雷达:从测距时序到可视化上位机全解析

STM32+Python超声波雷达:从测距时序到可视化上位机全解析 简介这套基于STM32单片机与Python开发的超声波雷达项目适合嵌入式初学者和物联网爱好者入门实践也适合有单片机基础者对超声波测距与串口通信进行综合演练。压缩包共5个文件包含C语言主程序、Python数据处理脚本、开源许可证、项目说明文档及字体文件整体大小为13.74MB结构清晰便于对照学习。目前已有174人学习下载。项目中STM32端负责超声波发射与回波检测通过串口将距离数据发送至PCPython端负责接收解析并可视化显示完整呈现从硬件控制到上层交互的开发流程。配套说明文档对超声波测距原理、系统初始化、延时控制以及开发步骤均做了梳理能够帮助读者快速复现并基于此扩展更多功能。1. 超声波雷达拆解STM32 采集端与 Python 可视化端的分工逻辑这套资源不是一套单纯的测距 demo而是一条完整的“传感器 → 单片机 → 串口 → Python 上位机”数据链路。main.c负责控制 HC-SR04 这类超声波模块的触发与回波计时把时间差换算成距离值后再通过 UART 把数据帧拼好发给上位机radar.py这边负责解析串口帧、做坐标映射并实时绘制出类似雷达扫描的扇形散点图。适合两类人一是刚接触 STM32 的开发者想看 GPIO、定时器输入捕获和串口发送怎么配合二是做课程设计或小型室内探测项目的人需要一个能直接改参数的测距 可视化模板。单片机端处理的是微秒级时间测量Python 端处理的是毫秒级界面刷新两者通过一个自定义的串口协议衔接这个分工本身就值得拆开细看。资源里的SourceHanSansSC-Regular.otf是 Python 绘图时的中文字体文件如果不注册这个字体matplotlib在 Linux 下画中文标签会直接变成方框这也是一个容易忽略的小坑。下面的内容会按“原理 → 采集端 → 上位机 → 调试”的顺序展开最后给出几个实际使用中才容易踩到的边界情况。2. 测距原理与 STM32 端的时序控制设计2.1 声波飞行时间法在 STM32 中的落地方式超声波测距的物理基础并不复杂声波在空气中的传播速度约为 343 m/s20°C 时测得发射脉冲与回波脉冲之间的时间差t距离d v * t / 2为什么要除以 2 是因为声波走了往返路程。但在 STM32 上实现时核心难点不在公式而在如何精确测量这个t。HC-SR04 的工作时序是外部给 TRIG 引脚一个大于 10 µs 的高电平脉冲模块内部自动发出 8 个 40 kHz 的方波脉冲同时 ECHO 引脚拉高当模块检测到回波后ECHO 引脚拉低所以 ECHO 引脚的高电平持续时间就是声波的飞行时间。STM32 要达到厘米级精度至少要能分辨 29 µs 的时间差对应约 1 cm 的距离这在 72 MHz 主频下用定时器捕获是绰绰有余的。项目的main.c中采用的是两种常见测距实现之一利用微秒级延时函数控制 TRIG然后通过定时器输入捕获测量 ECHO 的高电平脉宽。2.2 触发脉冲与回波窗口的边界条件触发方式相对简单把 TRIG 引脚配置为推挽输出拉高 15 µs 再拉低即可。但回波检测不能只看一个沿需要用“上升沿开启定时器、下降沿记录定时器值”的方式这样才不受制于中断响应的不确定性。这里有一个关键的设计约束超声波模块的最大量程通常标注为 4 mHC-SR04 实测可靠范围 2 cm 到 3.5 m 左右对应的最大飞行时间约为 23.3 ms。这意味着定时器至少要能计数超过这个时间窗口假如采用 1 µs 一次的计数频率23.3 ms 对应 23300 个计数超出了很多 16 位定时器的 65535 上限的 1/3但仍然是安全的。如果换用量程更大的模块就要考虑用 32 位定时器如 TIM2、TIM5或者加入溢出中断累加计数否则会出现大目标距离突然跳变到极小值的情况。#define TRIG_PORT GPIOA #define TRIG_PIN GPIO_PIN_0 #define ECHO_PORT GPIOA #define ECHO_PIN GPIO_PIN_1 void Ultrasonic_Trigger(void) { HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); Delay_Us(15); // 高电平保持 15 微秒大于模块要求的 10 微秒 HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); } uint32_t Ultrasonic_Measure(void) { Ultrasonic_Trigger(); uint32_t time1 0, time2 0; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) GPIO_PIN_RESET); time1 TIM2-CNT; // 记录上升沿时刻 while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) GPIO_PIN_SET); time2 TIM2-CNT; // 记录下降沿时刻 return time2 - time1; // 飞行时间单位微秒 }这段代码有两个地方需要说明。第一TIM2-CNT是直接读取计数器值而不是使用中断回调这是一种“阻塞式测距”实现好处是逻辑直观、时序精确坏处是测距期间 CPU 被完全占用无法响应其他任务。如果要并入 RTOS 或周期性任务调度应该改写为外部中断触发 定时器捕获比较的方式。第二超时处理是这段代码的薄弱点如果超声波前方没有障碍物ECHO 引脚会一直保持低电平while循环将陷入死等。实际项目里必须加上超时判断常见做法是在循环里检查TIM2-CNT是否超过 30 ms超过则认为测量无效。2.3 为什么用定时器计数而不是delay测量脉宽有的初学者会用HAL_GPIO_ReadPin不断轮询 ECHO 引脚同时用HAL_Delay或一个变量自增来计时这种做法的问题在于C 语言的循环判断、变量自增和函数调用本身都需要执行时间而且这个时间会随着编译器优化级别变化而变化更致命的是如果开启了中断中断服务程序会打断这个计时循环导致计时结果出现毫秒级偏差。超声波测距的精度建立在微秒级时间测量上任何毫秒级抖动都会被转化成几十厘米的距离误差所以main.c里采用定时器直接计数是更稳妥的做法。定时器在独立时钟驱动下计数不受 CPU 中断影响计数值读取只是一条指令误差在一个计数周期内72 MHz 时钟下误差约 14 ns对应距离误差不足 0.005 mm可以忽略。把定时器配置成 1 MHz 计数频率即 1 µs 计数一次是一个比较合理的折中分辨率足够覆盖厘米级精度同时计数值在 16 位范围内可以覆盖 65 ms 的时间窗口超过超声波模块最大量程的需求。通过__HAL_TIM_SET_PRESCALER(htim2, 72 - 1)即可将 72 MHz 主频分频到 1 MHz这里的72 - 1是因为定时器预分频值是从 0 开始计数的。2.4 距离值的计算与数据滤波时间差t拿到后距离值计算如下float distance (float)time_us * 0.0343f / 2.0f; // 声速常数单位 cm这里声速取 343 m/s即 0.0343 cm/µs。这个常量在常温下是准确的但声速会随温度变化约每升高 1°C 增加 0.6 m/s。如果项目需要在户外或温差较大的环境中使用建议加一个温度补偿。实测中发现在 5°C 到 35°C 的温度范围内声速从 331.5 m/s 变化到 351.9 m/s对应同一个 10 ms 的飞行时间计算出的距离会产生约 3 cm 的偏差。引入温度传感器后用公式v 331.4 0.6 * T计算实时声速再代入测距公式可以把这个误差收敛到毫米级。数值滤波是另一个容易被忽略的细节。单次测量受传感器噪声、目标表面反射特性和环境干扰影响读数会有不小的抖动。较为推荐的做法是连续采样 5 次取中位值因为中位滤波对脉冲噪声有天然的抑制能力而且不会像均值滤波那样把静态偏差平均进来。采集中位值后还可以再叠一层滑动平均适用于匀速运动的目标追踪场景但对快速移动的目标过度滤波会让位置更新滞后。3. main.c 的数据帧打包与串口发送机制3.1 串口通信格式为什么选择 ASCII 帧而不是二进制帧STM32 端测量出的距离需要发给 Python这一步涉及串口通信格式的选型。两种方案各有优劣二进制帧如两个字节分别表示整数部分和小数部分传输效率高但调试困难串口终端里看是乱码且字节边界错位后很难自恢复ASCII 帧如D:123.4\n虽然多占几个字节但可以用任何串口助手直接阅读出问题时排查链路方便得多。printf 格式化在重定向到串口后会直接调用HAL_UART_Transmit逻辑上保持一致。考虑扩展性和健壮性比较推荐的是设计成带帧头、数据和换行符的结构。帧头用于定位数据的起始位置数据段保留足够长度以支持多位数值最终以换行符作为结束标志。这样Python 端读取数据时可以逐行解析即使发生一帧数据损坏只需丢弃该行即可不会影响后续数据的解析。3.2 实际发送逻辑与缓冲区保护char data_buf[32]; float distance (float)(time_us) * 0.0343f / 2.0f; sprintf(data_buf, D:%.1f\r\n, distance); HAL_UART_Transmit(huart1, (uint8_t*)data_buf, strlen(data_buf), 100);sprintf将距离值格式化为D:123.4的字符串\r\n作为一帧数据的终止符便于 Python 端按行读取。HAL_UART_Transmit需要指定超时时间这里设为 100 ms如果串口发送被中断或对端不读数据发送函数不会永久阻塞。在实际调试中有几个细节很容易忽视。sprintf在默认 C 库下是带缓冲的需要确保重定向正确或直接使用snprintf限定缓冲区长度防止距离值超长时发生缓冲区溢出。串口波特率需要和 Python 端serial库配置完全一致常见的是 115200 或 9600main.c中使用的 115200 在数据量小的情况下完全够用而且在高负载下比 9600 更不容易造成接收端积压。在较高波特率下需要关注线路质量接线过长时容易产生误码表现为 Python 端偶尔解析出异常值。使用杜邦线连接时建议不超过 20 cm如果布局受限可以适当降低波特率换取稳定性。3.3 连续测距的周期控制与数据冲突规避如果主循环是死循环直接调用测距函数测距频率会受超声波模块本身的测量周期限制。HC-SR04 在发出超声波后模块内部需要一段时间稳定同时要避免上一次回波与下一次触发脉冲混淆手册上建议触发间隔不小于 60 ms对应最大测距频率约 16 Hz。实测中间隔缩短到 40 ms 时近距离目标的读数会偶尔出现上一周期的回波残留表现为固定距离叠加了随机的毛刺峰值。更常见的做法是借用定时器产生 100 ms 的中断标志位主循环检测到标志后执行一次测距和发送。这种设计把测距周期固化为稳定的 10 HzCPU 有充足空闲处理其他任务同时也降低了串口数据拥堵的可能。在main.c中如果采用这种结构定时器中断里只设置标志位具体测距动作放在主循环执行避免在中断上下文里做耗时操作导致主程序时序异常。3.4 这些配置里的“隐藏”参数main.c中还有几个不太起眼但有实际影响的配置点。GPIO 的速度等级对超声波模块影响不大但考虑到 TRIG 引脚需要输出 10 到 15 µs 的脉冲如果 GPIO 速度等级设为 LOW输出沿会变缓极端情况下可能无法满足模块对最小 10 µs 脉冲宽度的要求。建议把 TRIG 和 ECHO 的 GPIO 速度等级都设为GPIO_SPEED_FREQ_HIGH这不仅是为了脉冲形状也是为后续可能复用引脚功能留下余量。定时器是上升沿捕获还是下降沿捕获取决于使用的是输入捕获模式还是简单的电平轮询模式。如果main.c使用的是 2.2 节那种电平轮询 读 CNT 的方式定时器不需要配置输入捕获通道只需要让它自由运行即可。如果改为纯中断实现需要配置TIM_CHANNEL_1的上升沿捕获和TIM_CHANNEL_2的下降沿捕获在捕获中断回调中读取TIM2-CCR1和TIM2-CCR2并做差。这个方案对 CPU 更友好但代码复杂度明显上升。4. radar.py 上位机从串口数据解析到雷达视图4.1 串口读取与数据解析Python 端的第一步是读取串口数据常见的做法是用pyserial库的readline()方法逐行读取。这里有个兼容性问题Windows 和 Linux 下串口设备名不同Windows 是COM3这种形式Linux 下是/dev/ttyUSB0或/dev/ttyACM0代码里需要做成可配置项。打开串口时注意设置timeout参数如果设置过小readline()会在数据还没到达时就返回空字节串代码必须处理这种空数据的情况。import serial import matplotlib.pyplot as plt import matplotlib.animation as animation def read_distance(ser): 从串口按行读取数据解析 D:xxx.x 格式的距离值 if ser.in_waiting: line ser.readline().decode(utf-8, errorsignore).strip() if line.startswith(D:): try: distance float(line[2:]) return distance except ValueError: return None return None这里必须加decode和errorsignore因为串口数据在某些状态下会混入二进制噪声或者因为波特率不匹配导致乱码字节直接decode(utf-8)会抛异常中断整个读取循环。line[2:]是提取D:之后的部分字符串前两个字符是固定前缀这样即使距离值占的位数不同也能正确解析。在errorsignore模式下非法字节会被丢弃但要注意如果错误字节恰好出现在数字区域float()转换仍可能失败所以try-except是必须的。4.2 雷达视图背后的坐标变换逻辑这是radar.py中比较核心的部分。超声波传感器本质上是单点测距要想呈现出类似雷达的扇形扫描效果需要把传感器安装在步进电机或舵机上通过旋转改变探测方向。假设舵机在main.c或 Python 的控制下按角度步进那么单个距离值就有了对应的角度信息。没有舵机时可以用固定的角度绘制点或者用一个线段表示当前方向的探测范围。坐标变换公式是angle_rad math.radians(angle) x distance * math.cos(angle_rad) y distance * math.sin(angle_rad)之所以需要这样变换是因为matplotlib的默认坐标系是直角坐标而雷达图是极坐标系。可以直接用matplotlib的极坐标子图projectionpolar这样做的好处是避免了手动转换的麻烦。两者差别不止是 API 层面的简洁度极坐标图在做扫描动画时set_theta_offset可以方便地调整起始角度配合set_theta_direction设置为 -1顺时针扫描叠加起来能呈现非常接近真实雷达扫描的方向变化效果。资源中的radar.py大概率是用直角坐标 手动三角变换的因为这种方式更直观也好理解坐标映射过程。关键要让散射点随时间累积而不是覆盖控制每次扫描帧与帧之间的数据保留策略。4.3 matplotlib 实时动画的中文字体问题资源包里自带了SourceHanSansSC-Regular.otf在代码中的用法是动态加载字体文件并注册到matplotlib字体管理器中import matplotlib.font_manager as fm font_path SourceHanSansSC-Regular.otf fm.fontManager.addfont(font_path) plt.rcParams[font.family] fm.FontProperties(fnamefont_path).get_name()这段代码解决了matplotlib默认字体不支持中文字符导致标签显示为方框的问题。需要注意plt.rcParams[font.family]需要设置在创建任何绘图对象之前否则不会生效。如果想省事可以直接把plt.rcParams[axes.unicode_minus]设置成False避免负号显示为乱码但这只解决负号的问题汉字仍然需要字体方案。在不同操作系统上SourceHanSansSC-Regular.otf的路径也可能不同代码里尽量使用相对路径或通过os.path拼接绝对路径避免因工作目录不同而找不到字体文件。4.4 动态更新与性能控制matplotlib的实时动画建议使用animation.FuncAnimation它通过定时器周期性调用更新函数比在while循环中反复plt.draw()要高效得多。更新函数每次读取一次串口距离值如果读取成功就追加到数据序列中并更新散点图的坐标数据。def update_scan(self, frame): distance read_distance(self.ser) if distance is not None: angle_rad math.radians(self.current_angle) x distance * math.cos(angle_rad) y distance * math.sin(angle_rad) self.scatter.set_offsets(np.c_[self.angles, self.distances]) self.current_angle 2 if self.current_angle 360: self.current_angle 0 return self.scatter,这里有两个容易踩坑的地方。第一set_offsets接收的参数形状必须是(N, 2)的数组np.c_可以把两个一维数组合并成按列拼接的二维数组直接传两个列表会导致维度错误。第二self.current_angle是模拟的扫描角度真实项目中这个角度应该由舵机的 PWM 占空比换算而来并确保超声波发射时刻与角度记录时刻同步否则画出来的点会有角度偏移导致轮廓失真。更新interval参数控制动画帧间隔如果串口数据速率和动画刷新速率不匹配会出现画面跳跃或数据积压一般设置interval100即 10 FPS搭配 10 Hz 的测距频率是可行的组合。5. 从数据帧校验到多通道扩展超声波雷达的实战收尾上一篇文章讲到数据如何绘制成雷达视图但实际部署时还需要考虑数据链路本身的可信度。超声波数据在真实环境里并不总是干净的尤其是多个传感器同时工作时串扰会导致异常的远距离读数。一种数据帧级的校验方案是在 STM32 端添加校验字节例如把距离值四舍五入后的整数部分和异或校验值附加在帧尾Python 端收到后先验证校验值再决定是否丢弃。这里的关键在于只有校验通过的帧才能进入坐标转换否则画面边缘会出现毫无规律的散点极难排查原因。char data_buf[32]; int dist_int (int)(distance * 10); uint8_t checksum dist_int 0xFF; sprintf(data_buf, D:%d.%d:%d\r\n, dist_int / 10, dist_int % 10, checksum);解析端对应为line ser.readline().decode(utf-8, errorsignore).strip() parts line[2:].split(:) if len(parts) 2 and int(parts[1]) (int(float(parts[0]) * 10) 0xFF): distance float(parts[0])校验失败时直接丢弃该帧不参与绘图。这段逻辑的保护价值在数据量增大后才会体现出来。对于多传感器扩展一种简单有效的方式是分时复用。同一条总线上挂两个 HC-SR04触发间隔错开 50 ms数据帧前缀区分传感器编号例如S1:D:123.4和S2:D:234.5。在 Python 端解析完传感器编号后分别追加到不同的队列中绘制时用不同颜色区分这样可以只用一套串口链路同时探测两个角度方向而不需要增加额外的通信线路。但要注意两个传感器不能在物理上靠得太近否则一个传感器的发射波会被另一个传感器接收形成串扰。最后需要检查的是温度补偿。把声速常量改为实时工况下计算出的值在main.c中可以使用一个简易温度传感器或手动通过串口输入当前温度。Python 端也可以直接传入温度值让上位机在计算时修正这样的话 STM32 端只负责报原始时间差而把声速换算留在 Python 端硬件和软件的职责边界更清晰。radar.py中加一个环境温度参数temp_c用v 331.4 0.6 * temp_c替换固定的 0.0343 系数即可。本文还有配套的精品资源点击获取
返回列表