【stm32】I2C 中断模式数据收发

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。


I2C 中断模式数据收发 ------ 用 EEPROM 实战中断驱动的"事件型"通信

面向初学者的通俗解读,配合逐行注释的代码,以及每个知识点后的"一句话总结"。

本文基于 STM32F103(Cortex-M3)+ HAL 库 + AT24C02 EEPROM 实战讲解。


目录

  1. [I2C 与 NVIC 联动:为什么 I2C 有两个中断](#I2C 与 NVIC 联动:为什么 I2C 有两个中断)
  2. 本次实战目标
  3. [CubeMX 配置(回顾)](#CubeMX 配置(回顾))
  4. [中断版 HAL 函数签名](#中断版 HAL 函数签名)
  5. [完整代码(头文件 + 驱动 + 应用)](#完整代码(头文件 + 驱动 + 应用))
  6. 源码解析:这套驱动到底好在哪

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 为什么不能在回调里"死等"设备就绪

上一节已经点到了,这里单独强调,因为它是中断编程的铁律。

中断回调函数运行在中断上下文里,此时:

  1. 这个 I2C 外设的中断可能还没完全退出;
  2. 如果其他更低优先级的中断、或者主循环里正在做的事,都可能被这个"赖着不走"的中断卡住;
  3. 在中断里调用 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,防止重复调用。


全文总结(速记卡)

  1. I2C 有两个中断 :事件中断(EV,管正常流程)+ 错误中断(ER,管 NACK/仲裁丢失/总线错误等),I2C2 的中断号是 I2C2_EV_IRQn = 33、I2C2_ER_IRQn = 34。
  2. EEPROM 用 Mem 系列函数 :HAL_I2C_Mem_Read_IT / HAL_I2C_Mem_Write_IT,会自动发送内部存储地址字段。
  3. 设备地址要左移 :7 位地址 0x50 通信时用左移后的 0xA0。
  4. 页写不能跨页:AT24C02 每页 8 字节,写前要检查"页内偏移 + 长度 ≤ 8"。
  5. 写后要等内部写周期:数据发出后芯片还需约 5ms 落盘,靠 ACK 轮询确认。
  6. 回调里不能阻塞:等待、轮询、长打印都放主循环,回调只做置标志等轻量操作。
  7. 分层 + 状态机:结构体封装上下文,状态机追踪进度,两层回调解耦外设与业务。

相关推荐
铅笔小新z1 小时前
【stm32】理解弱引用
javascript·stm32·嵌入式硬件
铅笔小新z2 小时前
【stm32】DMA
stm32·单片机·嵌入式硬件
Brilliantwxx3 小时前
【STM32】 DMA中断机制、NVIC 联动与配置实战前章
stm32·单片机·嵌入式硬件·mcu
我叫洋洋3 小时前
涂鸦 T5+ hashcat 自动化破解 WPA2:从单缺 M2 到全自动值守的踩坑实录 破解wifi 密码
运维·单片机·自动化
Jason_zhao_MR9 小时前
米尔电子参展预告_中国工博会2026-外发版
嵌入式硬件·机器人·工业控制
Zw-awa10 小时前
电机家族:有的电机听“目标”,有的电机听“力气”
stm32
FPGA小徐13 小时前
STM32五路气敏传感器阵列电路详解:从HHC1000分压到AD8606跟随器和ADC采样
stm32·单片机·嵌入式硬件
Horn Still Sounds15 小时前
IMX6ULL裸机|I2C通信协议 + ADC模数转换学习笔记
arm开发·笔记·单片机·嵌入式硬件
智感子16 小时前
无人值守称重:地磅系统架构与流程
嵌入式硬件