news 2026/9/14 7:31:16

STM32实战:HC-SR04超声波测距与SSD1306 OLED显示

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32实战:HC-SR04超声波测距与SSD1306 OLED显示

简介:适用于STM32F103的嵌入式超声波测距显示工程,面向单片机入门及进阶开发者,以HC-SR04类超声波传感器结合4针I2C OLED实现距离实时显示,可作为课程设计、DIY项目或外设驱动参考。项目覆盖C/C++源码,包含传感器触发与回波捕捉逻辑、定时器计时、I2C驱动及OLED绘图刷新,代码中嵌入了GPIO、定时器和I2C外设的初始化配置,便于理解寄存器与HAL库之间的配合。压缩包共139个文件,约1.68MB,以35个h头文件和32个c源文件为主,另含Keil工程配置、编译中间产物、hex烧录文件与工程备份,目录结构基本对应ST标准外设库,可直接打开工程查看或修改再编译。已有2345人学习,整体代码简洁、注释清晰,适合对照学习从外设底层到应用层的数据流,也可以直接移植到其他STM32型号,在此基础上扩展报警、数据记录或无线传输等功能。

1. 把超声波的读数推到4针OLED屏上,这半屏点阵才是最硬的锻炼

任何学习STM32的人都会经历一个阶段:程序能跑,但不知道对不对。串口打印是一种答案,可一旦拔掉USB线,单片机就成了哑巴;换一块4针OLED,把HC-SR04测得的距离显示在屏幕上,成果就变得可以被“看见”。这个标题里最容易被低估的是“4针”两个字:它意味着OLED不是并行8080接口,而是走I2C的SSD1306,只有SCL、SDA、VCC、GND四根线。把“GPIO触发超声波、定时器捕获脉宽、I2C驱动点阵屏”串起来,正好覆盖STM32日常开发中最常用的三类外设。这篇适合已经会点灯、却还没把外设串成系统的人,HAL库优先,C和C++都适用。我把这项目里最容易被卡住的几个点拆开讲:I2C地址、Echo电平、取模方向,还有那些参数填错后很难排查的细节。

2. STM32引脚分配与CubeMX初始化:GPIO、I2C和定时器参数先做对

2.1 4针OLED、HC-SR04的引脚与供电

4针OLED(0.91寸128x32或0.96寸128x64)在STM32项目中一般挂在I2C1上:SCL→PB6、SDA→PB7。屏幕背面丝印通常会标SSD1306,7位从机地址是0x3C,换算成8位写地址是0x78;如果背面有电阻选择,还能改成0x3D/0x7A。IIC通信协议 oled 的驱动逻辑是一样的,区别只在地址和初始化参数。这里有一个非常具体的坑:0.91寸屏是128x32,命令0xA8后的MUX值是0x1F;0.96寸屏是128x64,MUX值是0x3F。用错后会表现为只亮一半或滚动错位。

HC-SR04这边则要留意模块电压。模块最稳定的供电是5V,Trig是输入,Echo是输出,Echo高电平幅度接近供电电压。若开发板只提供3.3V,测距会变短、失测变多;若用5V供电,Echo输出5V又超过STM32的IO耐压上限。常见做法是在Echo上串1kΩ电阻,或使用10k+10k分压后再进TIM2引脚。Trig由MCU输出,用普通GPIO推挽即可,不需要特殊时序,只要保证脉冲宽度在10到15us之间。

2.2 CubeMX参数表:GPIO、TIM2输入捕获与I2C

下面这组参数以STM32F103C8T6为例,72MHz主频。创建工程时,先在Pinout视图里把PA0设成GPIO_Output、PA1设成TIM2_CH1,然后打开TIM2的NVIC中断;PB6/PB7在系统默认配置里就是I2C1的SCL/SDA。把STM32芯片包安装好、HAL库版本不要太旧,这些选项的位置基本一致。

外设引脚关键参数说明
I2C1PB6-SCL,PB7-SDA400kHz,7位地址屏线短时400kHz;波形异常回退100kHz
TIM2CH1=PA1Prescaler=71,ARR=6553572MHz分频后计数频率1MHz,1计数=1us
GPIOPA0=Trig推挽输出,初始低触发脉冲建议15us

代码生成后,GPIO初始化大致长这样:

void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // I2C1在PB6/PB7,须打开GPIOB时钟 HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); GPIO_InitStruct.Pin = TRIG_Pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(TRIG_GPIO_Port, &GPIO_InitStruct); GPIO_InitStruct.Pin = ECHO_Pin; GPIO_InitStruct.Mode = GPIO_MODE_AF_INPUT; // 复用给TIM2_CH1捕获 GPIO_InitStruct.Pull = GPIO_PULLDOWN; HAL_GPIO_Init(ECHO_GPIO_Port, &GPIO_InitStruct); }

