【STM32】 从HAL库源码深度解析I2C(源码解析+面试题)


从HAL库源码深度解析I2C

1.初始化相关

1.1 MX_I2C1_Init ------ 初始化入口源码

cpp 复制代码
static void MX_I2C1_Init(void)
{
  hi2c1.Instance = I2C2;                          // 选择 I2C 寄存器基地址:I2C2
  hi2c1.Init.ClockSpeed = 400000;                 // 400kHz,快速模式 Fast Mode
  hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;         // 快速模式下高低电平占空比 2:1
  hi2c1.Init.OwnAddress1 = 0;                     // 本机从机地址(Master 用不到,设 0)
  hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;  // 7 位地址模式
  hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; // 禁用双地址
  hi2c1.Init.OwnAddress2 = 0;                     // 第二个地址(已禁用,无效)
  hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; // 禁用广播呼叫
  hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;     // 允许从机时钟拉伸
  if (HAL_I2C_Init(&hi2c1) != HAL_OK)
  {
    Error_Handler();
  }
}

要点:

这些配置项就是 CubeMX 图形界面上勾选的参数。

CubeMX 先把配置填进结构体**hi2c1.Init(数据在内存),再由 HAL_I2C_Init结构体数据"搬运" 到硬件寄存器****中。**

Init 成员 配置值 含义
ClockSpeed 400000 400 kHz 快速模式(AT24C02 手册:VCC≥2.7V 时最高 400 kHz)
DutyCycle I2C_DUTYCYCLE_2 SCL 高低电平占空比 2:1(低速模式无占空比概念)
OwnAddress1 0 本机作为从机时的地址
AddressingMode 7BIT 7 位地址模式
DualAddressMode DISABLE 不使用双地址
GeneralCallMode DISABLE 不响应广播呼叫(地址 0x00)
NoStretchMode DISABLE 允许从机时钟拉伸

1.2 I2C_HandleTypeDef 与 I2C_TypeDef ------ 句柄与寄存器

关键代码:hi2c1.Instance = I2C2;

I2C2 是什么 ------指向寄存器基地址的指针宏

cpp 复制代码
#define I2C2                ((I2C_TypeDef *)I2C2_BASE)
#define I2C2_BASE            (APB1PERIPH_BASE + 0x00005800UL)  // 0x4000 5800
#define APB1PERIPH_BASE      PERIPH_BASE
#define PERIPH_BASE          0x40000000UL

I2C_TypeDef ------I2C 外设内部连续排列的 9 个寄存器

cpp 复制代码
typedef struct
{
  __IO uint32_t CR1;     // 控制寄存器 1
  __IO uint32_t CR2;     // 控制寄存器 2
  __IO uint32_t OAR1;    // 自身地址寄存器 1
  __IO uint32_t OAR2;    // 自身地址寄存器 2
  __IO uint32_t DR;      // 数据寄存器
  __IO uint32_t SR1;     // 状态寄存器 1
  __IO uint32_t SR2;     // 状态寄存器 2
  __IO uint32_t CCR;     // 时钟控制寄存器
  __IO uint32_t TRISE;   // 上升时间寄存器
} I2C_TypeDef;

Instance 成员 ------I2C 句柄结构体中的关键字段

cpp 复制代码
typedef struct
{
  I2C_TypeDef                *Instance;     /*!< I2C 寄存器基地址 */
  I2C_InitTypeDef            Init;          /*!< I2C 通信参数(用户配置)*/
  uint8_t                    *pBuffPtr;     /*!< 指向 I2C 传输缓冲区 */
  uint16_t                   XferSize;      /*!< I2C 传输大小 */
  __IO uint16_t              XferCount;     /*!< I2C 传输计数器 */
  __IO uint32_t              XferOptions;   /*!< I2C 传输选项 */
  __IO uint32_t              PreviousState; /*!< 上一次状态与模式 */
  DMA_HandleTypeDef          *hdmatx;       /*!< I2C Tx DMA 句柄 */
  DMA_HandleTypeDef          *hdmarx;       /*!< I2C Rx DMA 句柄 */
  HAL_LockTypeDef            Lock;          /*!< I2C 锁对象 */
  __IO HAL_I2C_StateTypeDef  State;         /*!< I2C 通信状态 */
  __IO HAL_I2C_ModeTypeDef   Mode;          /*!< I2C 通信模式 */
  __IO uint32_t              ErrorCode;     /*!< I2C 错误码 */
  __IO uint32_t              Devaddress;    /*!< I2C 目标设备地址 */
  __IO uint32_t              Memaddress;    /*!< I2C 目标内存地址 */
  __IO uint32_t              MemaddSize;    /*!< I2C 目标内存地址宽度 */
  __IO uint32_t              EventCount;    /*!< I2C 事件计数 */
  ...
} I2C_HandleTypeDef;

结论(核心):

  • hi2c1.Instance = I2C2:让 Instance 指针直接指向片上外设 I2C2,基地址 0x4000 5800;
  • 从该地址开始是 I2C 连续 9 个寄存器(每个 32 位),寄存器内存顺序与 I2C_TypeDef 成员顺序完全一致,自然形成偏移量(CR1=0x00,CR2=0x04,...,TRISE=0x20);
  • 于是 Cortex-M3 可以用操作内存的方式(指针)直接操作 I2C 全部寄存器,如 hi2c->Instance->DR = data; 等价于向 0x4000 5800+0x10 写数据。

1.3 I2C 复用 GPIO ------ HAL_I2C_MspInit

cpp 复制代码
void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  if(hi2c->Instance==I2C2)
  {
    __HAL_RCC_GPIOB_CLK_ENABLE();
    /**I2C2 GPIO Configuration
     PB10    ------> I2C2_SCL
     PB11    ------> I2C2_SDA
     */
    GPIO_InitStruct.Pin = GPIO_PIN_10|GPIO_PIN_11;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;      // 复用开漏输出
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    /* Peripheral clock enable */
    __HAL_RCC_I2C2_CLK_ENABLE();
  }
}

MSP = MCU Support Package(微控制器支持包):特定芯片硬件相关的支持配置。

同样是 I2C 协议,STM32F1 和 F4 逻辑一样,但引脚(GPIO)、时钟(RCC)、中断号(IRQ)完全不同。

概念 含义
Alternate Function(复用功能) 引脚不再作为普通 GPIO,交给片上外设(I2C/SPI/USART)控制
Alternate Function Open Drain(复用开漏) 引脚只能拉低、靠外部上拉电阻拉高------正是 I2C"线与"机制需要的

1.4 开启 I2C2 时钟 ------ __HAL_RCC_I2C2_CLK_ENABLE

cpp 复制代码
#define __HAL_RCC_I2C2_CLK_ENABLE()   do { \
                                         __IO uint32_t tmpreg; \
                                         SET_BIT(RCC->APB1ENR, RCC_APB1ENR_I2C2EN); \
                                         tmpreg = READ_BIT(RCC->APB1ENR, RCC_APB1ENR_I2C2EN); \
                                         UNUSED(tmpreg); \
                                       } while(0U)

