0%

STM32——I2C中断与DMA模式,状态机编程读取AHT20温湿度

从上一篇到这篇

上篇我们学习了 I2C 轮询模式读取 AHT20:用 HAL_I2C_Master_Transmit 发送测量命令、HAL_I2C_Master_Receive 读取 6 字节数据,再拼接换算成温湿度。

但轮询模式有个老问题——阻塞。发送和接收函数内部”等一个字节搬一个字节”,整个过程 CPU 被占住,什么别的事都干不了。这和之前串口学的完全一样:轮询 → 中断 → DMA。

本篇把 I2C 通信改造成中断模式,并引入单片机里极其常见的状态机编程思想。中间我遇到两个很有价值的问题:

  1. 非阻塞函数 + 局部数组 = 数据被回收(要用 static)
  2. 跨文件共享变量:extern 声明 vs 定义(编译报 undeclared 的根源)

最后把中断版改成 DMA 版——因为中断和 DMA 在 HAL 里只差一个后缀。

本篇和上篇是同一块板子、同一个工程。


硬件原理

2.1 轮询 vs 中断 vs DMA(回顾 + 迁移)

串口那几篇已经详细讲过三种模式的区别,I2C 完全一样:

模式 发送/接收过程 CPU 在干嘛 怎么知道完成了
轮询 CPU 逐字节写入/读取寄存器 阻塞等待 函数返回 = 完成
中断 每搬完 1 字节触发中断,中断里搬下一字节 平时干别的,被中断叫去搬 回调函数
DMA DMA 硬件整批搬运 彻底解放 回调函数(DMA 通过中断通知)

关键区别一句话:轮询的函数返回 = 数据传完了;中断/DMA 的函数调用完 = 只是把任务交给了外设,数据还没传。

2.2 非阻塞函数的”大坑”:任务交给外设后,代码继续往下跑

这是本篇最容易出 bug 的地方。看这个改造前”想当然”的代码:

1
2
3
4
5
6
7
8
9
10
11
void AHT20_Read_IT()   // 想当然的错误写法
{
uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00}; // 局部数组
uint8_t readBuffer[6];

HAL_I2C_Master_Transmit_IT(&hi2c1, AHT20_ADDRESS, sendBuffer, 3); // 非阻塞,立即返回!
HAL_Delay(75);
HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, readBuffer, 6); // 同样立即返回!
// 到这里 readBuffer 里还是初始值,数据根本没收到!
// 而且 sendBuffer 是局部数组,函数返回后内存就被回收了,中断传输还没结束……
}

两个致命问题:

① 数据没收到就往下执行:Receive_IT 只是”通知外设开始接收”,真正搬运是中断里逐字节完成的。函数返回时 readBuffer 里全是初始值。这就需要一个状态机:把”发送命令”、”等待”、”读取”、”解析”拆成独立动作,由中断回调来驱动状态切换。

② 局部数组内存被回收:Transmit_IT 是非阻塞的,函数返回后中断还在用 sendBuffer 的地址传数据,但 sendBuffer 是局部变量,函数一结束它的内存就被回收、可能被其他变量覆盖——发出去的数据就错了。解决方案:加 static 让数组常驻内存。

2.3 状态机:让”异步”变得可控

非阻塞编程的核心思想就是状态机。我们把 stm32与AHT20交互 的完整流程拆成 5 个状态,由中断回调推进状态,主循环只负责”看状态、做该做的事”:

1
2
3
4
5
6
7
8
           AHT20_Measure()           中断回调              AHT20_Get()
┌──────┐ 发送测量命令 ┌──────┐ 发送完成 ┌──────┐ 读取数据 ┌──────┐
│ 0 │ ────────────────→ │ 1 │ ────────────→ │ 2 │ ────────────→ │ 3 │
│初始 │ │发送中 │ │等75ms│ │读取中 │
└──────┘ └──────┘ └──────┘ └──────┘
↑ │
│ 中断回调(接收完成) |
│ 串口打印,回到初始 ←────── 状态4:解析数据+传到电脑上 ←──────────────────┘
状态值 含义 谁触发的 当前状态要做的事
0 初始状态 上电/打印完 调用 AHT20_Measure() 发送测量命令 → 状态 1
1 正在发送 — 什么都不用做,等中断
2 发送完成 发送完成回调 延时 75ms → 调用 AHT20_Get() 读取数据 → 状态 3
3 正在读取 — 什么都不用做,等中断
4 读取完成 接收完成回调 解析数据 → 串口打印 → 回到状态 0

