
简介这是面向嵌入式学习者的STM32综合实践工程以HC-SR04超声波测距为主线将OLED显示、蜂鸣器报警和串口数据回传整合在同一项目中适合想打通传感器读取、外设驱动与串口调试的开发者参考。资源包共233个文件约6.33MB除主要C/H源码和启动汇编.s文件外还包含大量编译中间文件.o/.d/.crf及Keil工程文件.uvprojx/.uvoptx、链接脚本与调试配置打开工程即可查看完整构建结果。已有3132人学习下载。代码基于STM32标准外设库编写覆盖定时器配置、超声波触发与回波捕获、OLED刷新、蜂鸣器阈值报警以及UART发送等关键环节并配有工程备份、清理脚本和调试辅助文件便于对照理解从底层寄存器到应用层调用的完整流程。无论是课程设计还是入门嵌入式开发都能借助这份代码快速搭建测距原型再按需求修改阈值或通信方式。1. STM32HC-SR04 这套外设组合的底气在哪里把 HC-SR04、OLED、蜂鸣器和串口装进同一个 STM32 工程几乎是课设和动手项目里最常见的“完整闭环”传感器采集、本地显示、声音报警、数据上送四件事全部覆盖。这个标题看着是四个外设的拼接真正难的不是任何一个器件单独工作而是让它们在同一个主循环和中断上下文里不互相干扰。第一次跑通的人多半会被两个细节卡住Echo 引脚的高电平脉宽到底怎么量准以及 OLED 刷新和串口发送同时进行时为什么距离数据会跳。这篇文章按我平时搭这套系统的顺序把 HC-SR04 的时序、定时器输入捕获、距离换算与滤波、OLED 驱动、蜂鸣器分级报警、串口帧格式串起来讲。新手可以直接照着接线和抄代码熟手可以重点看 3.2 的溢出边界和 5.3 的帧校验。2. HC-SR04 测距原理与 STM32 定时器输入捕获配置HC-SR04 是一个 2cm400cm 的超声波模块模块上有四个引脚VCC、Trig、Echo、GND。给 Trig 一个大于 10us 的高电平脉冲模块内部会发出 8 个 40kHz 的方波同时把 Echo 拉高当声波碰到障碍物返回并被接收头检测到Echo 被拉低。Echo 高电平持续的时间就是声波从模块到障碍物再返回的总耗时测距的核心数据就是这一段脉宽。模块本身不做任何距离计算单片机要自己量这个高电平时间。2.1 HC-SR04 的引脚与回波时序10us 触发和脉宽含义接线时要注意电平问题。多数 HC-SR04 模块推荐 5V 供电这时 Echo 输出的高电平接近 5V而 STM32F103 的普通 IO 不是所有引脚都支持 5V 容忍直接接入有风险。常见的做法是给模块单独供 5V与 STM32 共地Echo 经过两个电阻分压例如 10k 串 20k 到地把高电平降到 3.3V 附近再进单片机引脚。Trig 是输入脚STM32 的 3.3V 高电平可以直接驱动。这套接法比把整个模块塞到 3.3V 下更稳定后者的测距上限会明显变差。2.2 为什么优先用定时器输入捕获而不是延时轮询量 Echo 脉宽有两种思路。第一种是触发后循环读 GPIO 电平用延时或系统节拍算高电平时间代码直观但精度受中断延迟影响很大主循环里一旦处理 OLED 或串口轮询就被打断测距值会时不时跳一下。第二种是定时器输入捕获把 Echo 同时接到定时器的两个捕获通道一个配上升沿捕获一个配下降沿捕获。信号边沿到来时硬件把当前计数器值锁存到捕获寄存器CPU 完全不用参与“看电平”这个动作测到的脉宽是硬件时间戳稳定且不受主循环拥堵影响。提示外部中断加读 CNT 也能做到类似效果本质一样。工程里用输入捕获的好处是 CubeMX 配完基本不用改底层中断回调里只做记录代码面更小。2.3 CubeMX 引脚配置与最小触发代码以 STM32F103C8T6、72MHz 主频为例我的引脚分配如下功能引脚模式配置Echo 上升沿捕获PA0TIM2_CH1Input Capture direct modeRisingEcho 下降沿捕获PA1TIM2_CH2Input Capture direct modeFallingTrig 触发输出PA2GPIO Output初始低电平蜂鸣器控制PA3GPIO Output初始低电平OLED I2CPB6 / PB7I2C1SCL / SDA串口调试PA9 / PA10USART1TX / RX115200-8-N-1TIM2 的时钟参数Prescaler 设为 71使计数频率为 72MHz / (711) 1MHz即计数器每加 1 表示 1usAutoReload 保持 65535这样单次脉宽测量期间计数器不会溢出到不可控。Echo 信号同时接 PA0 和 PA1两块输入捕获并联互不影响。/* 触发一次测距Trig 拉高至少 10us */ void HCSR04_Trigger(void) { HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(15); /* 留 5us 余量确保模块可靠识别 */ HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); }delay_us 用定时器延时或 DWT 实现都行只要保证不小于 10us超过 20us 也不会更好反而会拉长一次测距的周期。触发之后等待 Echo 高电平然后在中断回调里收结果。volatile uint32_t t_rise 0; volatile uint32_t t_fall 0; volatile uint32_t echo_pulse_us 0; volatile uint8_t echo_ready 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! TIM2) return; if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { t_rise HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); } else if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_2) { t_fall HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); echo_pulse_us t_fall - t_rise; /* 1 个计数 1us */ echo_ready 1; /* 置位通知主循环取数 */ } }初始化时执行两次启动调用HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_2);start 之后每次捕获完成HAL 库会自动重新使能中断不需要在回调里再次启动。两个通道的回调先后顺序由物理边沿决定信号上升沿先触发 CH1下降沿再触发 CH2所以 t_fall 一定晚于 t_rise差值就是高电平时间。若未来把测量量程扩大要注意检查 t_fall 与 t_rise 的先后关系防止计数器回绕产生的负值混进后续计算。3. 从 Echo 脉宽到厘米STM32 里的距离换算、补偿与滤波拿到脉宽只是第一步接下来要把 us 变成 cm。这一步的公式不复杂但声速取值、温度影响和量程边界三个问题不处理好同一个模块在不同房间测出来的值会差一截。3.1 距离公式推导脉宽、声速与往返路径的关系声波从模块出发碰到障碍物后原路返回Echo 高电平覆盖的是完整往返时间。所以单程时间是脉宽的一半距离按单程算距离(cm) echo_pulse_us × 声速(m/s) / 2 / 10000例如脉宽 1176us、声速 340m/s距离 1176 × 340 / 2 / 10000 19.992cm。20cm 左右的物体声波往返大约需要 1.18ms这个数据可以用来快速判断驱动是否正常。#define TEMPERATURE 25.0f float pulse_to_distance_cm(uint32_t pulse_us) { float c 331.4f 0.6f * TEMPERATURE; /* 20°C时为343.4m/s */ return (float)pulse_us * c / 2.0f / 10000.0f; }声速不是恒定的 340温度每升高 1°C声速大约增加 0.6m/s。在 0°C 到 40°C 区间声速从 331.4 变到 355.4相差约 7%对应 1m 处最大误差约 7cm。如果没有温度传感器把 340 写死也能用但建议按环境温度补偿有 I2C 温度传感器的话这个函数里的 TEMPERATURE 换成实测值即可。串口调试助手连续打印模式下温度补偿带来的变化量在远距离上肉眼可见。3.2 温度补偿与定时器溢出边界怎么算定时器配置里 Prescaler71、计数频率 1MHz意味着 16 位计数器最大能计到 65535us也就是 65.535ms。HC-SR04 的标称量程 400cm对应往返时间约 23.5ms远小于 65.5ms所以单次测距内 CNT 不会发生 0xFFFF 到 0 的回绕t_fall - t_rise 直接减是安全的。但要注意一个前提定时器必须是自由运行不能在中途清零。CubeMX 默认不开启更新中断时CNT 持续向上跑捕获寄存器各存各的时间戳差值算法才成立。如果读者参考某些教程在上升沿中断里把 CNT 清零那么下降沿读到的值就是从上升沿开始重新计数的结果虽然也能用但和双通道时间戳差值的思路不一样混用会得出完全错误的数据。量程边界同样要处理脉宽小于 100us约 1.7cm 以下时模块进入盲区回波不可靠脉宽大于 25000us 时基本是超出量程或没有回波。这两种情况建议不进滤波直接输出“无效”状态。3.3 限幅滤波和滑动平均数据跳变怎么压超声波测距最常见的现象是相邻两次测距偶尔跳几厘米原因包括声波打到倾斜面、测量表面边缘的杂波、模块供电纹波等。我会先做限幅再做滑动平均限幅只挡明显毛刺滑动平均负责把残余抖动磨平。float last_valid 0.0f; float limit_filter(float new_val) { if (last_valid ! 0.0f fabsf(new_val - last_valid) 10.0f) return last_valid; /* 单次跳变超过10cm判定为毛刺 */ last_valid new_val; return new_val; }#define AVG_N 5 float avg_buf[AVG_N] {0}; uint8_t avg_idx 0; float moving_average(float val) { avg_buf[avg_idx] val; avg_idx (avg_idx 1) % AVG_N; float sum 0.0f; for (int i 0; i AVG_N; i) sum avg_buf[i]; return sum / AVG_N; }两个函数串起来脉宽换算成距离后先进 limit_filter再进 moving_average得到结果用于显示、报警和串口。AVG_N 取 5 在 20ms 一个测距周期下大约产生 100ms 的响应延迟对静态测距正好对移动目标可以把 AVG_N 降到 3。初始化阶段 avg_buf 全为 0 会让前几个值偏小正式运行时可以先连测 5 轮把缓冲区填满再开始显示。4. OLED 显示层用 HAL 库驱动 SSD1306 并把距离上屏OLED 几乎是现在 STM32 课设的标配。0.96 寸 128×64 的屏幕绝大多数用 SSD1306 主控走 I2C 接口。用 HAL 库点亮 OLED 屏幕的完整流程里最容易翻车的不是 I2C 配置而是控制字节和初始化序列这两处细节。4.1 SSD1306 的 I2C 地址和控制字节0x3C 与 0x40 各代表什么SSD1306 在 I2C 总线上的 7 位地址由 SA0 引脚决定常见模块把 SA0 拉低7 位地址是 0x3C也有模块把 SA0 拉高地址变成 0x3D。HAL 库的 HAL_I2C_Master_Transmit 第二参数要求 8 位写地址所以要左移一位0x3C 左移 1 位等于 0x78。如果屏幕白屏第一件事不是查初始化而是用 I2C 地址扫描确认模块实际地址。uint8_t oled_found 0; for (uint8_t addr 0x30; addr 0x40; addr) { if (HAL_I2C_IsDeviceReady(hi2c1, addr 1, 1, 10) HAL_OK) { /* 找到设备addr 就是 7 位地址 */ oled_found addr; break; } }控制字节要单独记I2C 每次传输第一个字节是控制字节0x00 表示后续是命令0x40 表示后续是显示数据即 GDDRAM 内容。这解释了为什么网上 OLED 驱动函数里普遍有写命令和写数据两个函数二者就差别在这一字节。4.2 最小驱动代码用 HAL 库点亮 SSD1306 的完整流程#define OLED_ADDR 0x78 /* 7位地址0x3C左移1位 */ void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] {0x00, cmd}; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 20); } void OLED_WriteData(uint8_t dat) { uint8_t buf[2] {0x40, dat}; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 20); }这两个函数是全部 OLED 操作的地基。超时参数 20 表示单次传输最多等 20msI2C 从设备不响应时不会卡死系统。清屏就是对 128×64 的 8 页逐页写 0void OLED_Clear(void) { for (uint8_t page 0; page 8; page) { OLED_WriteCmd(0xB0 page); /* 设置页地址 0~7 */ OLED_WriteCmd(0x00); /* 列地址低字节 */ OLED_WriteCmd(0x10); /* 列地址高字节 */ for (uint8_t col 0; col 128; col) OLED_WriteData(0x00); } }写 1024 个字节的数据要调用 1024 次 HAL_I2C_Master_Transmit每次传输都带地址、ACK 和控制字节累计开销不小。这是分页方式驱动 SSD1306 的正常现象刷屏频率不高时不碍事想快可以用页地址模式一次性连续写一页 128 字节但代码复杂度和内存占用都会增加。显示距离的常用做法是先定位到第 N 行对应页再用 6×8 或 8×16 的 ASCII 字库把字符串逐字节写进去。4.3 从字模到显示ASCII 与汉字的取模工作流OLED 显示数字和英文从现成 ASCII 字库抄数组就行显示汉字必须走取模流程。常见流程是准备 16×16 或 12×12 点阵取模软件里选择纵向取模、高位在前生成 C 数组。之后按行列把数组内容送进 OLED 数据口void OLED_ShowChar(int16_t x, int16_t y, char ch) { uint8_t page y / 8, col x; OLED_WriteCmd(0xB0 page); OLED_WriteCmd(0x00 | (col 0x0F)); OLED_WriteCmd(0x10 | ((col 4) 0x0F)); for (uint8_t i 0; i 8; i) OLED_WriteData(F6x8[(uint8_t)ch - 32][i]); }x 是列坐标0 到 127y 是像素行坐标0 到 63。取模方向不一致会导致字符左右翻转或上下两半错位这是显示乱码最常见的原因和 I2C 本身没有关系。调试时先用一个已知字库在固定坐标显示大写字母 A确认字节排布正确后再叠加中文提示能大大缩短定位问题的时间。顺带说一句部分 0.91 寸小屏用的是 SH1106 或兼容主控直接套 SSD1306 的 128×64 初始化序列会出现只有半边亮的现象先确认主控型号再写初始化。5. 蜂鸣器分级报警策略与串口调试助手数据帧设计距离算出来后两个输出环节要同时工作蜂鸣器根据距离分级报警串口把结果发给调试助手。这两个环节初看简单放在一起就牵扯到阻塞与非阻塞的问题。5.1 有源与无源蜂鸣器驱动电路和选型差异蜂鸣器分有源和无源。有源蜂鸣器内部带振荡源给高电平就响适合只做“响/不响”的报警无源蜂鸣器需要外部提供一定频率的方波才能发声频率决定音调。驱动电路上两者都建议用三极管扩流STM32 的 GPIO 直接驱动蜂鸣器电流不够声音发闷且长期工作不稳定。典型接法是 GPIO 串联 1kΩ 限流电阻到 NPN如 S8050基极发射极接地蜂鸣器正极接 3.3V 或 5V负极接集电极。有源蜂鸣器注意正负极性接反就不会响无源蜂鸣器则要确认方波频率落在人耳敏感区间2kHz 到 4kHz 比较合适。工程里我更喜欢用无源蜂鸣器配定时器 PWM通过 PWM 频率改变音调可以做出“距离越近声音越急促”的提示比单纯开关有源蜂鸣器更有信息量。5.2 分级报警策略用 HAL_GetTick 做非阻塞的断续报警报警策略按距离分三档小于 20cm 连续响20cm 到 50cm 间歇响大于 50cm 不响。间歇响不能让主循环阻塞否则测距和 OLED 都会被拖垮。下面以有源蜂鸣器直连 IO 控制通断为例如果用的是无源蜂鸣器把 BUZZER_ON 换成启动 PWM 输出、BUZZER_OFF 换成停止 PWM 即可报警策略本身不用改。void Alarm_Handle(float distance_cm) { if (distance_cm 0.0f distance_cm 20.0f) { BUZZER_ON(); } else if (distance_cm 50.0f) { static uint32_t last_tick 0; if (HAL_GetTick() - last_tick 100) { last_tick HAL_GetTick(); BUZZER_TOGGLE(); /* 每100ms翻转一次形成“响100ms停100ms” */ } } else { BUZZER_OFF(); } }这段代码的关键是 BUZZER_TOGGLE 只在节拍里改变电平主循环不需要等待时间过去测距线程照常跑。如果追求更急促的“滴滴”效果可以把翻转周期从 100ms 改到 60ms具体按个人听觉调。无源蜂鸣器用 PWM 时CubeMX 里开一个定时器通道输出 4kHz、50% 占空比方波BUZZER_ON 就执行 HAL_TIM_PWM_StartBUZZER_OFF 执行 HAL_TIM_PWM_Stop音调由频率参数单独控制。5.3 串口数据帧格式定长 HEX 帧的设计与发送串口调试助手里最直观的用法是每秒打印一行“距离 xx.x cm”但正式项目建议发定长二进制帧上位机解析方便、可扩展性好。帧格式设计如下字节偏移内容说明00xAA帧头固定值10x01帧类型01 表示距离数据2距离高字节距离数值mm的高 8 位3距离低字节距离数值mm的低 8 位4校验和字节 1 到 3 相加取低 8 位50x0D帧尾 CR60x0A帧尾 LF距离值保留到毫米用 16 位无符号数承载400cm 以内容纳得下。发送代码void UART_SendDistance(float distance_cm) { uint8_t frame[7]; uint16_t dist_mm (uint16_t)(distance_cm * 10.0f); frame[0] 0xAA; frame[1] 0x01; frame[2] (uint8_t)(dist_mm 8); frame[3] (uint8_t)(dist_mm 0xFF); frame[4] (uint8_t)(frame[1] frame[2] frame[3]); frame[5] 0x0D; frame[6] 0x0A; HAL_UART_Transmit(huart1, frame, sizeof(frame), 100); }HAL_UART_Transmit 最后一个参数是超时毫秒数。USART1 在 115200 波特率下发 7 字节大约只要 0.6ms100ms 的超时留足了余量正常情况不会触发超时返回。调试助手里切换到 HEX 显示应该看到 AA 01 00 14 15 0D 0A 这样的定长帧其中 0x15 就是 0x01 加 0x00 加 0x14 的累加值。如果上位机要读取收到 AA 01 后连续取 3 字节并按同样算法算校验即完成一帧解析不需要依赖行尾。人眼调试阶段我也偶尔用串口调试助手SSCOM、XCOM 这类都行的文本模式接收 printf 输出。需要注意测距周期 20ms 时每秒会产生 50 条数据窗口滚动太快根本看不清建议把发送频率降到每秒 5 帧以下再观察。6. 整体联调、串口验证与三个高频异常排查6.1 五步联调顺序先分模块再合系统不要第一次就把全部代码烧进去。我习惯按五个步骤递进第一步只跑串口在 main 里周期性发送“Hello STM32”确认串口助手能收到第二步单独做测距把脉宽直接发串口用一把直尺验证几个固定距离第三步把距离显示到 OLED确认每一行位置正确第四步接蜂鸣器把 50cm 阈值临时调到 100cm 做短路测试第五步才合并全部功能。每一步都保留串口打印的能力出问题时能快速定位是哪一层。验证距离有个笨办法但最好用用卷尺量好 10cm、30cm、100cm 三个点对着平整墙面分别测记录串口显示值。如果三个点误差方向和大小一致说明声速取值有偏差按比例微调 3.1 里的声速常数即可如果单个点跳变明显优先查供电和滤波参数。6.2 三个高频异常串口乱码、OLED 白屏、Echo 无反应串口乱码最常见排查顺序是串口调试助手波特率是否与工程一致CubeMX 里 HSE 外部晶振频率填的是不是实际晶振值——8MHz 晶振在 CubeMX 里误填 25MHz 会让 UART 波特率偏差明显而且不是简单的显示乱码有时是稳定错码最后确认调试器虚拟串口没有和板载串口引脚冲突。晶振电容大小影响的是起振裕度很少直接导致乱码优先级排在晶振频率配置后面。OLED 白屏按三条排查I2C 地址是否 0x3C 或 0x3D 二选一初始化序列是否完整SSD1306 必须先发开启显示命令再刷数据顺序反了容易白屏I2C 时钟速率是不是太高先把 I2C 时钟设为 100kHz点亮后再调 400kHz。Echo 一直没反应时先看 Trig 波形有没有真的拉高 10us再看 Echo 分压后的电压是否落到 3.3V 逻辑高电平区间最后检查模块供电是否有足够的电流。如果手边有逻辑分析仪直接把探头夹在 Echo 上触发一次测距高电平宽度一眼就能判断问题在模块还是单片机侧。这几个坑按顺序查完剩下就是不断校准阈值的问题拿一把卷尺从 10cm 拖到 60cm来回拖三次对比串口记录和实际位置的变化趋势同时观察蜂鸣器在 20cm 和 50cm 两个阈值点附近是否切换以及 OLED 上的数字是否同步变化能同步整套系统就算真正能交付了。本文还有配套的精品资源点击获取