#define RCC_APB1ENR_I2C2EN_Pos   (22U)
#define RCC_APB1ENR_I2C2EN_Msk   (0x1UL << RCC_APB1ENR_I2C2EN_Pos)  /*!< 0x00400000 */
#define RCC_APB1ENR_I2C2EN       RCC_APB1ENR_I2C2EN_Msk

解析:

I2C2 挂在 APB1 总线上,操作 RCC->APB1ENR 的第 22 位(0x00400000)。

置 1 后 I2C2 接入时钟树,才能响应后续寄存器配置。


1.5 I2C 设备使能 ------ __HAL_I2C_ENABLE / DISABLE

cpp 复制代码
#define __HAL_I2C_ENABLE(__HANDLE__)  SET_BIT((__HANDLE__)->Instance->CR1, I2C_CR1_PE)
#define __HAL_I2C_DISABLE(__HANDLE__) CLEAR_BIT((__HANDLE__)->Instance->CR1, I2C_CR1_PE)

#define SET_BIT(REG, BIT)     ((REG) |= (BIT))
#define CLEAR_BIT(REG, BIT)   ((REG) &= ~(BIT))

#define I2C_CR1_PE_Pos  (0U)
#define I2C_CR1_PE_Msk  (0x1UL << I2C_CR1_PE_Pos)   /*!< 0x00000001 */
#define I2C_CR1_PE      I2C_CR1_PE_Msk              /*!< Peripheral Enable */

开启时钟 vs 设备使能(必须分清):

操作 作用 类比
开启时钟(RCC) 外设接入时钟树,可以配置了 给洗衣机插上电源
设备使能(PE) 外设本身开始工作,可以正常使用 按下"开始"键

**流程:**开启时钟 → 配置寄存器 → 设备使能 → 正常工作


1.6 MODIFY_REG ------ 修改寄存器指定位的万能宏

cpp 复制代码
#define MODIFY_REG(REG, CLEARMASK, SETMASK)  WRITE_REG((REG), (((READ_REG(REG)) & (~(CLEARMASK))) | (SETMASK)))

核心作用:

在不改变寄存器其他位的情况下,安全地修改指定某几位(含 1 位)的值。

执行五步拆解:

步骤 表达式 做什么
READ_REG(REG) 读出寄存器当前 32 位原始值
~(CLEARMASK) 清除掩码取反:保留位置 1、清除位置 0
REG & ~(CLEARMASK) 把指定位置清零,其他位不变
| SETMASK 给清空位置或上新值
WRITE_REG(REG, ...) 写回寄存器

本质:"先清空、再设置"。

Demo(8 位寄存器改 LED 模式):

假设第 5:4 位控制 LED**(00关/01低速/10高速/11常亮)****,当前 REG = 1011 0100(=11 常亮),要改成 10(高速闪)且不影响其他位:**

cpp 复制代码
REG          1011 0100
~CLEARMASK & 1100 1111      (CLEARMASK = 0011 0000)
----------------------
            1000 0100      <- 第[5:4]位清空,其他位未变
SETMASK   | 0010 0000
----------------------
            1010 0100      <- 完成:10 10 0100

若不先清空直接 |1011 0100 | 0010 0000 = 1011 0100,结果不变------这就是必须先清空的原因。

**实战案例:**配置 CR1 的 ENGC(bit6)与 NOSTRETCH(bit7):

复制代码
MODIFY_REG(hi2c->Instance->CR1, (I2C_CR1_ENGC | I2C_CR1_NOSTRETCH), (hi2c->Init.GeneralCallMode | hi2c->Init.NoStretchMode));
  • CLEARMASK = (1<<6)|(1<<7):先把这两个位清空;
  • 之前配置 GeneralCallMode=0、NoStretchMode=0,即 SETMASK=0 → 等效 MODIFY_REG(CR1, (ENGC|NOSTRETCH), 0),把两位清成 0(关闭广播、允许时钟拉伸);
  • 想开启直接传 I2C_CR1_ENGC | I2C_CR1_NOSTRETCH 作 SETMASK。

结论:

I2C 各标志位在寄存器中位置彼此独立、不重复,可以穷举所有位位置充当 CLEARMASK/SETMASK------这就是 stm32f103xe.hI2C_xxx_Pos/_Msk 宏清单的由来。用 MODIFY_REG,HAL 库把结构体选项(软件层)设置进寄存器(硬件层)


2. 设备就绪相关

应用层:

cpp 复制代码
bool wait_write_complete(void)
{
    if(!at24c02.is_init) return false;
    return HAL_I2C_IsDeviceReady(at24c02.i2c, at24c02.dev_address, 100, 10) == HAL_OK ? true : false;
}

2.1 HAL_I2C_IsDeviceReady ------ 参数

参数 含义
hi2c I2C 句柄结构体指针
DevAddress 目标设备地址;7 位地址调用前必须左移 1 位(最低位留给读写标志)
Trials 尝试次数,建议 1~10(上例 100)
Timeout 单次检测超时时间(毫秒),0 无限制(上例 10ms)

源码宏观循环:

cpp 复制代码
HAL_StatusTypeDef HAL_I2C_IsDeviceReady(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint32_t Trials, uint32_t Timeout)
{
  uint32_t I2C_Trials = 0U;
  if (hi2c->State == HAL_I2C_STATE_READY)
  {
    do
    {
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);        // 发 START
      hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);// 发从机地址(写)
      hi2c->State = HAL_I2C_STATE_READY;
      if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR) == SET)
      {
        ...
        return HAL_OK;                                    // 有一次成功就 OK
      }
      else
      {
        if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
          return HAL_ERROR;
      }
      I2C_Trials++;
    }
    while (I2C_Trials < Trials);
    ...
    return HAL_ERROR;
  }
  else
  {
    return HAL_BUSY;
  }
}

2.2 三种就绪条件 ------ 核心

必须同时满足:

# 条件 含义
1 设备本身空闲且正常 没有被任何程序(含中断)占用访问
2 总线就绪(不忙) SCL/SDA 上无正在进行的传输
3 目标设备在线且地址匹配 Slave 在线且能正确应答 ACK

类比:

① 女朋友愿意去(和闺蜜约了图书馆=忙);

② 道路畅通(堵车去不了);

③ 电影院营业且导航正确。


2.3 条件1:hi2c->State

复制代码
if (hi2c->State == HAL_I2C_STATE_READY)

为什么需要 State?

因为有中断------你用着 I2C 时中断函数也可能访问 I2C,导致重复访问。

用 State 做"谁先设置谁先用,其他人被拦截"的互斥。

cpp 复制代码
typedef enum
{
  HAL_I2C_STATE_RESET            = 0x00U,  /*!< 外设尚未初始化 */
  HAL_I2C_STATE_READY            = 0x20U,  /*!< 外设已初始化,可使用 */
  HAL_I2C_STATE_BUSY             = 0x24U,  /*!< 内部过程进行中 */
  HAL_I2C_STATE_BUSY_TX          = 0x21U,  /*!< 数据发送过程中 */
  HAL_I2C_STATE_BUSY_RX          = 0x22U,  /*!< 数据接收过程中 */
  HAL_I2C_STATE_LISTEN           = 0x28U,  /*!< 地址监听模式中 */
  HAL_I2C_STATE_BUSY_TX_LISTEN   = 0x29U,  /*!< 监听 + 发送中 */
  HAL_I2C_STATE_BUSY_RX_LISTEN   = 0x2AU,  /*!< 监听 + 接收中 */
  HAL_I2C_STATE_ABORT            = 0x60U,  /*!< 中止用户请求中 */
  HAL_I2C_STATE_TIMEOUT          = 0xA0U,  /*!< 超时状态 */
  HAL_I2C_STATE_ERROR            = 0xE0U   /*!< 错误 */
} HAL_I2C_StateTypeDef;