CubeMX 配置

3.1 开启 I2C1 的两个中断向量

在 CubeMX 中点击 I2C1 → NVIC Settings,勾选:

  • I2C1 event interrupt(事件中断,本篇主要用这个)
  • I2C1 error interrupt(错误中断)

i2c1 NVIC 中断配置

Ctrl+S 保存并生成代码后,stm32f1xx_it.c 里会自动多出两个中断服务函数:

1
2
3
4
5
6
7
8
9
void I2C1_EV_IRQHandler(void)   // 事件中断:发送/接收完成、地址匹配等
{
HAL_I2C_EV_IRQHandler(&hi2c1);
}

void I2C1_ER_IRQHandler(void) // 错误中断:总线错误、仲裁丢失等
{
HAL_I2C_ER_IRQHandler(&hi2c1);
}

3.2 DMA 模式

如果要切换到 DMA 模式,在 I2C1 → DMA Settings 里添加 I2C1_TX 和 I2C1_RX 两条 DMA 通道,默认配置即可。本篇主要讲中断模式,DMA模式仅需要把I2C收发函数的后缀从IT改为DMA即可image-20260802182354106


核心代码

4.1 aht20.h —— 头文件(声明拆分后的三个函数)

1
2
3
4
5
6
7
8
9
10
#ifndef INC_AHT20_H
#define INC_AHT20_H

#include "i2c.h" // 引入 hi2c1 句柄声明和 HAL_I2C 系列函数

void AHT20_Init(void); // 初始化传感器
void AHT20_Measure(void); // 触发测量(发送测量命令,非阻塞)
void AHT20_Get(void); // 读取测量数据(非阻塞)
void AHT20_Analysis(float* Temrerature, float* Humidity); // 解析 readBuffer 为温湿度
#endif

4.2 aht20.c —— 驱动实现(拆分为 Measure / Get / Analysis)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include "aht20.h"
#include "i2c.h"
#include "stm32f1xx_hal.h"
#include "stm32f1xx_hal_def.h"
#include "stm32f1xx_hal_i2c.h"
#include <stdint.h>

// ========== 设备地址 ==========
#define AHT20_ADDRESS 0x70

// 接收缓冲区:提升为全局变量!
// 因为 AHT20_Get() 触发的是非阻塞接收,数据由中断慢慢填进来,
// 而 AHT20_Analysis() 要在之后的某个时刻去读它——局部数组活不了那么久。
uint8_t readBuffer[6] = {0};

// 初始化:和上篇完全一样(轮询,阻塞,没问题)
void AHT20_Init(void)
{
uint8_t readBuffer;
HAL_Delay(40);
HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, &readBuffer, 1, HAL_MAX_DELAY);
if ((readBuffer & 0x08) == 0x00)
{
uint8_t sendBuffer[3] = {0xBE, 0x08, 0x00};
HAL_I2C_Master_Transmit(&hi2c1, AHT20_ADDRESS, sendBuffer, 3, HAL_MAX_DELAY);
}
}

// 触发测量:发送 3 字节测量命令(中断模式,非阻塞)
void AHT20_Measure(void)
{
// static 是关键!非阻塞函数返回后,局部数组内存会被回收,
// 但中断还在用这个地址传数据 → 必须让数组常驻内存
static uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00};
HAL_I2C_Master_Transmit_IT(&hi2c1, AHT20_ADDRESS, sendBuffer, 3);
}

// 读取数据:触发接收 6 字节(中断模式,非阻塞)
void AHT20_Get(void)
{
HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, readBuffer, 6);
}

