3. STM32的I2C外设与HAL库

好,咱们进入第三章。这一章我打算把STM32的I2C外设掰开揉碎了讲清楚。你可能会想,I2C协议不就是SCL和SDA两根线嘛,有啥好讲的?嗯,协议本身确实简单,但到了芯片外设这一层,事情就没那么单纯了。我早年做第一个I2C项目时,就因为在寄存器配置上踩了个坑,折腾了两天才把数据读出来。所以这一章,咱们不光看框图,还要把HAL库那几个关键API的用法和坑点都过一遍。

3.1 STM32 I2C外设框图解析

先看外设框图。STM32的I2C模块,说白了就是一个独立的状态机。它自己管理时钟、数据移位、地址匹配、应答检测这些事情。你只需要往数据寄存器里丢数据,或者从里面取数据,剩下的硬件帮你搞定。

我画了一张简化的框图,帮你理清核心模块的关系:

STM32 I2C 外设核心框图 时钟控制 CCR / TRISE 数据寄存器 DR (8位) 地址寄存器 OAR1 / OAR2 控制逻辑 CR1 / CR2 状态寄存器 SR1 / SR2 中断 / DMA IT / DMA 请求 SCL 引脚 | SDA 引脚 说明:数据寄存器DR是核心枢纽。发送时,CPU写DR,硬件自动移位输出到SDA;接收时,硬件从SDA移位进来,CPU读DR。 状态寄存器SR1/SR2记录总线状态、应答标志、溢出标志等,是判断传输是否成功的关键。

从图上你能看到几个关键模块:

  • 时钟控制:决定SCL的频率。标准模式100kHz,快速模式400kHz。CCR寄存器配错了,波形就不对。
  • 数据寄存器DR:8位,发送和接收共用。写DR就是启动发送,读DR就是取接收数据。
  • 地址寄存器:从机模式下用,主机模式不需要配这个。
  • 控制逻辑:启动、停止、应答使能、软件复位等都在这里配。
  • 状态寄存器:SB、ADDR、TXE、RXNE、BTF这些标志位,你得学会看。
  • 中断/DMA:可以配置事件中断、错误中断,也可以用DMA搬运数据。

我的经验:调试I2C时,我最先看的就是SR1寄存器。如果TXE一直不置位,说明数据没发出去;如果RXNE一直不置位,说明从机没响应。这两个标志位是排查问题的第一站。

3.2 HAL库I2C相关API详解

HAL库把I2C的操作封装成了几个核心函数。你不需要直接操作寄存器,但理解每个函数背后的硬件行为,对调试大有帮助。

3.2.1 HAL_I2C_Master_Transmit

这个函数用于主机向从机发送数据。原型如下:

HAL_StatusTypeDef HAL_I2C_Master_Transmit(
    I2C_HandleTypeDef *hi2c,
    uint16_t DevAddress,
    uint8_t *pData,
    uint16_t Size,
    uint32_t Timeout
);

参数说明:

参数说明
hi2cI2C句柄,比如 &hi2c1
DevAddress从机地址,注意是左对齐的7位地址(比如0x3C左移一位变成0x78)
pData要发送的数据缓冲区指针
Size发送的字节数
Timeout超时时间,单位毫秒

内部流程:硬件自动产生START信号,发送地址+写位,然后逐个字节发送数据,最后产生STOP信号。你只需要把数据准备好,调用这个函数就行。

注意:DevAddress传入的是7位地址左移1位后的值。比如从机地址是0x3C,你要传0x78。HAL库内部会再右移回来。我见过有人直接传0x3C,结果地址对不上,从机没反应。

3.2.2 HAL_I2C_Master_Receive

主机从从机接收数据:

HAL_StatusTypeDef HAL_I2C_Master_Receive(
    I2C_HandleTypeDef *hi2c,
    uint16_t DevAddress,
    uint8_t *pData,
    uint16_t Size,
    uint32_t Timeout
);