HAL库的GPIO_MODE_AF_INPUT会把这个引脚切换到TIM2的复用功能,而不是普通输入。Echo空闲为低电平,GPIO_PULLDOWN能保证没有回波时不会被悬空噪声误触发。Trig初始拉低,是为了避免上电瞬间给模块一个假触发。这里容易填错的是Prescaler数值:CubeMX界面里填的是分频系数,72MHz想要1MHz计数就填71,不是72。填成72会得到1.013MHz的计数频率,测距结果整体偏小约1.4%,这类误差不容易发现,但会直接影响后续换算精度。

2.3 初始化顺序与常见启动问题

STM32上电后,先初始化OLED,再启动TIM2的输入捕获,最后在主循环里发Trig。顺序反过来的麻烦在于,Echo中断可能在OLED还没稳定时就触发,回调里的显示函数会超时。OLED的SCL/SDA对时序较敏感,HAL库生成工程时,飞线超过10cm就建议把I2C从400kHz改到100kHz,能少掉很多随机雪花点。另外,如果程序卡死在HAL_I2C_Mem_Write返回HAL_TIMEOUT,先量SDA上拉电阻是否焊好,别急着改代码。

提示:如果板子上同时接了多个I2C从机,HC-SR04别挂在同一条总线上。它不是I2C设备,I2C总线空闲时被拉高,两者互不干扰,但Echo的方波会在示波器上看起来像总线毛刺,排错时容易误判。

3. SSD1306驱动与字符串显示:I2C协议、地址和字模

3.1 4针OLED为什么是I2C,以及SSD1306的通信格式

4针OLED把SCL、SDA、VCC、GND引出来,本质是把SSD1306的并行接口全砍掉,只留I2C从机接口。SSD1306的I2C传输格式里,跟在7位地址后面的是控制字节:0x00表示后续都是命令,0x40表示后续都是GRAM数据。在HAL库里,这个控制字节被当成MemAddress传进去,于是就有了HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 50)这种写法。

执行一条SSD1306命令,I2C总线上实际发生的是:START + 地址+W、控制字节0x00、命令字节,然后STOP。每次写数据都带上这个控制字节,才能让屏幕知道当前字节是命令还是数据。这个细节容易被忽略:有人用HAL_I2C_Master_Transmit直接发命令,丢掉控制字节,屏幕必然无反应。

控制字节含义HAL库用法
0x00后续字节为命令MemAddress填0x00
0x40后续字节为GRAM数据MemAddress填0x40

3.2 最小驱动:初始化、清屏、写数据

把初始化序列放在一个数组里逐个发送,最省事也最容易排错:

#define OLED_ADDR 0x78 static void OLED_Cmd(uint8_t cmd) { uint8_t d = cmd; HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, &d, 1, 50); } static void OLED_Data(uint8_t *buf, uint16_t len) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x40, I2C_MEMADD_SIZE_8BIT, buf, len, 100); } void OLED_Init(void) { const uint8_t init_seq[] = { 0xAE, 0xA8, 0x1F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0xA1, 0xC8, 0xDA, 0x02, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0x20, 0x00, 0xA4, 0xA6 }; for (uint16_t i = 0; i < sizeof(init_seq); i++) OLED_Cmd(init_seq[i]); OLED_Cmd(0xAF); }

OLED_Cmd里的0x00是控制字节,表示命令;OLED_Data里的0x40是数据控制字节。I2C_MEMADD_SIZE_8BIT告诉HAL,MemAddress是8位。0xA8, 0x1F对应128x32屏,若用128x64屏改成0xA8, 0x3F0x8D, 0x14是开启电荷泵,很多代码只写命令不写参数,屏幕会一直黑。写完0xAF后再清屏,做法是连续向GRAM写512个0x00,这里不再单独贴函数。

3.3 显示字符与数字:取模方向、页地址与UTF-8

SSD1306的显存按页组织,128x32屏分4页,每页8行像素、128列。显示ASCII字符时,最常用6x8字体,一个字符占6字节,每字节对应一列、8个点。取模工具选择“列行式、阴码、逐列式”,生成的数据顺序才能直接写进GRAM。把字模数组做成const uint8_t font6x8[95][6],从空格0x20开始:

