news 2026/9/16 16:47:19

STM32输入捕获实现HC-SR04超声波测距:从原理到工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32输入捕获实现HC-SR04超声波测距:从原理到工程实践

简介:面向嵌入式初、中级开发者的STM32单片机超声波测距完整工程源码,以HC-SR04传感器采集距离数据,经STM32处理后在OLED屏实时显示,同时驱动蜂鸣器实现超限报警,并通过UART将测距结果发送至串口调试助手,适用于智能小车避障、安防监测等场景。压缩包共233个文件,约6.33MB,主要包含C源码(.c)、头文件(.h)、Keil工程配置(.uvprojx/.uvoptx)及编译中间文件(.o/.d/.crf等),目录结构完整,便于直接打开和二次开发。已有3132人学习下载。项目代码涵盖STM32的HAL/LL库及外设驱动,覆盖GPIO、定时器、串口中断、OLED显示、超声波回波捕获等关键模块,并为蜂鸣器报警阈值、显示刷新等留有明确接口,可帮助读者快速理解嵌入式多外设协同开发流程,适合作为课程设计或毕业设计的参考资料。

1. 测距不难,难的是让测距不卡住整机

做超声波测距,第一版代码十个有九个是写 while 死等回波电平翻转。数据能出来,但 OLED 刷新时测距会卡顿,串口输出节奏跟着乱,蜂鸣器报警也慢半拍。问题不在 HC-SR04,而在 Echo 高电平最长要持续 23ms,这段时间如果让主循环死等,整机等于被传感器绑架。这个工程给出的方案是把回波高电平宽度的测量交给 STM32 的定时器输入捕获来干,主循环只做计算、显示和报警,串口还能按固定节奏把测距数据发给电脑上的串口调试助手。工程基于 STM32F103 标准外设库,手里有最小系统板就能跑,适合刚点亮过一颗 LED、想完整串一遍外设的人,也适合拿它当课程设计或毕设的基座。

2. STM32 定时器输入捕获:把 HC-SR04 回波时间交给硬件

打开工程前,先确认 Keil 的 Pack Installer 里已经装了 STM32F1xx 器件包。Keil5 不像 Keil4 那样打开即用,没装包时 Device 下拉是空的,直接编译会报找不到目标芯片。这个工程用的是标准外设库(SPL),源文件里的 stm32f10x_tim.c、stm32f10x_i2c.c、stm32f10x_adc.c 正好对应三个外设:定时器测回波、I2C 驱动 OLED、ADC 读阈值电位器。

2.1 HC-SR04 测距的本质:先测量时间,再换算距离

HC-SR04 正面两个金属筒,一个发 40kHz 超声波脉冲,一个接收回波。STM32 给 Trig 引脚一个至少 10us 的高电平,模块内部会连发 8 个 40kHz 脉冲,同时将 Echo 引脚拉高。Echo 高电平持续的时间就是声波从发射到碰到障碍物再返回的总时长,所以测距本质是测量一段高电平宽度,再用声速换算。

空气中的声速取 340m/s,高电平时间t_us对应的是往返时间,换算成单程距离后公式化简为:

距离(cm) = 时间(us) * 0.017

推导过程是1us * 340m/s / 2 / 1000,单位换算后正好是 0.017cm。反过来估算时间是时间(us) = 距离(cm) * 58.8。0.017 这个系数带入 1cm 误差约 1mm,对报警、显示和串口观察都足够。

| 目标距离 | 声波往返时间 | 说明 | | 10cm | 588us | 阈值报警常出现的区间 | | 100cm | 5882us | 桌面测试最常用的距离 | | 400cm | 23529us | 接近传感器标称上限 |

这个表解释代码里为什么要把时基配成 1MHz,因为每个计数就是 1us,读到的 CCR 值直接当时间用,不用再乘分频系数。

2.2 轮询 vs 输入捕获:23ms 的等待时间不能浪费