不用背,源码直接能翻。

若 State != READY,直接 return HAL_BUSY。


2.4 条件2:总线是否就绪 ------ BUSY 标志位

总线为什么忙?

对方来不及接收时可能利用"线与"机制拉低 SCL(时钟拉伸),不让发送方继续发------此时总线 busy。要发数据,总线必须 OK。

cpp 复制代码
if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
    return HAL_BUSY;

等待函数本体:

cpp 复制代码
static HAL_StatusTypeDef I2C_WaitOnFlagUntilTimeout(I2C_HandleTypeDef *hi2c, uint32_t Flag,
    FlagStatus Status, uint32_t Timeout, uint32_t Tickstart)
{
  while (__HAL_I2C_GET_FLAG(hi2c, Flag) == Status)      // 标志位一直是该状态就一直等
  {
    if (Timeout != HAL_MAX_DELAY)
    {
      if (((HAL_GetTick() - Tickstart) > Timeout) || (Timeout == 0U))
      {
        if ((__HAL_I2C_GET_FLAG(hi2c, Flag) == Status))
        {
          hi2c->State = HAL_I2C_STATE_READY;
          hi2c->ErrorCode |= HAL_I2C_ERROR_TIMEOUT;
          __HAL_UNLOCK(hi2c);
          return HAL_ERROR;
        }
      }
    }
  }
  return HAL_OK;
}

✨ 阻塞式接口基本都有超时时间,主要防止卡死。


标志位清单(注意设计规律):

cpp 复制代码
#define I2C_FLAG_OVR      0x00010800U   // SR1 溢出
#define I2C_FLAG_AF       0x00010400U   // SR1 应答失败
#define I2C_FLAG_ARLO     0x00010200U   // SR1 仲裁丢失
#define I2C_FLAG_BERR     0x00010100U   // SR1 总线错误
#define I2C_FLAG_TXE      0x00010080U   // SR1 数据寄存器空
#define I2C_FLAG_RXNE     0x00010040U   // SR1 数据寄存器非空
#define I2C_FLAG_STOPF    0x00010010U   // SR1 停止条件
#define I2C_FLAG_ADD10    0x00010008U   // SR1 10 位地址
#define I2C_FLAG_BTF      0x00010004U   // SR1 字节发送结束
#define I2C_FLAG_ADDR     0x00010002U   // SR1 地址发送结束
#define I2C_FLAG_SB       0x00010001U   // SR1 起始条件已发送
#define I2C_FLAG_DUALF    0x00100080U   // SR2 双地址标志
#define I2C_FLAG_GENCALL  0x00100010U   // SR2 广播呼叫
#define I2C_FLAG_TRA      0x00100004U   // SR2 传输方向
#define I2C_FLAG_BUSY     0x00100002U   // SR2 总线忙
#define I2C_FLAG_MSL      0x00100001U   // SR2 主从模式

🎯 重点:__HAL_I2C_GET_FLAG ------ "地址+偏移"封装:

cpp 复制代码
#define __HAL_I2C_GET_FLAG(__HANDLE__, __FLAG__) ((((uint8_t)((__FLAG__) >> 16U)) == 0x01U) ? \
  (((((__HANDLE__)->Instance->SR1) & ((__FLAG__) & I2C_FLAG_MASK)) == ((__FLAG__) & I2C_FLAG_MASK)) ? SET : RESET) : \
  (((((__HANDLE__)->Instance->SR2) & ((__FLAG__) & I2C_FLAG_MASK)) == ((__FLAG__) & I2C_FLAG_MASK)) ? SET : RESET))

#define I2C_FLAG_MASK  0x0000FFFFU

设计思想:

每个标志位宏是 32 位数------高 16 位选寄存器(0x0001→SR1,0x0010→SR2);

低 16 位定位标志位位置。

I2C_FLAG_BUSY = 0x00100002U 展开:

步骤 运算 结果
1 (uint8_t)(0x00100002 >> 16) 0x10 ≠ 0x01 → 选中 SR2
2 0x00100002 & 0x0000FFFF 0x00000002(低 16 位=2)
3 ((SR2 & 0x2) == 0x2) ? SET : RESET 检测 SR2 第 2 个比特位
4 翻参考手册 SR2.bit1 = BUSY:总线忙

为什么"自己比自己"(全比较):

寄存器的标志位可能多位连起来表示,只有全部匹配才成立。

类比取款机:

if (动作 & 规定)------只输密码(0x01)没插卡(0x02),0x01 & 0x03 = 0x01(真)→ 钱被吐出(错误);

if ((动作 & 规定) == 规定)------0x01 ≠ 0x03 → 必须密码+卡都有才放行(正确)。


2.5 条件3:目标设备在线 ------ 六步探测

本质:

在 SDA 上发送一个地址探测数据,携带地址让对方应答,收到 ACK 说明在线。

第1步:发 START

复制代码
SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);   // CR1 bit8,0x100

置 START 位后,硬件在 SCL 高电平期间让 SDA 产生从高到低跳变,表示开始。


第2步:等 SB(起始完成)

cpp 复制代码
if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, tickstart) != HAL_OK)
{
   if (READ_BIT(hi2c->Instance->CR1, I2C_CR1_START) == I2C_CR1_START)
     hi2c->ErrorCode = HAL_I2C_WRONG_START;
   return HAL_TIMEOUT;
}

I2C_FLAG_SB = 0x00010001U → SR1 最低位 bit0:起始条件已发送。


第3步:发从机地址(写方向)

cpp 复制代码
hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);

#define I2C_7BIT_ADD_WRITE(__ADDRESS__)  ((uint8_t)((__ADDRESS__) & (uint8_t)(~I2C_OAR1_ADD0)))

只要把数据写入 DR,I2C 接口自动发送(单位是字节)。

该宏把 DevAddress 最低位清 0(LSB=0 表示写)。

✨ 所以调用时 DevAddress 必须先左移 1 位。

不同平台/工具逻辑可能不同,养成读源码的习惯,以不变应万变。


第4步:等 ADDR / 查 AF(地址发送可靠性)

cpp 复制代码
tmp1 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR); // 地址发送是否结束
tmp2 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_AF);   // 有无应答失败
while ((hi2c->State != HAL_I2C_STATE_TIMEOUT) && (tmp1 == RESET) && (tmp2 == RESET))
{
    if (((HAL_GetTick() - tickstart) > Timeout) || (Timeout == 0U))
       hi2c->State = HAL_I2C_STATE_TIMEOUT;
    tmp1 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR);
    tmp2 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_AF);
}