void OLED_DrawString(uint8_t col, uint8_t page, const char *s) { while (*s) { if (*s >= 0x20 && *s <= 0x7E) { OLED_Data((uint8_t *)font6x8[*s - 0x20], 6); // 一次写6列 col += 6; if (col + 6 > 128) { col = 0; page++; } } s++; } }

这里有个容易踩的坑:OLED_Data一次写入的字节会被SSD1306按列地址自动递增保存。如果中途改行,比如从第0页跳到第2页,必须先发页设置命令(0xB0+page),不能再靠自动换行。想要显示“距离:123cm”,建议全部转成ASCII,数字和冒号有6x8字模,中文要额外挂16x16字库,会拖慢刷新,还会因编码不对显示成方块。

3.4 HAL库驱动OLED的常见坑

第一,I2C地址写错最常见。0x3C是7位地址,HAL_I2C_Mem_Write第二个参数要填0x78;如果直接填0x3C,HAL不会自动移位,屏幕偶尔亮偶尔不亮。第二,OLED的抗干扰能力不如LCD,飞线过长且没有上拉电阻时,SDA采样错位,屏幕会出现随机雪花点。第三,初始化时如果HAL_I2C_IsDeviceReady(&hi2c1, 0x78, 1, 100)超时,先检查OLED复位引脚。4针屏通常没有外部复位脚,靠内部上电复位,VCC从0到3.3V太慢时SSD1306会卡在未定义状态,这时候要重新断电或软件发0xE3软复位。

4. 超声波测距与定时器输入捕获:HC-SR04脉冲转距离

4.1 测距原理与公式

HC-SR04工作分两段:MCU给Trig一个10us以上高电平,模块发出8个40kHz脉冲;回波收到后Echo引脚输出高电平,高电平持续时间等于声波往返时间。按声速340m/s计算,cm = 时间(us)/58,mm = 时间(us)/5.8。这两个系数已经包含往返距离,所以别再除以2。如果做超声波测距报警系统,就在这个毫米值上加阈值判断,阈值本身也要考虑声速随温度的变化。

触发信号要稳定:脉冲太短模块不识别,太长也没好处,15us是比较稳的宽度。相邻两次测量的时间间隔不要少于60ms,因为4m量程时一次往返已经接近23ms,立刻触发会产生混叠。测量循环里别用HAL_Delay处理接收逻辑,HAL_Delay依赖SysTick,一旦被中断打断,Echo的下降沿就可能被漏掉。

距离往返时间1MHz计数器的echo_ticks
20cm1176us1176
50cm2941us2941
100cm5882us5882

4.2 用TIM2输入捕获测量高电平宽度

输入捕获的做法是让TIM2_CH1捕捉Echo上升沿和下降沿,两次CCR差值就是高电平时间。CubeMX里把PA1设置成TIM2_CH1、Prescaler填71,计数频率正好1MHz,一计数等于1us。主程序里发送触发并等待回调:

void Ultrasonic_Trigger(void) { HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); for (volatile uint8_t i = 0; i < 15; i++) ; // 约15us,具体看主频 HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); } void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { if (__HAL_TIM_GET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1) == TIM_ICPOLARITY_RISING) { last_rising = HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_ICPOLARITY_FALLING); } else { uint32_t falling = HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_1); echo_ticks = falling - last_rising; echo_done = 1; __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_ICPOLARITY_RISING); } } }

解释一下这段代码的用意:第一个if判断当前捕获极性,上升沿到来时记录CCR值,然后把捕获极性翻转为下降沿;下降沿再来时,两次CCR相减就是脉宽。falling - last_rising用无符号减法,即使计数器在两次捕获之间回绕,结果仍然正确,除非高电平超过65535us——HC-SR04的量程是4m,远达不到这个值。echo_done在中断里置位,主循环里查询并消费掉。不要在中断里调用HAL_Delay去做滤波或显示,中断里只留“记录时间和置标志”。

4.3 距离换算、中值滤波和超时处理

主循环的代码:

if (echo_done) { echo_done = 0; if (echo_ticks > 23500) { // 大于4m视为无效 dist_mm = 0; } else { dist_mm = echo_ticks * 10 / 58; // 定点数,us换算mm } OLED_DrawString(0, 1, "D:"); OLED_ShowNumber(dist_mm); }

echo_ticks * 10 / 58等价于us / 5.8,避免浮点格式化带来的问题。乘10后最多约46万,uint32_t不会溢出。滤波我一般用一个长度为5的环形缓冲做中值滤波,只保留最近5次测量结果,排序后取中间值,能滤掉窄幅噪声又不会像滑动平均那样滞后明显。测量间隔用状态机控制:发Trig后等echo_done,超时没等到就清标志继续下一轮。超时判断可以简单用SysTick差值,超过50ms就Reset捕获状态。

4.4 Echo电平与硬件干扰

Echo引脚如果在5V供电下输出5V高电平,经过分压后再进PA1。除了分压,还可以用一只10k电阻串在Echo输出上,靠STM32内部钳位保护,但长期可靠性不如分压。HC-SR04对电源纹波比较敏感,蜂鸣器或电机在同一电源上启动时,回声脉宽可能突变几微秒到几十微秒,间接反映成距离跳变。在模块电源附近接一个100uF电解电容并联0.1uF陶瓷电容,是成本最低的稳定方案。

提示:如果程序烧录后Echo引脚一直为高,多半是模块没发波或环境里有持续噪声。先拔掉Echo线,测量MCU引脚电压,确认是硬件问题还是代码问题。

5. 帧缓冲、局部刷新与C++封装:让显示组件可复用

5.1 用帧缓冲把整屏刷新变成内存搬运

前面直接调OLED_Data写单个字符,每写6字节都要走一次I2C的START/STOP,次数多了很浪费。更工程化的做法是在RAM里开一块128x32/8=512字节的framebuf,所有绘图操作都在这块内存上完成,最后一次性刷到SSD1306:

uint8_t framebuf[512]; void OLED_Flush(void) { OLED_Cmd(0x21); OLED_Cmd(0x00); OLED_Cmd(0x7F); OLED_Cmd(0x22); OLED_Cmd(0x00); OLED_Cmd(0x03); for (uint16_t i = 0; i < 512; i++) OLED_Data(&framebuf[i], 1); }

512字节在400kHz I2C下理论耗时约11.5ms,接近87FPS;考虑命令开销和屏幕内部状态机,实际也能稳定在50FPS以上。测距显示完全够用,但如果只改一位数字,整屏刷新就是浪费。局部刷新可以按这个表格取舍:

刷新范围数据量400kHz耗时场景
全屏128x32512B~11.5ms清屏、开机画面
4个ASCII字符24B~0.5ms距离数字变化区域
16x16区域32B~0.7ms图标、单位区域

局部刷新的前提是先记录上次数字的列位置,把那个区域清零,再在新位置写入,否则新旧数字会叠在一起。一般做法是把每位数映射到固定列,比如距离总是显示在“D:”右侧的3到6字符列,写入前对该区域清0。

5.2 用C++封装测距和显示,同时保留HAL回调

如果项目里愿意用C++,可以把HC-SR04和SSD1306都包成类,主循环变得非常短:

class Ultrasonic { public: bool Measure() { Ultrasonic_Trigger(); return echo_done; } uint32_t GetMM() const { return dist_mm_; } private: uint32_t dist_mm_ = 0; };

这里的前提是HAL回调还是C函数,在中断里只填全局变量;C++类只是读全局变量并换算。混合编译时,stm32f1xx_it.c里的回调函数签名是C链接,类成员函数是C++链接,两者互不干扰。如果非要在C++文件里实现回调,包一层extern "C"即可。这样拆分的好处是,测距逻辑和显示逻辑互不引用,之后把OLED换成LCD或串口屏,类的接口不用动。

5.3 验证方法与固定点校准

项目最后值得做到位的是验证:用示波器或逻辑分析仪挂在Echo引脚上,高电平时间应当与显示器读数一致。再用一把直尺分别放在5cm、20cm、50cm、100cm处,读取屏幕并记录误差。如果整体差一个固定百分比,多半不是公式问题,而是定时器时钟源或预分频器没配准;如果误差随距离增大而变大,检查Trig脉冲是否稳定、测量间隔是否太短。高精度场景下,声速受温度影响约0.6m/s每摄氏度,用温度传感器补偿后,把公式改成mm = us * 0.34 / 2 * (1 + 0.00166 * (T - 25)),误差能压到1%以内。最后,屏幕上不要直接显示浮点,用定点整数加单位,I2C上的数据量更少,帧率更稳,排错也方便。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 7:30:20

MATLAB锂离子电池P2D电化学仿真:从PDE离散到参数校准

简介&#xff1a;一套基于MATLAB的伪二维&#xff08;P2D&#xff09;模型仿真实现&#xff0c;即多伊尔-富勒-纽曼&#xff08;DFN&#xff09;模型&#xff0c;面向锂电池研发、电化学仿真方向的研究人员与工程师。其将电池复杂结构简化为一维电极厚度方向并引入粒子径向的伪…

作者头像 李华
网站建设 2026/9/14 7:30:20

边缘数据处理流水线:工业数采链路的实时性与可靠性设计

1. 项目概述&#xff1a;为什么“边缘数据处理流水线”不是锦上添花&#xff0c;而是数采链路的生死线 你手里的传感器刚传回一条温度数据——23.7℃。看起来很普通&#xff0c;对吧&#xff1f;但这条数据从设备端发出&#xff0c;到最终进入你的BI看板、触发告警、驱动PLC动作…

作者头像 李华
网站建设 2026/9/14 7:29:18

电磁场仿真边界条件原理与工程实践指南

1. 电磁场仿真中的边界条件概述在电磁场数值仿真中&#xff0c;边界条件的处理直接决定了计算结果的准确性和收敛性。边界条件本质上是对求解域边缘处场行为的数学描述&#xff0c;它告诉仿真软件"场在该边界处应该如何表现"。就像建造房屋时需要明确墙体材料特性一样…

作者头像 李华