
简介使用意法半导体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 主频下定时器设 PSC71、ARR19999每计数一次正好 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单位 mm0xFFFF 为无效\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默认校验位NoneASCII 协议自带帧头帧尾停止位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(portCOM7, baudrate115200, timeout1) def read_scan_point(ser): while True: raw ser.readline() if not raw: return None # 超时无数据 line raw.decode(ascii, errorsignore).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, projectionpolar) 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, s8, clime, alpha0.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, falloff0.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], s8) plt.draw() plt.pause(0.01)新点 alpha1.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 映射。这套流程做完剩余误差主要来自声速随温度变化的几个百分点对显示型雷达项目来说属于可以接受的精度。本文还有配套的精品资源点击获取