// 解析数据:和上篇 AHT20_Read 后半段完全一样
void AHT20_Analysis(float *Temrerature, float *Humidity)
{
if ((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
// 湿度 = Byte1<<12 | Byte2<<4 | Byte3>>4
data = ((uint32_t)readBuffer[1] << 12)
| ((uint32_t)readBuffer[2] << 4)
| ((uint32_t)readBuffer[3] >> 4);
*Humidity = data * 100.0f / (1 << 20);
// 温度 = (Byte3 & 0x0F)<<16 | Byte4<<8 | Byte5
data = (((uint32_t)readBuffer[3] & 0x0F) << 16)
| ((uint32_t)readBuffer[4] << 8)
| ((uint32_t)readBuffer[5]);
*Temrerature = data * 200.0f / (1 << 20) - 50;
}
}

4.3 i2c.c —— 中断回调(状态机的”驱动引擎”)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* USER CODE BEGIN 0 */
// 状态机变量真正定义在 main.c 里,extern 声明放在 main.h 里。
// i2c.c → i2c.h → main.h 的 include 链让它在这里直接可见,无需重复声明(详见踩坑记录 坑1)

// I2C 发送完成回调:测量命令发出去了 → 状态 1 → 2
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c==&hi2c1) // 确认是 I2C1 完成(多个 I2C 时区分用)
{
aht20_State=2;
}
}

// I2C 接收完成回调:6 字节数据收齐了 → 状态 3 → 4
void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c==&hi2c1)
{
aht20_State=4;
}
}
/* USER CODE END 0 */

4.4 main.c —— 状态机主循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* USER CODE BEGIN PV */
//0:初始状态,发送测量命令
//1:正在发送测量命令
//2:测量命令发送完成,等待75ms,读取数据
//3:正在读取数据
//4:读取完成,解析并展示数据,回到初始状态
uint8_t aht20_State = 0; // 状态机变量(定义,分配内存)
/* USER CODE END PV */

/* USER CODE BEGIN 2 */
AHT20_Init(); // 初始化传感器
float Temrerature = 0, Humidity = 0; // 存放温湿度
char message[50]; // 串口发送缓冲区
/* USER CODE END 2 */

/* USER CODE BEGIN WHILE */
while (1)
{
//初始状态:触发测量
if(aht20_State==0)
{
AHT20_Measure(); // 发送测量命令(非阻塞,立即返回)
aht20_State=1;
//测量命令发送完成:等待75毫秒,读取数据
}else if(aht20_State==2)
{
HAL_Delay(75); // 给传感器测量时间
AHT20_Get(); // 触发读取(非阻塞,立即返回)
aht20_State=3;
//读取完成:解析并展示数据
}else if(aht20_State==4)
{
AHT20_Analysis(&Temrerature, &Humidity); // 解析 readBuffer
sprintf(message,"温度:%.1f℃,湿度:%.1f%%\r\n",Temrerature,Humidity);
HAL_UART_Transmit(&huart2, (uint8_t*)message, strlen(message), HAL_MAX_DELAY);
HAL_Delay(2000); // 间隔 2 秒
aht20_State=0; // 回到初始状态
}
// 状态 1 和 3 什么都不用做:等中断回调改变状态
}
/* USER CODE END WHILE */

代码讲解

5.1 为什么把 AHT20_Read 拆成三个函数?

上篇的 AHT20_Read() 是一个”一气呵成”的大函数:发送命令 → 等待 → 接收 → 解析。这在轮询模式下没问题,因为函数内部阻塞执行,流程是线性的。

但中断模式下流程被切碎了:发送命令是一回事,等待 75ms 是另一回事,接收完成又是更后面的事——它们发生在不同的时间点(中间隔着中断)。所以必须拆开:

函数 做什么 什么时候调用
AHT20_Measure() 发送测量命令(非阻塞) 状态 0 → 1
AHT20_Get() 触发接收 6 字节(非阻塞) 状态 2 → 3(等 75ms 后)
AHT20_Analysis() 解析 readBuffer → 温湿度 状态 4(接收完成后)

5.2 重点:为什么 sendBuffer 必须加 static?

HAL_I2C_Master_Transmit_IT 是非阻塞的:函数内部只是”告诉外设:从这个地址开始传 3 个字节”,然后立即返回。真正的逐字节发送是之后由中断完成的——中断还在用 sendBuffer 这个地址。

而 sendBuffer 是 AHT20_Measure() 的局部变量,这个函数已经返回了,它的局部变量在内存里(栈上)的”地盘”被释放,可能被后面的函数调用覆盖。数据被改了就发错了。

