简介:基于STM32的倒车雷达课程设计工程包,使用HC-SR04超声波传感器完成测距,并将距离信息通过OLED屏幕实时显示,适合嵌入式初学者、电子类课程设计及竞赛备赛参考。工程基于STM32F1系列HAL库开发,完整涵盖超声波触发与回波捕获、定时器计时、SSD1306 OLED显示驱动等关键环节,能够帮助学习者深入理解传感器数据采集、外设初始化与中断处理的实际流程。压缩包共176个文件,包括核心C源码、Keil工程配置、STM32CubeMX初始化文件、编译生成的目标文件及简要说明文档,整体约6.38MB,目录结构清晰,可阅读源码学习,也可直接烧录验证效果。目前已有514人学习,对于想完整走通一个STM32物联网小项目的读者,是一份不错的实践样例。此外,读者可借助该工程熟悉定时器输入捕获测量脉冲宽度的方法,也可在此基础上扩展距离阈值报警或数码管显示等功能。
1. 基于STM32的倒车雷达项目,HC-SR04与OLED构成的最短实现路径
倒车雷达听起来像玩具,但把它拆开就是单片机工程师最常用的一套外设组合:GPIO输出脉冲、输入捕获测脉宽、传感器换算、显示驱动和状态分档。STM32在这里并不是非它不可,关键是HC-SR04回波信号是5V电平,STM32的IO耐压、定时器输入捕获精度和HAL库封装,恰好是这套做法最典型的选择。OLED屏解决“距离可见”问题,0.96寸128x64和0.91寸128x32都能用,I2C两根线就能把数据送出去。这套方案适合学完GPIO和中断、想做毕业设计或临时测距工具的人,二十块钱的成本,就能把串口里的一串距离值变成一块能装在模型车上的屏幕。
2. HC-SR04测距原理与OLED显示协议:先弄懂量程、精度和驱动开销
2.1 HC-SR04靠10us Trig触发,Echo高电平宽度就是回波时间
HC-SR04模块上电后不会自己工作,必须先给Trig引脚一个大于10us的高电平脉冲,模块内部自动发出8个40kHz方波,同时把Echo引脚拉高;当超声波遇到障碍物反射回来,接收电路检测到回波后把Echo拉低。也就是说,Echo引脚高电平持续的时间就是超声波从发射到返回经过的总时长,你拿到的不是一个距离数值,而是一个需要自己换算的脉宽。
换算关系决定了代码里的系数:距离不是声速乘时间,因为脉冲走了往返路径。常温下声速约340m/s,也就是0.034cm/us。如果脉宽单位是us,距离cm = 脉宽 * 0.034 / 2,整理后就是0.017。这个0.017会成为主循环里最核心的换算常数。
模块量程通常在2cm到400cm。2cm以下是盲区,因为发射和接收共用同一块压电陶瓷,回波还没等接收电路恢复就已经回来了。精度标称3mm,但这个数值只对平整硬质反射面有意义。墙面、地面、木板这类表面反射稳定,窗帘、衣物、草地这类吸声面经常丢回波,实测时不要拿毛绒玩具测距离后怀疑代码写错了。
2.2 0.96寸OLED是I2C还是SPI:从引脚数量和刷新率决定
市面上常见的0.96寸OLED模块,驱动芯片大多是SSD1306,少数是SH1106。两者初始化命令和显存组织有细微差别,I2C地址通常都是0x3C,也有0x3D,具体由模块上地址电阻决定。0.91寸规格是128x32,0.96寸是128x64,页寻址方式一样,只是显存行数和起始页不同,驱动代码基本可以复用。
| 接口 | 控制引脚 | 特点 | 适用场景 |
|---|---|---|---|
| I2C | SCL、SDA | 只需两个IO,刷新较慢 | 数字、短字符串 |
| SPI | SCL、SDA、DC、RES、CS | 刷新快,接线多 | 动画、波形绘制 |
I2C模式在400kHz下刷新一帧128x64大约要30ms,如果每秒只更新几个数字,这点时间完全不影响。SPI模式要占用5个IO,换来的刷新速度对倒车雷达这种场景没有感知,所以我一般会选I2C版本模块。买模块时看排针标注,如果是4针GND/VCC/SCL/SDA那可以直接用;如果排针是7针,说明卖家默认SPI模式,需要改板上的电阻才能切到I2C。
2.3 测Echo脉宽:GPIO轮询和定时器输入捕获两种做法的边界
读取Echo脉宽最直观的做法是GPIO轮询:发完Trig后反复读Echo电平,用一个微秒级计数器测高电平宽度。HAL_Delay的最小粒度是1ms,显然不能用,所以要自己实现delay_us,或者用DWT外设的CYCCNT寄存器。轮询的缺点是在Echo高电平期间CPU被占住,按4m量程算最长要占20多毫秒,这段时间里OLED没法刷新,按键响应也会卡顿。
定时器输入捕获是更干净的做法:Echo接到定时器通道引脚上,上升沿把计数器值锁存到CCR1,下降沿再锁存到CCR2,两个寄存器的差值乘以定时器计数周期就是脉宽。捕获过程不占CPU,精度由定时器分频决定。但配置代码比轮询复杂,还要事先算好计数范围:4m距离对应的脉宽约23.5ms,STM32F103的16位定时器在72MHz下直接计数只能数到0.9ms就溢出,必须分频到1us计数一次,让最大值能覆盖65ms以上,或者直接换用32位定时器TIM2/TIM5。
// 定时器分频到1us计数一次后,两次捕获相减就是脉宽 uint32_t capture_diff = TIM2->CCR2 - TIM2->CCR1; // 单位us float distance_cm = capture_diff * 0.017f;这段代码只做演示,实际工程里通常把差值换算放在中断下半部分,避免在中断里做浮点运算。把项目跑通和把项目做到能看之间的差别就在这里:倒车雷达对实时性没那么苛刻,教材里几乎都会选输入捕获展示外设能力,但工程上轮询方案代码量小、问题定位简单。先用轮询拿到稳定距离,再换捕获是常见演进路线。下一章先按轮询给出完整代码,捕获方案留在最后一章讲。
3. 在STM32 HAL库上输出倒车雷达代码:从CubeMX引脚配置到OLED显示
3.1 引脚接线表与CubeMX必改项
HC-SR04需要5V供电,Echo在高电平期间也是5V。不要直接把它接到STM32的3.3V IO上,F103有标注FT的5V容忍引脚,但G0、L4这些系列不全是FT,最稳的做法是加一级电阻分压,把5V降到3.3V以下再进IO。如果你更习惯用STM32标准库新建工程,CubeMX生成的代码可以只参考引脚配置,测距和OLED驱动平移过去时,把HAL_GPIO_WritePin替换成GPIO_SetBits/GPIO_ResetBits就能保持逻辑一致。
| 信号 | 模块端 | STM32端 | 说明 |
|---|---|---|---|
| Trig | Trig引脚 | PA0,GPIO输出 | 推挽输出,默认低电平 |
| Echo | Echo引脚 | PA1,GPIO输入 | 串10k电阻后接PA1 |
| OLED-SCL | SCL | PB6 | I2C1_SCL |
| OLED-SDA | SDA | PB7 | I2C1_SDA |
| 电源 | VCC/GND | 5V/GND | HC-SR04必须5V供电 |
CubeMX里除了时钟树,最关键的是三处:SYS里把Debug打开为Serial Wire,否则下载过一次程序后,第二次下载就可能提示找不到目标;RCC里打开HSE外部晶振,不要停留在HSI上;I2C1的频率设为400kHz,PA0和PA1保持默认的GPIO模式即可。如果打算用软件模拟I2C,I2C1可以不初始化,直接把PB6/PB7配置成开漏输出,外部接4.7k上拉电阻到3.3V。下面代码以硬件I2C为例,软件I2C的差异只在发送字节那一层。
打开Debug Serial Wire这个动作常被忽略。Keil5安装好STM32芯片包、新建工程后不配置这一项,第一次烧录正常,第二次烧录很容易报错,原因就是程序把SWD引脚复用掉了。
3.2 用GPIO翻转和微秒延时读Echo脉宽
轮询方式的完整测距函数,最关键的是超时保护。没有超时保护,一旦Echo一直低电平或一直高电平,整个单片机就卡死在那两行while里,OLED和按键全部失去响应。
#include "main.h" #include <stdio.h> #define TRIG_PORT GPIOA #define TRIG_PIN GPIO_PIN_0 #define ECHO_PORT GPIOA #define ECHO_PIN GPIO_PIN_1 void delay_us(uint16_t us) { // 近似空循环:72MHz主频下约18次循环对应1us for (uint32_t i = 0; i < us * 18; i++) { __NOP(); } } // 返回值:距离,单位cm;-1.0f表示无效测量 float HCSR04_ReadDistanceCM(void) { uint32_t pulse_us = 0; uint16_t guard; // 触发:先低后高,保证10us以上高电平 HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); delay_us(4); HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); delay_us(12); HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); // 等待Echo变高,超时约10ms guard = 0; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) == GPIO_PIN_RESET) { if (++guard > 10000) return -1.0f; delay_us(1); } // 测量Echo高电平时长,超时约25ms,对应4米量程 pulse_us = 0; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) == GPIO_PIN_SET) { if (++pulse_us > 25000) return -1.0f; delay_us(1); } return pulse_us * 0.017f; }代码里,Trig先从低变高,等12us再拉低,满足HC-SR04高电平至少10us的要求。第一个等待循环guard上限10000对应10ms,Echo在模块正常响应后通常在1ms内拉高,超过10ms说明模块没触发或Echo线没接。第二个循环pulse_us上限25000对应25ms,常温下4m距离产生的脉宽约23.5ms,超过这个数就该认定无效。返回-1.0f让业务层能区分“距离超限”和“测距失败”,这是倒车雷达必须有的分支。
delay_us用的是空循环近似,72MHz下每个循环大约4个时钟周期,us * 18约等于1us。开了-O2优化后循环会被压缩,距离值会系统性偏移。想省心就用DWT外设,把CYCCNT读出来精确延时,但那在CubeMX里要多配一个全局变量,初期先用空循环把流程跑通即可。
3.3 用HAL库点亮OLED屏幕全流程:从I2C地址到字符输出
给OLED写数据底层是I2C。用HAL硬件I2C时,地址要左移一位,因为HAL接口参数需要7位地址左移后的值,0x3C写成0x3C << 1。很多人在第一步就卡在这里,代码里用0x3C还是0x78,取决于驱动库封装是直接调HAL函数还是自行拼地址字节。
#define OLED_ADDR (0x3C << 1) void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 10); } void OLED_WriteData(uint8_t data) { uint8_t buf[2] = {0x40, data}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 10); } void OLED_Init(void) { const uint8_t cmds[] = { 0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF }; for (uint16_t i = 0; i < sizeof(cmds); i++) { OLED_WriteCmd(cmds[i]); } } void OLED_Clear(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); OLED_WriteCmd(0x00); OLED_WriteCmd(0x10); for (uint8_t col = 0; col < 128; col++) { OLED_WriteData(0x00); } } }这组初始化命令是SSD1306的标准序列:0xAE关显示,0x8D 0x14打开内部电荷泵,0xA1和0xC8设置扫描方向,0xAF开显示。OLED显存按页组织,128x64被分成8页,每页8个像素点。OLED_Clear遍历8页、每页128列全部写0,最慢的I2C下约30ms,所以主循环里不要频繁调用。
显示距离值的最小改动是只在变化时刷新数值区域,先写静态标题再覆盖动态内容:
sprintf(buf, "%3dcm", (int)distance); OLED_ShowString(0, 0, "Distance:"); OLED_ShowString(24, 3, buf);sprintf里%3d保证三位数占位,距离从99cm跳变到100cm时不会留下旧字符残留。OLED_ShowString需要你自己实现字模表,常见的SSD1306驱动库都带这句接口;显示汉字则要专用取模工具,这部分放在第4章档位图标里展开。
4. 给倒车雷达加显示逻辑与距离档位判断:阈值、滤波和刷新节奏
4.1 主循环结构与OLED刷新率:不要每次测量都全屏刷新
测完距离立刻刷新屏幕,这是倒车雷达程序最常见的问题。HC-SR04一个测量周期最低也要十几毫秒,如果主循环跑一次测距就清一次屏,屏幕会明显闪烁。原因是OLED_Clear本身要占30ms左右,加上取模、数字转字符串,刷新时间超过人眼闪烁融合频率。
常见做法是把显示拆成两层:第一层是静态部分,比如页眉的“Distance”文字,只在初始化时写一次;第二层是动态部分,比如距离数值和档位图标,只在数值变化时更新。OLED显存按页组织,写单个字符前先拉到对应页地址,写入字符的5x8点阵数据,成本远低于全屏重写。
float dist; int last_disp = -1; char buf[16]; while (1) { dist = HCSR04_ReadDistanceCM(); if (dist > 0 && abs((int)dist - last_disp) >= 1) { sprintf(buf, "%3dcm", (int)dist); OLED_ShowString(24, 3, buf); // 只覆盖数值区域 last_disp = (int)dist; } HAL_Delay(80); }刷新阈值设为1cm,距离变化不到1cm不重画。last_disp初始化为-1,保证第一次循环必定刷新一次。延时80ms相当于每秒约12次测距,HC-SR04模块建议测量间隔不小于60ms,50ms偏快,模块内部电容和压电晶体来不及完全复位,距离值会偶发抖动;80到100ms是稳妥区间。
4.2 距离分档显示:安全、警告、危险三档的判断逻辑
倒车雷达不能只显示数字,装在车模或小车上时要能根据距离触发蜂鸣器或改变屏幕提示。分档判断是独立于测距逻辑的一层,这样后续把OLED换成LCD1602或加蜂鸣器,只改显示层。
typedef enum { ZONE_SAFE = 0, ZONE_WARN, ZONE_DANGER } ZoneType; ZoneType check_zone(float dist) { if (dist < 0) return ZONE_DANGER; // 无效回波按最危险处理 if (dist < 30.0f) return ZONE_DANGER; if (dist < 80.0f) return ZONE_WARN; return ZONE_SAFE; }| 档位 | 距离范围 | 屏幕提示 | 建议动作 |
|---|---|---|---|
| 安全 | 大于80cm | SAFE | 不提示 |
| 警告 | 30到80cm | WARN | 蜂鸣器慢速间隔 |
| 危险 | 小于30cm或无效 | STOP! | 蜂鸣器连续响,准备制动 |
阈值30cm和80cm是倒车雷达最常见的保守值。30cm接近停车极限,80cm是预警线。HC-SR04盲区是2cm,2cm以内的值本来就测不到,check_zone收到-1这类无效值时要按危险处理,不能丢弃后保持上一帧的安全状态,否则车已经撞上屏幕还显示绿色。OLED端的分档显示用字符串比画图标好控制:
const char *zone_text[3] = {"SAFE ", "WARN ", "STOP! "}; ZoneType zone = check_zone(dist); OLED_ShowString(24, 6, (char *)zone_text[zone]);危险档还可以把蜂鸣器接到另一个GPIO,用定时器控制鸣叫节奏。OLED上显示反色的STOP! 比单纯数字更能提醒人。
4.3 温度补偿与中值滤波:让回波脉宽更可信
HC-SR04模块内部没有温度传感器,默认按常温声速计算距离。室外冬天零下10度,声速约328m/s,误差接近4%。倒车雷达在近距离场景误差不大,但要把这套代码当测距仪器用,温度补偿就是必需项。
声速与温度的关系式是 c = 331.4 + 0.607 x T(m/s)。25度代入得346.6m/s,工程上这个公式精度足够。换算系数0.017对应常温声速340m/s,温度变化时改为:
float tempC = 25.0f; // 从DS18B20或BMP280读入 float sound_speed = 331.4f + 0.607f * tempC; float coeff = sound_speed / 1000000.0f / 2.0f; return pulse_us * coeff;coeff的单位是cm/us。25度时约0.0173,比固定0.017多1.8%,对应4m距离约7cm偏差。倒车雷达可以不做,但加入后代码结构不变,只替换return那一行。
滤波环节,中值滤波比均值滤波更适合HC-SR04。超声波测距的错误值经常是突发抖动,比如某次回波丢失导致距离跳变到满量程,均值滤波会把错误值带偏好几帧,中值滤波直接把它排在序列外:
#define FILTER_N 5 uint16_t median5(uint16_t *arr) { // 只排序5个元素,不需要优化 for (uint8_t i = 0; i < FILTER_N - 1; i++) { for (uint8_t j = 0; j < FILTER_N - 1 - i; j++) { if (arr[j] > arr[j + 1]) { uint16_t t = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = t; } } } return arr[FILTER_N / 2]; }采样流程是连续测5次,每次间隔100ms,排序后取中位数。这样响应时间增加400ms,对倒车雷达的慢速接近完全够用,但对运动中的物体不合适。想要响应快就改3点中值,或者用滑动窗口,新样本插入有序数组而不是重新采5次。
5. 倒车雷达实测与排错:OLED不亮、Echo悬空、ST-LINK连不上的定位方法
5.1 OLED只亮背光不显示内容:先查地址再查初始化序列
OLED模块上电后屏幕全亮或半亮但字不显示,问题几乎出在两个地方。一是I2C地址不对,0x3C和0x3D的差别在模块背面有标注,HAL里确认用0x3C << 1。二是初始化序列顺序错了,SSD1306命令里0xAF开显示必须放在最后,提前开显示会导致后续命令刷成花屏。排查时先用逻辑分析仪看SDA线上有没有ACK,没有ACK别改驱动,先查供电和地址。烧录阶段如果遇到“No STM32 target found! If your product embeds Debug Authentication”,多半是SWD引脚被代码复用成普通GPIO,按住复位键再点下载,或者直接用STM32 ST-LINK Utility整片擦除再回来。
5.2 Echo引脚悬空和Trig宽度导致的距离异常
HC-SR04如果只接VCC和GND,Trig悬空时模块不工作,但Echo脚在某些模块上有弱上拉,表现出来是距离值永远不变。不要默认引脚悬空时是低电平,万用表量一下:不上电时Echo对地接近0V,上电后静止状态也是0V。如果Echo空闲时是高电平,说明模块内部有缺陷或者输出端被上拉,串接1k电阻再测。另一个常见问题是Trig高电平不够宽,HC-SR04对10us的要求比较宽松,但delay_us近似循环在编译优化后可能缩水到5us,模块间歇性不触发。这时把delay_us里us * 18改成us * 24,或者换成DWT精确延时。
5.3 把距离值打印到上位机:一处printf改动
OLED显示是最终输出,调试时先信串口。STM32串口接USB转TTL,波特率115200,重定向fputc之后每个测距周期输出一行“dist: 123.4 cm”:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 10); return ch; } printf("dist: %.1f cm\r\n", dist);OLED显示的数值和串口数值对不上时,以串口为准,OLED的整形截断只影响小数部分。串口打印还能看出OLED刷新是否拖慢测距循环:如果打印间隔在刷新时明显拉长,就说明OLED_ShowString占了太多时间,回到4.1的局部刷新方案处理。
5.4 更进一步的技巧:Echo改接到定时器输入捕获通道
GPIO轮询在功能上能跑,但Echo高电平期间CPU被完全占住。把Echo从PA1挪到TIM2_CH1对应的引脚,配置上升沿和下降沿都捕获,两次捕获差值就是脉宽。CubeMX里给TIM2开Global interrupt,回调里做记数切换:
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { static uint32_t start_us; if (capture_count == 0) { start_us = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); capture_count = 1; } else { pulse_us = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1) - start_us; capture_count = 0; } } }注意TIM2在STM32F103里是32位定时器,分频到1us后32位计数器溢出要4294秒,完全不用担心脉宽量程。切换成输入捕获后,delay_us近似的误差也消失了,距离值跳动会明显减少。改动量集中在CubeMX的定时器配置和这个回调函数里,OLED显示代码一行都不用动。
本文还有配套的精品资源,点击获取