#define I2C_FLAG_ADDR  0x00010002U   // SR1 bit2
#define I2C_FLAG_AF    0x00010400U   // SR1 bit11

循环继续的条件:

没有超时 + ADDR 标志没置位 + AF 应答失败标志也没置位

  1. I2C_FLAG_ADDR (SR1 bit2) 主机发送从机地址 + 读写位之后:
    • 从机应答 ACK → 硬件置 ADDR=1(SET),代表地址发送完成
    • 还没收到 ACK → ADDR=0(RESET)
  2. I2C_FLAG_AF (SR1 bit11) Acknowledge Failure 应答失败
    • 从机无应答 NACK → AF 硬件置 1 (SET)
    • 正常收到 ACK → AF=0 (RESET

结束条件三种:

异常1 超时;

异常2 应答失败(AF);

正常:ADDR=1(Master 收到 ACK 后硬件置 ADDR,它只是对地址收到 ACK 的标识)。


第5步:判断

复制代码
if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR) == SET) { ...; return HAL_OK; }

正常情况只有一种,看 ADDR 是否置位即可。


第6步:发 STOP 收尾

cpp 复制代码
SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);       // CR1 bit9,0x200
__HAL_I2C_CLEAR_ADDRFLAG(hi2c);
if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
    return HAL_ERROR;
hi2c->State = HAL_I2C_STATE_READY;
__HAL_UNLOCK(hi2c);
return HAL_OK;   // 终于成功,设备 ready!

写 STOP 位后,Master 自动在 SCL 高电平期间让 SDA 产生从低到高跳变,表示停止。

✨ STOP 没有"完成标志位",也不需要 Master 确认 Slave 收到------发出即完成。


3. 写相关:HAL_I2C_Mem_Write

3.1 完整源码(带逐段注释)

cpp 复制代码
HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
    uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout)
{
  uint32_t tickstart = HAL_GetTick();        // 用于超时计数
  assert_param(IS_I2C_MEMADD_SIZE(MemAddSize));

  if (hi2c->State == HAL_I2C_STATE_READY)    // 设备必须 ready 才能发
  {
    if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
      return HAL_BUSY;                        // 总线忙超时

    __HAL_LOCK(hi2c);                         // 加锁,防止其他任务/中断再进来

    if ((hi2c->Instance->CR1 & I2C_CR1_PE) != I2C_CR1_PE)
      __HAL_I2C_ENABLE(hi2c);                 // 鲁棒性:确保 I2C 已使能

    CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_POS);   // 写不用 POS,清零

    hi2c->State     = HAL_I2C_STATE_BUSY_TX;  // 状态置"发送中"
    hi2c->Mode      = HAL_I2C_MODE_MEM;       // 模式 MEM:第一个数据带内存地址
    hi2c->ErrorCode = HAL_I2C_ERROR_NONE;

    hi2c->pBuffPtr  = pData;                  // 待发送数据起始地址
    hi2c->XferCount = Size;
    hi2c->XferSize  = hi2c->XferCount;        // 还剩多少字节
    hi2c->XferOptions = I2C_NO_OPTION_FRAME;

    // ★ 核心:START + 设备地址 + 字地址
    if (I2C_RequestMemoryWrite(hi2c, DevAddress, MemAddress, MemAddSize, Timeout, tickstart) != HAL_OK)
      return HAL_ERROR;

    while (hi2c->XferSize > 0U)               // 发送实际数据
    {
      if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)  // 等 DR 空
      {
        if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
          SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 应答失败 → 发 STOP 收尾
        return HAL_ERROR;
      }
      hi2c->Instance->DR = *hi2c->pBuffPtr;   // 写一个字节
      hi2c->pBuffPtr++;
      hi2c->XferSize--;
      hi2c->XferCount--;

      // BTF:DR 和移位寄存器都空 → 立即补下一个(双缓冲提速)
      if ((__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == SET) && (hi2c->XferSize != 0U))
      {
        hi2c->Instance->DR = *hi2c->pBuffPtr;
        hi2c->pBuffPtr++;
        hi2c->XferSize--;
        hi2c->XferCount--;
      }
    }

    /* Wait until BTF flag is set */
    // 循环结束只是"用户数据全部写进 DR/移位寄存器",不一定被对方接受了!
    // CPU 执行和 I2C 发送是异步的,必须等 BTF:所有数据真正发出并被对方收到
    if (I2C_WaitOnBTFFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)
    {
      if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
        SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);
      return HAL_ERROR;
    }

    SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 发停止位
    hi2c->State = HAL_I2C_STATE_READY;
    hi2c->Mode  = HAL_I2C_MODE_NONE;
    __HAL_UNLOCK(hi2c);
    return HAL_OK;
  }
  else
    return HAL_BUSY;
}

调用样例:

cpp 复制代码
HAL_StatusTypeDef state = HAL_I2C_Mem_Write(
        at24c02.i2c, at24c02.dev_address, at24c02.mem_address,
        at24c02.mem_address_size, data, len, HAL_MAX_DELAY);

bool dev_init(I2C_HandleTypeDef *i2c)
{
    if(!i2c) return false;
    at24c02.dev_address = (DEV_ADDRESS<<1);      // 7位地址左移1位
    at24c02.mem_address = MEM_ADDRESS;
    at24c02.mem_address_size = I2C_MEMADD_SIZE_8BIT;
    at24c02.page_size = PAGE_SIZE;
    at24c02.i2c = i2c;
    at24c02.is_init = true;
    return true;
}

3.2 细节1:工作模式(Mode)

STM32F103 没有直接的 Master/Slave 配置项,

主从由调用哪类函数决定------谁发 START 谁是 Master。

cpp 复制代码
typedef enum
{
  HAL_I2C_MODE_NONE    = 0x00U,   /*!< 空闲,无任何通信 */
  HAL_I2C_MODE_MASTER  = 0x10U,   /*!< Master_Transmit/Receive:地址+原始数据流,机械收发字节 */
  HAL_I2C_MODE_SLAVE   = 0x20U,   /*!< Slave_Transmit/Receive:被动等主机呼叫,硬件不主动产生 START/STOP */
  HAL_I2C_MODE_MEM     = 0x40U    /*!< Mem_Read/Write:地址+内存地址+数据,是 MASTER 的特化,
                                       内部自动处理"先写内存偏移地址,再读写数据"的复合时序 */
} HAL_I2C_ModeTypeDef;

这就是写 EEPROM 不用 HAL_I2C_Master_Transmit 的原因:

它只发**"设备地址+数据流"****,没有提前选中 AT24C02 内部内存地址的过程。**


3.3 核心函数:I2C_RequestMemoryWrite