static 的作用:把数组从”栈上的临时内存”挪到”常驻内存”(静态存储区),程序运行期间一直占用、永不回收。代价是它不再跟随函数创建销毁,函数每次进来用的是同一个数组。

类比:你给快递员(外设)留了张字条(数组)让他去取货,但字条贴在了马上要拆的墙上(栈)——拆墙后字条就没了。static 就是”把字条贴到永久公告栏上”。

5.3 HAL 中断收发函数参数详解

1
2
HAL_I2C_Master_Transmit_IT(&hi2c1, AHT20_ADDRESS, sendBuffer, 3);
HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, readBuffer, 6);
参数 含义 本项目中填的值
I2C 句柄指针 操作哪个 I2C 外设 &hi2c1
从机地址 和谁通信(HAL 库自动处理读写位) AHT20_ADDRESS (0x70)
数据缓冲区指针 数据从哪里来(发送)/ 存到哪里(接收) sendBuffer / readBuffer
传输长度 传多少字节 3 / 6
超时时间 中断模式没有这个参数(不阻塞,无需超时) —

对比轮询版 HAL_I2C_Master_Transmit(..., HAL_MAX_DELAY):只改了函数名加 _IT 和删掉超时参数,其他完全一样。

5.4 回调函数:中断的”完成通知”

数据搬运完成后,HAL 库通过回调函数通知我们:

1
2
发送完成 → HAL_I2C_MasterTxCpltCallback(hi2c)   ← 我们重写的
接收完成 → HAL_I2C_MasterRxCpltCallback(hi2c) ← 我们重写的

回调里判断 if(hi2c == &hi2c1):因为程序里可能挂了多个 I2C(比如以后加 OLED),回调是所有 I2C 共用的,必须确认是哪个外设完成了。

回调里做的事很简单——改状态:

  • 发送完成 → aht20_State = 2(可以开始等待+读取了)
  • 接收完成 → aht20_State = 4(可以解析+打印了)

主循环”看到”状态变化后,就去做对应的事。这就是状态机 + 中断回调的协作模式。

5.5 为什么 readBuffer 要全局变量?

和 sendBuffer 同理:AHT20_Get() 触发非阻塞接收后立即返回,数据是中断之后慢慢填进 readBuffer 的;而 AHT20_Analysis() 在更后面(状态 4)才来读它。局部变量活不过函数返回,必须提升为全局变量(常驻内存)。

注意区分:sendBuffer 用 static(还是函数内可见的”局部静态”),readBuffer 用全局。两者本质一样——常驻内存——但 readBuffer 要跨函数使用,所以直接全局。


解读初始化代码

6.1 中断服务函数的调用链

CubeMX 生成的 stm32f1xx_it.c 里,两个中断服务函数是硬件中断的入口:

1
2
3
4
5
6
7
8
9
硬件事件(发送完成/接收完成/地址匹配...)
↓ 触发中断
I2C1_EV_IRQHandler() ← stm32f1xx_it.c,CubeMX 生成
↓
HAL_I2C_EV_IRQHandler(&hi2c1) ← HAL 库内部:处理事件、逐字节搬运
↓
HAL_I2C_MasterTxCpltCallback(&hi2c1) ← 我们重写的回调(__weak 弱定义)
↓
aht20_State = 2; ← 状态机推进

之前学串口中断时解析过完全一样的链路(USART2_IRQHandler → HAL_UART_IRQHandler → UART_Receive_IT → HAL_UART_RxCpltCallback),I2C 只是把名字换成了 EV(事件)。中断编程的心智模型是通用的。

6.2 MspInit 里的 NVIC 配置

i2c.c 的 HAL_I2C_MspInit() 里(CubeMX 自动生成):

1
2
3
4
5
/* I2C1 interrupt Init */
HAL_NVIC_SetPriority(I2C1_EV_IRQn, 0, 0); // 事件中断:抢占优先级0,子优先级0
HAL_NVIC_EnableIRQ(I2C1_EV_IRQn); // 使能事件中断
HAL_NVIC_SetPriority(I2C1_ER_IRQn, 0, 0); // 错误中断:优先级0
HAL_NVIC_EnableIRQ(I2C1_ER_IRQn); // 使能错误中断
函数 含义 参数
HAL_NVIC_SetPriority 设置中断优先级 (中断号, 抢占优先级, 子优先级)
HAL_NVIC_EnableIRQ 使能中断(不开则中断不会触发) 中断号