第一个版本大多数新手写的是轮询方式:触发后 while 读 Echo 引脚,等它变高再开秒表,等它变低再停。优点是代码直白,缺点是 Echo 在 4m 量程时高电平要持续约 23ms,这段时间主循环被完全卡住。OLED 刷新会暂停,蜂鸣器报警会延迟,串口输出节奏也不稳定。

输入捕获的方案则完全不同:TIM2_CH1 检测到上升沿时,硬件自动记录当前计数值并触发中断,接着等下降沿再次捕获。整个过程主循环不参与电平等待,这也是这个工程在结构上比第一版 demo 更接近真实产品的原因。配置要点如下表:

| 配置项 | 值 | 原因 | | Echo 引脚 | PA0 (TIM2_CH1) | 默认映射,无需重映射 | | Trig 引脚 | PB10 | 避开 SWD 调试口和 I2C 引脚 | | 预分频器 | 71 | 72MHz/72 = 1MHz,1 计数 = 1us | | 自动重装载 | 0xFFFF | 65535us 远大于回波最长时间 23529us |

2.3 触发函数:12us 高电平脉冲

HC-SR04 的触发逻辑非常简短,关键是 Trig 高电平的脉宽要够。规格书要求不小于 10us,工程里给 12us 留出余量。

#define TRIG_PORT GPIOB #define TRIG_PIN GPIO_Pin_10 void HC_SR04_Trigger(void) { GPIO_SetBits(TRIG_PORT, TRIG_PIN); delay_us(12); /* 规格书要求不小于 10us,留余量 */ GPIO_ResetBits(TRIG_PORT, TRIG_PIN); }

触发函数的重点是 12us 这个脉宽。如果板子上 Trig 走线较长,分布电容会把窄脉冲吃掉,这时可以把延时拉到 20us,不影响最终测距精度,因为 Echo 高电平宽度由模块内部逻辑决定,与 Trig 脉宽无关。这里的delay_us在工程里一般由 SysTick 或 DWT 实现,不要在中断里调用它。

2.4 TIM2 输入捕获配置与中断处理

输入捕获初始化里,先开 TIM2 和 GPIOA 时钟,再把 PA0 配成输入模式。Echo 空闲时低,选择浮空输入即可。时基部分把计数频率拉到 1MHz,计数范围用 16 位最大值的 0xFFFF,保证 23ms 的回波不会造成计数器溢出。

void TIM2_IC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_ICInitTypeDef TIM_ICInitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler = 71; /* 72MHz / 72 = 1MHz,1us 计数 */ TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; /* 滤掉窄毛刺 */ TIM_ICInit(TIM2, &TIM_ICInitStructure); NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_ITConfig(TIM2, TIM_IT_CC1 | TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); }

TIM_ICFilter 设为 0x0F 的作用是开启输入滤波,可以剔除电子噪声引起的窄脉冲,避免在 Echo 空闲状态误触发捕获中断。中断服务函数是测量逻辑的主体,用一个状态变量区分正在等待上升沿还是下降沿。第一次进中断时把计数器清零并改为下降沿捕获,第二次进中断时读 CCR1 值作为高电平时间。代码里TIM_OC1PolarityConfig虽然叫输出比较,但它操作的是 CCER 寄存器的 CC1P 位,恰好就是输入捕获边沿选择的位置。

volatile uint32_t echo_us = 0; volatile uint8_t echo_ready = 0; static uint8_t echo_state = 0; /* 0: 等待上升沿, 1: 等待下降沿 */ void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); if (echo_state == 0) { TIM_SetCounter(TIM2, 0); /* 从0计数 */ TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Falling); echo_state = 1; } else { echo_us = TIM_GetCapture1(TIM2); TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Rising); echo_ready = 1; echo_state = 0; } } if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); if (echo_state == 1) { /* 高电平超过 65ms,按超时处理 */ echo_us = 0xFFFFFFFF; echo_ready = 1; echo_state = 0; } } }

