# 超声波测距 — HC-SR04 + 定时器输入捕获

超声波测距 --- HC-SR04 + 定时器输入捕获

配套硬件 :DshanMCU-F407(STM32F407ZGT6)+ HC-SR04 超声波模块

前置知识 :GPIO 输出/输入、中断、定时器基础、printf 重定向

学习目标 :理解定时器输入捕获的原理,学会用其测量脉宽,实现超声波测距

文档类型:逐行详解版(适合从头理解后,再自己重敲一遍)


目录

  1. [HC-SR04 超声波模块工作原理](#HC-SR04 超声波模块工作原理)
  2. 什么是"定时器输入捕获"?
  3. [为什么选 TIM9_CH2?------引脚复用](#为什么选 TIM9_CH2?——引脚复用)
  4. [CubeMX 配置逐项详解](#CubeMX 配置逐项详解)
  5. [增量式开发 ------ 代码是一步步长出来的](#增量式开发 —— 代码是一步步长出来的)
  6. 代码逻辑框架
  7. 变量定义逐行解释
  8. [udelay ------ 微秒延时](#udelay —— 微秒延时)
  9. [Ultrasonic_Trigger ------ 发触发信号](#Ultrasonic_Trigger —— 发触发信号)
  10. [Ultrasonic_GetDistance ------ 核心测距函数](#Ultrasonic_GetDistance —— 核心测距函数)
  11. [输入捕获回调 ------ 中断里发生了什么](#输入捕获回调 —— 中断里发生了什么)
  12. [main 函数中的测量循环](#main 函数中的测量循环)
  13. [为什么用 float?为什么打印整数+小数?](#为什么用 float?为什么打印整数+小数?)
  14. [volatile 是什么?为什么需要它?](#volatile 是什么?为什么需要它?)
  15. [完整代码 + 逐行注释版](#完整代码 + 逐行注释版)
  16. 遇到问题怎么办

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 ≥ tnowtold < 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.017ff 后缀表示这是 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);

这行干了三件事:

  1. 使能 TIM9 的计数器(CNT 开始计数)
  2. 使能 Channel 2 的输入捕获(PE6 上有边沿时触发)
  3. 使能 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 显示

相关推荐
yugi9878388 小时前
STM32 串口计算器实现
stm32·单片机·嵌入式硬件
科芯创展9 小时前
XZ4115B工作电压6-40V 输出电流1.2A 降压恒流LED驱动芯片
stm32·单片机·嵌入式硬件
求知喻11 小时前
KEIL5构建软件最小系统
单片机·嵌入式
MC_J11 小时前
STM32H7 串口 UART/USART从原理到实战
stm32·单片机·嵌入式硬件
学不懂飞行器12 小时前
电赛保姆级教程】从炸管到国一:电赛电源类(DC-DC/单相逆变)硬核避坑与拓扑全指南
stm32·单片机·嵌入式硬件·电赛·fft
大阳12313 小时前
ARM5.(beep,key,中断)
单片机·嵌入式硬件
崇山峻岭之间13 小时前
单片机RNG实验
单片机·嵌入式硬件
JNX_SEMI13 小时前
EG1160:600V半桥驱动,2.5A强驱带保护
stm32·单片机·嵌入式硬件
芯岭技术13 小时前
PY32L020单片机,多种低功耗模式,电流低至 0.7μA,适合电池供电产品
单片机·嵌入式硬件