3. STM32的I2C外设与HAL库
好,咱们进入第三章。这一章我打算把STM32的I2C外设掰开揉碎了讲清楚。你可能会想,I2C协议不就是SCL和SDA两根线嘛,有啥好讲的?嗯,协议本身确实简单,但到了芯片外设这一层,事情就没那么单纯了。我早年做第一个I2C项目时,就因为在寄存器配置上踩了个坑,折腾了两天才把数据读出来。所以这一章,咱们不光看框图,还要把HAL库那几个关键API的用法和坑点都过一遍。
3.1 STM32 I2C外设框图解析
先看外设框图。STM32的I2C模块,说白了就是一个独立的状态机。它自己管理时钟、数据移位、地址匹配、应答检测这些事情。你只需要往数据寄存器里丢数据,或者从里面取数据,剩下的硬件帮你搞定。
我画了一张简化的框图,帮你理清核心模块的关系:
从图上你能看到几个关键模块:
- 时钟控制:决定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
);
参数说明:
| 参数 | 说明 |
|---|---|
| hi2c | I2C句柄,比如 &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发起传输后返回,传输完成触发中断回调 | 实时性要求不高、数据量中等 |
| DMA | CPU发起后由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在带寄存器地址设备中的重要性。最后对比了轮询和中断模式的选择策略。