距离转换不要放在中断里,中断里只置echo_ready。主循环发现echo_ready后再做浮点计算,否则浮点运算会拉长中断执行时间,下一次捕获中断可能被延迟。超时分支把echo_us写成 0xFFFFFFFF,主循环里判断这个值就能区分是正常回波还是空数据。

3. OLED 屏幕的 SSD1306 驱动与 UART 串口数据上传

0.96 寸 OLED 模块出厂默认是 I2C 或者 SPI 接口,硬件的差异直接决定驱动代码。这个工程把 OLED 放在 PB6/PB7,这两个引脚同时也是 I2C1 的复用引脚,不过这里用软件模拟 I2C 来驱动。

3.1 为什么用软件模拟 I2C 而不是硬件 I2C

标准外设库的硬件 I2C 有总线仲裁、超时恢复等问题,初始化错一个时序位,问题就出现在看似正常的下一次通信上。软件模拟 I2C 完全由代码控制时钟和数据的翻转顺序,出问题也好定位,只要示波器或逻辑分析仪挂在 SCL/SDA 上就能看清是哪一步没走出来。网上大量“stm32hal库点亮oled屏幕全流程”的例子是基于 HAL 库的,API 差异大,直接搬进标准库工程要改引脚时钟、GPIO 模式和传输函数,不建议混着抄。工程源码里保留 stm32f10x_i2c.c 是因为标准外设库工程通常会整组添加外设驱动文件,实际驱动路径走的是软件模拟 IO。

| 对比项 | 硬件 I2C | 软件模拟 | | 时钟产生 | 外设自动产生 | 代码翻转 GPIO | | 总线忙恢复 | 需要额外处理 | 重新初始化引脚即可 | | 代码量 | 少但难调 | 多但直观 | | 标准库支持 | 状态机复杂 | 无依赖 |

3.2 软件 I2C 时序与 SSD1306 初始化

SSD1306 是 OLED 的控制芯片,128×64 分辨率,I2C 设备地址由模块上电阻决定,常见 7 位地址 0x3C,写地址是 0x78。通信时先发器件地址,再发控制字节,控制字节 0x00 表示后续是命令、0x40 表示后续是数据。软件 I2C 的四步是起始、传字节、停止,SCL 高电平期间 SDA 必须稳定,否则会被误认为起始或停止信号。

#define OLED_SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define OLED_SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define OLED_SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define OLED_SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) static void OLED_I2C_Start(void) { OLED_SDA_H(); OLED_SCL_H(); delay_us(5); OLED_SDA_L(); delay_us(5); OLED_SCL_L(); } static void OLED_I2C_Stop(void) { OLED_SDA_L(); OLED_SCL_H(); OLED_SDA_H(); delay_us(5); } static void OLED_I2C_SendByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) OLED_SDA_H(); else OLED_SDA_L(); OLED_SCL_H(); delay_us(2); OLED_SCL_L(); dat <<= 1; } } static void OLED_WriteByte(uint8_t dat, uint8_t type) /* type 0:命令 1:数据 */ { OLED_I2C_Start(); OLED_I2C_SendByte(0x78); /* 写地址,7位地址0x3C左移一位 */ OLED_I2C_SendByte(type ? 0x40 : 0x00); OLED_I2C_SendByte(dat); OLED_I2C_Stop(); } #define OLED_WR_CMD(cmd) OLED_WriteByte(cmd, 0) #define OLED_WR_DAT(dat) OLED_WriteByte(dat, 1)

这段代码没有读 ACK。模块内部是慢速 I2C 从机,延迟足够宽,不读 ACK 不影响可靠性;万一总线被拉死,重新上电或复位再初始化即可。写地址如果不对,现象通常是从头到尾白屏,硬件上要区分模块背后的电阻配置是接 0x78 还是 0x7A。

