简介:使用意法半导体STM32单片机与Python语言构建的超声波雷达项目,面向嵌入式入门者及数据可视化爱好者,旨在演示声纳测距原理,并打通单片机采集到上位机显示的完整链路。压缩包内共5个文件,包含STM32侧C语言主程序、Python端雷达绘制脚本、项目说明文档、开源许可证及界面字体文件,整体仅13.74MB,结构简洁。C主程序涵盖GPIO、定时器、串口初始化及测距逻辑,负责超声波发射与回波检测;Python脚本通过串口接收数据,计算距离并实时渲染雷达扫描界面。已有174人学习下载,适合作为毕业设计、课程实验或兴趣项目参考。资源提供完整源码与说明,读者可复现超声波测距流程,掌握定时器配置、串口通信及Python绘图库的联合开发方法,还可将设计思路迁移到避障小车、液位检测等场景。
1. STM32 与 Python 超声波雷达到底在做什么
超声波雷达这个组合,拆开看并不神秘:STM32 单片机负责测距和扫描,Python 负责显示和解析。用一个 HC-SR04 超声传感器测量距离,把它固定在舵机上旋转,每转一个角度读一次数据,再通过串口把“角度-距离”对发给电脑上的 Python 程序,画成极坐标雷达图。真正动手会发现,难点不在读距离本身——HC-SR04 就一根 TRIG 一根 ECHO——而在测量时序怎么做到微秒级不漂移、舵机转动和采样怎么同步、串口协议怎么设计得让 PC 端不用猜、180 个采样点 Python 端怎么刷新才不卡死。下面按硬件原理、STM32 端、Python 端、调优四层展开,代码可以直接抄进工程。这个项目规模不大但技术栈完整,也是常见的基于 STM32 的课程设计和毕业设计原型,适合手里有开发板、想快速把显示型项目跑起来的人。
2. 超声波测距原理与雷达扫描的结构选型
2.1 HC-SR04 的测量时序与距离公式
HC-SR04 测距的核心是“发一个脉冲,量回波高电平持续了多久”。完整时序是:MCU 给 TRIG 引脚至少 10µs 高电平,模块内部发出 8 个 40kHz 的声波脉冲,同时把 ECHO 引脚拉高;声波碰到障碍物反射回来,模块检测到回波后把 ECHO 拉低。因此 ECHO 的高电平时间恰好等于声波往返时间。近距目标回波宽度只有一百多微秒,所以时序测量粒度必须到微秒级,这是 STM32 端设计的第一约束。
距离计算只需要一条公式:
// echo_us: 回波高电平时间(µs),声速按 340m/s uint32_t distance_cm = (uint32_t)((float)echo_us * 0.017f); // 往返换算成单程把速度和微秒统一换算后,结果等价于echo_us × 0.017厘米。这个 0.017 是整条链路共用的系数,C 端和 Python 端写代码时都要注明来源,避免两边各自推导出不同近似值,导致同一块木板两端读数差出几厘米。
| 参数 | 典型值 | 对项目的影响 |
|---|---|---|
| 工作电压 | 5V,约 15mA | 不能由 GPIO 直接供电 |
| 量程 | 2cm ~ 400cm | 小于 2cm 数值失真,超过 4m 无回波 |
| 波束角 | 约 15° | 距离越远反射面越大,相邻角度数据互相串扰 |
| ECHO 输出 | 5V 电平 | 直接接 STM32 GPIO 有耐压风险 |
| 触发间隔 | ≥ 60ms | 两次 TRIG 太密会导致测量不稳定 |
波束角这一行最容易被忽略。15° 意味着传感器正前方一个圆锥范围内都算“有效反射”,目标越远这个圆锥半径越大,测量到的距离可能来自圆锥边缘而不是正前方。这直接导致了后面第 5 章要处理的相邻角度跳变问题,它本质上是声学特性,不是电气噪声。
2.2 舵机扫描与极坐标系的建立
雷达图上的每个点本质是一个极坐标对:角度由舵机决定,距离由 HC-SR04 决定。最常见的扫描机构是 SG90 这类 180° 舵机,传感器用螺丝或热熔胶固定在舵机臂上,舵机转到一个角度、停下来测一次距,扫完半圈再反向扫回来。角度和 PWM 占空比的换算决定了扫描的精准度,SG90 的控制信号是 50Hz PWM,周期固定 20ms,高电平时间 0.5ms 到 2.5ms 对应 0° 到 180°:
uint32_t pulse_us = 500 + (uint32_t)(angle * 2000.0f / 180.0f);角度步进的选择直接影响一帧雷达图的耗时。1° 步进一帧 181 个点,图形最密,但加上舵机稳定和测距时间,单程往往超过 3 秒;3° 步进只有 61 个点,形状略粗,帧率能提升近一半。雷达显示是扫描态而不是测绘态,能看出目标轮廓和运动趋势就够,所以 2°~3° 步进是更常见的折中。扫描结构固定后还有一个几何盲区必须接受:传感器波束有宽度,舵机臂、支架和传感器外壳会挡掉一小段角度,这段范围怎么测都不准。雷达图上 0° 附近的扇形缺口就是这么来的,不要试图用滤波把它“修”出来,后面第 5 章会讲怎么绕开它。
2.3 供电和电平匹配:两个容易烧板子的细节
HC-SR04 需要 5V 供电,ECHO 引脚输出电平也是 5V。STM32F103 的部分引脚标称可耐 5V,但“耐压”和“作为普通输入可靠工作”是两回事,稳妥做法是给 ECHO 加分压电阻:1kΩ 串联、2kΩ 对地,把 5V 分到约 3.3V 再进 GPIO。很多人的雷达图一上电就连点成片、数值乱跳,先查这一级电平,再查算法。
舵机的电流问题更隐蔽。SG90 启动和堵转瞬间电流能到几百毫安,如果舵机和 MCU 共用同一个 LDO,舵机一动作就会把供电电压拉低,表现是程序跑飞、串口乱码、单片机反复复位。常见做法是舵机单独接 5V/1A 以上的外接电源,MCU 用自己那路供电,两边只共 GND。这两处处理好,整个系统才有一个稳定的测量环境,后面所有优化才有意义。
3. STM32 端实现:微秒级测距、舵机 PWM 与串口协议
3.1 用 DWT 计数器解决微秒级时序问题
STM32 开发里最常见的时序坑是 HAL_Delay 的粒度只有 1ms,而超声波测距要精确到 10µs 的触发脉冲和几十微秒的回波宽度,直接用 HAL_Delay 写,测出来的距离能差出好几厘米。最省事的替代是 ARM Cortex-M3/M4 内核自带的 DWT 计数器,它以 CPU 主频递增,不额外占用定时器资源:
void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } uint32_t DWT_GetUs(void) { return DWT->CYCCNT / (SystemCoreClock / 1000000U); } void delay_us(uint32_t us) { uint32_t start = DWT_GetUs(); while (DWT_GetUs() - start < us); }DWT_Init 在 main 里上电调用一次,delay_us 是忙等循环,10µs 触发脉冲完全够用。如果你已经在用某个定时器维护 1µs 时基,效果也等价;DWT 的好处是零配置、不占外设。特别注意 SystemCoreClock 必须和实际主频一致,否则所有微秒换算都会线性偏移,72MHz 工程如果忘了配置直接跑 8MHz 内部时钟,距离误差会成倍放大。如果不想用 DWT,也可以开一个 1µs 周期的基本定时器封装成 delay_us,效果一样,但注意不要在中断回调里调用忙等函数,嵌套中断里死等会把系统卡住。
3.2 EXTI 中断测量 ECHO 高电平宽度
TRIG 用普通 GPIO 输出,ECHO 用双边沿外部中断。上升沿记下起始时刻,下降沿用当前值减起始时刻,就得到回波宽度。这套方案比定时器输入捕获直观,也更容易在移植到不同型号的 STM32 时保持代码不变:
volatile uint32_t echo_start_us; volatile uint32_t echo_width_us; volatile uint8_t echo_ready; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == ECHO_Pin) { if (HAL_GPIO_ReadPin(ECHO_GPIO_Port, ECHO_Pin) == GPIO_PIN_SET) { echo_start_us = DWT_GetUs(); // 上升沿:记起点 } else { echo_width_us = DWT_GetUs() - echo_start_us; // 下降沿:算宽度 echo_ready = 1; } } } uint16_t read_distance_mm(void) { echo_ready = 0; HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(10); HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); uint32_t start_ms = HAL_GetTick(); while (echo_ready == 0 && HAL_GetTick() - start_ms < 60); // 4m 满量程超时 if (echo_ready == 0) { return 0xFFFF; // 无回波,标记为无效 } return (uint16_t)((float)echo_width_us * 0.34f / 2.0f); // 单位 mm }这里返回毫米而不是厘米,毫米能在串口协议里多保留一位有效信息,显示端要不要四舍五入由 Python 决定。0xFFFF 表示“没有回波”,对应目标超出 4m 或波束角度太偏。60ms 超时按 4m 满量程设定,目标距离越近这个函数平均耗时越短,所以它基本能实时返回。需要注意 EXTI 回调里不要做串口发送这类耗时操作,这里只更新三个全局变量,主循环里再统一取走。
3.3 舵机 PWM 与角度换算
舵机 PWM 在 CubeMX 里配置最简单:72MHz 主频下定时器设 PSC=71、ARR=19999,每计数一次正好 1µs,周期 20000 计数即 20ms。角度换算和输出如下:
void set_servo_angle(float angle) { if (angle < 0.0f) angle = 0.0f; if (angle > 180.0f) angle = 180.0f; uint32_t pulse_us = 500 + (uint32_t)(angle * 2000.0f / 180.0f); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulse_us); }0.5ms~2.5ms 映射是 SG90 的典型值,但不同批次舵机的机械中位会差几度。实际项目里我一般不做两端校准,只在 Python 端加一个整体角度偏移量,因为机械零点偏差是常量误差,修正一次就够,反复在单片机里改映射反而容易引入角度跳变。如果你用的舵机是 270° 之类的大角度型号,把公式里的 180.0f 和限幅值一起改掉即可,协议和显示端都不用动。
3.4 主循环:扫描、采样与串口帧格式
PC 端解析越简单越好,所以帧格式用 ASCII 文本而不是二进制,出错时用串口助手肉眼也能看懂:
| 字段 | 含义 | 示例 | 说明 |
|---|---|---|---|
| S | 帧头 | S | 过滤串口噪声 |
| angle | 扫描角度 | 90 | 范围 0~180,单位 ° |
| dist | 距离 | 340 | 单位 mm,0xFFFF 为无效 |
| \r\n | 帧尾 | — | Python readline 按行切帧 |
主循环让舵机 0°→180°→反向来回扫,不用快速回程,效率比“扫到 180 再转回 0”高一倍:
void scan_once(uint8_t angle) { set_servo_angle((float)angle); HAL_Delay(30); // 舵机稳定时间 uint16_t dist = read_distance_mm(); char buf[24]; int len = sprintf(buf, "S%u,%u\r\n", (unsigned int)angle, (unsigned int)dist); HAL_UART_Transmit(&huart1, (uint8_t*)buf, len, 10); } void radar_loop(void) { while (1) { for (int dir = 0; dir < 2; dir++) { if (dir == 0) { for (uint8_t a = 0; a <= 180; a += 2) { scan_once(a); } } else { for (uint8_t a = 180; a > 0; a -= 2) { scan_once(a); } } } } }scan_once 里先转舵机、等 30ms 稳定,再触发超声波,目的是让舵机残余抖动平息,否则读数会跟着抖。2° 步进是速度和密度的折中,要更密改成 1°,帧率减半即可。这一帧文本最长 12 字节,115200 波特率下传输不足 1ms,串口在这条链路里不是瓶颈。
4. Python 端实现:串口解析与 matplotlib 极坐标雷达图
4.1 用 pyserial 打开串口并按行解析
先确认 Python 环境里装好 pyserial 和 matplotlib,串口打开参数必须与 STM32 端一致,乱码问题基本都出在这个环节:
| 参数 | 取值 | 说明 |
|---|---|---|
| 波特率 | 115200 | 两端一致即可 |
| 数据位 | 8 | 默认 |
| 校验位 | None | ASCII 协议自带帧头帧尾 |
| 停止位 | 1 | 默认 |
import serial import serial.tools.list_ports # 打印所有可用串口,核对 STM32 对应的是哪个设备 for p in serial.tools.list_ports.comports(): print(p.device, p.description) ser = serial.Serial(port='COM7', baudrate=115200, timeout=1) def read_scan_point(ser): while True: raw = ser.readline() if not raw: return None # 超时无数据 line = raw.decode('ascii', errors='ignore').strip() if not line.startswith('S'): continue # 噪声帧,跳过 parts = line.split(',') if len(parts) != 2: continue # 半帧,跳过 try: angle = int(parts[0][1:]) dist = int(parts[1]) return angle, dist except ValueError: continue # 解析失败就跳过,不退出PC 端解析最容易踩的坑是“半帧”:单片机刚上电、舵机转动的瞬间,串口发出来的可能是断成两截的行,readline 也会把它当作一帧读完。所以解析函数要求帧头、字段数、整数转换全部通过才返回,任何一步异常都静默跳过而不是抛异常退出。如果打开串口后 readline 一直返回空,先查两点:串口号是否被别的程序占用(比如烧录器虚拟串口),以及 STM32 是否仍在复位循环里。用 list_ports 打印结果核对设备描述,比逐个试端口快得多。
4.2 极坐标雷达图的绘制与刷新
绘图用 matplotlib 的 polar 投影,角度转弧度,距离作为半径。一帧目标轮廓持续变化,先清空再重画是最容易写对、也最容易理解的刷新方式:
import matplotlib.pyplot as plt import numpy as np plt.ion() fig = plt.figure(figsize=(6, 6)) ax = fig.add_subplot(111, projection='polar') angles_rad = [] dists_mm = [] while True: point = read_scan_point(ser) if point is None: continue ang, dist = point if dist >= 0xFFFF: # 无效距离,不画 continue angles_rad.append(np.deg2rad(ang)) dists_mm.append(min(dist, 1500)) # 显示上限 1.5m if len(angles_rad) % 60 == 0: # 每 60 个点刷新一次 ax.clear() ax.set_theta_zero_location('N') # 0° 指向上方 ax.set_theta_direction(-1) # 角度顺时针增大 ax.set_ylim(0, 1500) ax.scatter(angles_rad, dists_mm, s=8, c='lime', alpha=0.8) plt.draw() plt.pause(0.01)set_theta_zero_location('N') 把 0° 放到正上方,set_theta_direction(-1) 让角度顺时针增大,这样舵机从左往右扫时图上点也按从左往右排列,和实物方向一致。set_ylim(0, 1500) 把显示半径压到 1.5m,如果场景最远只有这么远,保留 4m 的默认量程会让近处目标挤成一团看不清。每隔固定点数刷新而不是每收到一个点就重绘,是为了避免重绘太频繁导致主循环跟不上串口数据。plt.pause(0.01) 除了刷新画布,还给事件循环让出时间,窗口才不会被拖死。
4.3 用“余晖”效果提升雷达图的观感
实际雷达屏上总有余晖:扫过之后亮点慢慢变暗而不是立刻消失。matplotlib 里做余晖的最简单方式是不清空散点,而是维护一组带 alpha 的散点,每帧把旧一点的点的 alpha 按比例衰减:
points = [] # 每个元素: (theta, r, alpha) def age_points(points, falloff=0.6): aged = [] for theta, r, alpha in points: alpha *= falloff if alpha > 0.05: aged.append((theta, r, alpha)) return aged # 主循环里每收到一个新点: if len(angles_rad) % 60 == 0: points.append((angles_rad[-1], dists_mm[-1], 1.0)) points = age_points(points) ax.clear() ax.scatter([p[0] for p in points], [p[1] for p in points], c=[[0.7, 1.0, 0.2, p[2]] for p in points], s=8) plt.draw() plt.pause(0.01)新点 alpha=1.0 加入列表,旧点每帧乘 0.6,低于 0.05 就淘汰。这个余晖带来的最大好处是观察者能同时看到“刚刚扫过”和“很久以前”的位置差异,目标移动方向变得非常直观。如果嫌两层散点叠得乱,可以退一步:旧点统一画成灰色小点,只有最近一轮点用亮色,视觉上更干净,实现也更简单。
5. 超声波雷达的距离跳变、扫描刷新率与盲区调优
5.1 有效距离判定与三点中值滤波
距离数据最常见的两种异常:突然变成 0xFFFF 满量程,或者相邻角度读数相差巨大。前者多半是波束角度太斜没收到回波,后者常是多径反射——超声波先打到墙面又弹到别的物体,ECHO 量到的是绕远路的回波。三点中值滤波可以把这类单点突刺拉平:
def median_of_last_three(history, new_dist): if new_dist != 0xFFFF: # 无效帧直接丢弃 history.append(new_dist) if len(history) > 3: history.pop(0) if len(history) == 3: return sorted(history)[1] # 三点取中值 return None # 有效样本不足,先不输出无效帧直接丢弃而不是替历史补值,等凑满 3 个有效读数再输出中值。中值滤波对单个离群点的效果比均值滤波好,均值会被一个满量程值拉出几百毫米的假距离。窗口取 3 够用,取 5 会明显变钝,传感器自身的响应延迟本来就有几十毫秒,滤波窗口不宜再放大。调试时可以临时在 Python 端把原始距离逐点打印出来,对比滤波前后的曲线,确认跳变是单点毛刺还是连续漂移——连续漂移通常是舵机角度没对准,单点毛刺才是滤波要处理的对象。
5.2 刷新率瓶颈在舵机,不在串口和显示
算一笔账:2° 步进单程 91 个点,每点 30ms 稳定加平均十几毫秒测距,单程约 4 秒;改成 3° 步进、稳定时间实测调到 20ms,单程能压到 2 秒左右。扫到端点时舵机会有短暂停顿,这是机械换向的正常现象。想继续提速的选择按性价比排序:步进改 3°、稳定时间按实际舵机微调、扫描角度缩到只覆盖目标区域。主频、串口波特率、matplotlib 刷新在这条链路里都不是限制,不要在没瓶颈的地方优化。另一个容易被忽略的事实是:来回扫的模式下,一帧完整图其实是两半扇区拼出来的,前半程数据到 PC 端时显示的是旧的后半程,理解这一点就不会误判“图像上下不对称”。
5.3 机械安装决定盲区,角度校准落到 Python 端
HC-SR04 近距盲区只有 2cm 量级,真正挡数据的往往是安装件。传感器正对舵机臂时,0° 附近会扫到安装支架自身的反射,读数要么偏近要么乱跳。我的做法是把 0° 方向只当作舵机回程对齐用的机械零点,数据从 10° 或 20° 开始取,缺口部分在显示端自然留白。角度校准流程很简单:场景里放一块已知距离的立板,转动舵机让距离读数最小,记录这个角度和设定角度的差值,在 Python 端整体修正角度,不要在单片机里反复改 PWM 映射。这套流程做完,剩余误差主要来自声速随温度变化的几个百分点,对显示型雷达项目来说属于可以接受的精度。
本文还有配套的精品资源,点击获取