用法和Transmit对称。硬件自动产生START,发送地址+读位,然后接收Size个字节,最后STOP。接收到的数据放在pData缓冲区里。

3.2.3 HAL_I2C_Mem_Write 和 HAL_I2C_Mem_Read

这两个函数是专门为带内部寄存器地址的I2C设备准备的。比如LCD驱动芯片SSD1306、触摸芯片FT6336,它们内部有寄存器地址,你要先写寄存器地址,再读写数据。

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
);

HAL_StatusTypeDef HAL_I2C_Mem_Read(
    I2C_HandleTypeDef *hi2c,
    uint16_t DevAddress,
    uint16_t MemAddress,
    uint16_t MemAddSize,
    uint8_t *pData,
    uint16_t Size,
    uint32_t Timeout
);

多出来的两个参数:

  • MemAddress:设备内部的寄存器地址。
  • MemAddSize:寄存器地址的字节数,一般是I2C_MEMADD_SIZE_8BIT或I2C_MEMADD_SIZE_16BIT。

内部流程:硬件先发START,发设备地址+写位,然后发寄存器地址,再发START(重复起始),发设备地址+读位,最后收数据。这个流程叫"复合传输",HAL库帮你封装好了。

核心区别:Master_Transmit/Receive只做一次寻址,适合没有内部寄存器地址的设备(比如某些传感器)。Mem_Write/Read做了两次寻址,适合带寄存器地址的设备(比如LCD、触摸芯片)。

3.3 轮询与中断模式选择

HAL库的I2C API支持三种模式:轮询、中断、DMA。这里我重点讲轮询和中断,DMA我们后面用到时再细说。

模式特点适用场景
轮询CPU忙等,直到传输完成或超时简单应用、初始化阶段、数据量小
中断CPU发起传输后返回,传输完成触发中断回调实时性要求不高、数据量中等
DMACPU发起后由DMA搬运数据,传输完成触发中断大数据量、高吞吐场景

轮询模式:调用函数后,CPU就在循环里等硬件标志位。优点是代码简单,缺点是CPU被占着啥也干不了。我一般在系统初始化阶段用轮询,比如配置LCD的初始化序列,这时候还没上RTOS,轮询最省事。

中断模式:调用函数后立即返回,传输完成后HAL库会调用回调函数HAL_I2C_MasterTxCpltCallback或HAL_I2C_MasterRxCpltCallback。你可以在回调里设置标志位,主循环检测到标志位后再处理数据。

举个例子,中断模式接收的典型写法:

// 全局变量
volatile uint8_t i2c_rx_done = 0;
uint8_t rx_buffer[16];

// 发起接收
HAL_I2C_Master_Receive_IT(&hi2c1, 0x78, rx_buffer, 16, 100);
// 函数立即返回,CPU可以干别的

// 中断回调
void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c) {
    if (hi2c->Instance == I2C1) {
        i2c_rx_done = 1;  // 设置标志位
    }
}

// 主循环
while (1) {
    if (i2c_rx_done) {
        i2c_rx_done = 0;
        // 处理 rx_buffer 中的数据
    }
    // 其他任务
}

我曾经踩过的坑:中断回调里千万不要做耗时操作,比如printf、延时、复杂计算。回调里只设标志位或唤醒任务,数据处理放到主循环或任务里做。否则会影响后续中断响应,严重时会导致I2C总线卡死。

我个人习惯是:初始化用轮询,运行时用中断。初始化阶段数据量小,轮询简单可靠;运行时用中断,CPU可以处理其他任务,系统响应更及时。

3.4 小结

这一章我们看了STM32 I2C外设的框图,理解了数据寄存器、状态寄存器、控制逻辑这些核心模块。然后详细讲了四个HAL库API的用法和参数含义,特别是Mem_Write和Mem_Read在带寄存器地址设备中的重要性。最后对比了轮询和中断模式的选择策略。