初始化序列里最容易漏的是电荷泵命令。SSD1306 内部升压电路默认关闭,如果只写了 0xAF 开显示而没有先写 0x8D 0x14,屏幕会一直白屏或完全不亮,但 I2C 通信又是正常的,示波器上能看到 SCL/SDA 有波形。初始化代码如下:

void OLED_Init(void) { delay_ms(100); /* 等模块上电稳定 */ OLED_WR_CMD(0xAE); /* 关闭显示 */ OLED_WR_CMD(0xD5); OLED_WR_CMD(0x80); OLED_WR_CMD(0xA8); OLED_WR_CMD(0x3F); /* 64行 */ OLED_WR_CMD(0xD3); OLED_WR_CMD(0x00); OLED_WR_CMD(0x40); /* 显示起始行 */ OLED_WR_CMD(0x8D); OLED_WR_CMD(0x14); /* 开电荷泵,漏这句白屏 */ OLED_WR_CMD(0x20); OLED_WR_CMD(0x02); /* 页寻址 */ OLED_WR_CMD(0xA1); /* 段重映射 */ OLED_WR_CMD(0xC8); /* COM扫描方向 */ OLED_WR_CMD(0xDA); OLED_WR_CMD(0x12); OLED_WR_CMD(0x81); OLED_WR_CMD(0xCF); /* 对比度 */ OLED_WR_CMD(0xD9); OLED_WR_CMD(0xF1); OLED_WR_CMD(0xDB); OLED_WR_CMD(0x40); OLED_WR_CMD(0xA4); OLED_WR_CMD(0xA6); /* 正常显示 */ OLED_WR_CMD(0xAF); /* 打开显示 */ OLED_Clear(); }

命令 0x20 的寻址模式要留意。页寻址下写入自动跳到同一页的下一列,跨页需要重新设置列地址和页地址。如果显示出现字符错位,多半是在页地址切换时漏了 OLED_Set_Pos 里的 0xB0 加页码命令。页地址范围是 0~7,对应 64 行的 8 个行块,每一页 8 个像素高。

3.3 把距离显示在 OLED 上

显示距离值实际上是把数字拆成单字符,再查 ASCII 字模表写入显存。字符字模通过取模软件生成,十六进制数组按列排列,每个字符 16×8。OLED_Show_Num 的循环里用一个 divisor 取出特定位的数字,再转成 ASCII 码传给 OLED_Show_Char。

void OLED_Show_Num(uint8_t x, uint8_t y, uint16_t num, uint8_t len) { uint8_t i, k; for (i = 0; i < len; i++) { uint16_t divisor = 1; for (k = 0; k < len - 1 - i; k++) { divisor *= 10; } uint8_t ch = (num / divisor) % 10; OLED_Show_Char(x + i * 8, y, ch + '0', 16); } }

len表示显示的位数,例如显示三位距离就填 3;如果实际距离只有两位数,最前面会显示 0,所以通常配合背景填充把多余位刷成空白。OLED_Show_Char 内部会先设置页地址和列地址,再把 16 行字模逐字节写入。主界面第一行可以放固定标题“Distance:”,第二行显示数值,第三行显示阈值,布局如下:

OLED_Show_String(8, 0, "Distance:", 16); OLED_Show_Num( 8, 2, (uint16_t)dist_cm, 3); OLED_Show_Num( 8, 4, (uint16_t)thr_cm, 3);

3.4 UART 串口发送:SSCOM 里看实时数据

串口上传这部分用的是 USART1,PA9 做 TX,PA10 做 RX,和 USB-TTL 连接时注意交叉连接,板子的 TX 接模块的 RX,板子的 RX 接模块的 TX,还要共地。串口参数固定为 115200-8-N-1,波特率不一致时现象是乱码,或者数据偶尔跳一个字符。

void USART1_Init(uint32_t baud) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baud; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