优先级 0/0 是最高优先级。注意:勾选了 NVIC 才生成这几行;如果忘了勾选,回调函数永远不会被调用,状态机永远卡在状态 1 和 3。

6.3 配置对应关系表

CubeMX 设置 代码位置 作用
I2C1 event interrupt 勾选 HAL_NVIC_EnableIRQ(I2C1_EV_IRQn) 使能事件中断(搬运完成后通知 CPU)
I2C1 error interrupt 勾选 HAL_NVIC_EnableIRQ(I2C1_ER_IRQn) 使能错误中断
— I2C1_EV_IRQHandler → HAL_I2C_EV_IRQHandler 中断入口,HAL 库内部逐字节搬运
— HAL_I2C_MasterTxCpltCallback / RxCpltCallback 完成回调,我们写状态推进逻辑

编译 & 烧录

老样子,这里不再展开(参考这篇博客)。


效果展示

操作 现象
打开串口助手,选择对应 COM 口,波特率 115200 每 2 秒刷新一行温湿度
观察温度读数 与室温一致,约 26-32℃
用手捂住传感器 温度上升、湿度升高,读数随环境变化

此处效果和上一篇轮询模式一模一样,不再重复录制


踩坑记录

坑 1:aht20_State undeclared —— extern 是”文件级”的,且 extern 不是定义

现象:main.c 里写了 extern uint8_t aht20_State;,但 i2c.c 的回调里用 aht20_State 编译报 undeclared。

原因:这里踩了两个叠加的坑——

  1. extern 声明只对声明它的文件可见:每个 .c 文件是独立编译的,main.c 里的 extern 对 i2c.c 来说不存在。i2c.c 要用,必须能”看到”声明——靠 include 链。
  2. extern 是声明不是定义:extern uint8_t aht20_State; 只是”这变量在别处定义了”,本身不分配内存。全项目必须有且仅有一个文件写不带 extern 的定义(uint8_t aht20_State = 0;),否则链接报 undefined reference。

解决:定义放 main.c,extern 声明放 main.h——这样所有 include 了 main.h 的文件(i2c.c 通过 i2c.h → main.h 间接 include)都能看到声明,不需要在 i2c.c 里重复写:

1
2
3
4
5
6
7
// main.c(USER CODE BEGIN PV):定义(分配内存,全项目唯一)
uint8_t aht20_State = 0;

// main.h(USER CODE BEGIN PV):声明,全局可见
extern uint8_t aht20_State;

// i2c.c:什么都不用加,i2c.c → i2c.h → main.h 的 include 链让声明自动可见

通用规则:跨文件共享变量 → 一个 .c 里定义,extern 放公共头文件(谁用谁 include)。这样不用在每个使用的 .c 文件里各写一遍 extern,也避免漏写。

坑 2:sendBuffer 忘记加 static

现象:加不加 static 都能编译,但发送的数据会随机出错(内存被覆盖)。

原因:非阻塞函数返回后局部数组内存被回收,中断传数据时地址里的内容已被其他代码覆盖。编译不报错、现象随机——这是最难排查的一类 bug,只能靠理解原理预防。


总结

新知识点 说明
HAL_I2C_Master_Transmit_IT / Receive_IT 中断模式收发,非阻塞,无超时参数
状态机编程 0→1→2→3→4 状态循环,中断回调驱动状态
回调函数 MasterTxCpltCallback(发送完成)/ MasterRxCpltCallback(接收完成)
static 局部变量 非阻塞函数的数组必须常驻内存(函数返回后不回收)
全局变量提升 readBuffer 跨函数使用 → 全局
extern vs 定义 一个 .c 定义(分配内存),extern 放公共头文件(include 即用)
DMA 模式 _IT → _DMA,一行切换(CubeMX 需先加 DMA 通道)

和上一篇的对比

