超声波测距 --- HC-SR04 + 定时器输入捕获
配套硬件 :DshanMCU-F407(STM32F407ZGT6)+ HC-SR04 超声波模块
前置知识 :GPIO 输出/输入、中断、定时器基础、printf 重定向
学习目标 :理解定时器输入捕获的原理,学会用其测量脉宽,实现超声波测距
文档类型:逐行详解版(适合从头理解后,再自己重敲一遍)
目录
- [HC-SR04 超声波模块工作原理](#HC-SR04 超声波模块工作原理)
- 什么是"定时器输入捕获"?
- [为什么选 TIM9_CH2?------引脚复用](#为什么选 TIM9_CH2?——引脚复用)
- [CubeMX 配置逐项详解](#CubeMX 配置逐项详解)
- [增量式开发 ------ 代码是一步步长出来的](#增量式开发 —— 代码是一步步长出来的)
- 代码逻辑框架
- 变量定义逐行解释
- [udelay ------ 微秒延时](#udelay —— 微秒延时)
- [Ultrasonic_Trigger ------ 发触发信号](#Ultrasonic_Trigger —— 发触发信号)
- [Ultrasonic_GetDistance ------ 核心测距函数](#Ultrasonic_GetDistance —— 核心测距函数)
- [输入捕获回调 ------ 中断里发生了什么](#输入捕获回调 —— 中断里发生了什么)
- [main 函数中的测量循环](#main 函数中的测量循环)
- [为什么用 float?为什么打印整数+小数?](#为什么用 float?为什么打印整数+小数?)
- [volatile 是什么?为什么需要它?](#volatile 是什么?为什么需要它?)
- [完整代码 + 逐行注释版](#完整代码 + 逐行注释版)
- 遇到问题怎么办
1. HC-SR04 超声波模块工作原理

1.1 测距的本质
你在山谷里大喊一声 "啊---------"
声音传出去 → 碰到山壁 → 反弹回来 → 你听到回声
你从"喊"到"听到"用了 2 秒
声音速度 ≈ 340 米/秒
山壁的距离 = 340 × 2 ÷ 2 = 340 米
↑ 除以 2 是因为声音走了"去"和"回"
超声波模块做的是同一件事,只不过:
- 它发的是人耳听不到的 40kHz 超声波
- 它用电子信号测量"发出到接收"的时间
- 精度可以达到毫米级
1.2 HC-SR04 的 4 个引脚
| 引脚 | 接 F407 | 作用 |
|---|---|---|
| VCC | 5V | 供电(5V,不要接 3.3V,否则测距不准) |
| GND | GND | 共地 |
| Trig | PE4(GPIO 输出) | 发 10μs 高电平,告诉模块"开始测距" |
| Echo | PE6(TIM9_CH2 输入捕获) | 模块返回高电平,宽度 = 超声波往返时间 |
1.3 工作时序图
这是最关键的图,搞懂它就懂了全部代码逻辑:
你控制的 Trig:
──────┐ ┌───────────── 平时低电平
│ 10μs │
└──────┘
模块输出的 Echo:
┌──────────────────────┐
│ 高电平宽度 = 往返时间 │
└──────────────────────┘
↑ ↑
上升沿(risng) 下降沿(falling)
时间轴:
t0 t1 t2
↑ 你在 t0 发 Trig ↑ 你收到 Echo 结束
Trig 发出后大约 50μs,Echo 从低变高
Echo 从 t1(高) 持续到 t2(低)
距离 = (t2 - t1) × 声速 / 2
1.4 距离计算公式
声速 = 340 m/s = 34000 cm/s = 0.034 cm/μs
因为声音走了"去"和"回"两趟:
距离 = 脉宽(μs) × 0.034(cm/μs) ÷ 2
= 脉宽(μs) × 0.017(cm/μs)
举例:
脉宽 = 1000 μs(1ms)
距离 = 1000 × 0.017 = 17 cm
脉宽 = 2000 μs(2ms)
距离 = 2000 × 0.017 = 34 cm
1.5 最大/最小测量范围
| 范围 | 值 | 说明 |
|---|---|---|
| 最小 | 2cm | 再近就测不准了(盲区) |
| 最大 | ~400cm | 4 米左右,模块规格标 4~5 米 |
| Echo 最大脉宽 | ~30ms | 对应约 5 米的距离 |
我们的定时器 TIM9 是 16 位 ,最大计数 65535(在 1MHz 下 ≈ 65.5ms),远大于 30ms,所以够用。
2. 什么是"定时器输入捕获"?
2.1 定时器的本质
先把之前学过的定时器知识回顾一下:
STM32 的定时器 = 一个"计数器"
它有一个寄存器叫 CNT(Counter),每来一个时钟脉冲就加 1。
时钟来源:
系统时钟 168MHz
↓
预分频器(Prescaler)→ 分频后的时钟
↓
计数器 CNT 每来一个时钟就 ++
↓
CNT == ARR(自动重装载值)时 → 溢出 → 归零
2.2 预分频器的计算
预分频器值(Prescaler)= 168 - 1
输入时钟 = 168MHz
输出时钟 = 168MHz ÷ 168 = 1MHz = 1,000,000 次/秒
所以:CNT 每加 1 = 1μs
CNT 加 1000 = 1ms
CNT 加 65535 ≈ 65.5ms(TIM9 满了)
这就是我们配 Prescaler = 168-1 的原因------让每个计数值 = 1μs,这样读到的 CNT 直接就是微秒数。
2.3 输入捕获模式
普通模式:
CNT 一直加 → 加满 → 归零 → 再加
输入捕获模式:
在普通模式基础上多了"抓拍"功能。
当某个 GPIO 引脚上出现"边沿"(上升沿或下降沿)时:
① 当前 CNT 的值被"抓拍"到一个叫 CCR(Capture/Compare Register)的寄存器
② 触发中断(如果开了中断)
③ CNT 继续加,不受影响
中文翻译:输入捕获 = "在外设信号变化的瞬间,拍下当前计时器的照片"
2.4 用输入捕获测脉宽
我们只需要 一个通道 的输入捕获:
第 1 次捕获(上升沿):记下 CNT 值 → rise_time = 3762
接着立即把捕获极性改为"下降沿"
第 2 次捕获(下降沿):记下 CNT 值 → fall_time = 5738
脉宽 = 5738 - 3762 = 1976 μs
距离 = 1976 × 0.017 = 33.6 cm
2.5 为什么不直接用 GPIO 中断?
你也可以用 GPIO 中断(EXTI)来做:
Echo 上升沿 → EXTI 中断 → 读取 SysTick 或定时器的 CNT
Echo 下降沿 → EXTI 中断 → 再读一次
但 EXTI 中断的响应延迟不稳定(中断优先级、其他中断的影响),可能有几微秒的误差。输入捕获由硬件自动完成,延迟固定为 0~1 个时钟周期,精度更高。
3. 为什么选 TIM9_CH2?------引脚复用
3.1 什么是 Alternate Function(复用功能)?
STM32 的每个 GPIO 引脚不只能做普通输入/输出。通过配置"复用功能(AF)",引脚可以连接到芯片内部的特定外设。
PA9 有两种可能的"身份":
┌──────────────────────┐
│ 作为 GPIO:HAL_GPIO_WritePin(...) │
│ 作为 USART1_TX:HAL_UART_Transmit(...)│
└──────────────────────┘
在 CubeMX 中,你选 USART3_TX → PB10 自动变成 AF7
3.2 PE6 的 Alternate Function
在 CubeMX 中点击 PE6,看到的选项:
| 选项 | 含义 |
|---|---|
| Reset_State | 复位状态,默认 |
| DCMI_D7 | 数字摄像头接口 |
| FSMC_A22 | 外部存储器接口地址线 |
| SYS_TRACED3 | 调试跟踪 |
| TIM9_CH2 | TIM9 的通道 2(输入捕获/输出比较) |
| GPIO_Input | 普通输入 |
| GPIO_Output | 普通输出 |
| GPIO_Analog | 模拟输入(ADC) |
| EVENTOUT | 事件输出 |
| GPIO_EXTI6 | 外部中断 |
其中 TIM9_CH2 就是我们需要的------让 PE6 连接到 TIM9 的通道 2,使 TIM9 可以自动捕获 PE6 上的边沿信号。
3.3 为什么是 TIM9 而不是别的定时器?
不是我们"选"了 TIM9,而是 芯片设计时就固定了 PE6 只能连到 TIM9 的通道 2。就像是:
PE6 的内部连接图:
┌──────────────────────────┐
│ PE6 这个引脚(物理) │
│ ├── 可以连到 GPIO 电路 │
│ ├── 可以连到 TIM9_CH2 │
│ ├── 可以连到 DCMI_D7 │
│ └── ...(由你选择) │
└──────────────────────────┘
我们在 CubeMX 中选 TIM9_CH2,就是接通了 PE6 → TIM9_CH2 这条内部通路。
3.4 TIM9 是"高级"还是"通用"定时器?
TIM9 属于 通用定时器(General-purpose):
- 16 位计数器(最大 65535)
- 有 2 个独立通道
- 支持输入捕获、输出比较、PWM
对比:
| 特性 | TIM9(通用) | TIM1/TIM8(高级) |
|---|---|---|
| 位数 | 16 位 | 16/32 位 |
| 通道数 | 2 | 4+ |
| 刹车输入 | ❌ | ✅ |
| 互补输出 | ❌ | ✅ |
| 我们需不需要? | ✅ 够用 | 杀鸡用牛刀 |
4. CubeMX 配置逐项详解
4.1 整体配置一览
| 标签页 | 配置项 | 值 | 原因 |
|---|---|---|---|
| Pinout | PE4 | GPIO_Output | 发 Trig 触发信号 |
| Pinout | PE6 | TIM9_CH2 | Echo 接输入捕获 |
| Pinout | PB10 | USART3_TX | 串口打印 |
| Pinout | PC11 | USART3_RX | 串口(保留,本次没用) |
| Clock | HCLK | 168 MHz | F407 最高主频 |
4.2 TIM9 参数详解
Counter Settings:
| 参数 | 我们设的值 | 为什么是这个值? |
|---|---|---|
| Prescaler (PSC) | 168-1 | 输入时钟 168MHz,168÷168=1MHz,每 tick=1μs |
| Counter Mode | Up | 向上计数(CNT 从 0 开始,加满到 ARR 后归零) |
| Counter Period (ARR) | 65535 | TIM9 是 16 位定时器,最大值就是 65535 |
| Internal Clock Division | No Division | 内部时钟不分频(已经用 Prescaler 分好了) |
| auto-reload preload | Enable | 更新 ARR 时,等到 CNT 归零才生效,更稳定 |
Input Capture Channel 2:
| 参数 | 我们设的值 | 为什么? |
|---|---|---|
| Polarity Selection | Rising Edge | 先捕获上升沿(低→高),代码中再手动切到下降沿 |
| IC Selection | Direct | 直连模式(信号从 PE6 直接到捕获通道) |
| Prescaler Division Ratio | No division | 捕获不分频(每次边沿都捕获) |
| Input Filter | 0 | 不滤波(超声波信号比较干净,不需要滤波) |
4.3 Prescaler = 168-1 的全部分解
为什么是 168-1?彻底搞懂:
STM32F407 主频(HCLK)= 168 MHz
TIM9 挂在 APB2 总线上(查数据手册可知)
APB2 时钟 = HCLK = 168 MHz(因为 APB2 预分频 = /2 时,定时器时钟 = HCLK × 2)
TIM9 实际输入时钟 = 168 MHz
↓
预分频器(Prescaler)= 168-1 = 167
↓
TIM9 计数器时钟 = 168 MHz ÷ (167+1) = 168 MHz ÷ 168 = 1 MHz
↓
1 MHz = 1,000,000 次/秒 → 每 1 次计数 = 1/1,000,000 秒 = 1 微秒
所以 Prescaler = 168-1 让 CNT 每加 1 代表 1μs。 测出来的脉宽值直接就是微秒数。
4.4 为什么 ARR = 65535?
ARR 是计数器的"最大值"。CNT 从 0 加到 ARR,再来一个时钟就归零。
TIM9 是 16 位计数器 → 内部存储 CNT 的寄存器只有 16 位宽 → 最大能存 2^16 - 1 = 65535
所以 ARR 最大 = 65535
在 1MHz 下,65535 个 tick ≈ 65.5ms
超声波在最远距离(~5m)时的脉宽 ≈ 30ms
65.5ms > 30ms ✅ 够用
如果 ARR 设小了(比如设 1000),超声波的脉宽超过 1000μs = 1ms 时,CNT 会溢出归零,
你测到的脉宽就会不对(出现大幅跳变)。
4.5 NVIC 配置
左侧 NVIC → 找到 TIM9 global interrupt → 勾选 Enabled
为什么要在 NVIC 打勾?
NVIC 是中断的"总闸"。不打勾的后果:
- 定时器确实检测到了边沿
- 也确实产生了中断请求
- 但 NVIC 把请求挡住了,CPU 收不到
- HAL_TIM_IC_CaptureCallback 永远不会被执行
- capture_done 永远是 0 → 每次都是 TimeOut
5. 增量式开发 ------ 代码是一步步长出来的(重点!)
5.1 核心思想
不要试图一口气写完所有代码。
先让最简单的东西跑起来,再一点一点往上加。
每次加一点,测试一点,确认有效再继续。
变量是在"需要它的时候"才定义,不是开头就全写好的。
实际开发中没人这样做:
❌ 打开 main.c 先写几十行全局变量
❌ 然后写 5 个函数
❌ 然后编译,报一堆错
正确的做法:
✅ 先加一行 printf → 烧录 → 看到打印
✅ 再加一个函数 → 烧录 → 看效果
✅ 再发现需要变量 → 定义它
✅ 有问题只查新增的那几行
5.2 第 0 步:CubeMX 生成后,先保证工程活着
CubeMX 生成的 main.c 是空的框架:
c
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART3_UART_Init();
MX_TIM9_Init();
while (1) { }
}
第一件事:让 printf 能打印,证明工程能编译、烧录、运行。
操作:
① 在 USER CODE Includes 加:#include <stdio.h>
② 在 USER CODE 4 写 fputc 重定向(从串口课复制)
③ 在 USER CODE 2 加:printf("System Started!\r\n");
④ 编译(F7)→ 烧录(F8)→ 串口助手看到 "System Started!"
✅ 这时候没定义任何全局变量。只有一个 while(1) 空循环和 printf。
5.3 第 1 步:需要发 Trig 信号
现在想:要测距,先得触发超声波模块。
Trig 脚需要:拉高 10μs → 再拉低
写个 Trigger 函数的雏形:
c
void Ultrasonic_Trigger(void)
{
// 把 Trig 引脚拉高
// 等 10μs
// 拉低
}
→ 发现需要:知道 Trig 接在哪个引脚
所以定义 #define(把硬件信息集中放在顶部):
c
#define TRIG_PORT GPIOE
#define TRIG_PIN GPIO_PIN_4
→ 发现需要:10μs 延时,HAL_Delay 做不到
所以写 udelay(函数内部全是局部变量,用到什么定义什么):
c
void udelay(int us)
{
// 读当前 SysTick 值 → told
// 循环读新值 → tnow
// 看递减了多少 → cnt
// cnt >= 需要数 → 退出
uint32_t told = SysTick->VAL;
uint32_t tnow;
uint32_t load = SysTick->LOAD;
uint32_t ticks = us * (load + 1) / 1000;
uint32_t cnt = 0;
while (1) { ... }
}
→ 补全 Trigger:
c
void Ultrasonic_Trigger(void)
{
HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET);
udelay(10);
HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET);
}
✅ 这时候定义了:2 个 #define + 1 个 udelay + 1 个 Trigger 。全部都是"写着写着发现需要"才加的。
✅ 仍然没定义任何全局变量。
可以编译烧录测试了吗?不能------因为 Trigger 发出去后我们还没办法测 Echo。
5.4 第 2 步:测量 Echo 脉宽
现在要想:Echo 回来一个高电平,我要测它的宽度。
选项 A:在 while(1) 里不断读 PE6 → 不准
选项 B:用 GPIO 中断 → 有延迟
选项 C:定时器输入捕获 → 硬件自动,最准 ✅
选 C,因为我们在 CubeMX 里已经配好了 TIM9_CH2。
在 main 初始化中启动它:
c
HAL_TIM_IC_Start_IT(&htim9, TIM_CHANNEL_2);
但这行只是"打开了捕获"。捕获到信号后,数据去哪了?
→ 发现需要:写一个回调函数来接收捕获值
5.5 第 3 步:写回调函数(中断里)
先写一个最简版本:
c
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM9)
{
uint32_t val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
// 读到了 CNT 的值,但怎么知道是上升沿还是下降沿?
}
}
→ 发现需要:区分第 1 次(上升沿)和第 2 次(下降沿)
需要一个标志来记录"现在是第几次":
c
volatile uint8_t capture_edge = 0; // 0=等上升沿, 1=等下降沿
→ 发现需要:把两次捕获的值记下来,后面算脉宽
c
volatile uint32_t rise_time = 0; // 上升沿捕获值
volatile uint32_t fall_time = 0; // 下降沿捕获值
→ 发现需要:通知主循环"我测完了"
c
volatile uint8_t capture_done = 0; // 1=完成
补全回调:
c
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM9)
{
if (capture_edge == 0) // 第 1 次:上升沿
{
rise_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
capture_edge = 1;
TIM9->CCER |= TIM_CCER_CC2P; // 改成下降沿
TIM9->SR = ~TIM_SR_CC2IF; // 清假标志
}
else // 第 2 次:下降沿
{
fall_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
capture_done = 1;
}
}
}
✅ 这时候定义了 4 个全局变量 ,全部是在写回调的过程中,发现"这里需要一个变量来记东西"时才定义的。
5.6 第 4 步:写主循环中的测距逻辑
现在主循环的结构应该是:
while (1)
{
① 触发超声波
② 等 Echo 回来(等 capture_done)
③ 超时保护(防卡死)
④ 算距离
⑤ 打印
⑥ 等 200ms
}
封装成一个函数,写的过程中发现需要什么就定义什么:
c
float Ultrasonic_GetDistance(void)
{
// ------ 写函数体的过程中逐步添加 ------
// ① 复位标志
capture_done = 0; // 使用已有的全局变量
capture_edge = 0; // 使用已有的全局变量
TIM9->CCER &= ~TIM_CCER_CC2P; // 确保是上升沿
// ② 发触发信号
Ultrasonic_Trigger();
// ③ 等完成 → 需要超时,避免死等
uint32_t timeout = HAL_GetTick() + 100; // ← 写到这里才定义
while (capture_done == 0 && HAL_GetTick() < timeout);
// ④ 超时 → 需要一种"错误"返回值
if (capture_done == 0)
return -1.0f; // ← 写到这里才想到:用 -1 表示出错
// ⑤ 算脉宽 → 需要局部变量
uint32_t pulse; // ← 写到这里才定义
if (fall_time >= rise_time)
pulse = fall_time - rise_time;
else
pulse = (65535 - rise_time) + fall_time + 1; // 溢出处理
// ⑥ 算距离 → 需要浮点数
float dist = pulse * 0.017f; // ← 写到这里才定义
return dist;
}
5.7 第 5 步:在 while(1) 中调用
c
while (1)
{
float dist = Ultrasonic_GetDistance();
if (dist < 0)
printf("TimeOut!\r\n");
else
{
// 想直接 printf("%.1f", dist) → MicroLIB 不支持
// 需要整数+小数分开打印
int int_part = (int)dist;
int dec_part = (int)(dist * 10) % 10;
printf("Distance: %d.%d cm\r\n", int_part, dec_part);
}
HAL_Delay(200);
}
✅ 至此全部代码完成。
5.8 回顾:变量是怎么定义的
| 变量/函数 | 在哪一步定义的 | 为什么在这步才定义 |
|---|---|---|
#define TRIG_PORT/PIN |
第 1 步 | 写 Trigger 函数时需要知道引脚号 |
udelay() |
第 1 步 | 写 Trigger 时发现 HAL_Delay 精度不够 |
Ultrasonic_Trigger() |
第 1 步 | 需要触发超声波 |
rise_time, fall_time |
第 3 步 | 写回调时需要保存捕获值 |
capture_edge |
第 3 步 | 写回调时需要区分上升/下降沿 |
capture_done |
第 3 步 | 写回调时需要通知主循环 |
timeout(局部) |
第 4 步 | 写 GetDistance 时需要防卡死 |
pulse(局部) |
第 4 步 | 算脉宽时才需要 |
dist(局部) |
第 4 步 | 算距离时才需要 |
int_part, dec_part |
第 5 步 | 打印时才需要 |
没有一个是"还没写代码就先定义好"的。全都是写的过程中发现需要才加的。
5.9 这个思路的通用法则
每次只写一小步:
① 定下"这一步我要实现什么功能"
② 写最简代码实现它
③ 编译、烧录、验证
④ 如果需要新变量 → 定义
⑤ 如果需要新函数 → 写
⑥ 确认这一步没问题 → 进入下一步
每一步只增加很少的代码。出了问题,只需要检查"新增的那几行"。
错误做法:
一口气写 200 行代码 → 编译报 20 个错 → 不知道从哪排查
找到 1 个 bug 修了 → 还有 5 个 → 心态崩了
正确做法:
每次只加 5~10 行 → 编译过了 → 测试验证
下一轮再加 5~10 行 → ...
200 行代码分 20 轮写完,每轮都有成就感
6. 代码逻辑框架
在写具体代码之前,先理解整体的流程:
main() 中的 while(1) 循环:
① 发 Trig 信号(10μs 高电平)
② 等 Echo 返回
③ Echo 上升沿 → 定时器捕获(硬件自动)→ 中断(软件记录时间)
④ Echo 下降沿 → 定时器捕获(硬件自动)→ 中断(软件记录时间)
⑤ 计算脉宽 → 算距离 → printf 打印
⑥ HAL_Delay(200)
⑦ 回到 ①
中断回调中的逻辑:
第 1 次进回调(上升沿):
- 读 CCR → 得到 rise_time
- 把捕获极性从上升沿改成下降沿
- 等待下一次捕获
第 2 次进回调(下降沿):
- 读 CCR → 得到 fall_time
- 设置标志 capture_done = 1
- main 循环中的 while 看到标志 → 开始计算距离
7. 变量定义逐行解释
c
volatile uint32_t rise_time = 0; // 上升沿捕获值
volatile uint32_t fall_time = 0; // 下降沿捕获值
volatile uint8_t capture_done = 0; // 1=一次测量完成
volatile uint8_t capture_edge = 0; // 0=等上升沿, 1=等下降沿
6.1 uint32_t 是什么?
| 类型 | 全称 | 位数 | 取值范围 |
|---|---|---|---|
| uint8_t | unsigned 8-bit integer | 8 位 | 0~255 |
| uint16_t | unsigned 16-bit integer | 16 位 | 0~65535 |
| uint32_t | unsigned 32-bit integer | 32 位 | 0~4294967295 |
为什么 rise_time 和 fall_time 用 uint32_t 而不是 uint16_t?
TIM9 是 16 位定时器,CCR 寄存器最大只存 0~65535。
按理说 uint16_t 就够了。
但 HAL_TIM_ReadCapturedValue() 函数的返回值类型是 uint32_t。
这是 HAL 库的通用设计------有些定时器是 32 位的(如 TIM2、TIM5),
HAL 库统一用 uint32_t 返回,兼容所有定时器。
所以我们用 uint32_t 来接收,不会丢数据。
capture_done 和 capture_edge 只是 0/1 开关,uint8_t 绰绰有余。
6.2 为什么叫"上升沿"、"下降沿"?
上升沿(Rising Edge):
电压从低变高的那一瞬间
┌─────
│
────┘
下降沿(Falling Edge):
电压从高变低的那一瞬间
────┐
│
└─────
超声波模块 Echo 引脚的输出:
──────────────────────── 没测距时是低电平
┌────────────────── 收到 Trig 后变成高(上升沿)
│ |
│ └──── 超声波返回后变回低(下降沿)
└──────────────────
6.3 #define TRIG_PORT / TRIG_PIN
c
#define TRIG_PORT GPIOE
#define TRIG_PIN GPIO_PIN_4
为什么用 #define?
好处 1:改引脚时只需改这一处
如果下次 Trig 改到 PB0,只需:
#define TRIG_PORT GPIOB
#define TRIG_PIN GPIO_PIN_0
不用满世界找 HAL_GPIO_WritePin(GPIOE, GPIO_PIN_4, ...) 去改
好处 2:名字有含义
看到 TRIG_PORT 就知道这是超声波 Trig 的端口
看到 GPIOE 还要想一下"E 组是干嘛的"
这是嵌入式开发的常用技巧------把硬件相关的定义集中放在顶部,
后面写逻辑代码时只关心"发 Trig",不关心"哪个引脚"。
8. udelay ------ 微秒延时
c
void udelay(int us)
{
uint32_t told = SysTick->VAL; // 当前 SysTick 的计数值
uint32_t tnow;
uint32_t load = SysTick->LOAD; // SysTick 的重装载值
uint32_t ticks = us * (load + 1) / 1000; // 需要等多少个 tick
uint32_t cnt = 0;
while (1)
{
tnow = SysTick->VAL; // 读当前值
if (told >= tnow)
cnt += told - tnow; // 正常递减
else
cnt += told + load + 1 - tnow; // SysTick 溢出了(归零了)
told = tnow;
if (cnt >= ticks)
break;
}
}
7.1 SysTick 是什么?
SysTick = System Tick Timer(系统滴答定时器)
是 ARM Cortex-M 内核内部 的一个 24 位递减计数器,不属于 STM32 厂家外设。
STM32CubeMX 默认配置:
SysTick 每 1ms 产生一次中断
中断里每来一次 → uwTick++(HAL_GetTick() 读取的就是它)
如何做到 1ms 中断一次?
SysTick 的时钟 = HCLK / 8 = 168MHz / 8 = 21MHz
SysTick->LOAD = 21000 - 1
21MHz ÷ 21000 = 1000 Hz → 每 1ms 中断一次
7.2 为什么不直接用 HAL_Delay?
HAL_Delay(10):延时 10ms ✅
HAL_Delay(0.01):❗ 不行!
HAL_Delay 的参数是 uint32_t,只能传整数毫秒
而且 SysTick 本身每 1ms 才计一次,做不到微秒级
所以我们需要一个能延时"微秒"的函数。
udelay(10) = 延时 10μs = 0.01ms
7.3 udelay 的原理
SysTick 虽然每 1ms 才中断一次,但它的 VAL 寄存器 是 实时递减 的,每 1 个时钟周期就减 1。
时间轴:
↓ 中断(uwTick++) ↓ 中断 ↓ 中断
─┬────────────────────┬──────────────────┬──→
│ │ │
└ VAL = LOAD └ VAL = LOAD └ VAL = LOAD
VAL 在两次中断之间不断递减:
LOAD → LOAD-1 → LOAD-2 → ... → 1 → 0 → LOAD(重装)
udelay 利用这个特性------不依赖中断,直接读 VAL 寄存器,观察递减了多少个 tick,算出过去了多少微秒:
168MHz ÷ 8 = 21MHz = 21,000,000 tick/秒
1 tick = 1/21,000,000 秒 ≈ 0.0476 μs
延时 10μs 需要等 ≈ 210 个 tick
udelay 内部就是读 VAL → 看递减了多少 → 到了指定的 ticks 数 → 退出
told ≥ tnow 和 told < tnow 两种情况,分别处理正常和溢出:
情况 1:没有溢出(told ≥ tnow)
VAL: 1000 → 999 → 998 → ...
↑told ↑tnow
差值 = told - tnow(正常递减)
情况 2:溢出(told < tnow)
VAL: 3 → 2 → 1 → 0 → LOAD → LOAD-1 → ...
↑told ↑tnow
差值 = told + (LOAD+1) - tnow
(先减到 0 的部分 + 重装后又减的部分)
9. Ultrasonic_Trigger ------ 发触发信号
c
void Ultrasonic_Trigger(void)
{
HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); // PE4 输出高
udelay(10); // 维持 10μs
HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); // PE4 恢复低
}
8.1 为什么必须是 10μs?
HC-SR04 模块的规格要求:
- Trig 高电平持续时间必须 ≥ 10μs
- 太短 → 模块可能不响应
- 太长 → 没问题(模块检测到高电平就触发,但通常不建议)
10μs 是数据手册明确写的最小值,所以我们精准地给 10μs。
8.2 能不能用 HAL_Delay?
不能。HAL_Delay(1) 是最小值 = 1ms = 1000μs,太长。
而且 Trig 的 10μs 要求是硬实时的,必须用微秒级延时。
8.3 Trigger 之后发生了什么?
你的 F407: HC-SR04 模块:
Trig ↑(10μs) ──────────────→ 收到触发信号
↓
内部发出 8 个 40kHz 超声波脉冲
↓
等待回声
↓
收到回声 → Echo 引脚输出高电平
Echo ↑(上升沿) ←──────────── Echo 变高
超声波越远,Echo 高电平越长
Echo ↓(下降沿) ←──────────── Echo 变低(回声结束)
10. Ultrasonic_GetDistance ------ 核心测距函数
c
float Ultrasonic_GetDistance(void)
{
uint32_t pulse;
float dist;
uint32_t timeout;
capture_done = 0; // ① 复位标志
capture_edge = 0; // ② 从上升沿开始
/* 确保是上升沿捕获 */
TIM9->CCER &= ~TIM_CCER_CC2P; // ③ 清除下降沿标志
/* 发触发信号 */
Ultrasonic_Trigger(); // ④ 发 10μs 高电平
/* 等测量完成,超时 100ms */
timeout = HAL_GetTick() + 100; // ⑤ 设超时
while (capture_done == 0 && HAL_GetTick() < timeout); // ⑥ 等待
if (capture_done == 0)
return -1.0f; // ⑦ 超时返回 -1
/* 计算脉宽(μs) */
if (fall_time >= rise_time)
pulse = fall_time - rise_time; // ⑧ 正常情况
else
pulse = (65535 - rise_time) + fall_time + 1; // ⑨ 处理溢出
/* 距离 = 脉宽(μs) × 0.017 (cm/μs) */
dist = pulse * 0.017f; // ⑩ 算距离
return dist;
}
9.1 第 ①~② 步:复位标志
c
capture_done = 0;
capture_edge = 0;
每次测距前,把控制标志复位:
capture_done = 0→ 告诉代码"这次测量还没完成"capture_edge = 0→ 告诉回调"下一个边沿是上升沿"
9.2 第 ③ 步:确保上升沿捕获
c
TIM9->CCER &= ~TIM_CCER_CC2P;
这一行直接操作 TIM9 的寄存器。CCER = Capture/Compare Enable Register。
TIM9 的 CCER 寄存器(部分位):
Bit 5(CC2P):Channel 2 的极性选择
0 = 上升沿捕获(非极性)
1 = 下降沿捕获(极性)
TIM9->CCER &= ~TIM_CCER_CC2P
↓
清除 bit5 → 设成上升沿捕获
为什么需要这行?
因为上一次测距后,我们在回调里把极性改成了下降沿。
下次测距前,必须手动改回上升沿,否则永远捕获不到上升沿。
等效的 HAL 写法(更安全但没必要这样写):
c
// 这行代码只是为了帮你理解,我们的代码中没用到
TIM_OC_InitTypeDef sConfig;
sConfig.OCPolarity = TIM_OCPOLARITY_HIGH; // 上升沿
// ... HAL_TIM_OC_ConfigChannel(...)
直接操作寄存器更快、更直接。
9.3 第 ⑤~⑥ 步:超时检测
c
timeout = HAL_GetTick() + 100; // 超时 = 当前时间 + 100ms
while (capture_done == 0 && HAL_GetTick() < timeout);
// ↑ 测量完成了? ↑ 超时了?
// 两者任一成立 → 退出等待
为什么需要超时?
正常情况下:
发 Trig → Echo 上升沿 → Echo 下降沿 → capture_done = 1
异常情况(没测到物体 / 模块坏了):
发 Trig → Echo 一直低电平 → 永远没有上升沿 → 永远没有下降沿
→ capture_done 永远是 0
→ while 永远出不去 → 程序卡死在这
超时机制就是在"永远等不到"时,设定一个最大等待时间。
HAL_GetTick() 返回系统运行的毫秒数(从 HAL_Init 开始)。
timeout = HAL_GetTick() + 100 表示"最多等 100ms"。
100ms >> 超声波最大往返时间 ~30ms,完全够用。
如果 100ms 后 capture_done 还是 0 → 返回 -1 表示超时。
9.4 第 ⑧~⑨ 步:脉宽计算
c
if (fall_time >= rise_time)
pulse = fall_time - rise_time;
else
pulse = (65535 - rise_time) + fall_time + 1;
正常情况下(fall_time > rise_time):
TIM9 从 0 开始计数,碰到上升沿时 CNT=1000,碰到下降沿时 CNT=3000:
rise_time = 1000
fall_time = 3000
pulse = 3000 - 1000 = 2000 μs = 2ms
✓ 正确
溢出情况(fall_time < rise_time):
TIM9 从 0 开始计数,上升沿在 65000 到来,但下降沿在 1000 到来
(说明 CNT 在 65535 溢出,归零后又加到了 1000):
rise_time = 65000
fall_time = 1000
实际经过的 tick = (65535 - 65000) + 1000 + 1
= 535 + 1000 + 1
= 1536 μs
✓ 正确
9.5 第 ⑩ 步:距离计算
c
dist = pulse * 0.017f;
pulse = 2000(μs)
dist = 2000 × 0.017 = 34.0 cm
0.017f 的 f 后缀表示这是 float 类型,不是 double。在 STM32 上 float 运算比 double 快,而且 MicroLIB 对 double 支持有限。
9.6 为什么返回 -1.0f?
-1.0 作为"出错标志":
正常距离永远是正数(几厘米到几百厘米)
返回 -1.0 告诉调用者"这次测量没成功"
main 函数中检查:
if (dist < 0) → 打印 "TimeOut!"
else → 打印距离值
11. 输入捕获回调 ------ 中断里发生了什么
c
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM9) // ① 确认是 TIM9 的中断
{
if (capture_edge == 0) // ② 第 1 次(上升沿)
{
rise_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
capture_edge = 1; // ③ 标记"下次是下降沿"
TIM9->CCER |= TIM_CCER_CC2P; // ④ 改为下降沿捕获
TIM9->SR = ~TIM_SR_CC2IF; // ⑤ 清除可能的假标志
}
else // ⑥ 第 2 次(下降沿)
{
fall_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
capture_done = 1; // ⑦ 标记测量完成
}
}
}
10.1 回调函数的调用链
HC-SR04 Echo 引脚上升沿
↓
TIM9 的硬件检测到 PE6 上有边沿
↓
TIM9 自动把当前 CNT 复制到 CCR2 寄存器(硬件完成,不需要软件参与)
↓
TIM9 产生中断请求
↓
NVIC 判断优先级,允许中断
↓
CPU 暂停 while(1),跳转
↓
stm32f4xx_it.c 中的 TIM9_IRQHandler()
↓
HAL_TIM_IRQHandler(&htim9) ← HAL 库统一入口
↓
判断原因是"输入捕获"
↓
调用 HAL_TIM_IC_CaptureCallback(&htim9) ← 我们写的函数
↓
在回调里读取 CCR2 的值 → 就是 rise_time
↓
返回 while(1)
10.2 第 ① 行:htim->Instance == TIM9
c
if (htim->Instance == TIM9)
htim 是 HAL 库的定时器句柄,里面有一个 Instance 成员指向具体的定时器硬件。
多个定时器共用同一个回调函数:
如果同时用了 TIM3 和 TIM9,它们都会触发这个回调。
通过 htim->Instance 区分是哪个定时器的回调。
我们的心情:
只用了 TIM9,理论上不会误触发。
但加这个判断是良好的编程习惯,也更安全。
10.3 第 ②~③ 行:判断上升沿还是下降沿
c
if (capture_edge == 0) // 0 = 第一次触发 = 上升沿
{
// ... 记录上升沿时间 ...
capture_edge = 1; // 改成 1,下次进来走 else 分支
}
else // 1 = 第二次触发 = 下降沿
{
// ... 记录下降沿时间 ...
}
状态机思路:
初始状态:capture_edge = 0
上升沿来了 → 记录时间 → capture_edge = 1
下降沿来了 → 记录时间 → capture_done = 1
下次测距时(Ultrasonic_GetDistance 开头):
capture_done = 0
capture_edge = 0 → 状态复位
10.4 第 ④ 行:切换捕获极性
c
TIM9->CCER |= TIM_CCER_CC2P;
TIM_CCER_CC2P 的值是 (1 << 5) = 0x0020
TIM9->CCER |= 0x0020
↓
把 CCER 寄存器的 bit5 设为 1
↓
捕获极性从上升沿变成下降沿
↓
下次 PE6 上出现下降沿时,TIM9 会再次捕获
10.5 第 ⑤ 行:清除假标志
c
TIM9->SR = ~TIM_SR_CC2IF;
TIM_SR_CC2IF = (1 << 6) = 0x0040(Channel 2 捕获中断标志)
TIM9->SR = ~0x0040 = 0xFFBF
↓
写入 SR 寄存器,清除 CC2IF 位
为什么需要这行?
改变极性时,可能因为信号毛刺或寄存器切换产生假的中断挂起位。
如果不清除,改完极性后可能立即又进一次中断(假触发)。
特别注意 :这里是 = 不是 &=。TIM9->SR = ~TIM_SR_CC2IF 是给整个 SR 寄存器赋值,只清除指定位。在 STM32 中,写 SR 寄存器时,写 0 的标志位会被清除。
上面这行代码其实有优化的空间。标准清除写法是
TIM9->SR = ~TIM_SR_CC2IF,它会把 CC2IF 以外的位也写了。更精确的写法是TIM9->SR &= ~TIM_SR_CC2IF。但两者在绝大多数场景下效果一样。
10.6 为什么不在中断里做距离计算?
在中断里做距离计算的缺点:
① 中断占用时间太长 → 影响其他中断的响应
② printf 在中断里可能死锁(HAL_UART_Transmit 是阻塞的)
③ 中断里调 HAL_Delay 可能死锁(SysTick 中断优先级问题)
所以:"中断里只做最必要的事"(设标志位)
"while(1) 里处理复杂计算和打印"
12. main 函数中的测量循环
c
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART3_UART_Init();
MX_TIM9_Init();
/* USER CODE BEGIN 2 */
HAL_TIM_IC_Start_IT(&htim9, TIM_CHANNEL_2); // ① 启动输入捕获
printf("Ultrasonic Ranging Started!\r\n");
/* USER CODE END 2 */
while (1)
{
float dist = Ultrasonic_GetDistance(); // ② 测距
if (dist < 0)
printf("TimeOut!\r\n");
else
{
int int_part = (int)dist; // ③ 取整数部分
int dec_part = (int)(dist * 10) % 10; // ④ 取小数部分
printf("Distance: %d.%d cm\r\n", int_part, dec_part);
}
HAL_Delay(200); // ⑤ 等 200ms
}
}
11.1 第 ① 行:启动输入捕获中断
c
HAL_TIM_IC_Start_IT(&htim9, TIM_CHANNEL_2);
这行干了三件事:
- 使能 TIM9 的计数器(CNT 开始计数)
- 使能 Channel 2 的输入捕获(PE6 上有边沿时触发)
- 使能 TIM9 的中断(边沿产生中断时调用回调)
如果不调用这行:
- TIM9 的 CNT 不跑
- PE6 上有信号也不捕获
- 回调函数永远不会被执行
- capture_done 永远为 0 → 永远 TimeOut
等效的"手动"写法(只是为了让你理解 HAL 干了什么):
c
// HAL_TIM_IC_Start_IT 内部大概做了这三步(简化版):
TIM9->DIER |= TIM_DIER_CC2IE; // 使能通道2中断
TIM9->CCER |= TIM_CCER_CC2E; // 使能通道2捕获
TIM9->CR1 |= TIM_CR1_CEN; // 启动定时器计数
11.2 第 ③~④ 行:距离的整数/小数分离
c
int int_part = (int)dist; // 取整数部分
int dec_part = (int)(dist * 10) % 10; // 取小数第一位
为什么这样写?
因为 MicroLIB 对 printf 的 %f 支持有限。
直接 printf("%.1f", dist) 可能输出 "?" 或乱码。
所以用手动分离:
dist = 34.56
int_part = (int)34.56 = 34 // 强制转成整数,丢掉小数
dist * 10 = 345.6
(int)(345.6) = 345
345 % 10 = 5 // 取模 10 = 个位数
→ 打印 "34.5 cm"
11.3 第 ⑤ 行:为什么等 200ms?
每次测距周期 = 200ms + 测量时间(~30ms) ≈ 230ms
为什么要间隔?
① 超声波模块需要"安静时间"------每次发完声波,要等余震平息
② 防止多次触发互相干扰
③ 200ms 够用了,没必要更快
如果你把 HAL_Delay 改成 50ms,测距频率更高但可能受干扰。
改成 1000ms,每秒就测一次。
200ms ≈ 每秒测 4~5 次,适合实时显示。
13. 为什么用 float?为什么打印整数+小数?
12.1 距离值不是整数
距离 = 脉宽 × 0.017
如果脉宽 = 1003 μs(刚好不是整数):
1003 × 0.017 = 17.051 cm
用整数(int)存 → 得到 17(丢了 0.051)
用浮点数(float)存 → 得到 17.051(保留了小数)
12.2 float 和 int 的区别
c
int a = 5; // 整数,只能存没有小数的数
float b = 5.0f; // 浮点数,可以存 5.0、5.1、5.12345
int c = 5 / 2; // c = 2(整数除法,丢掉小数!)
float d = 5.0f / 2.0f; // d = 2.5(浮点除法,保留小数)
为什么之前不用 float?
之前我们点的 LED、读按键、计中断次数,全是整数操作:
- LED 亮/灭 → 0 或 1
- 按键计数 → 整数
- PIR 触发 → 整数
现在为什么需要 float?
距离 = 脉宽 × 0.017,结果天然是小数。
12.3 float 在 STM32 上的代价
float 运算的代价:
CPU 需要更多指令来处理浮点数
F407 有 FPU(浮点运算单元),所以还好
但如果用更低端的芯片(如 F103),float 会慢很多
所以当可以不用的场合尽量不用:
你看到的代码把 float 距离拆成整数+小数打印,
就是为了避免直接用 printf("%.1f", dist)
12.4 printf 整数+小数分离的数学原理
c
float dist = 34.56f;
int int_part = (int)dist; // (int)34.56 = 34
// C 语言的强制类型转换,直接丢掉小数
int dec_part = (int)(dist * 10) % 10; // dist * 10 = 345.6
// (int)345.6 = 345
// 345 % 10 = 5
// % 是"取余数"运算符
printf("Distance: %d.%d cm\r\n", int_part, dec_part);
// 输出:"Distance: 34.5 cm"
如果要显示两位小数:
c
int dec_part = (int)(dist * 100) % 100; // 34.56 → 3456 → 56
printf("%d.%02d", int_part, dec_part); // "34.56"
// %02d 表示"至少显示 2 位,不够补 0"
14. volatile 是什么?为什么需要它?
13.1 volatile 的字面意思
volatile = 易变的 、不稳定的
13.2 编译器优化的"陷阱"
看这段代码:
c
// 假设没有 volatile
int flag = 0; // 全局变量
void interrupt_handler(void) // 中断里改它
{
flag = 1;
}
void main_loop(void) // main 里读它
{
while (flag == 0) // 等 flag 变成 1
{
// 空转
}
}
编译器可能这样"优化":
编译器看到:
while(flag == 0):在循环体内,flag 从来没有被修改过
↓
"flag 不可能变啊,while 就是死循环嘛"
↓
优化成:
if (flag == 0) // 只判断一次
while(1); // 直接死循环,不用再读了
↓
中断里把 flag 改成 1 也没用!
因为 CPU 不再读 flag 的内存,永远"认为"它还是 0
13.3 volatile 的作用
c
volatile int flag = 0; // 告诉编译器:"这个变量可能被意外修改"
加了 volatile 后:
编译器不敢对这个变量做任何"读取优化"。
每次访问 flag 都从内存中重新读,而不是用寄存器里的缓存值。
13.4 什么时候必须用 volatile
① 中断和主循环共享的变量:
main 里等 capture_done,中断里设 capture_done
② 多个线程共享的变量(FreeRTOS 中):
任务 A 写,任务 B 读
③ 硬件寄存器:
*(volatile uint32_t *)0x40021414 操作 GPIO 寄存器
如果用非 volatile 指针,编译器可能优化掉某些写入
13.5 我们的代码中哪些变量用了 volatile
c
volatile uint32_t rise_time = 0; // 中断里写,主循环里读
volatile uint32_t fall_time = 0; // 中断里写,主循环里读
volatile uint8_t capture_done = 0; // 中断里写,主循环里读
volatile uint8_t capture_edge = 0; // 中断里写,主循环里读
这四个变量都是"中断修改,主循环读取"的模式,必须加 volatile。
15. 完整代码 + 逐行注释版
注意:
USER CODE BEGIN/END区域是 CubeMX 生成的标记。代码必须写在这些区域内,否则下次重新生成会被覆盖。
main.c 完整内容:
c
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
/* USER CODE BEGIN PV */
volatile uint32_t rise_time = 0; // 上升沿捕获值(中断里写入,加 volatile)
volatile uint32_t fall_time = 0; // 下降沿捕获值
volatile uint8_t capture_done = 0; // 1=一次测量完成
volatile uint8_t capture_edge = 0; // 0=等上升沿, 1=等下降沿
#define TRIG_PORT GPIOE // Trig 引脚端口
#define TRIG_PIN GPIO_PIN_4 // Trig 引脚号
/* USER CODE END PV */
/* USER CODE BEGIN 0 */
/**
* udelay - 微秒延时
* 通过直接读取 SysTick->VAL 实现,不依赖 SysTick 中断
* us: 要延时的微秒数(1μs = 0.000001s)
*/
void udelay(int us)
{
uint32_t told = SysTick->VAL; // 读取当前的 SysTick 计数值
uint32_t tnow; // 用于存每次循环读取的新值
uint32_t load = SysTick->LOAD; // SysTick 重装载值(= 21000-1)
uint32_t ticks = us * (load + 1) / 1000; // us 微秒需要多少个 tick
uint32_t cnt = 0; // 累计计数差
while (1)
{
tnow = SysTick->VAL; // 读当前值
if (told >= tnow)
cnt += told - tnow; // 正常递减
else
cnt += told + load + 1 - tnow; // 溢出情况
told = tnow; // 更新"上次"的值
if (cnt >= ticks) // 够了没?
break;
}
}
/**
* Ultrasonic_Trigger - 发给 HC-SR04 的触发信号
* 向 Trig 引脚输出 10μs 的高电平
*/
void Ultrasonic_Trigger(void)
{
HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); // 拉高
udelay(10); // 等 10μs
HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); // 拉低
}
/**
* Ultrasonic_GetDistance - 测距
* 返回值:距离(单位 cm),超时返回 -1.0
*/
float Ultrasonic_GetDistance(void)
{
uint32_t pulse; // 脉宽(μs)
float dist; // 距离(cm)
uint32_t timeout; // 超时时刻
capture_done = 0; // 复位完成标志
capture_edge = 0; // 从上升沿开始
TIM9->CCER &= ~TIM_CCER_CC2P; // 确保是上升沿捕获
Ultrasonic_Trigger(); // 发触发信号
timeout = HAL_GetTick() + 100; // 设超时 = 当前时间 + 100ms
while (capture_done == 0 && HAL_GetTick() < timeout);
if (capture_done == 0) // 超时了?
return -1.0f; // 返回 -1 表示失败
/* 计算脉宽 */
if (fall_time >= rise_time)
pulse = fall_time - rise_time; // 正常:下降沿 > 上升沿
else
pulse = (65535 - rise_time) + fall_time + 1;
// 溢出(CNT 从 65535 回到 0 又加到 fall_time)
/* 计算距离 */
dist = pulse * 0.017f; // 0.017 = 0.034 ÷ 2
return dist;
}
/* USER CODE END 0 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART3_UART_Init();
MX_TIM9_Init();
/* USER CODE BEGIN 2 */
HAL_TIM_IC_Start_IT(&htim9, TIM_CHANNEL_2); // 启动输入捕获中断
printf("Ultrasonic Ranging Started!\r\n");
/* USER CODE END 2 */
while (1)
{
float dist = Ultrasonic_GetDistance();
if (dist < 0)
{
printf("TimeOut!\r\n");
}
else
{
int int_part = (int)dist; // 整数部分
int dec_part = (int)(dist * 10) % 10; // 小数第一位
printf("Distance: %d.%d cm\r\n", int_part, dec_part);
}
HAL_Delay(200);
}
}
/* USER CODE BEGIN 4 */
/**
* fputc - printf 的底层输出函数(重定向到 USART3)
*/
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart3, (uint8_t *)&ch, 1, 1000);
return ch;
}
/**
* HAL_TIM_IC_CaptureCallback - 定时器输入捕获中断回调
* 在 Echo 信号上升沿和下降沿各调用一次
*/
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM9)
{
if (capture_edge == 0) // 第 1 次:上升沿
{
rise_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
capture_edge = 1;
TIM9->CCER |= TIM_CCER_CC2P; // 切换成下降沿
TIM9->SR = ~TIM_SR_CC2IF; // 清除假标志
}
else // 第 2 次:下降沿
{
fall_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
capture_done = 1; // 标记测量完成
}
}
}
/* USER CODE END 4 */