cpp 复制代码
static HAL_StatusTypeDef I2C_RequestMemoryWrite(I2C_HandleTypeDef *hi2c,
    uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint32_t Timeout, uint32_t Tickstart)
{
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);         // ① 发开始
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, Tickstart) != HAL_OK)
    return HAL_TIMEOUT;                                // ② 等开始完成(SB)

  hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);// ③ 发设备地址(写方向)
  if (I2C_WaitOnMasterAddressFlagUntilTimeout(hi2c, I2C_FLAG_ADDR, Timeout, Tickstart) != HAL_OK)
    return HAL_ERROR;                                  // ④ 等地址发送完毕(ADDR)

  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);                      // ⑤ 清 ADDR
  if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
    return HAL_ERROR;                                  // ⑥ 等 DR 空(TXE)

  if (MemAddSize == I2C_MEMADD_SIZE_8BIT)              // 8位字地址
  {
    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);// ⑦a 直接发低字节
  }
  else                                                 // 16位字地址
  {
    hi2c->Instance->DR = I2C_MEM_ADD_MSB(MemAddress);// ⑦b 先高字节
    if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK) { ... }
    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);// 再低字节
  }
  return HAL_OK;   // 这里没检测 DR 空表示发送完成------在后续代码中检测
}

#define I2C_MEMADD_SIZE_8BIT 0x00000001U / I2C_MEMADD_SIZE_16BIT 0x00000010U。AT24C02 字地址 8 位足够(256 字节)。


3.4 细节2:TXE 与 BTF(发送侧核心)

cpp 复制代码
// I2C_WaitOnTXEFlagUntilTimeout:等 TXE
while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_TXE) == RESET)
{
  if (I2C_IsAcknowledgeFailed(hi2c) != HAL_OK)   // 检测 NACK(发送完下一时钟周期 SDA 为高=没 ACK)
    return HAL_ERROR;
  ...超时处理...
}

#define I2C_FLAG_TXE  0x00010080U   // SR1 第8位:DR 空

✨ 结论:

I2C_WaitOnTXEFlagUntilTimeout 返回 OK,说明 DR 的数据已移入移位寄存器、DR 空了------但不一定发出去了哦!

cpp 复制代码
// I2C_WaitOnBTFFlagUntilTimeout:等 BTF
while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == RESET)
{
  if (I2C_IsAcknowledgeFailed(hi2c) != HAL_OK)   // 发送数据收到的 ACK 是软件处理的
    return HAL_ERROR;
  ...超时处理...
}

#define I2C_FLAG_BTF  0x00010004U   // SR1 第3位

✨ TXE vs BTF(发送角度):

标志位 含义 说明
TXE DR 空 数据从 DR 移到移位寄存器(还没发出去)
BTF DR 与移位寄存器都空 该字节真正被发送出去了

这就是 HAL_I2C_Mem_Write 尾部必须再等一次 BTF 的原因:

确认所有字节都从移位寄存器发出并被对方收到,才能发 STOP。


4 读相关:HAL_I2C_Mem_Read

4.1 读为什么麻烦:最后的 NACK

为什么第 m+1 步主机发 NACK 而不是 ACK?

之前 Master 写、Slave 读时 Slave 一直在发 ACK;

到读取方向,Master 收完最后一个字节后要回 NACK + STOP,通知从机"不要再发了"。

抛出结论:

  • 若不做任何提前配置,收到最后一个字节后 Master 会主动发 ACK------这不是我们想要的;
  • 我们要的是:收到最后一个字节后,Master 回复 NACK,然后发送 STOP;
  • 这个工作由软件提前配置完成;
  • 为了让 Master 正确产生 NACK 和 STOP,软件必须在收到倒数第 2 个字节时立即提前配置**(寄存器配置生效有延后性)****。**

坑(源码演进史):

这段代码个人猜测是被意法工程师 debug 出来的------把读取分成 4 种情况:读 1 字节、读 2 字节、读 3 字节、读 N 字节。客户踩了不少坑反馈后,工程师花很长时间 debug,这段代码至今没人敢随便改(可参考意法论坛 2015 年 HAL bug 讨论)。

源码宏观轮廓:

cpp 复制代码
if (hi2c->State == HAL_I2C_STATE_READY)
{
  if (I2C_RequestMemoryRead(...) != HAL_OK) return HAL_ERROR;  // 步骤1:写地址时序
  if (hi2c->XferSize == 0U)      { ... }   // 步骤2:按情况提前配置
  else if (hi2c->XferSize == 1U) { ... }
  else if (hi2c->XferSize == 2U) { ... }
  else                           { ... }
  while (hi2c->XferSize > 0U)              // 步骤3:开始接收
  {
    if (hi2c->XferSize <= 3U)
    {
      if (hi2c->XferSize == 1U)      { ... }   // 收 1 字节
      else if (hi2c->XferSize == 2U) { ... }   // 收 2 字节
      else                          { ... }   // 收 3 字节
    }
    else                           { ... }     // 收 >3 字节
  }
  ...
  return HAL_OK;
}

4.2 步骤1:I2C_RequestMemoryRead(写地址时序)

前半部分(状态检查、BUSY、LOCK、PE、State=BUSY_RX、Mode=MEM、参数准备)与写函数几乎一样。

cpp 复制代码
static HAL_StatusTypeDef I2C_RequestMemoryRead(I2C_HandleTypeDef *hi2c,
    uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint32_t Timeout, uint32_t Tickstart)
{
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);          // ★ 读取要先开 ACK(自己收到数据要给对方应答)

  SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);         // ① 发开始
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, Tickstart) != HAL_OK)
    return HAL_TIMEOUT;                                // ② 等开始完成

  hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);// ③ 发地址+写位(要向设备写字地址,必须是写)
  if (I2C_WaitOnMasterAddressFlagUntilTimeout(hi2c, I2C_FLAG_ADDR, Timeout, Tickstart) != HAL_OK)
    return HAL_ERROR;                                  // ④ 等地址发送完成
  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);                      // ⑤ 清 ADDR

  if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
    return HAL_ERROR;                                  // ⑥ 等 DR 空

  if (MemAddSize == I2C_MEMADD_SIZE_8BIT)
  {
    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);  // ⑦a 发字地址(8位)
  }
  else
  {
    hi2c->Instance->DR = I2C_MEM_ADD_MSB(MemAddress);  // 16位:先高字节
    if (I2C_WaitOnTXEFlagUntilTimeout(...) != HAL_OK) { ... }
    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);  // 再低字节
  }

  if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
    return HAL_ERROR;                                  // ⑧ 等字地址发送完成

  SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);         // ⑨ ★ 重新启动 Restart!
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, Tickstart) != HAL_OK) { ... }  // ⑩ 等第二个开始

  hi2c->Instance->DR = I2C_7BIT_ADD_READ(DevAddress); // ⑪ ★ 再次发从机地址,这次是读方向
  if (I2C_WaitOnMasterAddressFlagUntilTimeout(hi2c, I2C_FLAG_ADDR, Timeout, Tickstart) != HAL_OK)
    return HAL_ERROR;                                  // ⑫ 等地址被确认

  return HAL_OK;  // 时序完成:START + 地址(W) + 字地址 + RESTART + 地址(R)
}

细节1:

ACK 标志位(CR1 bit10 = 0x400):ACK 使能后,硬件每收到一个字节的第 9 个时钟周期自动发 ACK。

**细节2:**I2C_7BIT_ADD_READ:

复制代码
#define I2C_7BIT_ADD_READ(__ADDRESS__)   ((uint8_t)((__ADDRESS__) | I2C_OAR1_ADD0))
#define I2C_OAR1_ADD0  0x00000001U   /* Bit 0 */

