ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32 HAL库实现HC-SR04超声波测距OLED显示与蜂鸣器报警

STM32 HAL库实现HC-SR04超声波测距OLED显示与蜂鸣器报警 简介一套基于STM32单片机与HC-SR04超声波测距传感器的完整嵌入式实践工程面向具备一定C语言基础、希望入门STM32开发的电子爱好者或高校学生。项目围绕测距数据采集、OLED实时显示、超阈值蜂鸣报警、串口输出到调试助手等典型环节展开涵盖从外设驱动到应用层联调的主要流程。压缩包共233个文件以.c/.h源文件、Keil工程文件.uvprojx/.uvoptx、启动与链接文件.s/.sct以及.o/.axf等编译生成物为主压缩后约6.33MB可直接用Keil打开查看或重新编译。已有3132人学习/浏览适合作为课程设计或综合实训参考。代码中包含定时器、GPIO、中断、I2C/SPI驱动OLED、UART串口通信等关键知识点并保留完整工程结构便于对照学习传感器触发时序、距离换算逻辑和报警阈值处理也有助于快速理解嵌入式项目从配置到调试的完整组织方式。1. 超声波测距显示报警这套组合在调试中为什么值得做做单片机测距项目时最容易被“数据可见化”卡住。串口打印一串串数字眼睛根本跟不上也不知道当前值是否准确OLED屏幕却能让人一眼就看到距离变化配合蜂鸣器报警相当于同时有了视觉和听觉反馈。这篇内容会把 HC-SR04 测距、OLED 显示、蜂鸣器阈值报警、串口上送数据四个环节完整串起来按 STM32 HAL 库的写法讲适合正在做课设、毕设或想快速搭一套环境感知外设的工程师。我默认你用的是 STM32F103 系列时钟 72MHz开发环境是 CubeMX Keil 或 STM32CubeIDE。这套方案不只适用于 F103只要把定时器、串口、I2C 的引脚和时钟重新映射就能移植到 F4、G0、L4 上。下面先从 HC-SR04 的时序讲起因为它是整个系统的“数据源”后面所有显示和报警都建立在可靠的测距数据之上。2. HC-SR04测距原理与STM32的时序捕获实现2.1 HC-SR04的触发和回波时序HC-SR04 模块上有四个引脚VCC、GND、Trig、Echo。VCC 接 5VTrig 是触发输入Echo 是回波输出。使用时只要给 Trig 一个大于 10us 的高电平脉冲模块内部就会发出 8 个 40kHz 的超声波脉冲同时把 Echo 引脚拉高当超声波遇到障碍物返回并被模块接收后Echo 会被拉低。Echo 高电平持续的时间就是超声波从发射到返回的总耗时。这个时序决定了软件要做的工作一次完整的测距先触发一次 Trig然后等待 Echo 产生上升沿和下降沿记下高电平宽度。高电平宽度乘以声速再除以 2就得到障碍物距离。HC-SR04 的量程一般标称 2cm 到 450cm最小测量盲区在 2cm 左右小于这个距离时回波会和发射波混在一起模块可能报出乱值后面需要加过滤处理。需要注意电平匹配。模块的 VCC 接 5V 时Echo 输出的高电平也是 5V而 STM32 的普通 IO 是 3.3V 容忍直接接上去长期运行有风险。常见做法是用两个电阻分压比如 2K 串联在 Echo 上1K 下拉到地把 5V 分到约 1.67V不具体分压2K 接 Echo 到 PA01K 从 PA0 到地分压系数 1/(21)1/35V 约1.67V太低。正确的是用 1K 串联、2K 下拉分压系数 2/(12)2/35V 约 3.33V接近 3.3V。或者直接用 10K 和 20K。我常用 1K 和 2K简单可靠。Trig 引脚只需要 3.3V 就能触发不需要电平转换。2.2 用HAL库定时器输入捕获测高电平宽度测量 Echo 高电平宽度常见有外部中断定时器、定时器输入捕获两种。外部中断方案需要记录上升沿和下降沿时的定时器计数值代码里要处理中断嵌套精度取决于中断响应延迟。输入捕获方案由硬件在捕获事件发生时自动锁存定时器计数值不受中断延迟影响适合这种微秒级测量。我以 TIM2_CH1PA0为例配置成上升沿捕获收到捕获中断后把极性切换成下降沿这样同一个通道可以先后捕获上升沿和下降沿。在 CubeMX 里把 PA0 设为 TIM2_CH1定时器时钟选择内部时钟预分频 PSC 设为 72-1计数频率就是 1MHz每 1us 计一个数。自动重装载 ARR 设为 0xFFFF。HC-SR04 的最大回波脉宽约 38ms小于 65.535ms所以不用担心计数器溢出。初始化代码TIM_IC_InitTypeDef sConfigIC {0}; htim2.Instance TIM2; htim2.Init.Prescaler 72 - 1; htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(htim2); sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler TIM_ICPSC_DIV1; sConfigIC.ICFilter 0; HAL_TIM_IC_ConfigChannel(htim2, sConfigIC, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1);代码逻辑说明先初始化定时器为向上计数模式频率 1MHz再配置输入捕获通道的参数选择直连映射TI1 对应 CH1不分频不滤波最后使能捕获中断。ICFilter 设 0 是因为 HC-SR04 的回波沿比较干净开启滤波会引入延迟。然后在中断回调里切换捕获极性并计算脉宽uint32_t rise_time 0; uint32_t echo_width 0; volatile uint8_t measure_done 0; volatile uint8_t capture_flag 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { if (capture_flag 0) { rise_time HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); capture_flag 1; } else { uint32_t fall_time HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); echo_width fall_time - rise_time; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); capture_flag 0; measure_done 1; } } }这里有一个要注意的点HAL 库里切换捕获极性不能直接用 HAL_TIM_IC_ConfigChannel 重新配置因为那会清除通道配置并可能停止捕获。推荐用 __HAL_TIM_SET_CAPTUREPOLARITY 宏它直接修改 CCER 寄存器的对应位不影响捕获状态。回调里通过 capture_flag 区分是上升沿还是下降沿避免因为中断重入导致状态错乱。主循环里触发测距时用下面的函数void sonar_trigger(void) { HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(15); HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); }delay_us 可以用 DWT 或其他微秒延时实现。触发后等待 measure_done 标志置位再读取 echo_width。注意 HC-SR04 要求两次触发间隔至少 50ms否则模块内部逻辑会混乱。2.3 距离换算公式与温度修正脉宽转换成距离最常用的近似公式是float distance_cm echo_width * 0.01715f;这个系数的来源声速在 20℃ 空气中约为 343m/s即 0.0343cm/us。脉宽是往返时间距离等于声速乘以时间的一半所以 1us 脉宽对应 0.01715cm。反过来1cm 距离约需要 58.3us 脉宽所以也有公式distance_cm echo_width / 58。如果环境温度变化大声速会漂移。声速与温度的关系为float sound_speed 331.45f 0.607f * temperature; // m/s float distance_cm echo_width * sound_speed * 0.0001f / 2.0f;这里0.0001是将 m/s 乘以 us 换算成 cm1us 1e-6ssound_speed * 1e-6 得到米乘以 100 得到厘米所以系数是 sound_speed * 1e-4 / 2。20℃ 时代入就是 0.01717与上面的简化值一致。如果只是室内作业用 0.01715 足够如果做户外项目建议加个 DS18B20 测温每 100ms 更新一次声速。3. OLED屏幕显示距离数据从底驱动到动态刷新3.1 0.96寸I2C OLED的驱动要点常见 0.96 寸 OLED 分辨率为 128x64驱动芯片是 SSD1306I2C 接口。这里不讨论 SPI 版本因为 I2C 只需要两根线连线方便适合这类数据量不大、刷新率不高的场景。I2C 地址多数是 0x3C7 位地址但在 HAL 库的 HAL_I2C_Master_Transmit 中地址需要左移一位变成 0x788 位写地址。如果你的屏幕数据手册写的是 0x3A 或 0x78先确认一下实在不行可以通过扫描 I2C 地址的方式来判断。驱动 SSD1306 的底层就是两个函数写命令和写数据。命令字节前面要加控制字节 0x00数据字节前加 0x40。下面是用 HAL 库实现的简单版本void oled_write_cmd(uint8_t cmd) { uint8_t buf[2] {0x00, cmd}; HAL_I2C_Master_Transmit(hi2c1, 0x78, buf, 2, 10); } void oled_write_data(uint8_t *data, uint16_t len) { uint8_t buf[2 len]; buf[0] 0x40; memcpy(buf[1], data, len); HAL_I2C_Master_Transmit(hi2c1, 0x78, buf, len 1, 10); }初始化 OLED 的序列必须包含开启电荷泵否则屏幕不亮。常见顺序0xAE 关闭显示、0xA8 设置复用比、0xD5 设置时钟分频、0x8D 设置电荷泵并开、0xAF 开启显示。完整初始化代码可以直接用开源驱动但关键是理解每个命令的作用。至少要知道不发送 0x8D 和 0x14屏幕永远不会亮。3.2 显示字符串和数字的函数封装显示距离数字需要把浮点数转成字符串。用 sprintf 最省事但 STM32 的 printf 浮点支持如果不开启微库会占很多 flash。建议只显示一位小数自己写一个转换函数或者直接用 sprintf 然后关掉不用的格式化特性。我比较推荐用snprintf格式化到局部字符数组再逐字符写入显存缓冲区。OLED 的一个页是 8 像素高128 列所以 128x64 需要 8 页。字模一般用 6x8 的点阵即每个字符占 6 字节每字节对应一列 8 个像素。下面是显示字符串到缓冲区的函数void oled_show_string(uint8_t page, uint8_t col, char *str) { uint8_t len strlen(str); for (uint8_t i 0; i len; i) { if (col i * 6 128) break; for (uint8_t j 0; j 6; j) { oled_buffer[(page * 128) col i * 6 j] font6x8[(uint8_t)(str[i] - 32) * 6 j]; } } }逻辑说明page 参数是页索引0~7col 是起始列。每个字符 6 列所以计算显存偏移时用col i * 6。font6x8是一个 8 位字符集的字模数组从索引 0 开始对应空格索引 1 对应!所以字符要减去 32。写完后调用一次oled_refresh()把整个缓冲区通过 I2C 发送到屏幕void oled_refresh(void) { for (uint8_t page 0; page 8; page) { oled_write_cmd(0xB0 page); oled_write_cmd(0x00); // 低列地址 oled_write_cmd(0x10); // 高列地址 oled_write_data(oled_buffer[page * 128], 128); } }显示距离时可以这样组合char buf[20]; snprintf(buf, sizeof(buf), D:%5.1fcm, distance_cm); memset(oled_buffer, 0, sizeof(oled_buffer)); oled_show_string(2, 0, buf); oled_refresh();注意刷新前清空缓冲区否则上次显示的内容会残留。如果不想全屏清空可以只清需要更新的区域或者保持固定显示格式每次覆盖同样的字符位置。3.3 刷新策略避免闪烁和残影OLED 屏幕在数据变化频繁时容易闪烁。闪烁的根源是“全屏清空全屏刷新”执行太频繁每次刷新需要传输 1024 字节这期间 I2C 优先级可能被串口或定时器中断打断导致画面撕裂感。常见做法是把刷新频率限制在 10Hz 到 20Hz 之间也就是每 50ms 到 100ms 刷新一次。另一种有效策略是“变化刷新”只有当距离变化超过 0.5cm 时才更新屏幕。这样可以显著减少 I2C 传输量也避免数字连续跳动造成视觉疲劳。代码可以这样写static float last_display_dist -1.0f; if (fabs(distance_cm - last_display_dist) 0.5f) { // 更新显示 last_display_dist distance_cm; }另外OLED 长时间显示同一静态画面会有烧屏风险。如果设备持续运行几天建议在空闲时让屏幕内容整体左右移动一列或者定时翻转对比度。这个属于长期可靠性问题如果不是连续通电超 24 小时影响不大。4. 蜂鸣器报警阈值判定与串口数据上送4.1 有源/无源蜂鸣器驱动电路与GPIO控制蜂鸣器按内部结构分为有源和无源两种。“有源”指内部带有振荡电路只要加直流电就能发声“无源”需要外部提供方波信号才能发出固定频率的声音。做阈值报警用有源蜂鸣器最简单一个 GPIO 高电平就能驱动。如果手上只有无源蜂鸣器可以用定时器 PWM 输出不同频率例如 2kHz 方波时声音清脆1kHz 时沉闷。STM32 的 GPIO 输出能力有限直接驱动蜂鸣器容易导致电压跌落或引脚损坏。常见做法是用一颗 NPN 三极管如 S8050做开关。电路连接方式简单描述蜂鸣器正极接 5V 电源蜂鸣器负极接三极管集电极三极管发射极接地基极通过 1K 电阻接 STM32 GPIO蜂鸣器两端反向并联一个 1N4148 二极管用于吸收关断时的反向电动势这个电路里GPIO 输出高电平时三极管导通蜂鸣器通电发声输出低电平时截止。注意如果用的是蜂鸣器模块小板模块上一般已经集成了驱动三极管和续流二极管直接用 GPIO 接模块的 I/O 脚即可但模块可能要求低电平触发要查一下具体丝印。GPIO 控制代码void buzzer_on(void) { HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_SET); } void buzzer_off(void) { HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_RESET); }有源蜂鸣器的响应速度一般在几毫秒不适合做极高频率的滴滴声。如果要用“急促”和“缓慢”两种报警节奏应该用定时器去翻转 GPIO而不是在中断里直接调用 buzzer_on/off。4.2 距离阈值分级报警的非阻塞写法报警逻辑通常分为两级距离小于 20cm 时缓慢报警小于 10cm 时急促报警。如果直接在测距完成后用 HAL_Delay 控制蜂鸣器时长整个主循环会被卡住测距和串口都无法继续。正确的姿势是用HAL_GetTick()实现非阻塞延时每次主循环检查当前时间与上次翻转的时间差。uint32_t last_toggle_ms 0; uint8_t alarm_level 0; void update_buzzer(float distance_cm) { if (distance_cm 10.0f) { alarm_level 2; } else if (distance_cm 20.0f) { alarm_level 1; } else { alarm_level 0; } uint32_t now HAL_GetTick(); switch (alarm_level) { case 2: if (now - last_toggle_ms 100) { HAL_GPIO_TogglePin(BUZZER_GPIO_Port, BUZZER_Pin); last_toggle_ms now; } break; case 1: if (now - last_toggle_ms 500) { HAL_GPIO_TogglePin(BUZZER_GPIO_Port, BUZZER_Pin); last_toggle_ms now; } break; default: HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_RESET); last_toggle_ms now; break; } }这段代码的逻辑先根据距离确定报警等级然后通过时间差判断是否要翻转蜂鸣器。100ms 翻转一次大约是 5Hz 的“滴-滴-滴”500ms 翻转一次是 1Hz 的慢节奏。当距离恢复到安全区间后强制输出低电平关闭蜂鸣器。这里有个细节now - last_toggle_ms使用无符号减法即使now溢出也能正确计算时间差。不要写成now last_toggle_ms 100那样在系统运行 49 天后会出错。4.3 串口DMA发送与调试助手的帧格式设计串口上送距离数据最简单的做法是重定向 printf 到 USART但有经验的同学都知道printf 在中断上下文或者高频调用时可能卡死。更可控的方式是自己构造发送缓冲区使用HAL_UART_Transmit阻塞发送。如果主循环里每 50ms 发送一次阻塞几毫秒完全没问题。帧格式建议使用可读的 ASCIID:123.4, A:2\r\n。这样在任意串口调试助手上都能直接看到数据不用写解析程序。如果你希望上位机画曲线可以输出二进制帧比如0xAA 0x55 距离高字节 距离低字节 报警等级 校验和下面用 ASCII 帧示例char tx_buf[32]; uint16_t len snprintf(tx_buf, sizeof(tx_buf), D:%.1f, A:%d\r\n, distance_cm, alarm_level); HAL_UART_Transmit(huart1, (uint8_t *)tx_buf, len, 50);如果要使用 DMA 发送注意 DMA 缓冲区必须在传输完成前保持有效。主循环下一次调用发送函数时可能上一次 DMA 还没结束此时写入 tx_buf 会破坏数据。常见做法是用双缓冲区或者用一个uart_busy标志volatile uint8_t uart_busy 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { uart_busy 0; } void uart_send(const char *buf, uint16_t len) { if (uart_busy 0) { uart_busy 1; HAL_UART_Transmit_DMA(huart1, (uint8_t *)buf, len); } }但这样有风险当 uart_busy 为 1 时新数据会被丢弃。更好的方案是把要发送的数据复制到静态数组在发送完成回调里再允许下次复制。对于距离数据这种低频数据阻塞发送已经足够。5. 测量误差、多设备干扰与调试技巧5.1 测量值跳变的过滤方法HC-SR04 在遇到斜面板、软质障碍物或测量目标边缘时会产生明显偏离真实值的异常脉宽。比如对着墙角测量声波经过多次反射后 Echo 脉宽可能突然变成 300ms实际上最大 38ms但会跳到一个不合理的大数。在程序中必须做数据有效性检查脉宽在 0 到 35000us 之间才认为是有效数据超出直接丢弃。然后使用滑动窗口中值滤波。中值滤波比均值滤波更能抵抗单个离群点。比如连续 5 次测量的值排序取中间值。代码实现如下#define FILTER_LEN 5 static float filter_values[FILTER_LEN]; static uint8_t filter_index 0; float distance_median_filter(float new_value) { filter_values[filter_index] new_value; filter_index (filter_index 1) % FILTER_LEN; float tmp[FILTER_LEN]; for (uint8_t i 0; i FILTER_LEN; i) tmp[i] filter_values[i]; for (uint8_t i 0; i FILTER_LEN - 1; i) { for (uint8_t j i 1; j FILTER_LEN; j) { if (tmp[i] tmp[j]) { float t tmp[i]; tmp[i] tmp[j]; tmp[j] t; } } } return tmp[FILTER_LEN / 2]; }使用时每次测距完成调用distance_median_filter(distance_cm)作为实际显示和报警的值。注意数组初始值建议设为 0否则开机前几帧可能输出一个不存在的中间值。5.2 多路超声波轮流触发避免串扰如果项目不止一路 HC-SR04比如小车前后各一个就要处理声波串扰。同时触发多个模块时模块 A 发出的声波可能被模块 B 接收导致 B 测出错误距离。解决办法很简单轮流触发每个模块测量完成后再触发下一个模块间隔至少 30ms。可以定义一个结构体数组typedef struct { GPIO_TypeDef *trig_port; uint16_t trig_pin; GPIO_TypeDef *echo_port; uint16_t echo_pin; volatile uint8_t done; float distance; } sonar_t; sonar_t sonars[2] { {GPIOB, GPIO_PIN_5, GPIOA, GPIO_PIN_0, 0, 0}, {GPIOB, GPIO_PIN_6, GPIOA, GPIO_PIN_1, 0, 0}, };引脚配置就用上面第 2 章的方法每个模块的路号要记录。但要注意 TIM2_CH1 只能接一个模块的 Echo所以两路超声波就需要两个定时器通道或者用外部中断加定时器的方式。常见的做法是让每路 Echo 接在同一个定时器的不同通道上比如 TIM2_CH1 和 TIM2_CH2两个通道独立捕获中断回调里根据通道号区分模块。这样就能共享一个定时器时间基准。5.3 用串口调试助手验证原始脉宽和时序很多初学者看到串口输出距离值抖动第一反应是滤波不够。但真正该做的是先把原始脉宽打出来判断抖动是来自物理测量还是软件问题。建议串口在输出距离的同时也输出脉宽snprintf(tx_buf, sizeof(tx_buf), T:%lu, D:%.1f\r\n, echo_width, distance_cm);对着 1m 平整墙面脉宽应在 5800us 左右。如果脉宽稳定但距离计算后抖动那就是声速系数或温度补偿的问题如果脉宽本身剧烈跳动说明模块摆放角度或供电有问题。用这种方式可以快速定位瓶颈。最后一个是实践技巧在串口调试助手中开启时间戳显示逐帧核对发送周期是否稳定。HC-SR04 的测距频率不要超过 20Hz所以发送间隔建议固定在 80ms 到 100ms。如果发现串口数据帧间隔忽长忽短回去查主循环里是否有阻塞操作比如 HAL_Delay 或重 printf。这时候可以试着把 OLED 刷新改成 200ms 一次把串口发送改成独立的定时器定时触发主循环只做状态机和数据采集整个系统就干净了。本文还有配套的精品资源点击获取
返回列表