fputc 重定向之后,main 里直接 printf 就能把数据发出去。有一个前提:Keil 工程必须勾选 Use MicroLIB,否则重定向的 printf 不生效,现象是程序在 while 里正常跑,但电脑上收不到任何数据。推荐的数据格式是带单位、带阈值的文本帧,例如:

printf("DIST:%.1fcm,THR:%.1fcm\r\n", dist_cm, thr_cm);

SSCOM 或正点原子 XCOM 这类串口调试助手都能直接显示。勾选“显示时间戳”可以看到每次测量的时间间隔,如果间隔抖动明显,说明 main 循环里的阻塞延时不是定值,需要检查测距等待和 OLED 刷新的配合。串口发送不需要中断,查询发送标志位就够了,数据量只有一帧文本,占不满串口带宽。

4. 蜂鸣器报警联动与 ADC 动态阈值调节

报警不是简单比较一次距离就响,需要考虑蜂鸣器类型、误触发和阈值来源。这个工程里蜂鸣器用 GPIO 直控方案,ADC 则负责把阈值做成可调。

4.1 有源蜂鸣器与无源蜂鸣器的驱动差异

蜂鸣器分有源和无源两类。“有源”的“源”指振荡源,有源蜂鸣器内部有振荡电路,通电就响,音调固定;无源蜂鸣器内部没有振荡源,需要外部给方波或 PWM 才发声,音调由频率决定。驱动电路上两者差别不大,常见做法是用一个 NPN 三极管做开关,GPIO 输出通过 1k 电阻接基极,蜂鸣器接在 5V 和三极管集电极之间,发射极接地。网上搜“无源蜂鸣器驱动电路图”看到的多数是这种结构,区别只在于基极要不要加下拉电阻,防止单片机上电瞬间 GPIO 悬空导致误响。

| 类型 | 振荡源 | 驱动方式 | 声音特点 | 适用场景 | | 有源 | 内置 | GPIO 电平 | 固定频率 | 报警开关、提示音 | | 无源 | 无 | PWM/方波 | 频率可调 | 不同距离不同音调 |

如果是有源蜂鸣器,工程里只需要推挽输出控制电平。蜂鸣器接 PB5,初始化代码如下:

void BEEP_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_ResetBits(GPIOB, GPIO_Pin_5); } #define BEEP_ON() GPIO_SetBits(GPIOB, GPIO_Pin_5) #define BEEP_OFF() GPIO_ResetBits(GPIOB, GPIO_Pin_5)

换成无源蜂鸣器时,把同一引脚复用为定时器 PWM 输出,例如 TIM3_CH3 的 PB0,初始化后通过修改 TIM_Period 控制频率、通过占空比控制音量。报警音如果做成距离越近音调越高,人耳判断比看数字更直观。注意有源蜂鸣器的发声频率是固定的,PWM 对无效,非要做变调必须换无源蜂鸣器。

注意:切换蜂鸣器类型时,驱动代码、供电电流和硬件电路都要一起改,有源蜂鸣器直驱的 GPIO 推挽代码不能直接套在无源蜂鸣器上。

4.2 阈值报警不需要每次数据都响应

HC-SR04 在目标边缘和透明、吸音物体前会产生单次异常值,比如距离突然从 50cm 跳到 10cm,一次就判断并鸣叫会出现误报。报警逻辑里加一个连续计数,连续若干次低于阈值才拉高蜂鸣器,只要有一次正常值就把计数器清零。这样既不会漏报,也不会被单个离群点干扰。

#define OVER_LIMIT 3 #define DEFAULT_THR 30.0f /* 默认报警阈值,单位cm */ static uint8_t over_cnt = 0; static float thr_cm = DEFAULT_THR; void Alarm_Update(float dist_cm) { if (dist_cm < thr_cm) { if (over_cnt < OVER_LIMIT) over_cnt++; if (over_cnt >= OVER_LIMIT) { BEEP_ON(); } } else { over_cnt = 0; BEEP_OFF(); } }