对比项 上篇(轮询) 本篇(中断)
HAL 函数 Master_Transmit/Receive Master_Transmit_IT/Receive_IT(去 _IT 即 DMA)
超时参数 HAL_MAX_DELAY 无
函数形态 一个大函数 AHT20_Read() 一气呵成 拆成 Measure / Get / Analysis + 状态机
CPU 占用 阻塞,全程等待 非阻塞,只在中断里搬一下
数组生命周期 局部数组即可 sendBuffer 要 static,readBuffer 要全局

核心收获:本篇不只是学了 I2C 中断模式,更重要的是状态机编程——非阻塞外设(中断/DMA)驱动下的标准写法。后面学 OLED、FreeRTOS 时,稍微复杂一点的程序处处都是状态机的身影。


进阶思考:什么时候才需要状态机?

写完这篇博客后,我正在复习 AHT20_Init() 里的轮询收发函数,突然冒出一个问题:既然 CubeMX 里 I2C1 的中断和 DMA 都打开了,那是不是只要把 receive/transmit 后面加上 _IT/_DMA,这里的阻塞也就能变成非阻塞的了?

紧接着第二个问题来了:如果非阻塞这么”香”,为什么之前那么多项目(串口中断、串口 DMA、蓝牙)都没用显式状态机,也跑得好好的?本篇却非要引入状态机不可?

这两个问题值得好好拆一拆。

一、AHT20_Init 能直接加 _IT 吗?——不能,而且它保持轮询是正确的

看 Init 的代码结构:

1
2
3
4
5
6
7
8
9
void AHT20_Init(void)
{
HAL_Delay(40);
HAL_I2C_Master_Receive(&hi2c1, AHT20_ADDRESS, &readBuffer, 1, HAL_MAX_DELAY); // ① 读状态字
if ((readBuffer & 0x08) == 0x00) // ② 马上用读到的结果做判断!
{
...发送初始化命令...
}
}

这里有一条**”读→判→写”依赖链:第②行的判断依赖**第①行读到的数据。阻塞模式下没问题——函数返回时数据一定到位。但改成 Receive_IT 后:

1
2
HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, &readBuffer, 1);  // 立即返回,readBuffer 还是垃圾值!
if ((readBuffer & 0x08) == 0x00) // ← 在垃圾值上做判断,逻辑全错

依赖关系是阻塞/非阻塞切换的死穴:只要”后面一行要用前面调用的结果”,改成非阻塞就炸。真要改,只能把 Init 也拆进状态机(读状态字 → 回调里判断 → 发初始化命令 → 再回调 → 完成),一个 10 行的函数变成 3 个状态 + 2 个回调。

而它值得吗?初始化只在上电时执行一次,那一刻系统里本来就没有别的任务——CPU 等这几毫秒毫无损失。所以正确做法就是保持轮询。这引出一个重要原则:

该阻塞时阻塞,该非阻塞时才非阻塞。非阻塞 + 状态机是为了”腾出 CPU 做别的”,如果当时没有”别的”可做,阻塞就是最优解。

二、之前的项目为什么不用状态机?——回调就是”隐式状态机”

回顾之前所有项目的收数据方式:

项目 启动接收 消费数据的时机 显式状态机
串口轮询 HAL_UART_Receive(阻塞) 函数返回 = 数据到位,直接处理 ❌
串口中断 Receive_IT 启动 在回调函数里处理 ❌
串口 DMA 不定长 ReceiveToIdle_DMA 启动 在回调函数里解析数据包 ❌
蓝牙 同上 在回调函数里解析 ❌
I2C AHT20(本篇) Measure / Get 分两次启动 分阶段,各等各的 ✅

前面几个项目里,”启动接收”和”消费数据”是两个分离的代码位置——启动函数只管启动,数据到位后的处理全部写在回调函数里。回调本身就是”数据好了”的事件通知,处理逻辑天然发生在数据到位之后,根本不存在”数据没到就用”的问题。

所以它们其实不是没用状态机,而是用了一个隐式的两状态机:

1
隐式状态机:状态A(等数据)──收到数据→ 状态B(回调:处理数据)──处理完→ 回到状态A

整个系统只有两个状态,**根本不需要显式变量记录”走到哪了”**——回调被调用的那一刻,CPU 天然知道”数据到了”。