把 DevAddress 最低位强制置 1(LSB=1 表示读)。

由于"或 1"结果必为 1,所以无论读写,传设备地址都要 <<1(让出最低位给读写标志)。


4.3 步骤2:提前配置(0/1/2/3+ 字节)

为什么"提前":

修改寄存器是 CPU 直接改比特位,但修改完后 I2C 硬件还要在时序控制下根据配置完成动作------I2C 配置生效有延后性。

**这就是源码大量"等待完成"的原因:**CPU 配置很快,I2C 生效较慢。

类比:地铁到站播报当前站,并提前播报下一站(提前配置"下一站要不要下车")。

cpp 复制代码
if (hi2c->XferSize == 0U)                 // ① 没有数据要接收
{
  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 直接结束
}
else if (hi2c->XferSize == 1U)            // ② 只有 1 字节
{
  CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);  // 关自动应答 → 收完即回 NACK
  __disable_irq();                         // 关中断(EV6_3 时序必须完整执行)
  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // ★ 提前设置 STOP(并不立即发!)
  __enable_irq();
}
else if (hi2c->XferSize == 2U)            // ③ 有 2 字节
{
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_POS);    // ★ 设置 POS 位(跨字节预约 NACK)
  __disable_irq();
  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);         // 必须这么做
  CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK); // 必须这么做
  __enable_irq();
}
else                                      // ④ >=3 个数据
{
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);    // 开启自动 ACK
  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);
}

细节1:

STOP 提前设置------SET_BIT(CR1, STOP) 不会立即发送,而是等硬件收到最后一个字节、发完 NACK 后自动发出。

细节2:

POS 位(Acknowledge/PEC Position,CR1 bit11):

  • POS 专门处理 2 字节读取;
  • 核心作用:决定 NACK 发给当前字节还是下一个字节**;**
  • 使用配置:POS=1 且 ACK=0;
  • 霸道之处:临时剥夺 ACK 位对当前字节的控制权------对当前正在收的字节强行无视 NACK 设置、必须回 ACK,把 NACK 留给后面那个字节。

为什么必须有 POS(硬核推演):

  1. 收到数据回 ACK 是 I2C 自动做的;
  2. 移位寄存器收满完整 1 字节(8 个时钟周期),第 9 个时钟周期自动回 ACK,同时可能移入 DR;
  3. 收 2 字节时无 POS:收倒数第二个 → 自动 ACK;收最后一个 → 默认只能继续 ACK。但我们需要最后一个回 NACK;
  4. 想在倒数第二个自动 ACK 后、最后一个到达移位寄存器前让 CPU 关 ACK------时序要求极高,这个窗口极短,尤其有中断时几乎不可能,不确定性太大;
  5. 所以用 POS 提前设置,给硬件下达"跨字节预约 NACK":第 1 个字节自动 ACK,第 2 个字节自动 NACK。

一句话:

POS 是"为了一顿醋,包了顿饺子"------解决 2 字节读取收尾时序的专用机制。


4.4 步骤3:开始接收(1/2/3/N 字节)

① 收 1 字节(提前配置已关 ACK,收到即回 NACK):

cpp 复制代码
if (hi2c->XferSize == 1U)
{
  if (I2C_WaitOnRXNEFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)  // 等 RXNE:DR 有数据
    return HAL_ERROR;
  *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;   // 读走
  hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;
}

② 收 2 字节(POS=1 + ACK=0:第 1 个自动 ACK,第 2 个自动 NACK):

cpp 复制代码
else if (hi2c->XferSize == 2U)
{
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BTF, RESET, Timeout, tickstart) != HAL_OK)  // 等 BTF
    return HAL_ERROR;
  __disable_irq();
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);       // 置 STOP(数据还没读走!)
  *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;    // 读第 1 个
  hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;
  __enable_irq();
  *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;    // 读第 2 个(移位寄存器移入 DR)
  hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;
}

③ 收 3 字节(默认 ACK 开,读走第 1 个前关 ACK,让第 3 个回 NACK):

cpp 复制代码
else   // XferSize == 3
{
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BTF, RESET, Timeout, tickstart) != HAL_OK)  // 前 2 个收齐,都已自动 ACK
    return HAL_ERROR;

  CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);      // ★ 此时还没读 DR,最后 1 个还没收。
                                                    //   关 ACK → 读走 DR 后移位寄存器数据移入 DR,
                                                    //   第 3 个收满后第 9 时钟周期回 NACK,完美!
  __disable_irq();
  *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;    // 拿走第 1 个
  hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;

  count = I2C_TIMEOUT_FLAG * (SystemCoreClock / 25U / 1000U);   // 再次等 BTF:最后一个完整接收并已 NACK
  do { count--; if (count == 0U) { ...超时...; return HAL_ERROR; } }
  while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == RESET);

  SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);       // 3 个应答都完成,发 STOP
  *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;    // 读第 2 个
  hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;
  __enable_irq();
  *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;    // 读第 3 个
  hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;
}

④ 收 N 字节(>3)(RXNE 逐个读 + BTF 双缓冲 + 剩 3 个时关 ACK):

复制代码
else   // XferSize > 3
{
  if (I2C_WaitOnRXNEFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)
    return HAL_ERROR;
  *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;
  hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;

  if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == SET)   // DR 和移位寄存器都有 → 再读一个(提速)
  {
    if (hi2c->XferSize == 3U)
      CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);    // ★ 还剩 3 个时提前关 ACK,为最后 NACK 准备
    *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;
    hi2c->pBuffPtr++; hi2c->XferSize--; hi2c->XferCount--;
  }
}

收尾统一:

State=READY; Mode=NONE; __HAL_UNLOCK; return HAL_OK;

细节1:

RXNE 标志位(0x00010040U,SR1 第 7 位)------只在接收用:1 = DR 有数据可读。

细节2:

BTF(接收角度)------读取模式下,DR 有数据 && 移位寄存器有数据时 BTF=1。

✨ RxNE vs BTF(接收角度):

标志位 含义 配合效果
RXNE DR 有数据可读 通知 CPU"可以取走一个字节"
BTF DR 和移位寄存器都有数据 两个都满:CPU 拷贝 DR 的同时,移位寄存器继续接收下一字节

接收也是双寄存器缓冲:

CPU 取 DR 数据的同时,I2C 硬件在移位寄存器继续接收,提高吞吐量。

5. 本章总结(速查表)

① I2C 寄存器总览(基地址 0x40005800)

寄存器 偏移 本章涉及的关键位 作用
CR1 0x00 PE(0)、ENGC(6)、NOSTRETCH(7)、START(8)、STOP(9)、ACK(10)、POS(11) 控制:使能、起停、应答
CR2 0x04 FREQ5:0 外设时钟频率范围
OAR1 0x08 ADD0~ADD7、ADDMODE 自身地址(从机用)
OAR2 0x0C ENDUAL、ADD2 双地址
DR 0x10 数据字节 读写数据的"窗口"
SR1 0x14 SB(0)、ADDR(2)、BTF(3)、RXNE(7)、TXE(8)、AF(11) 等 状态标志(发送/接收过程)
SR2 0x18 MSL(0)、BUSY(1)、TRA(2) 等 状态标志(总线/主从)
CCR 0x1C F/S、DUTY、CCR 时钟速度与占空比
TRISE 0x20 TRISE 上升时间