16. 遇到问题怎么办
16.1 输出全是 TimeOut!
可能原因:
① Echo 线没接好 → 检查 PE6 有没有正确接到 HC-SR04 的 Echo
② 模块没供电 → HC-SR04 需要 5V,不能接 3.3V
③ TIM9 中断没打勾 → 检查 NVIC 中 TIM9 global interrupt 是否 Enabled
④ HAL_TIM_IC_Start_IT 没调 → 检查 main 初始化中是否调用了它
16.2 距离固定不变(如一直 16.0 cm)
可能是上次的 rise_time 值残留 ,Ultrasonic_GetDistance 开始时记得清零标志。检查代码最开头有没有设:
c
capture_done = 0;
capture_edge = 0;
TIM9->CCER &= ~TIM_CCER_CC2P;
16.3 距离值偶尔跳变很大(如 800cm)
可能原因:
- Echo 信号有干扰,导致误捕获
- 定时器溢出处理没做对
检查代码中:
if (fall_time >= rise_time)
pulse = fall_time - rise_time;
else
pulse = (65535 - rise_time) + fall_time + 1;
16.4 &= 写成 = → 一直 TimeOut
c
// ✅ 正确:只清除 CC2P 位,其他位保留
TIM9->CCER &= ~TIM_CCER_CC2P;
// ❌ 错误:把整个 CCER 写成 ~CC2P,其他位全被清零
// CC2E(通道使能位)也被清掉 → 捕获不工作 → 一直 TimeOut
TIM9->CCER = ~TIM_CCER_CC2P;
&= 按位与等于是"只把指定位清 0,其他不动"。
= 直接赋值是"整个寄存器都被覆盖",其他配置全部丢失。
16.5 用 printf 打印 float 值有问题
这是 MicroLIB 的限制,用我们代码中的"整数部分 + 小数部分"方式打印即可。
c
int int_part = (int)dist;
int dec_part = (int)(dist * 10) % 10;
printf("%d.%d", int_part, dec_part);
总结:这一节你学到了什么?
| 知识点 | 掌握程度 |
|---|---|
| HC-SR04 工作原理(Trig → 10μs → Echo 测脉宽) | ⭐⭐⭐ MUST |
| 定时器 Prescaler 的计算方法(168-1 → 1MHz → 1μs) | ⭐⭐⭐ MUST |
| 输入捕获模式(边沿触发 → 硬件自动记录 CNT) | ⭐⭐⭐ MUST |
| 16 位定时器的范围限制(ARR=65535,够用) | ⭐⭐ SHOULD |
| 通过寄存器切换捕获极性(CCER 的 CC2P 位) | ⭐⭐ SHOULD |
| volatile 在中断中的必要性 | ⭐⭐⭐ MUST |
| float 和 int 的区别 | ⭐⭐ SHOULD |
| 超时机制防止死循环 | ⭐⭐ SHOULD |
编写日期 :2026-05-28
适用硬件 :DshanMCU-F407(STM32F407ZGT6)+ HC-SR04 超声波模块
关键前置 :中断概念、定时器基础、printf 重定向(USART3)
重点概念 :输入捕获、预分频器、volatile、浮点数
下一篇预告:环形缓冲区 + 超声波多次测量取均值 + LCD 显示