三、AHT20 为什么必须显式状态机?——多阶段流水线

而 AHT20 的流程是一条多阶段流水线:

1
发测量命令 →(等) 75ms →(等) 读数据 →(等) 解析 → 循环
  • 75ms 延时必须等发送完成之后才开始
  • 读取必须等 75ms 过后才能触发
  • 解析必须等接收完成之后才能做

阶段之间有严格的先后依赖,每一阶段都要”等一个外部事件”才能推进。这时候回调就不够用了——回调只知道”某个事件发生了”,但主循环不知道这是流水线的第几步。必须有一个变量记录”我现在走到哪一步了”,这个变量就是状态机。

1
2
3
显式状态机:0 →(发命令) 1 →(发完回调) 2 →(延时+Get) 3 →(收完回调) 4 →(解析打印) 0
↑
每一步都知道自己在流水线的什么位置

四、结论:什么时候用状态机

场景 判断依据 做法
不用 一次性流程,阻塞等待无代价(初始化、开机自检) 保持轮询
不用 单次异步等待:收完就处理,没有后续阶段 回调里直接处理
用 多阶段流水线,阶段间有先后依赖,且要重复循环 显式状态变量 + 回调推进
用 系统里还有其他任务要跑,CPU 不能长期占用 非阻塞 + 状态机

一句话判断法:代码里有没有”等一件事做完,才能开始下一件事”的链条?链条只有一环 → 回调够用;链条有多环 → 显式状态机。

所以本篇引入状态机不是”I2C 必须这样”,而是”任何外设的多阶段异步流程都这样“——以后学 OLED、FreeRTOS、电机控制,遇到”发指令→等应答→再发”的交互,全是同一个套路。


概念辨析:I2C 明明是”同步通信”,为什么这里又说”异步”?

除了上面的进阶思考,你可能又被一个概念卡住:I2C 不是同步通信吗?怎么变成了异步? 因为这是两个不同维度的”同步/异步”,中文里撞了同一个词。

维度一:硬件时钟(I2C 为什么叫”同步通信”)

这是通信协议层面的分类——双方有没有共用时钟信号:

同步通信 异步通信
时钟 主机提供 SCL,大家看同一个钟表 双方各按自己的波特率时钟
代表 I2C、SPI UART

I2C 的 SCL 时钟线由主机产生,从机跟着它走——所以叫”同步”。这个特性 I2C 永远是同步的,无论代码怎么写都不会变。

维度二:程序执行(本篇说的”异步”)

这是编程层面的分类——函数调用后 CPU 是等还是不等:

阻塞调用(同步执行) 非阻塞调用(异步执行)
函数返回时 操作已完成 操作才刚开始
完成通知 不需要,返回就是完成 通过中断回调通知
HAL 函数 Transmit/Receive(轮询) Transmit_IT/Receive_IT、_DMA

本篇说的”异步”是这个维度——I2C 用了中断/DMA 后,调用立即返回,CPU 不等了,操作完成后由回调通知。这是软件执行方式的变化。

两个维度正交,I2C 落在四个象限之一

时钟同步(I2C/SPI 硬件特性) 时钟异步(UART 硬件特性)
阻塞调用(软件) I2C 轮询(上篇)、SPI 轮询 UART 轮询(串口那篇)
非阻塞调用(软件) I2C 中断/DMA(本篇)、SPI 中断/DMA UART 中断/DMA(串口中断/DMA 那两篇)

所以准确的说法是:

I2C 的通信方式(时钟)始终同步——SCL 那根线决定了硬件层面的一切;
本篇改的是程序执行方式——从”CPU 阻塞等待”变成”中断回调异步通知”,跟时钟没有关系。

打个比方:I2C 同步通信 = 大家一起按节拍器走路(硬件时钟);本篇的”异步” = 你不再盯着别人的脚步走,而是人家走完喊你一声(中断回调)——节拍器还在,只是你不再死等了。

这也是为什么之前串口那几篇(硬件异步)和本篇(硬件同步)用的编程模式完全一样——轮询/中断/DMA 这套东西属于软件维度,跟硬件是同步还是异步无关。


参考资源