从上一篇到这篇
上篇我们学习了 I2C 轮询模式读取 AHT20:用 HAL_I2C_Master_Transmit 发送测量命令、HAL_I2C_Master_Receive 读取 6 字节数据,再拼接换算成温湿度。
但轮询模式有个老问题——阻塞。发送和接收函数内部”等一个字节搬一个字节”,整个过程 CPU 被占住,什么别的事都干不了。这和之前串口学的完全一样:轮询 → 中断 → DMA。
本篇把 I2C 通信改造成中断模式,并引入单片机里极其常见的状态机编程思想。中间我遇到两个很有价值的问题:
- 非阻塞函数 + 局部数组 = 数据被回收(要用
static) - 跨文件共享变量:extern 声明 vs 定义(编译报 undeclared 的根源)
最后把中断版改成 DMA 版——因为中断和 DMA 在 HAL 里只差一个后缀。
本篇和上篇是同一块板子、同一个工程。
硬件原理
2.1 轮询 vs 中断 vs DMA(回顾 + 迁移)
串口那几篇已经详细讲过三种模式的区别,I2C 完全一样:
| 模式 | 发送/接收过程 | CPU 在干嘛 | 怎么知道完成了 |
|---|---|---|---|
| 轮询 | CPU 逐字节写入/读取寄存器 | 阻塞等待 | 函数返回 = 完成 |
| 中断 | 每搬完 1 字节触发中断,中断里搬下一字节 | 平时干别的,被中断叫去搬 | 回调函数 |
| DMA | DMA 硬件整批搬运 | 彻底解放 | 回调函数(DMA 通过中断通知) |
关键区别一句话:轮询的函数返回 = 数据传完了;中断/DMA 的函数调用完 = 只是把任务交给了外设,数据还没传。
2.2 非阻塞函数的”大坑”:任务交给外设后,代码继续往下跑
这是本篇最容易出 bug 的地方。看这个改造前”想当然”的代码:
1 | void AHT20_Read_IT() // 想当然的错误写法 |
两个致命问题:
① 数据没收到就往下执行:Receive_IT 只是”通知外设开始接收”,真正搬运是中断里逐字节完成的。函数返回时 readBuffer 里全是初始值。这就需要一个状态机:把”发送命令”、”等待”、”读取”、”解析”拆成独立动作,由中断回调来驱动状态切换。
② 局部数组内存被回收:Transmit_IT 是非阻塞的,函数返回后中断还在用 sendBuffer 的地址传数据,但 sendBuffer 是局部变量,函数一结束它的内存就被回收、可能被其他变量覆盖——发出去的数据就错了。解决方案:加 static 让数组常驻内存。
2.3 状态机:让”异步”变得可控
非阻塞编程的核心思想就是状态机。我们把 stm32与AHT20交互 的完整流程拆成 5 个状态,由中断回调推进状态,主循环只负责”看状态、做该做的事”:
1 | AHT20_Measure() 中断回调 AHT20_Get() |
| 状态值 | 含义 | 谁触发的 | 当前状态要做的事 |
|---|---|---|---|
| 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(错误中断)

Ctrl+S 保存并生成代码后,stm32f1xx_it.c 里会自动多出两个中断服务函数:
1 | void I2C1_EV_IRQHandler(void) // 事件中断:发送/接收完成、地址匹配等 |
3.2 DMA 模式
如果要切换到 DMA 模式,在 I2C1 → DMA Settings 里添加 I2C1_TX 和 I2C1_RX 两条 DMA 通道,默认配置即可。本篇主要讲中断模式,DMA模式仅需要把I2C收发函数的后缀从IT改为DMA即可
核心代码
4.1 aht20.h —— 头文件(声明拆分后的三个函数)
1 |
|
4.2 aht20.c —— 驱动实现(拆分为 Measure / Get / Analysis)
1 |
|
4.3 i2c.c —— 中断回调(状态机的”驱动引擎”)
1 | /* USER CODE BEGIN 0 */ |
4.4 main.c —— 状态机主循环
1 | /* USER CODE BEGIN PV */ |
代码讲解
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 | HAL_I2C_Master_Transmit_IT(&hi2c1, AHT20_ADDRESS, sendBuffer, 3); |
| 参数 | 含义 | 本项目中填的值 |
|---|---|---|
| I2C 句柄指针 | 操作哪个 I2C 外设 | &hi2c1 |
| 从机地址 | 和谁通信(HAL 库自动处理读写位) | AHT20_ADDRESS (0x70) |
| 数据缓冲区指针 | 数据从哪里来(发送)/ 存到哪里(接收) | sendBuffer / readBuffer |
| 传输长度 | 传多少字节 | 3 / 6 |
| 中断模式没有这个参数(不阻塞,无需超时) | — |
对比轮询版 HAL_I2C_Master_Transmit(..., HAL_MAX_DELAY):只改了函数名加 _IT 和删掉超时参数,其他完全一样。
5.4 回调函数:中断的”完成通知”
数据搬运完成后,HAL 库通过回调函数通知我们:
1 | 发送完成 → HAL_I2C_MasterTxCpltCallback(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 | 硬件事件(发送完成/接收完成/地址匹配...) |
之前学串口中断时解析过完全一样的链路(USART2_IRQHandler → HAL_UART_IRQHandler → UART_Receive_IT → HAL_UART_RxCpltCallback),I2C 只是把名字换成了 EV(事件)。中断编程的心智模型是通用的。
6.2 MspInit 里的 NVIC 配置
i2c.c 的 HAL_I2C_MspInit() 里(CubeMX 自动生成):
1 | /* I2C1 interrupt Init */ |
| 函数 | 含义 | 参数 |
|---|---|---|
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。
原因:这里踩了两个叠加的坑——
- extern 声明只对声明它的文件可见:每个 .c 文件是独立编译的,main.c 里的 extern 对 i2c.c 来说不存在。i2c.c 要用,必须能”看到”声明——靠 include 链。
- 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 | // main.c(USER CODE BEGIN PV):定义(分配内存,全项目唯一) |
通用规则:跨文件共享变量 → 一个 .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 | void AHT20_Init(void) |
这里有一条**”读→判→写”依赖链:第②行的判断依赖**第①行读到的数据。阻塞模式下没问题——函数返回时数据一定到位。但改成 Receive_IT 后:
1 | HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, &readBuffer, 1); // 立即返回,readBuffer 还是垃圾值! |
依赖关系是阻塞/非阻塞切换的死穴:只要”后面一行要用前面调用的结果”,改成非阻塞就炸。真要改,只能把 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 | 显式状态机: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 这套东西属于软件维度,跟硬件是同步还是异步无关。