2. STM32 I2C 外设架构:模块框图、时钟树、中断与 DMA

好,咱们直接进入正题。I2C 外设到底长什么样?它怎么挂到时钟树上的?中断和 DMA 又该怎么用?

我个人习惯,看一个新外设,先看它的模块框图。这玩意儿就像一张地图,告诉你数据从哪进、从哪出,中间经过了哪些关卡。

2.1 I2C 模块框图:数据通路与核心寄存器

先上一张图,这是我根据 STM32 参考手册重新画的,把关键路径都标出来了。

STM32 I2C 模块内部框图(数据通路) 数据寄存器组 DR (数据寄存器) TXDR (发送数据) RXDR (接收数据) OAR1/OAR2 (自身地址) 控制与状态逻辑 CR1/CR2 (控制寄存器) SR1/SR2 (状态寄存器) CCR (时钟控制) TRISE (上升时间) 引脚接口 SCL (时钟线) SDA (数据线) GPIO 复用功能 开漏输出 + 上拉 数据总线 SCL/SDA 中断与 DMA TXIS / RXNE / ADDR / STOP 中断请求 DMA 控制器 DMA1/DMA2 通道 DMA 请求 💡 关键路径: CPU/DMA → 数据寄存器 DR → 移位寄存器 → SDA 引脚(发送) SDA 引脚 → 移位寄存器 → 数据寄存器 DR → CPU/DMA(接收)

这张图里,我重点标出了三个区域:数据寄存器组、控制与状态逻辑、引脚接口。数据从 APB 总线过来,先写到 DR 寄存器,然后通过移位寄存器一位一位地从 SDA 线送出去。反过来,接收的时候也是一位一位收进来,拼成一个字节,放到 DR 里等你来读。

我的经验: 调试 I2C 通信时,我最先查的就是 DR 寄存器有没有更新。如果 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
⚠️ 我曾经踩过的坑: 有一次我改了系统时钟,忘了更新 I2C 的 CCR 值。结果 SCL 频率跑到了 500 kHz,从机直接不响应了。调了一下午才发现是时钟树没同步。所以,改系统时钟后,一定要重新算一遍 I2C 的分频系数。

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 事件 外设 → 内存
我的建议: 如果一次传输超过 16 个字节,果断用 DMA。CPU 可以趁这个时间去处理其他任务,比如刷新屏幕、处理按键。我做过一个项目,用 DMA 传输 256 字节的传感器数据,CPU 占用率从 30% 降到了 2%。

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 负责搬数据,中断只处理传输完成事件。
⚠️ 注意: DMA 虽然好,但有个坑——DMA 传输完成中断 和 I2C STOP 中断 的时序问题。我曾经遇到过 DMA 已经传完了,但 I2C 还没发出 STOP 条件,导致从机以为数据还没结束。解决办法是在 DMA 完成中断里,手动触发 STOP 条件。

2.6 小结:架构决定了调试思路

理解 I2C 外设架构,不是为了背寄存器,而是为了调试时能快速定位问题。

  • SCL 没波形?查时钟树和 CCR 寄存器。
  • SDA 一直低?查总线错误中断,看是不是有设备拉死了总线。
  • 数据收发不对?查 DR 寄存器和 DMA 配置。
  • 中断没触发?查 NVIC 使能和中断标志位清除。

嗯,架构清楚了,后面调试起来就顺手多了。