2. STM32 I2C 外设架构:模块框图、时钟树、中断与 DMA
好,咱们直接进入正题。I2C 外设到底长什么样?它怎么挂到时钟树上的?中断和 DMA 又该怎么用?
我个人习惯,看一个新外设,先看它的模块框图。这玩意儿就像一张地图,告诉你数据从哪进、从哪出,中间经过了哪些关卡。
2.1 I2C 模块框图:数据通路与核心寄存器
先上一张图,这是我根据 STM32 参考手册重新画的,把关键路径都标出来了。
这张图里,我重点标出了三个区域:数据寄存器组、控制与状态逻辑、引脚接口。数据从 APB 总线过来,先写到 DR 寄存器,然后通过移位寄存器一位一位地从 SDA 线送出去。反过来,接收的时候也是一位一位收进来,拼成一个字节,放到 DR 里等你来读。
2.2 时钟树:I2C 的脉搏从哪来?
I2C 的时钟,说白了就是 SCL 线上的那个方波。这个方波的频率由谁决定?
看时钟树。I2C 外设挂载在 APB1 总线 上(F4 系列是 APB1,H7 系列可能不同)。APB1 的时钟频率,经过一个分频器,就生成了 I2C 的 SCL 时钟。
| 参数 | 说明 | 典型值 |
|---|---|---|
| APB1 时钟 | I2C 外设的输入时钟源 | 42 MHz (F4) / 100 MHz (H7) |
| CCR 寄存器 | 时钟控制寄存器,设置分频系数 | 0x3C (100kHz) / 0x09 (400kHz) |
| TRISE 寄存器 | 上升时间寄存器,用于标准/快速模式 | 0x2A (100kHz) / 0x0A (400kHz) |
| SCL 频率 | 最终输出到 SCL 引脚的时钟 | 100 kHz / 400 kHz / 1 MHz |
计算公式很简单:
SCL 频率 = APB1 时钟频率 / (CCR 值 × 2)
举个例子:APB1 = 42 MHz,想要 100 kHz
CCR = 42,000,000 / (100,000 × 2) = 210 = 0xD2
2.3 中断:事件驱动的通信方式
I2C 的中断,说白了就是告诉你「有事发生了」。你不用一直轮询状态寄存器,让硬件来通知你。
常用的 I2C 中断事件有这些:
- TXIS:发送缓冲区空,可以写下一个数据了。
- RXNE:接收缓冲区非空,有数据可以读了。
- ADDR:地址匹配成功,从机响应了。
- STOP:检测到停止条件,一次传输结束。
- NACK:从机没有应答,通信失败。
- BERR:总线错误,比如 SDA 被拉低太久。
我个人习惯,在调试阶段,把 NACK 和 BERR 中断都打开。一旦出问题,中断服务函数里直接打印错误码,定位问题快很多。
中断配置示例(HAL 库):
// 使能 I2C 中断
HAL_NVIC_SetPriority(I2C1_EV_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(I2C1_EV_IRQn);
HAL_NVIC_SetPriority(I2C1_ER_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(I2C1_ER_IRQn);
// 在中断服务函数中处理
void I2C1_EV_IRQHandler(void)
{
HAL_I2C_EV_IRQHandler(&hi2c1);
}
void I2C1_ER_IRQHandler(void)
{
HAL_I2C_ER_IRQHandler(&hi2c1);
}
2.4 DMA:解放 CPU 的利器
如果你要传输大量数据,比如一次读写几百个字节,用中断一个一个搬,CPU 会被频繁打断。这时候就该 DMA 上场了。
DMA 可以自动把数据从内存搬到 I2C 的 DR 寄存器,或者从 DR 寄存器搬到内存,完全不需要 CPU 干预。
| 传输方向 | DMA 通道 | 触发源 | 数据方向 |
|---|---|---|---|
| 发送 | DMA1 通道6 (I2C1_TX) | TXIS 事件 | 内存 → 外设 |
| 接收 | DMA1 通道7 (I2C1_RX) | RXNE 事件 | 外设 → 内存 |
DMA 配置也不复杂,核心就是设置好源地址、目标地址、传输长度和触发源。
// DMA 发送配置示例
__HAL_LINKDMA(&hi2c1, hdmatx, hdma_i2c1_tx);
hdma_i2c1_tx.Instance = DMA1_Channel6;
hdma_i2c1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_i2c1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_i2c1_tx.Init.MemInc = DMA_MINC_ENABLE;
hdma_i2c1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_i2c1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_i2c1_tx.Init.Mode = DMA_NORMAL;
hdma_i2c1_tx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_i2c1_tx);
2.5 中断 vs DMA:怎么选?
很多初学者会纠结这个问题。我直接给个判断标准:
- 单字节或少量数据(1-8 字节):用轮询或者中断就够了。DMA 的初始化开销反而比传输本身还大。
- 中等数据量(8-64 字节):用中断。CPU 处理中断的开销可以接受。
- 大量数据(64 字节以上):必须用 DMA。否则 CPU 会被中断淹没,其他任务就别想跑了。
- 实时性要求高:用 DMA + 中断配合。DMA 负责搬数据,中断只处理传输完成事件。
2.6 小结:架构决定了调试思路
理解 I2C 外设架构,不是为了背寄存器,而是为了调试时能快速定位问题。
- SCL 没波形?查时钟树和 CCR 寄存器。
- SDA 一直低?查总线错误中断,看是不是有设备拉死了总线。
- 数据收发不对?查 DR 寄存器和 DMA 配置。
- 中断没触发?查 NVIC 使能和中断标志位清除。
嗯,架构清楚了,后面调试起来就顺手多了。