I2C 中断模式收发数据实战:从阻塞到中断的架构改造
工程流程与逻辑梳理
一、工程搭建流程(6 步走完)
第 1 步:CubeMX 配置(只配 4 处)
配置项 值 说明 I2C2 使能,PB10=SCL / PB11=SDA AT24C02 挂 I2C2 I2C 速度 Standard Mode(100kHz) 兼容性最稳,AT24C02 支持 400k 但不必须 USART1 使能,PA9/PA10,115200-8N1 结果打印 NVIC 勾选 I2C2 event interrupt 和 error interrupt ⚠️ 最容易漏------不勾,回调永远不触发 时钟 HSE 8MHz → PLL ×9 → 72MHz 串口波特率依赖正确时钟
第 2 步:生成 MDK 工程
生成后自动得到
MX_I2C2_Init()、MX_USART1_UART_Init()、MX_GPIO_Init()等初始化骨架,以及I2C2_EV_IRQHandler/I2C2_ER_IRQHandler中断入口(CubeMX 已帮你写进stm32f1xx_it.c)。
第 3 步:写 BSP 驱动层(两个文件)
文件 职责 BSP_AT24C02_IT.h设备结构体 eeprom_t、状态枚举、操作类型、 回调函数指针、宏定义BSP_AT24C02_IT.cInit / Ready / Read_Page_IT Write_Page_IT / Process + 重写 3 个 HAL 回调
第 4 步:main 业务(逻辑主线只有 3 行)
cpp初始化(HAL → 时钟 → GPIO/I2C2/USART1 → printf 重定向 → BSP_AT24C02_Init) ↓ 发起写:BSP_AT24C02_Write_Page_IT(0, tx_buffer, 8, 回调) ↓ 主循环:if (BSP_AT24C02_Process()) → 发起读回验证
第 5 步:编译烧录
SWD 下载后复位。
若串口没有任何输出先查:printf 重定向(MicroLIB)、USART1 时钟/引脚。
第 6 步:串口验证(115200,按顺序应看到 4 条日志)
cppInit At24C02 SUCCESS ← 设备探测通过(IsDeviceReady 有 ACK) Write Page success ← 写传输完成回调 等待设备就绪完成... ← Process 消费写完成状态 Read Page success! data: 0x11 0x22 ... ← 读回验证成功
二、运行逻辑(4 条链路)
链路 1:初始化链(上电 → 就绪)
cppHAL_Init(SysTick 1ms) → SystemClock_Config(72MHz) → MX_GPIO_Init / MX_I2C2_Init / MX_USART1_UART_Init → BSP_Usart_Init(printf 重定向到串口) → BSP_AT24C02_Init: 存句柄/地址(0x50<<1=0xA0)/页大小 → HAL_I2C_IsDeviceReady(起始 + 地址+W,等 ACK) → 有 ACK → status=READY, is_init=true逻辑要点:
任何 I2C 操作前必须先"探测"------设备可能没上电、地址不对、上拉缺失,先验明正身再干活。
链路 2:发起写(主循环侧,立即返回)
cppWrite_Page_IT(addr, data, len, cb) ├─ 关卡1 未初始化/参数空 → cb(false) ├─ 关卡2 跨页(page_offset+len>8)→ cb(false) ├─ 关卡3 status==BUSY(防重入)→ cb(false) └─ 通过:status=BUSY,存 buffer/len/operator=WRITE/cb → HAL_I2C_Mem_Write_IT(...) → HAL_OK 返回 true;非 OK → ERROR + cb(false)逻辑要点:
函数只负责"检查 + 发起",不负责传输;
失败路径全部走
cb(false),调用方只认一个失败信号。
链路 3:后台传输 + 回调(中断侧)
cppI2C2 EV 中断 → HAL_I2C_EV_IRQHandler → 按状态机推进: 起始 → 地址+W → ACK → MemAddress → ACK → 8 字节数据 → ACK → 停止 传输完成 → HAL_I2C_MemTxCpltCallback(__weak,被我们重写) ├─ 判断 Instance 是自己的 I2C2 ├─ 判断 operator==WRITE ├─ status = SUCCESS └─ user_callback(true)(打印 "Write Page success") (若中途出错)I2C2 ER 中断 → HAL_I2C_ER_IRQHandler → ErrorCallback → status=ERROR + user_callback(false)逻辑要点:
回调运行在中断上下文,只做"置状态 + 通知",禁止阻塞(EEPROM 写周期约 5ms,绝不能在回调里等)。
链路 4:主循环调度(闭环推进)
cppwhile(1): BSP_AT24C02_Process() ├─ 条件:operator==WRITE && status==SUCCESS ├─ 动作:BSP_AT24C02_Ready() 再探测一次 → 打印"等待设备就绪完成" │ → status 复位为 READY → return true └─ main 收到 true → 发起 BSP_AT24C02_Read_Page_IT(0, rx, 8, 回调) → 后台读回(重复起始 + 地址+R + 8 字节,最后 NACK) → MemRxCpltCallback → SUCCESS → 回调打印 rx_buffer 数据逻辑要点:
写→读的"业务推进"全部由主循环驱动,中断只负责"传输完成信号"------这就是前后台架构的纪律:前台报信,后台办事。
三、数据流(硬件层面)
写方向:****
tx_buffer → (BSP buffer) → HAL → I2C DR → 移位寄存器 → SDA 总线 → AT24C02 页缓冲 → 内部写周期(约5ms) → 存储单元读方向:****
存储单元 → 页缓冲 → SDA → 移位寄存器 → 比较器校验地址 → DR → HAL → rx_buffer → 回调打印
四、这套工程的 5 个设计决策
决策 为什么 状态机 + BUSY 状态 中断模式调用即返回,必须有"忙"标记防重入,否则并发发起会乱 status/operator_type 用 volatile 中断回调写、主循环读,防止编译器缓存旧值 回调分层(HAL 回调 → 用户回调) 驱动与业务解耦:驱动不写死"打印/存数据",用户可换回调 跨页检查放在发起前 AT24C02 页=8 字节,跨页会地址回绕覆盖页首 写成功后由 Process 触发读回 中断里不嵌套发起新传输,业务推进线性可控
工程知识
1. I2C 与 NVIC 联动:两个中断,各管一摊
STM32F103 的 I2C 中断在向量表里是分家的(这是很多人第一次接触会懵的地方):
中断 向量号 管什么 I2C1_EV_IRQn 31 事件中断:起始完成、地址匹配、TXE、RXNE、停止 I2C1_ER_IRQn 32 错误中断:NACK(AF)、总线错误(BERR)、仲裁丢失(ARLO)、溢出(OVR) I2C2_EV_IRQn 33 I2C2 事件中断 I2C2_ER_IRQn 34 I2C2 错误中断 为什么事件和错误要分开?
- 事件中断高频(每收发一个字节都可能触发),错误中断低频;
- 分开后可以分别设置优先级------错误可以设得比事件更高,保证出问题时第一时间响应;
- 调试时也清晰:进错中断就是出错了,不用在事件中断里翻标志。
对应到 I2C 的功能框图(参考手册 Figure),数据通路是:
SDA ↔ 数据控制 → 移位寄存器 ↔ 数据寄存器(DR),移位结果送比较器与地址寄存器(OAR)比对判断是否被寻址;
SCL由时钟控制(CCR) 产生;整个状态机由控制逻辑驱动,它读写CR1/CR2(控制)和SR1/SR2(状态),并向 NVIC 输出中断、向 DMA 输出请求。
2. 中断模式的 API:HAL_I2C_Mem_Read_IT / Write_IT
阻塞版本函数名去掉
_IT就是中断版本,签名几乎一样:
cppHAL_StatusTypeDef HAL_I2C_Mem_Read_IT(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size); HAL_StatusTypeDef HAL_I2C_Mem_Write_IT(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size);参数逐条说:
参数 含义 注意 hi2cI2C 外设句柄 指向已初始化的句柄 DevAddress目标设备地址 必须传左移 1 位后的 8 位地址(见下方坑 1) MemAddress器件内部寄存器/存储地址 如 AT24C02 的字节地址 0~255 MemAddSize内部地址位宽 I2C_MEMADD_SIZE_8BIT或16BITpData数据缓冲区指针 收发都指向它 Size字节数 单页场景 ≤ 页大小 返回值是一个
HAL_StatusTypeDef:
HAL_OK:操作成功启动(注意:不是传输完成);HAL_BUSY:外设正在忙,本次调用没接单;HAL_ERROR:参数无效或内部错误。和阻塞版最大的三个区别:
- 非阻塞:函数发起后立即返回,实际传输在后台由中断服务程序完成;
- 回调通知:传输完成后进
HAL_I2C_MemRxCpltCallback(读完成)或HAL_I2C_MemTxCpltCallback(写完成),你在代码里实现它;- 专用性:这两个函数专用于带内部存储映射的器件(EEPROM、传感器),会自动在起始条件之后发送
MemAddress字段------相比HAL_I2C_Master_Transmit_IT省去了手动拼地址的步骤。
3. 架构设计:设备对象 + 状态机 + 回调解耦
中断模式最怕什么?
乱。调用返回了,但传输还在飞,什么时候完、完了怎么知道、中途能不能再发起新操作------全靠一套明确的"协议"。课件里的解法很标准,三层结构:
3.1 设备结构体
eeprom_t:把全局状态装进一个对象
cpptypedef enum { EEPROM_IDLE = 0, // 空闲 EEPROM_READY, // 就绪(检测通过) EEPROM_BUSY, // 忙(传输进行中) EEPROM_SUCCESS, // 成功(回调置位) EEPROM_ERROR // 错误(错误回调置位) } Status_t; #define NONE_OPERATOR 0 #define READ_OPERATOR 1 #define WRITE_OPERATOR 2 typedef void (*user_callback_t)(bool); // 用户回调:bool 表示成功/失败 typedef struct { I2C_HandleTypeDef *hi2c; // I2C 句柄 uint16_t dev_address; // 从设备 8 位地址 uint8_t page_size; // 页大小 bool is_init; // 是否初始化成功 volatile Status_t status; // ★ 设备状态(中断改、主循环读) uint8_t *buffer; // 本次操作的缓冲区 uint8_t len; // 本次操作长度 volatile uint8_t operator_type; // 当前操作类型(读/写/无) user_callback_t user_callback; // 用户回调函数指针 } eeprom_t;每个字段为什么存在?
status+operator_type:中断回调和主循环之间靠它俩通信,被中断修改 → 必须 volatile(否则编译器可能把主循环里的读取优化到寄存器缓存,永远读到旧值);buffer / len:中断回调需要知道"数据传到了哪块缓冲、一共多少字节",这些信息在发起调用的函数返回后就丢了,必须存进结构体;user_callback:把"驱动层"和"业务层"解耦------驱动只负责传输,传完了调用户回调,用户想打印就打印、想存数据就存数据,驱动代码不用改。
3.2 状态机:防重入 + 统一出口
cppIDLE → (Init 检测通过) → READY → (发起操作) → BUSY → (完成回调) → SUCCESS → (Process 消费) → READY BUSY → (错误回调) → ERROR
- BUSY 的意义是防重入:上一次传输没结束,不允许再发起新操作,直接拒绝并回调 false;
- SUCCESS 是"传输完成"的信号:注意它不代表 EEPROM 已经保存完毕(见第 5 节的坑 3);
- ERROR 是统一失败出口:任何一步失败都走
ErrorCallback→ 置 ERROR → 回调 false,调用方逻辑只认这一个信号。
4. 代码逐行解析
4.1 初始化:先"验明正身"
cppbool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c) { if (!hi2c) return false; at24c02.hi2c = hi2c; at24c02.dev_address = DEV_ADDRESS << 1; // 0x50 << 1 = 0xA0(坑 1) at24c02.page_size = PAGE_SIZE; // 8 at24c02.is_init = false; at24c02.buffer = NULL; at24c02.len = 0; at24c02.user_callback = NULL; at24c02.status = EEPROM_IDLE; at24c02.operator_type = NONE_OPERATOR; // 1. 产生起始 // 2. 发送从设备地址 + W(0) // 3. 从机 ACK → 说明设备活着 // 4. STOP if (HAL_OK == HAL_I2C_IsDeviceReady(at24c02.hi2c, at24c02.dev_address, SCAN_RETYIES, SCAN_TIME_MS)) { at24c02.status = EEPROM_READY; at24c02.is_init = true; } return at24c02.is_init; }
HAL_I2C_IsDeviceReady做的事:发起"起始 + 从机地址+W",等待 ACK。- 有 ACK = 从机在线;没有 = 不在(可能没上电、地址不对、上拉电阻缺失)。
retries和timeout是重试次数与单次等待超时。- 初始化失败直接返回 false,main 里会打印
Init At24C02 ERROR并死循环,防止后面用未初始化的外设。
4.2 发起写:检查三重关卡
cppbool BSP_AT24C02_Write_Page_IT(uint16_t start_address, void *data_in, uint8_t data_len, user_callback_t cb) { // 关卡 1:初始化、参数合法性 if (!at24c02.is_init || !data_in || data_len == 0) { if (cb) cb(false); return false; } // 关卡 2:跨页检查(坑 2) uint16_t page_offset = start_address % at24c02.page_size; // 页内偏移 if (page_offset + data_len > at24c02.page_size) { if (cb) cb(false); return false; } // 关卡 3:忙检查(防重入) if (at24c02.status == EEPROM_BUSY) { if (cb) cb(false); return false; } // 记录本次操作上下文(回调要用) at24c02.status = EEPROM_BUSY; at24c02.buffer = data_in; at24c02.len = data_len; at24c02.operator_type = WRITE_OPERATOR; at24c02.user_callback = cb; // 真正发起:非阻塞,立即返回 HAL_StatusTypeDef state = HAL_I2C_Mem_Write_IT(at24c02.hi2c, at24c02.dev_address, start_address, I2C_MEMADD_SIZE_8BIT, data_in, data_len); if (state != HAL_OK) { at24c02.status = EEPROM_ERROR; if (cb) cb(false); return false; } return true; }三个关卡的意义:
- 跨页检查(关卡 2):AT24C02 页大小 8 字节,页编程只能在同一页内,跨页会地址回绕覆盖页首数据。
page_offset = start_address % 8算出页内偏移,偏移 + 长度 > 8就是跨页,直接拒绝。多页数据怎么办?拆成多段、每段写一页(和 SPI Flash 跨页写同思路)。- 忙检查(关卡 3):
status == EEPROM_BUSY说明上一次传输还没结束,此时再发起,HAL 也会返回HAL_BUSY------与其等 HAL 拒绝,不如我们自己先拦住,语义更清晰。- 统一失败出口:每个失败分支都
cb(false),调用方不用关心具体哪一步挂的。
4.3 三个回调:中断里只做"置状态 + 通知"
cpp// 写完成回调:走到这里,数据已经被 HAL 通过 I2C 发完了 void HAL_I2C_MemTxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c->Instance != at24c02.hi2c->Instance) return; if (at24c02.operator_type == WRITE_OPERATOR) { // 注意:发送完毕 ≠ EEPROM 已经保存完毕! // 绝对不能在中断回调里等待设备就绪(坑 3) at24c02.status = EEPROM_SUCCESS; if (at24c02.user_callback) { at24c02.user_callback(true); at24c02.user_callback = NULL; // 用后置空,防止重复调用 } } } // 读完成回调 void HAL_I2C_MemRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c->Instance != at24c02.hi2c->Instance) return; if (at24c02.operator_type == READ_OPERATOR) { at24c02.status = EEPROM_SUCCESS; if (at24c02.user_callback) { at24c02.user_callback(true); at24c02.user_callback = NULL; } } } // 错误回调:传输过程中出错自动进来 void HAL_I2C_ErrorCallback(I2C_HandleTypeDef *hi2c) { if (hi2c->Instance != at24c02.hi2c->Instance) return; at24c02.status = EEPROM_ERROR; if (at24c02.user_callback) { at24c02.user_callback(false); at24c02.user_callback = NULL; } }三个要点:
- 先判 Instance:工程里可能同时用 I2C1/I2C2 甚至多个从机,回调是全局的,必须先确认"这次回调是不是我的 I2C 发起的",否则会误处理别人的设备;
- 只置状态 + 通知:回调运行在中断上下文,越短越好,绝不阻塞;
- user_callback 用完置 NULL:回调只该被触发一次,置空防止同一操作被重复通知(比如后续又误触发)。
4.4 主循环调度:Process 负责"消费状态"
cppbool BSP_AT24C02_Process(void) { // 写操作成功之后,把状态复位,让系统可以发起下一步 if (at24c02.operator_type == WRITE_OPERATOR && at24c02.status == EEPROM_SUCCESS) { BSP_AT24C02_Ready(); // 再确认设备在线 printf("等待设备就绪完成...\n"); at24c02.status = EEPROM_READY; return true; } return false; }main 里的用法就是"写成功 → 读回来验证":
cppBSP_AT24C02_Write_Page_IT(0, tx_buffer, 8, EEPROM_WriteCallBack); // 先写 8 字节 while (1) { if (BSP_AT24C02_Process()) // 写完成信号被消费 { // 写成功了,读回来验证 BSP_AT24C02_Read_Page_IT(0, rx_buffer, 8, EEPROM_ReadCallBack); } }为什么"读回"要放在 Process 里、而不是写回调里?
在回调里直接再发起一次读属于"中断里嵌套发起新传输",状态机要处理的情况急剧变多;放主循环按状态推进,逻辑线性、好调试、还方便中间插延时。
4.5 用户回调:业务代码只写一处
cppvoid EEPROM_WriteCallBack(bool result) { if (result) printf("Write Page success\n"); else printf("Write Page failed\n"); } void EEPROM_ReadCallBack(bool result) { if (result) { printf("Read Page success! data : \n"); for (int i = 0; i < 8; i++) printf("0x%x ", rx_buffer[i]); printf("\n"); } else { printf("Read Page failed\n"); } }驱动层完全不关心"写完后是打印还是存数据库",它只负责把
bool result传回来------这就是回调解耦的价值。
5. 三个必须记住的坑
坑 1:DevAddress 要左移一位
AT24C02 数据手册给的是 7 位地址
0x50,但 HAL 的DevAddress参数要的是8 位地址(第 0 位留给 R/W 方向)。所以:
at24c02.dev_address = DEV_ADDRESS << 1; // 0x50 << 1 = 0xA0传成 0x50 会怎样?
I2C 总线上发出去的地址不对,从机不 ACK,
IsDeviceReady永远失败。
坑 2:跨页写入会覆盖页首
AT24C02 页 = 8 字节,页编程时地址只在页内递增,超过页尾就回绕到页首。
从地址 7 写 2 字节,第二个字节会写到地址 0。
所以发起前必须做
page_offset + data_len <= page_size检查,多页数据要拆段写。
坑 3:回调里绝对不能等 EEPROM 就绪
这是最隐蔽的一个。
HAL_I2C_MemTxCpltCallback触发,只代表 I2C 总线上的传输完成了,而 EEPROM 内部还在把数据从页缓冲写入存储单元(AT24C02 写周期约 5ms)。如果在中断回调里调
IsDeviceReady忙等,5ms 里所有中断都被堵死------这是活生生的"中断里阻塞 = 系统失聪"反面教材。正确姿势:置
SUCCESS,把"等写周期"交给主循环(Process 里再Ready()确认),或主循环延时后再发起读。
6. 阻塞 / 中断 / DMA:什么时候用哪个?
维度 阻塞模式 中断模式(本篇) DMA 模式 调用后主循环 被卡住 立即返回 立即返回 CPU 占用 高(忙等) 中(每字节一次事件中断) 低(搬运不占 CPU) 代码复杂度 最低 中(状态机 + 回调) 中高 典型场景 调试、单次小数据 命令交互、需要并发 大块数据(如整片读写) EEPROM 页(8B) 场景 可以 推荐 收益小(数据太少) 一句话:要并发、要响应其他任务 → 中断模式;数据量大到中断开销不可接受 → DMA。
7. 总结
调用点 所在上下文 做什么 返回值 / 效果 main → Write_Page_IT 主循环 四道检查 + 存上下文 bool(是否发起成功) Write_Page_IT → Mem_Write_IT 主循环 HAL 保存 xfer + 发起始 + 开中断 HAL_OK(立即返回) 硬件 → EV_IRQHandler 中断 逐标志推进传输 无 完成 → MemTxCpltCallback 中断 HAL 通知(__weak) 无 BSP 重写体 中断 判 Instance/operator + 置 SUCCESS 无 BSP → user_callback 中断 调用用户函数指针 无 user_callback → printf 中断 业务输出 无
cpp【T0】主循环 ① main 调 BSP_AT24C02_Write_Page_IT(0, tx, 8, EEPROM_WriteCallBack) ② BSP 层把 EEPROM_WriteCallBack 的地址存进 at24c02.user_callback ③ BSP 层调 HAL_I2C_Mem_Write_IT → 库启动传输 → 返回 HAL_OK ④ main 继续 while 循环 ------ 此刻 user_callback 只被"登记",还没被"调用" 【T1】几百微秒后(中断上下文,和主循环是两条不同的执行线) ⑤ 硬件产生 I2C2 EV 中断 → 进入 ISR → HAL_I2C_EV_IRQHandler ⑥ 库在 ISR 里逐事件推进:写地址、写 8 字节数据、发停止条件 ⑦ 传输收尾 → 库内部调 I2C_ITMasterCpltCallback(hi2c) ⑧ 该函数按 Mode 判断是 MEM 操作 → 调 HAL_I2C_MemTxCpltCallback(hi2c) ------ 注意:这一步调的其实是【你的重写体】(__weak 被覆盖) 【T2】你的重写体(仍在中断里) ⑨ 判断 hi2c->Instance == &hi2c2 ?是,认领 ⑩ 判断 operator_type == WRITE_OPERATOR?是 ⑪ status = EEPROM_SUCCESS ← 主循环能"看见"完成 ⑫ user_callback(true) → 真正执行 EEPROM_WriteCallBack → printf ⑬ user_callback = NULL ← 防止同一操作重复通知 【T3】中断退出,回到主循环 ⑭ 主循环 BSP_AT24C02_Process 看到 status==SUCCESS → 触发读回I2C 中断模式收发,本质是把"一次阻塞调用"拆成四个阶段:
- 发起(主循环):检查参数/跨页/忙 →
_IT函数启动 → 立即返回;- 后台传输(I2C 事件中断):逐事件推进时序;
- 回调通知(HAL 回调 → 驱动回调 → 用户回调):只置状态 + 通知,不阻塞;
- 主循环调度(Process):轮询状态,复位就绪,发起下一步。