② 关键标志位速查

标志 宏值 所在寄存器·位 含义(本章语境)
SB 0x00010001 SR1 bit0 起始条件已发送
ADDR 0x00010002 SR1 bit2 地址发送结束(收到 ACK)
BTF 0x00010004 SR1 bit3 发送:DR+移位寄存器都空(字节真发出); 接收:DR+移位都有数据
RXNE 0x00010040 SR1 bit7 接收时 DR 有数据可读
TXE 0x00010080 SR1 bit8 发送时 DR 空(可写下一字节)
AF 0x00010400 SR1 bit11 应答失败(NACK)
BUSY 0x00100002 SR2 bit1 总线忙
MSL 0x00100001 SR2 bit0 主/从模式

标志位宏规律:

32 位数 = 高 16 位选寄存器(0x0001→SR1,0x0010→SR2)+ 低 16 位定位标志位位置。

③ 核心概念对比

对比项 说明
开启时钟 vs 设备使能 时钟=RCC 使能(可配置); 使能=CR1.PE(可工作)。 插电 vs 按开始
Master vs Slave 无专门配置项,谁发 START 谁是 Master MODE 由调用函数决定
Master_Transmit Mem_Write 前者只发**"地址+数据流"(无内存地址过程)**;** 后者带"内存/寄存器地址"复合时序**
TXE vs BTF(发送) TXE=DR 空(数据进移位寄存器); BTF=DR+移位寄存器都空(数据真正发出)
RXNE vs BTF(接收) RXNE=DR 有数据; BTF=DR+移位寄存器都有数据(双缓冲提速)
ACK vs NACK 收到字节后第 9 个时钟周期的应答:低=ACK 继续,高=NACK 结束
POS=1+ACK=0 2 字节读取专用:当前字节强制 ACK,NACK 预约给下一个字节
7 位地址左移 **最低位留给读写位:写=0(&~1)、读=1(`

面试题总结

一、基础概念

1. I2C 通信的基本信号有哪些?START 和 STOP 的时序特征是什么?

答案:

起始 START(SCL 高电平期间 SDA 高→低)、

停止 STOP(SCL 高电平期间 SDA 低→高)、

数据位(SCL 高电平期间 SDA 电平稳定有效,SCL 低电平期间允许变化)、

应答 ACK(第 9 个时钟周期 SDA 拉低)、

非应答 NACK(第 9 个时钟周期 SDA 保持高)。

2. AT24C02 的 7 位器件地址怎么构成?为什么调用 HAL 函数前要左移 1 位?

答案:

完整 7 位器件地址格式为 0101 0 A2 A1 A0(AT24C02 固定前 4 位 1010,A2/A1/A0 由硬件引脚电平决定)。

**I2C 发送时第 8 位是读写位(0=写,1=读),所以 7 位地址要 <<1 腾出最低位;**HAL 内部用 I2C_7BIT_ADD_WRITE(最低位清 0)或 I2C_7BIT_ADD_READ(最低位置 1)补齐。

3**. 为什么 I2C 的 SCL/SDA 要配置成开漏输出(AF_OD)并接上拉电阻?**

答案:

开漏输出只能拉低、不能主动拉高,靠外部上拉电阻把总线拉高,实现"线与"(任一设备拉低则总线为低)。这允许:① 多主机仲裁;② 从机时钟拉伸(拉低 SCL 暂停主机);③ 应答位由接收方拉低 SDA 实现。

4. 时钟拉伸(Clock Stretching)是什么?课件里 NoStretchMode 配置成什么?

答案:

从机处理不过来时拉低 SCL,让主机暂停发送。

课件配置 NoStretchMode = I2C_NOSTRETCH_DISABLE,即允许从机时钟拉伸(禁用"禁止拉伸"),这是标准行为。


二、初始化与寄存器

5. "开启时钟"和"设备使能"有什么区别?

答案:

开启时钟(RCC 使能)是让外设接入时钟树、可以配置寄存器;

设备使能(CR1.PE 置 1)是外设本身开始工作。

类比洗衣机:插电源(开时钟)→ 设定参数(配置)→ 按开始(设备使能)→ 正常工作。两者缺一不可。

6. MODIFY_REG 宏的原理是什么?为什么"先清空再设置"?

答案:

WRITE_REG(REG, (READ_REG(REG) & ~CLEARMASK) | SETMASK)

先读出寄存器 → 清除掩码取反后与运算(把指定位置清零、其他位不变)→ 或上 SETMASK(写入新值)→ 写回。

若不先清空直接 |,原值中的 1 会残留,结果不变,无法正确设置。

7. hi2c1.Instance = I2C2 这行代码做了什么?结构体为什么能直接映射到寄存器?

答案:

I2C2((I2C_TypeDef*)0x40005800),把句柄的 Instance 指针指向 I2C2 寄存器基地址。

因为 I2C_TypeDef 结构体成员顺序(CR1→CR2→...→TRISE)与 I2C 外设 9 个连续 32 位寄存器的偏移顺序完全一致,所以可以用指针像操作内存一样直接读写寄存器(如 hi2c->Instance->DR 等价于访问 0x40005810)。

8. HAL_I2C_MspInit 是做什么的?为什么 I2C 的 GPIO 配置要放在这里而不是 HAL_I2C_Init?

答案:

MSP(MCU Support Package)是芯片硬件相关配置(GPIO 引脚、时钟使能、中断),因芯片而异。

HAL_I2C_Init 是协议逻辑(寄存器配置),与具体芯片无关;

两者分离,换芯片只改 MspInit,协议代码不用动。


三、设备就绪检测

9. HAL_I2C_IsDeviceReady 判断"设备就绪"要满足哪三个条件?

答案:

① 设备本身空闲(hi2c->State == HAL_I2C_STATE_READY,未被中断/其他任务占用,否则返回 HAL_BUSY);

② 总线就绪(SR2.BUSY 复位,无传输占用);

③ 目标设备在线且地址匹配(发出地址后收到 ACK)。

10. __HAL_I2C_GET_FLAG 宏如何定位标志位?以 I2C_FLAG_BUSY = 0x00100002U 为例。

答案:

标志位宏是"地址+偏移"封装**------高 16 位选寄存器(0x0001→SR1,0x0010→SR2),低 16 位定位标志位位置。**

BUSY 高 16 位=0x10≠0x01 → 选 SR2;

低 16 位=0x0002 → 检测 (SR2 & 0x2) == 0x2,即 SR2 的 bit1(总线忙)。

11. 为什么标志位判断要写成**(寄存器 & 掩码) == 掩码 而不是(寄存器 & 掩码)**非零即可?

答案:

这是"全比较"。

标志位可能由多位组成,只有全部匹配才成立;

若只判非零,部分位为 1 也会误判通过(类似取款机:只输密码没插卡,0x01 & 0x03 非零就放行是错的,必须 == 0x03)。

12. 简述 HAL_I2C_IsDeviceReady 中地址探测的六步流程。

答案:

① 置 CR1.START 发起始条件;

② 等 SR1.SB(起始完成,超时返回 HAL_TIMEOUT);

③ 写 DR = I2C_7BIT_ADD_WRITE(DevAddress) 发从机地址(写方向);

④ 循环检测 ADDR/AF(等地址发送结束且无应答失败);

⑤ 判 ADDR==SET(收到 ACK,设备在线);

⑥ 发 STOP、清 ADDR、等 BUSY 复位、恢复 State=READY、返回 HAL_OK。

13. 为什么写完后要调用 HAL_I2C_IsDeviceReady 等待而不是直接 delay?

答案:

AT24C02 内部写入需要 5ms+ 完成,期间不响应总线操作。

用 delay 粗暴等待无法感知"实际写完了没",而 IsDeviceReady 通过地址探测确认设备恢复应答,时间更精准、更可靠(阻塞式接口也都有超时保护防卡死)。


四、读写机制(重点)

14. TXE 和 BTF 标志位的区别?为什么 HAL_I2C_Mem_Write 结尾还要再等一次 BTF?

答案:

TXE=DR 空(数据已从 DR 移入移位寄存器,不一定发出去了);

BTF=DR 和移位寄存器都空(该字节真正在总线上发完)。

CPU 写 DR 和 I2C 接口移位发送是异步的,while 循环结束只是"用户数据全部写入 DR/移位寄存器",必须等 BTF 确认所有字节都被对方收到,才能发 STOP。

15. 为什么写 EEPROM 用 HAL_I2C_Mem_Write 而不是 HAL_I2C_Master_Transmit?

答案:

Master_Transmit 只完成"设备地址+原始数据流"的机械发送,没有提前选中设备内部内存地址的过程;

而 EEPROM 必须先发字地址(要访问哪个字节)才能读写数据。

Mem_Write 是 MASTER 模式的特化,内部自动处理"设备地址+内存地址+数据"的复合时序(对应 Mode=HAL_I2C_MODE_MEM)。

16. 读取时为什么最后一个字节要回 NACK?什么时候配置?

答案:

NACK 告诉从机"收够了,别再发",配合 STOP 结束本次读取。

因为I2C 配置生效有延后性(CPU 改寄存器很快、硬件按时序执行较慢),所以必须在收到倒数第 2 个字节时提前配置(关 ACK/置 STOP),硬件才会在最后一个字节后自动回 NACK。

17. POS 位是干什么的?为什么 2 字节读取必须靠它?

答案:

POS(CR1 bit11)决定 NACK 是发给当前字节还是下一个字节**。**

收 2 字节时硬件对每个字节自动回 ACK,若要最后回 NACK,理论上可在"第 1 个字节自动 ACK 后、第 2 个字节到达前"由 CPU 关 ACK------但该时序窗口极短、中断下几乎不可行。

POS=1 + ACK=0 时硬件"跨字节预约":当前字节强制回 ACK,NACK 预约给下一字节,彻底绕开时序竞争。

18. 读取 1/2/3/N 字节时,ACK/NACK 分别是如何处理的?

答案:

  • 1 字节:提前关 ACK + 置 STOP → 数据移入 DR 时自动回 NACK;
  • 2 字节:POS=1 + ACK=0 → 第 1 个自动 ACK、第 2 个自动 NACK,等 BTF 后读走两个;
  • 3 字节:默认开 ACK,前 2 个自动 ACK;读走第 1 个前关 ACK,第 3 个收满回 NACK,再置 STOP;
  • N 字节(>3):等 RXNE 逐字节读,BTF 满则双缓冲再读一个提速,剩 3 个时关 ACK,之后走 3 字节流程。

19. RxNE 和 BTF(接收角度)有什么区别?

答案:

RXNE=DR 有数据可读(CPU 可以取走一个字节);

BTF=DR 和移位寄存器都有数据。

两者配合实现双寄存器缓冲:CPU 拷贝 DR 数据的同时,移位寄存器继续接收下一字节,提升吞吐量。


五、深挖/开放

20. STM32F103 中没有 Master/Slave 配置项,主从模式是怎么确定的?

答案:

由调用哪类 HAL 函数决定------谁发 START 谁是 Master

hi2c->Mode 只是逻辑分类记录(MASTER=地址+数据流;MEM=地址+内存地址+数据的特化;SLAVE=被动等主机呼叫,硬件不主动产生 START/STOP)。

21. 为什么 HAL 库中所有等待循环都带超时?HAL_MAX_DELAY 是什么含义?

答案:

阻塞式接口等待标志位时可能因总线异常、设备无响应而永远等不到,超时防止卡死(单片机开发铁律)。

HAL_MAX_DELAY 表示无限等待(不超时),由调用者自行承担风险。

22.(开放)如果 I2C 通信突然卡死(如 SDA 被从机拉低),你会如何排查?

答案要点:

① 示波器/逻辑分析仪看 SCL/SDA 电平是否被拉低,定位是主机还是从机导致;

② 检查从机是否处于忙状态(如 EEPROM 写入中未完成);

③ 检查总线是否有设备地址冲突/仲裁失败(AF/ARLO/BERR 标志位);

④ 软件恢复手段:连续发送 9 个 SCL 时钟脉冲让从机释放总线,或复位从机;

⑤ 检查上拉电阻是否虚焊、线长是否过长(影响上升沿,400kHz 快速模式更敏感)。

23.(开放)阻塞式、中断式、DMA 式 I2C 怎么选?

答案要点:

阻塞式最简单、代码直观,但 CPU 全程等待,适合低速小数据(如 EEPROM 读写);

中断式让 CPU 在等待时干别的,适合中等数据量;

DMA 式由硬件搬运数据,CPU 零参与,适合大数据量/高频率传输,但代码和资源管理最复杂。工程上按数据量和实时性要求取舍。


相关推荐
xx~t44 分钟前
嵌入式——ARM——汇编1
linux·汇编·arm开发·嵌入式硬件·arm
小白学大数据1 小时前
Python 小工具实战:用问财接口搭建金融数据采集器
开发语言·人工智能·python
泡海椒1 小时前
jquick-pdf 核心原理解析:基于 HTML 模板动态渲染 PDF 的实现逻辑
java·开发语言·pdf
拒绝内耗。1 小时前
高并发系统如何提升吞吐量?从扩容、缓存、异步到数据库优化
架构·高并发
PiaoKe___1 小时前
从端云解耦到可编程 Android 实例:云手机架构解析与 Python 批量管控实战
arm开发·python·架构·自动化
H_oRIZoN_2 小时前
Linux入门DAY44 ARM 入门 Day02|ARM 汇编指令、模式切换、栈操作、汇编 C 混合编程
linux·单片机·嵌入式硬件·arm·linux应用编程
小此方3 小时前
「C++AI大模型接入SDK」(一) API接入与本地两种方式对比、API Key获取、API报文详解与简单API的构建
开发语言·c++·人工智能
国科安芯3 小时前
卫星电源管理系统中宽温域低功耗MCU的选型与适应性分析
嵌入式硬件·mcu·抗干扰·抗辐射·宽温
wuyk5554 小时前
Python实战项目02:学生成绩管理系统(控制台|CSV导出|完整落地)
开发语言·python