
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

I2C 中断模式数据收发 ------ 用 EEPROM 实战中断驱动的"事件型"通信
面向初学者的通俗解读,配合逐行注释的代码,以及每个知识点后的"一句话总结"。
本文基于 STM32F103(Cortex-M3)+ HAL 库 + AT24C02 EEPROM 实战讲解。
目录
- [I2C 与 NVIC 联动:为什么 I2C 有两个中断](#I2C 与 NVIC 联动:为什么 I2C 有两个中断)
- 本次实战目标
- [CubeMX 配置(回顾)](#CubeMX 配置(回顾))
- [中断版 HAL 函数签名](#中断版 HAL 函数签名)
- [完整代码(头文件 + 驱动 + 应用)](#完整代码(头文件 + 驱动 + 应用))
- 源码解析:这套驱动到底好在哪
1. I2C 与 NVIC 联动:为什么 I2C 有两个中断
1.1 先搞清楚:I2C 和 SPI 在"中断"上的根本区别
上一课学 SPI 时,每个 SPI 外设只有一个中断号(SPI1_IRQn = 35),一个中断入口处理所有事情。
但 I2C 不一样 ------每个 I2C 外设都有两个中断号:
- 事件中断(Event Interrupt,EV) :负责"正常业务",比如起始条件完成、地址发送完成、收到一个字节、发送一个字节等正常流程事件。
- 错误中断(Error Interrupt,ER) :专门负责"报警",比如仲裁丢失、应答失败(NACK)、总线错误、超时等异常情况。
为什么要拆成两个?因为 I2C 是个 "有来有回、讲究握手" 的总线,比 SPI 复杂得多:
- SPI 发数据是"单向流水线",发完就完,几乎不会"被拒绝"。
- I2C 每发一个字节,都要等对方回一个 ACK/NACK 应答;还可能遇到总线被抢占(多主机)、从设备忙不应答等各种"社会性问题"。
把这些"正常"和"异常"分开,能让中断处理更清晰、更高效------正常流程走 EV,出问题走 ER。
1.2 中断编号表 IRQn_Type(节选 + 注释)
下面这段是 STM32F103 官方头文件里的中断编号表,重点看 I2C 那几行:
c
/*!< 中断号定义 */
typedef enum
{
/****** Cortex-M3 内核异常编号(内核自带,不归 NVIC 外设管)******/
NonMaskableInt_IRQn = -14, /*!< 不可屏蔽中断 NMI */
HardFault_IRQn = -13, /*!< 硬件错误 Hard Fault */
MemoryManagement_IRQn = -12, /*!< 内存管理错误 */
BusFault_IRQn = -11, /*!< 总线错误 */
UsageFault_IRQn = -10, /*!< 用法错误 */
SVCall_IRQn = -5, /*!< 系统调用 SVCall */
DebugMonitor_IRQn = -4, /*!< 调试监控 */
PendSV_IRQn = -2, /*!< 可挂起系统调用 PendSV */
SysTick_IRQn = -1, /*!< 系统滴答定时器 SysTick */
/****** STM32 外设中断编号(芯片厂商加的外设,交给 NVIC 管)******/
WWDG_IRQn = 0, /*!< 窗口看门狗 */
PVD_IRQn = 1, /*!< 电源电压检测 */
TAMPER_IRQn = 2, /*!< 入侵检测 */
RTC_IRQn = 3, /*!< RTC 全局中断 */
FLASH_IRQn = 4, /*!< FLASH 全局中断 */
RCC_IRQn = 5, /*!< RCC 时钟全局中断 */
EXTI0_IRQn = 6, /*!< 外部中断线 0 */
EXTI1_IRQn = 7, /*!< 外部中断线 1 */
EXTI2_IRQn = 8, /*!< 外部中断线 2 */
EXTI3_IRQn = 9, /*!< 外部中断线 3 */
EXTI4_IRQn = 10, /*!< 外部中断线 4 */
DMA1_Channel1_IRQn = 11, /*!< DMA1 通道 1 全局中断 */
DMA1_Channel2_IRQn = 12, /*!< DMA1 通道 2 全局中断 */
DMA1_Channel3_IRQn = 13, /*!< DMA1 通道 3 全局中断 */
DMA1_Channel4_IRQn = 14, /*!< DMA1 通道 4 全局中断 */
DMA1_Channel5_IRQn = 15, /*!< DMA1 通道 5 全局中断 */
DMA1_Channel6_IRQn = 16, /*!< DMA1 通道 6 全局中断 */
DMA1_Channel7_IRQn = 17, /*!< DMA1 通道 7 全局中断 */
ADC1_2_IRQn = 18, /*!< ADC1 和 ADC2 全局中断 */
USB_HP_CAN1_TX_IRQn = 19, /*!< USB 高优先级 或 CAN1 发送 */
USB_LP_CAN1_RX0_IRQn = 20, /*!< USB 低优先级 或 CAN1 接收0 */
CAN1_RX1_IRQn = 21, /*!< CAN1 接收1 中断 */
CAN1_SCE_IRQn = 22, /*!< CAN1 状态改变错误中断 */
EXTI9_5_IRQn = 23, /*!< 外部中断线 9~5 */
TIM1_BRK_IRQn = 24, /*!< TIM1 刹车中断 */
TIM1_UP_IRQn = 25, /*!< TIM1 更新中断 */
TIM1_TRG_COM_IRQn = 26, /*!< TIM1 触发和换向中断 */
TIM1_CC_IRQn = 27, /*!< TIM1 捕获比较中断 */
TIM2_IRQn = 28, /*!< TIM2 全局中断 */
TIM3_IRQn = 29, /*!< TIM3 全局中断 */
TIM4_IRQn = 30, /*!< TIM4 全局中断 */
I2C1_EV_IRQn = 31, /*!< I2C1 事件中断 <-- 本课主角 */
I2C1_ER_IRQn = 32, /*!< I2C1 错误中断 <-- 本课主角 */
I2C2_EV_IRQn = 33, /*!< I2C2 事件中断 <-- 本课主角 */
I2C2_ER_IRQn = 34, /*!< I2C2 错误中断 <-- 本课主角 */
SPI1_IRQn = 35, /*!< SPI1 全局中断 */
SPI2_IRQn = 36, /*!< SPI2 全局中断 */
USART1_IRQn = 37, /*!< USART1 全局中断 */
USART2_IRQn = 38, /*!< USART2 全局中断 */
USART3_IRQn = 39, /*!< USART3 全局中断 */
EXTI15_10_IRQn = 40, /*!< 外部中断线 15~10 */
RTC_Alarm_IRQn = 41, /*!< RTC 闹钟(经 EXTI)中断 */
USBWakeUp_IRQn = 42, /*!< USB 从挂起唤醒中断 */
TIM8_BRK_IRQn = 43, /*!< TIM8 刹车中断 */
TIM8_UP_IRQn = 44, /*!< TIM8 更新中断 */
TIM8_TRG_COM_IRQn = 45, /*!< TIM8 触发和换向中断 */
TIM8_CC_IRQn = 46, /*!< TIM8 捕获比较中断 */
ADC3_IRQn = 47, /*!< ADC3 全局中断 */
FSMC_IRQn = 48, /*!< FSMC 全局中断 */
SDIO_IRQn = 49, /*!< SDIO 全局中断 */
TIM5_IRQn = 50, /*!< TIM5 全局中断 */
SPI3_IRQn = 51, /*!< SPI3 全局中断 */
UART4_IRQn = 52, /*!< UART4 全局中断 */
UART5_IRQn = 53, /*!< UART5 全局中断 */
TIM6_IRQn = 54, /*!< TIM6 全局中断 */
TIM7_IRQn = 55, /*!< TIM7 全局中断 */
DMA2_Channel1_IRQn = 56, /*!< DMA2 通道 1 全局中断 */
DMA2_Channel2_IRQn = 57, /*!< DMA2 通道 2 全局中断 */
DMA2_Channel3_IRQn = 58, /*!< DMA2 通道 3 全局中断 */
DMA2_Channel4_5_IRQn = 59, /*!< DMA2 通道 4 和 5 全局中断 */
} IRQn_Type;
1.3 I2C 中断的"两个入口"
I2C 的中断和 SPI 最大区别就在这:一个 I2C 外设对应两个中断向量。
I2C2 硬件产生事件 / 错误
│
├── 正常事件(起始完成、地址发完、收发字节等)→ 走 I2C2_EV_IRQn = 33
│ │
│ ▼
│ I2C2_EV_IRQHandler() ──> HAL_I2C_EV_IRQHandler()
│
└── 异常事件(NACK、仲裁丢失、总线错误、超时)→ 走 I2C2_ER_IRQn = 34
│
▼
I2C2_ER_IRQHandler() ──> HAL_I2C_ER_IRQHandler()
CubeMX 勾选 I2C 的全局中断后,会同时注册这两个中断号,并生成两个中断服务函数。这就是为什么 I2C 比 SPI "多一个中断"。
📌 本知识点一句话总结
I2C 与 SPI 在中断上的根本区别是:每个 I2C 外设有两个中断号 ------事件中断(EV)和错误中断(ER)。事件中断负责正常收发流程,错误中断负责 NACK、仲裁丢失、总线错误、超时等异常。以 I2C2 为例,
I2C2_EV_IRQn = 33、I2C2_ER_IRQn = 34,分别对应HAL_I2C_EV_IRQHandler和HAL_I2C_ER_IRQHandler。之所以拆成两个,是因为 I2C 是带握手应答的复杂总线,把正常流程和异常情况分开,中断处理更清晰高效。
2. 本次实战目标
把之前阻塞方式 访问 AT24C02(EEPROM)的代码,改写成中断方式。
为了节省时间,本次只处理单页读写(AT24C02 一页 8 字节,本次读写就是 8 字节)。
处理完成的结果,用串口 printf 打印出来,方便我们验证。
补充背景:AT24C02 是一颗 2Kbit(= 256 字节)的 EEPROM,掉电不丢失。它按"页"组织存储空间:
- 总容量:256 字节 = 32 页 × 8 字节
- 页大小:8 字节(一次连续写不能跨页)
- 7 位设备地址 :
0x50(实际通信时要用左移 1 位后的0xA0)- 写周期:写完数据后,芯片内部还要花约 5ms 把数据真正"烧"进存储单元,期间不响应
📌 本知识点一句话总结
本次目标是把 AT24C02 EEPROM 的单页读写从阻塞改为中断方式。AT24C02 是 256 字节的 EEPROM,每页 8 字节,7 位地址为
0x50(通信时左移为0xA0)。写操作有个特殊点:数据发出后芯片还要约 5ms 的"内部写周期"才能真正落盘,期间不产生应答,这是 I2C EEPROM 区别于普通 I2C 传感器的关键特性。
3. CubeMX 配置(回顾)
配置沿用之前 I2C 阻塞通信的内容,核心回顾:
| 配置项 | 值 | 说明 |
|---|---|---|
| I2C2 Mode | I2C(标准模式) | AT24C02 挂在 I2C2 上(本课用 I2C2) |
| Clock Speed | 100 kHz(标准模式) | EEPROM 支持 100k/400k |
| 从设备地址 | 0x50(7 位) | AT24C02 的固定地址 |
| NVIC Settings | ✅ I2C2 event interrupt + ✅ I2C2 error interrupt | 中断模式需勾选两个 |
关于 AT24C02 更详细的原理(地址构成、时序、页写流程等),参见配套文档 8-1 I2C 阻塞通信实战与 HAL 源码,这里不重复展开。
📌 本知识点一句话总结
I2C 中断模式的 CubeMX 配置,相比阻塞模式只是多勾选了 NVIC 里的 I2C 事件中断和错误中断两个选项(因为 I2C 有 EV/ER 两个中断源)。其余参数(模式、时钟速率、从设备地址)与阻塞模式完全一致。配置完成后,初始化代码会自动把两个中断号注册进 NVIC。
4. 中断版 HAL 函数签名
AT24C02 这种 EEPROM 属于"带内部存储映射 "的设备,访问它不只是"发几个字节",还要先发一个内部存储地址(MemAddress),告诉芯片"我要读写第几个字节"。
所以 HAL 专门为这类设备提供了一对带 Mem 的中断函数:
4.1 HAL_I2C_Mem_Read_IT(按地址读)
c
HAL_StatusTypeDef HAL_I2C_Mem_Read_IT(I2C_HandleTypeDef *hi2c,
uint16_t DevAddress,
uint16_t MemAddress,
uint16_t MemAddSize,
uint8_t *pData,
uint16_t Size);
| 参数 | 说明 |
|---|---|
hi2c |
I2C 句柄指针,指向已初始化的 I2C 外设句柄(如 &hi2c2) |
DevAddress |
目标设备地址。必须用左移一位后的 8 位地址 (如 0x50 << 1 = 0xA0),而不是手册里的 7 位地址 |
MemAddress |
设备内部寄存器/存储器的起始地址(如 0 表示从第 0 字节开始) |
MemAddSize |
内部地址的位宽:I2C_MEMADD_SIZE_8BIT(8 位)或 I2C_MEMADD_SIZE_16BIT(16 位) |
pData |
接收缓冲区指针,用于存放读到的数据 |
Size |
要读取的数据字节数 |
4.2 HAL_I2C_Mem_Write_IT(按地址写)
c
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);
| 参数 | 说明 |
|---|---|
hi2c |
I2C 句柄指针(如 &hi2c2) |
DevAddress |
目标设备地址(同样要左移一位后的 8 位地址) |
MemAddress |
设备内部存储器的起始地址 |
MemAddSize |
内部地址位宽(8bit / 16bit) |
pData |
待发送数据的缓冲区指针 |
Size |
要发送的数据字节数 |
4.3 返回值
c
HAL_OK // 成功启动中断传输
HAL_BUSY // 外设正在忙
HAL_ERROR // 参数无效或错误
返回值同样只表示"成功启动",不代表"传输完成"。
4.4 关键注意点(务必牢记)
- 这两个函数都是非阻塞中断模式,调用后立即返回,真正传输在后台由中断完成。
- 传输完成后会调用回调函数 :
- 读完成 →
HAL_I2C_MemRxCpltCallback - 写完成 →
HAL_I2C_MemTxCpltCallback - 出错 →
HAL_I2C_ErrorCallback
- 读完成 →
- 这两个函数专用于带内部存储映射的 I2C 设备 (如 EEPROM、传感器),它们会自动在起始条件后发送
MemAddress字段,省去我们手动拼"地址 + 数据"的麻烦。
补充:和它对应的还有不带
Mem的HAL_I2C_Master_Transmit_IT/HAL_I2C_Master_Receive_IT,适用于"不关心内部地址"的设备。带Mem的版本本质上是"先发一个地址字节,再收发数据"的封装。
📌 本知识点一句话总结
对 EEPROM 这类带内部存储映射的设备,HAL 提供了
HAL_I2C_Mem_Read_IT/HAL_I2C_Mem_Write_IT两个中断函数,它们会在起始条件后自动发送MemAddress内部地址字段,再收发数据。关键参数有三个易错点:①DevAddress必须传左移一位后的 8 位地址 (0x50<<1 = 0xA0);②MemAddSize决定内部地址是 8 位还是 16 位(AT24C02 用I2C_MEMADD_SIZE_8BIT);③ 返回值只表示"成功启动",真正结果在HAL_I2C_MemRxCpltCallback/HAL_I2C_MemTxCpltCallback/HAL_I2C_ErrorCallback回调里处理。
5. 完整代码(头文件 + 驱动 + 应用)
本次代码比 SPI 那次更"工程化",采用了结构体封装 + 状态机 + 用户回调的设计,值得仔细体会。
5.1 头文件 BSP_AT24C02_IT.h
c
#ifndef __BSP_AT24C02_IT_H // 防止头文件被重复包含
#define __BSP_AT24C02_IT_H
#ifdef __cplusplus // C++ 工程兼容处理
extern "C"
{
#endif
#include <stdbool.h> // 提供 bool 类型
#include "stm32f1xx_hal.h" // HAL 库头文件
// 设备工作状态枚举(相当于一个小型"状态机"的状态集合)
typedef 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 // 写操作
// 用户回调函数类型:接收一个 bool 参数(成功 true / 失败 false),无返回值
typedef void (*user_callback_t)(bool);
// 从设备描述结构体:把 AT24C02 相关的所有信息打包在一起
typedef struct
{
I2C_HandleTypeDef *hi2c; // I2C 句柄指针(本课是 &hi2c2)
uint16_t dev_address; // 从设备地址(左移后的 8 位地址)
uint8_t page_size; // 页大小(AT24C02 = 8 字节)
bool is_init; // 是否已成功初始化
// ---- 中断模式相关的"上下文"字段(本质是全局变量,收进结构体更好管理)----
volatile Status_t status; // 当前状态(volatile:会在中断里改)
uint8_t *buffer; // 数据缓冲区指针(读/写共用)
uint8_t len; // 本次数据长度
volatile uint8_t operator_type; // 当前操作类型(READ/WRITE/NONE)
user_callback_t user_callback; // 用户回调函数指针
} eeprom_t;
// 设备参数宏定义
#define DEV_ADDRESS 0x50 // AT24C02 的 7 位设备地址
#define PAGE_SIZE 8 // 页大小 8 字节
#define DEV_TOTAL_SIZE 256 // 总容量 256 字节
// 设备就绪检测参数(用于初始化时扫描设备是否在线)
#define SCAN_RETYIES 2 // 重试次数
#define SCAN_TIME_MS 5 // 每次重试间隔(毫秒)
// 设备就绪轮询参数(用于写完后等待内部写周期结束)
#define POLL_RETYIES 10 // 重试次数
#define POLL_TIME_MS 2 // 每次重试间隔(毫秒)
// ---- 对外接口函数声明 ----
bool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c); // 初始化设备
bool BSP_AT24C02_Read_Page_IT(uint16_t start_address, void *data_out,
uint8_t data_len, user_callback_t cb); // 中断读页
bool BSP_AT24C02_Write_Page_IT(uint16_t start_address, void *data_in,
uint8_t data_len, user_callback_t cb); // 中断写页
bool BSP_AT24C02_Process(void); // 主循环轮询处理(写后等待就绪等)
#ifdef __cplusplus
}
#endif
#endif
5.2 驱动实现 BSP_AT24C02_IT.c
c
#include "BSP_AT24C02_IT.h"
#include <stdio.h> // 提供 printf
// 定义一个全局的 EEPROM 设备实例(整个程序只有这一个 AT24C02)
eeprom_t at24c02;
// 初始化 AT24C02:绑定 I2C 句柄、设置参数、并扫描设备是否在线
bool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c)
{
if (!hi2c) // 参数校验:句柄为空则失败
return false;
at24c02.hi2c = hi2c; // 绑定 I2C 句柄
at24c02.dev_address = DEV_ADDRESS << 1; // 7 位地址左移 1 位 → 8 位地址
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; // 无操作
// 扫描设备是否在线。底层 HAL 会做这样的动作:
// 1. 产生起始条件
// 2. 发送从设备地址 + 写位(0)(一共 8 位)
// 3. 收到 ACK → 说明从设备"活着"
// 4. 产生停止条件
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; // 返回初始化结果
}
// 中断模式"读页":支持按字节、按页、按任意长度读(本课用单页)
bool BSP_AT24C02_Read_Page_IT(uint16_t start_address, void *data_out,
uint8_t data_len, user_callback_t cb)
{
// 参数校验:未初始化 / 输出指针为空 / 长度为 0,都算非法
if (!at24c02.is_init || !data_out || data_len == 0)
{
if (cb) cb(false); // 通知用户失败
return false;
}
// 越界校验:起始地址 + 长度不能超过总容量 256
if (start_address + data_len > DEV_TOTAL_SIZE)
{
if (cb) cb(false);
return false;
}
// 忙检查:设备还在传输中,拒绝新任务
if (at24c02.status == EEPROM_BUSY)
{
if (cb) cb(false);
return false; // 注意:这里不能漏掉 return
}
// 登记本次读操作的上下文
at24c02.status = EEPROM_BUSY; // 状态切到"忙"
at24c02.buffer = data_out; // 记录输出缓冲
at24c02.len = data_len; // 记录长度
at24c02.operator_type = READ_OPERATOR; // 标记为读操作
at24c02.user_callback = cb; // 记录用户回调
// 启动中断读:发起始+地址,再发内部地址,然后接收数据
HAL_StatusTypeDef state = HAL_I2C_Mem_Read_IT(
at24c02.hi2c,
at24c02.dev_address, // 8 位设备地址
start_address, // 内部存储起始地址
I2C_MEMADD_SIZE_8BIT, // 8 位内部地址
data_out, // 接收缓冲
data_len); // 读取长度
if (state != HAL_OK) // 启动失败
{
at24c02.status = EEPROM_ERROR; // 状态置错误
if (cb) cb(false); // 通知用户失败
return false;
}
return true; // 成功启动,等待回调
}
// 判断设备当前是否就绪(用于写完后等待内部写周期结束)
static bool BSP_AT24C02_Ready()
{
if (!at24c02.is_init) // 未初始化则直接返回 false
return false;
// 底层靠"发送地址 + 看是否 ACK"来判断:ACK 说明写完了,可以继续
return HAL_OK == HAL_I2C_IsDeviceReady(at24c02.hi2c, at24c02.dev_address,
POLL_RETYIES, POLL_TIME_MS);
}
// 中断模式"写页":单页、页内写入(不能跨页)
bool BSP_AT24C02_Write_Page_IT(uint16_t start_address, void *data_in,
uint8_t data_len, user_callback_t cb)
{
// 参数校验
if (!at24c02.is_init || !data_in || data_len == 0)
{
if (cb) cb(false);
return false;
}
// ---- 检查写入是否"跨页" ----
// page_offset:本次写入在"某一页内部"的起始下标
uint16_t page_offset = start_address % at24c02.page_size;
// 页内起始偏移 + 数据长度,不能超过一页的大小(8)
if (page_offset + data_len > at24c02.page_size)
{
if (cb) cb(false); // 跨页了,拒绝(简化处理)
return false;
}
// 忙检查
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;
}
// 获取当前设备状态
Status_t At24c02_Status()
{
return at24c02.status;
}
// 主循环轮询函数:处理"写完后等待设备就绪"这类需要延迟的收尾工作
bool BSP_AT24C02_Process(void)
{
// 如果当前是写操作,且已经写成功(数据已发完)
if (at24c02.operator_type == WRITE_OPERATOR && at24c02.status == EEPROM_SUCCESS)
{
BSP_AT24C02_Ready(); // 等待内部写周期结束(ACK 轮询)
printf("等待设备就绪完成...\n");
at24c02.status = EEPROM_READY; // 重新回到就绪态
return true; // 返回 true,通知主循环"可以读回了"
}
return false;
}
// ====== 重写 HAL 的回调函数(用户在这里接管完成事件)======
// 写完成回调:走到这里,数据已经被 HAL 库通过 I2C 全部发出去了
void HAL_I2C_MemTxCpltCallback(I2C_HandleTypeDef *hi2c)
{
// 判断是不是我们关心的 I2C2(通过寄存器基地址比较)
if (hi2c->Instance == at24c02.hi2c->Instance)
{
// 只处理写操作
if (at24c02.operator_type == WRITE_OPERATOR)
{
// 注意:这里只代表"数据发完了",不代表 EEPROM 已经真正保存完成!
// 内部写周期(约 5ms)还没结束,绝不能在这里死等 BSP_AT24C02_Ready(),
// 否则会卡死中断。正确做法是置状态,交给主循环的 Process 去轮询。
at24c02.status = EEPROM_SUCCESS;
// 通知用户"数据已发出"
if (at24c02.user_callback)
{
at24c02.user_callback(true);
at24c02.user_callback = NULL; // 用完清空,防止重复调用
}
}
}
}
// 读完成回调:走到这里,数据已经被 HAL 库读完了,且已存入 buffer
void HAL_I2C_MemRxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if (hi2c->Instance == at24c02.hi2c->Instance)
{
// 只处理读操作
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)
{
at24c02.status = EEPROM_ERROR; // 状态置错误
if (at24c02.user_callback)
{
at24c02.user_callback(false); // 通知用户失败
at24c02.user_callback = NULL;
}
}
}
5.3 应用层 main.c
c
#include "BSP_AT24C02_IT.h" // 引入 EEPROM 驱动
#include "BSP_Usart_Redir.h" // 串口 printf 重定向
/* USER CODE BEGIN PV */
uint8_t tx_buffer[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; // 待写入的 8 字节
uint8_t rx_buffer[8] = {0}; // 读回的 8 字节缓冲
/* USER CODE END PV */
/* USER CODE BEGIN PFP */
void EEPROM_WriteCallBack(bool result); // 自定义写完成回调
void EEPROM_ReadCallBack(bool result); // 自定义读完成回调
/* USER CODE END PFP */
int main(void)
{
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration-----------------------------------------------*/
HAL_Init(); // 复位外设、初始化 Flash 接口和 SysTick
/* USER CODE BEGIN Init */
/* USER CODE END Init */
SystemClock_Config(); // 配置系统时钟
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
MX_GPIO_Init(); // 初始化 GPIO
MX_I2C2_Init(); // 初始化 I2C2(含 EV/ER 中断使能)
MX_USART1_UART_Init(); // 初始化串口
/* USER CODE BEGIN 2 */
BSP_Usart_Init(&huart1); // 串口重定向
// 初始化 AT24C02,失败则卡死提示
if (!BSP_AT24C02_Init(&hi2c2))
{
printf("Init At24C02 ERROR\n");
while (1); // 初始化失败,停在这里
}
else
{
printf("Init At24C02 SUCCESS\n");
}
// 发起中断写:从地址 0 开始写 8 字节,完成后回调 EEPROM_WriteCallBack
BSP_AT24C02_Write_Page_IT(0, tx_buffer, 8, EEPROM_WriteCallBack);
// 下面是"写完再读回"的另一种直接写法(默认注释掉,由 Process 串联)
// BSP_AT24C02_Read_Page_IT(0, rx_buffer, 8, EEPROM_ReadCallBack);
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
// 写完成且等待就绪结束后,Process 返回 true,此时再读回验证
if (BSP_AT24C02_Process())
{
BSP_AT24C02_Read_Page_IT(0, rx_buffer, 8, EEPROM_ReadCallBack);
}
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
/* USER CODE BEGIN 4 */
// 自定义"写完成"回调:由 HAL 的写完成回调间接调用
void 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++) // 打印读回的 8 字节
{
printf("0x%x ", rx_buffer[i]);
}
printf("\n");
}
else
{
printf("Read Page failed\n");
}
}
/* USER CODE END 4 */
5.4 整体执行流程(一图看懂)
main()
│
├─ BSP_AT24C02_Init(&hi2c2) // 扫描设备是否在线
│
├─ BSP_AT24C02_Write_Page_IT(0, ...) // 发起中断写,登记上下文后立即返回
│
└─ while(1)
│
├─ (后台) I2C2_EV 中断逐字节发数据 → 发完
│ HAL_I2C_MemTxCpltCallback() → 置 SUCCESS + 调 EEPROM_WriteCallBack(true)
│
├─ BSP_AT24C02_Process() // 检测到"写成功",等内部写周期结束
│ BSP_AT24C02_Ready() // ACK 轮询,直到设备重新应答
│ return true
│
└─ BSP_AT24C02_Read_Page_IT(0, ...) // 读回验证
│
└─ (后台) 读完成 → HAL_I2C_MemRxCpltCallback()
→ EEPROM_ReadCallBack(true) → 打印 8 字节
📌 本知识点一句话总结
这套 EEPROM 中断驱动有三个核心设计:① 用
eeprom_t结构体把 I2C 句柄、设备地址、状态、缓冲区、操作类型、用户回调等"上下文"集中管理;② 用Status_t状态机(IDLE→BUSY→SUCCESS/ERROR)配合volatile标志在中断和主循环之间传递进度;③ 把 HAL 的三个回调(MemTxCpltCallback/MemRxCpltCallback/ErrorCallback)统一转成用户的user_callback_t(bool)回调。主循环通过BSP_AT24C02_Process()串起"写完→等就绪→读回"的完整流程,实现非阻塞的异步读写。
6. 源码解析:这套驱动到底好在哪
6.1 为什么要用"状态机 + 结构体",而不是一堆全局变量
初学中断时,很多人会直接写一堆全局变量:volatile bool done、volatile uint8_t *buf、volatile int op...... 变量一多,又散落在各处,代码就乱。
本课用一个 eeprom_t 结构体把这些上下文信息打包:
c
typedef struct
{
I2C_HandleTypeDef *hi2c;
uint16_t dev_address;
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;
好处是:所有和"这个 EEPROM"相关的东西都收在一起,一眼看清;将来要扩展多块 EEPROM,直接定义 eeprom_t eeprom1, eeprom2; 即可复用。
而 Status_t 状态机(IDLE / READY / BUSY / SUCCESS / ERROR)则把"设备现在在干嘛"这个复杂问题,变成了一个清晰的枚举值,配合 operator_type(读/写)就能精确描述任意时刻的状态。
📌 本知识点一句话总结
用"结构体 + 状态机"封装外设驱动,是为了解决中断模式下"上下文信息散乱、状态难以追踪"的问题。
eeprom_t结构体把句柄、地址、缓冲、状态、操作类型、回调等打包,Status_t枚举(IDLE/READY/BUSY/SUCCESS/ERROR)精确描述设备状态。这样做的好处是:信息内聚、状态清晰、可扩展到多设备,比散落的全局变量更易维护和复用。
6.2 为什么 DevAddress 必须左移一位
这是 I2C 新手最常踩的坑。数据手册里 AT24C02 的地址写作 0x50,但这是 7 位地址。
I2C 协议规定:起始条件后,主机发送的第一个字节是 7 位地址 + 1 位读写标志:
[ A6 A5 A4 A3 A2 A1 A0 | R/W ]
\_________ 7位地址 ________/ ↑
0=写, 1=读
所以真正发到总线上的 8 位是 0x50 << 1 | R/W:
- 写:
0x50 << 1 = 0xA0 - 读:
0x50 << 1 | 1 = 0xA1
HAL 库的 DevAddress 参数要求传的就是这个左移后的 8 位地址 (0xA0),而不是 7 位的 0x50。所以代码里写:
c
at24c02.dev_address = DEV_ADDRESS << 1; // 0x50 → 0xA0
小技巧:HAL 内部会自己根据读/写操作在后面补上 R/W 位,所以我们只需要传左移后的 7 位地址即可。
📌 本知识点一句话总结
I2C 数据手册里的设备地址是 7 位地址(如
0x50),但总线上实际发送的是"7 位地址 + 1 位读写标志"组成的 8 位字节。HAL 的DevAddress参数要求传左移一位后的 8 位地址 (0x50<<1 = 0xA0),读写位由 HAL 内部根据操作自动补上。如果直接传 7 位地址,地址对不上,设备不会应答,通信必然失败。
6.3 页写入为什么要检查"跨页"
AT24C02 的存储是按页组织的:每页 8 字节,地址 0~7 是第 0 页,8~15 是第 1 页...... 共 32 页。
EEPROM 的页写 有一个硬件限制:一次连续写操作不能跨越页边界。如果从地址 6 开始写 4 字节(6、7、8、9),就会跨到下一页,超出的部分会"回绕"覆盖本页开头(6、7、0、1),导致数据错乱。
所以写之前必须检查:
c
uint16_t page_offset = start_address % at24c02.page_size; // 页内起始偏移
if (page_offset + data_len > at24c02.page_size) // 页内偏移 + 长度 > 8?
{
// 跨页了,拒绝或拆分成多次写
}
本课为了简单,跨页直接返回失败。真实工程里更严谨的做法是拆成多次页写,每次只写页内剩余空间能容纳的部分。
📌 本知识点一句话总结
AT24C02 页写有"不能跨页"的硬件限制:一次连续写不能越过 8 字节的页边界,否则数据会回绕到本页开头,造成错误。因此在写之前必须用
start_address % page_size算出页内偏移,再判断偏移 + 长度 > 页大小,跨页则拒绝或拆分。这是驱动 EEPROM 时必须做的基本校验。
6.4 写完之后为什么不能立刻读?------"内部写周期"与 ACK 轮询
这是 EEPROM 最容易让人困惑的地方。看这条时序:
主机发数据 → 数据全部发完 → 主机收到 ACK(表示"数据我收到了")
│
└── 但此时数据还没真正写进存储单元!
EEPROM 收到数据后,还要花约 5ms 把数据真正"烧录"进存储单元,这段时间叫内部写周期(Write Cycle) 。在此期间,芯片不响应任何命令、不回 ACK。
所以代码里有一个非常关键的注释:
c
// 写完成回调里:
// 发送完毕,不代表 EEPROM 已经保存完了。
// 但绝对不能在这里等待设备就绪!
at24c02.status = EEPROM_SUCCESS;
那"等待就绪"放哪做?答案是主循环的 BSP_AT24C02_Process():
c
bool BSP_AT24C02_Process(void)
{
if (at24c02.operator_type == WRITE_OPERATOR && at24c02.status == EEPROM_SUCCESS)
{
BSP_AT24C02_Ready(); // ACK 轮询:不断发地址,直到芯片回 ACK
at24c02.status = EEPROM_READY;
return true;
}
return false;
}
BSP_AT24C02_Ready() 底层调 HAL_I2C_IsDeviceReady,本质是反复发"设备地址"试探,看芯片是否回 ACK:
- 不回 ACK → 还在写周期里,继续等。
- 回 ACK → 写完了,可以继续下一步(读回验证)。
📌 本知识点一句话总结
EEPROM 写操作分两步:先通过 I2C 把数据发给芯片(此时回调触发),芯片收到后还要经历约 5ms 的"内部写周期"把数据真正写入存储单元,期间不回 ACK。所以"写回调触发"只代表"数据已发出",不等于"已保存"。必须通过 ACK 轮询(反复发地址看是否应答)确认写周期结束,才能进行后续读写。这个等待绝不能放在中断回调里做,只能放到主循环轮询,否则会长时间占用中断、卡死系统。
6.5 为什么不能在回调里"死等"设备就绪
上一节已经点到了,这里单独强调,因为它是中断编程的铁律。
中断回调函数运行在中断上下文里,此时:
- 这个 I2C 外设的中断可能还没完全退出;
- 如果其他更低优先级的中断、或者主循环里正在做的事,都可能被这个"赖着不走"的中断卡住;
- 在中断里调用
HAL_I2C_IsDeviceReady(阻塞等待 ACK)会形成一个**"在中断里死等"的局面------等 5ms 也许还好,但更糟的是:如果设备永远不应答(比如硬件坏了),中断就会永远卡死在这里**,整个系统崩溃。
所以正确的姿势是:
- 回调里只做"轻量"的事:置标志、赋值、记录状态、通知(置个 flag 让别处处理)。
- 耗时的事交给主循环:等待、轮询、打印长内容、做业务逻辑,统统放主循环。
本课代码正是这么设计的:写回调里只置 status = EEPROM_SUCCESS,真正等就绪的 BSP_AT24C02_Ready() 在主循环的 Process() 里才调用。
补充:
printf本身也偏慢(尤其阻塞式),严格说也不该大量放在中断里,本课为演示方便在用户回调里用了printf,实际工程里常用DMA串口或环形缓冲异步打印。
📌 本知识点一句话总结
中断回调函数必须"短小快":只做置标志、赋值、记录状态等轻量操作,绝不能在里面做阻塞等待(如
HAL_I2C_IsDeviceReady轮询、长时间printf、delay)。因为回调运行在中断上下文,一旦阻塞会让系统卡死、丢失其他中断。耗时操作(等待写周期、轮询、打印)应放到主循环里轮询处理。这是中断编程的一条铁律。
6.6 回调链:HAL 回调 → 用户回调
这套代码里其实有两层回调,理解它有助于看懂整个调用链:
I2C 中断发生
│
▼
HAL_I2C_MemTxCpltCallback() ← 第一层:HAL 库的回调(我们重写它)
│ 它负责:判断设备、判断操作类型、更新状态机
│
▼
at24c02.user_callback(true) ← 第二层:用户的业务回调(通过函数指针调用)
│
▼
EEPROM_WriteCallBack(true) ← 最终业务处理:打印结果
为什么要分两层?因为:
- HAL 回调是"外设级"的:它知道"这个 I2C 外设传输完成了",但不知道"这个完成对上层业务意味着什么"。
- 用户回调是"业务级"的:它只关心"我的写/读成功没",不关心底层是哪个外设、用了什么中断。
中间用 user_callback_t 函数指针解耦,让驱动层(BSP)把底层细节封装起来,向上层只暴露"成功/失败"这一个结果。这就是分层设计的思想。
补充:
user_callback用完后立即置NULL,防止同一回调被意外调用两次(和 SPI 那课"收尾只执行一次"的思想一致)。
📌 本知识点一句话总结
这套代码用了"两层回调"设计:第一层是重写 HAL 的
MemTxCpltCallback/MemRxCpltCallback,负责判断设备和更新状态机;第二层是用户通过user_callback_t函数指针注册的业务回调(如EEPROM_WriteCallBack)。分层的好处是把"外设细节"和"业务逻辑"解耦,驱动层向上只暴露"成功/失败"的结果。回调指针用完后置NULL,防止重复调用。
全文总结(速记卡)
- I2C 有两个中断 :事件中断(EV,管正常流程)+ 错误中断(ER,管 NACK/仲裁丢失/总线错误等),I2C2 的中断号是
I2C2_EV_IRQn = 33、I2C2_ER_IRQn = 34。 - EEPROM 用 Mem 系列函数 :
HAL_I2C_Mem_Read_IT/HAL_I2C_Mem_Write_IT,会自动发送内部存储地址字段。 - 设备地址要左移 :7 位地址
0x50通信时用左移后的0xA0。 - 页写不能跨页:AT24C02 每页 8 字节,写前要检查"页内偏移 + 长度 ≤ 8"。
- 写后要等内部写周期:数据发出后芯片还需约 5ms 落盘,靠 ACK 轮询确认。
- 回调里不能阻塞:等待、轮询、长打印都放主循环,回调只做置标志等轻量操作。
- 分层 + 状态机:结构体封装上下文,状态机追踪进度,两层回调解耦外设与业务。