OVER_LIMIT 设 3 意味着连续 3 次测距都低于阈值才报警。在 100ms 的测距周期下,响应延迟约 300ms,人耳几乎无感。如果场景里飞虫或落叶频繁穿越探头造成误报,把 OVER_LIMIT 调到 5 即可,代价是报警响应慢一些。

4.3 ADC 读电位器:报警阈值可调才方便实测

stm32f10x_adc.c 在这个工程里承担的就是阈值采样。PA1 接一个 10k 电位器,电位器两端分别接 3.3V 和 GND,中间抽头接 PA1,旋转电位器就能改变 PA1 上的分压值。STM32F103 的 ADC 是 12 位,采样值范围 0~4095,对应 0~3.3V。阈值距离用比例换算:电位器调到最大时阈值接近 100cm,调到最小时接近 0cm。

void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); /* ADC时钟最高不超过14MHz */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_55Cycles5); ADC_Cmd(ADC1, ENABLE); } uint16_t ADC_Read_Threshold(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); }

电位器信号是直流缓慢变化,没必要开连续转换和扫描模式,单次转换足够。ADC 采样时间用 55.5 周期,内部采样电容能充分充电,读数稳定;如果电位器到 PA1 的走线很长,可以在 PA1 对地并联一个 0.1uF 电容滤掉高频干扰,ADC 值就不会反复跳。

主循环里把这些模块串起来,完整骨架如下:

int main(void) { uint16_t adc; float thr_cm_set; float dist_cm; SystemInit(); BEEP_GPIO_Init(); USART1_Init(115200); OLED_Init(); OLED_Show_String(8, 0, "Distance:", 16); TIM2_IC_Init(); ADC1_Init(); while (1) { HC_SR04_Trigger(); delay_ms(60); /* 触发间隔必须大于最大回波时间 */ if (echo_ready) { if (echo_us == 0xFFFFFFFF) { echo_ready = 0; continue; } dist_cm = echo_us * 0.017f; echo_ready = 0; adc = ADC_Read_Threshold(); thr_cm_set = (float)adc * 100.0f / 4096.0f; OLED_Show_Num(8, 2, (uint16_t)dist_cm, 3); OLED_Show_Num(8, 4, (uint16_t)thr_cm_set, 3); Alarm_Update(dist_cm); printf("DIST:%.1fcm,THR:%.1fcm\r\n", dist_cm, thr_cm_set); } } }

delay_ms(60) 是测距周期,也是报警响应的一条时间基线。HC-SR04 在 4m 量程时回波最长 23ms,60ms 周期可以保证上一次回波完全结束后才开始下一次触发,丢波率明显下降。周期也不是越小越好,太短会连续误触发,数据帧会变得稀疏。

5. 数据滤波、整机验证与故障排查

5.1 中值滤波:让距离数据不再乱跳

HC-SR04 的数据抖动主要来自两种场景:被测物边缘在波束边缘来回移动,或者波束打到倾斜表面后反射路径不固定。这类异常点的特征是偶发且幅度大,用滑动平均反而会把一次大的离群值带入结果,影响后续好几帧数据。中值滤波输出的是窗口内排序后的中间值,能直接丢掉离群点,窗口取 5 实现如下:

#define FILTER_N 5 static uint16_t ring[FILTER_N]; static uint8_t ring_idx; uint16_t Median_Filter(uint16_t sample) { uint8_t i, j; uint16_t tmp, buf[FILTER_N]; ring[ring_idx] = sample; ring_idx = (ring_idx + 1) % FILTER_N; for (i = 0; i < FILTER_N; i++) { buf[i] = ring[i]; } for (i = 0; i < FILTER_N - 1; i++) { for (j = i + 1; j < FILTER_N; j++) { if (buf[j] < buf[i]) { tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } } } return buf[FILTER_N / 2]; }

调用时把距离先算出来再进入滤波:dist_cm = Median_Filter((uint16_t)(echo_us * 0.017f)) * 1.0f;。这里的输出还是整数厘米,对报警和显示足够。如果后续要做 0.1cm 精度的数据记录,就把滤波对象改成 echo_us 而不是距离,浮点放大后再滤波会保留更多分辨率。

5.2 整机验证与排查清单

整机验证建议按下面的顺序走:先不接 HC-SR04,单独用 USB-TTL 连串口,确认上电能收到初始化打印;再接 HC-SR04,用手在探头前 20cm 和 80cm 之间来回移动,观察 OLED 数字是否连续变化;最后旋转电位器,把阈值调到 30cm 左右,用手快速划过探头,确认蜂鸣器能响应。几个最常见的故障直接列在表里:

| 现象 | 原因 | 排查方向 | | ST-Link 报 no target found | SWD 速率太高或接触不良 | 在 ST-Link Utility 里把速率降到 1MHz,检查 SWDIO/SWCLK 接线 | | 串口乱码 | 晶振频率和库配置不一致 | 核对 stm32f10x.h 的 HSE_VALUE 与实际晶振,检查起振电容 | | OLED 白屏但 I2C 有波形 | 电荷泵命令缺失 | 检查 0x8D 0x14 是否在 0xAF 之前执行 | | 距离恒为 0 或恒为最大值 | 触发间隔太短或 Echo 引脚没接对 | 确认 Trig 脉宽,测距周期保持 60ms 以上 |

no target found 还有一个容易被忽略的点:如果板子上同时接了 HC-SR04 和 OLED,它们的电源滤波电容不足,测量瞬间电流冲击会把调试器拉掉。给 VCC 和 GND 之间并一个 100uF 电解电容,多数情况下就好了。最后在 SSCOM 里勾选时间戳,观察数据帧间隔稳定在 100ms 左右、数值变化平滑,说明整个采集链路是通的,后面再往上叠透传、数据记录或者多传感器轮询,都只是在这个骨架上加外设的问题。

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

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

STM32F103 CAN Bootloader工业级固件升级方案

简介&#xff1a;本资源是一套面向嵌入式开发工程师与STM32进阶学习者的CAN总线Bootloader实战方案&#xff0c;聚焦STM32F103系列MCU的固件在线升级&#xff08;OTA&#xff09;实现&#xff0c;解决工业设备、汽车电子等高可靠性场景下无需拆机即可安全更新程序的核心需求。压…

作者头像 李华
网站建设 2026/9/16 16:46:57

开源AI音乐生成模型YuE:以歌词驱动端到端生成完整歌曲

最近开源音乐生成圈子里讨论度最高的名字&#xff0c;应该就是 YuE 了。先说结论&#xff1a;这是一个真正把“歌词”当作第一输入源、端到端生成完整歌曲的开源项目。你给一段歌词&#xff0c;它直接返回一首带人声、带伴奏的成品曲目&#xff0c;而不是那种只有旋律没有演唱的…

作者头像 李华
网站建设 2026/9/16 16:46:54

gog:在终端中掌控 Google Workspace 的命令行客户端使用指南

gog&#xff1a;在终端中掌控 Google Workspace 的命令行客户端使用指南 【免费下载链接】gogcli Google Workspace in your terminal. 项目地址: https://gitcode.com/GitHub_Trending/gogcl/gogcli gog 是一个面向 Gmail、Calendar、Drive、Docs、Sheets 等 Google Wor…

作者头像 李华
网站建设 2026/9/16 16:45:57

Sentinel链路流控模式原理与实践指南

1. Sentinel链路流控模式深度解析作为一名长期使用Sentinel进行系统流量控制的开发者&#xff0c;我发现很多团队对链路流控模式的理解存在误区。今天我将结合实战经验&#xff0c;详细拆解这个功能的核心机制与配置细节。链路模式&#xff08;Entry Limit&#xff09;是Sentin…

作者头像 李华