3. MCU硬件I2C外设详解:以STM32为例

好,咱们今天来啃硬骨头——STM32的硬件I2C外设。

说实话,我刚接触STM32那会儿,也被它的I2C模块搞得有点头疼。尤其是早期标准库版本,那个I2C总线上有个小bug,处理不当就容易卡死。不过现在的HAL库和LL库已经成熟多了,但底层原理你还得懂,不然出了问题根本不知道从哪下手。

3.1 I2C外设的整体架构

STM32的I2C模块,说白了就是一个独立的状态机。它不占用CPU去模拟时序,自己就能完成起始、停止、应答、数据收发这些操作。

核心思想:硬件I2C = 状态机 + 移位寄存器 + 时钟发生器 + 中断/DMA控制器。你只需要配置好寄存器,然后等着中断或DMA告诉你“活干完了”。

我画了一张架构图,你看一眼就明白了:

STM32 I2C外设内部架构 I2C状态机 (主/从模式切换) 数据移位寄存器 (DR / TXDR / RXDR) 时钟发生器 (SCL线控制 + 波特率) 控制逻辑 (CR1 / CR2寄存器) 中断控制器 (状态标志 + NVIC) DMA接口 (TX/RX请求) 外部引脚:SCL (PB6/PB8) | SDA (PB7/PB9) (开漏输出 + 上拉电阻)

3.2 寄存器配置详解

STM32的I2C寄存器,不同系列略有差异。F1系列用标准外设库,F4/H7系列用HAL库。但核心寄存器就那么几个,我列个表给你看:

寄存器 功能 关键位
CR1 控制寄存器1 PE(使能)、START(起始)、STOP(停止)、ACK(应答)、NOSTRETCH(时钟拉伸)
CR2 控制寄存器2 FREQ(外设时钟频率)、ITBUFEN(缓冲中断)、ITEVTEN(事件中断)、ITERREN(错误中断)
OAR1/OAR2 自身地址寄存器 ADD[9:0](地址位)、ADDMODE(7位/10位模式)
DR 数据寄存器 DR[7:0](发送/接收数据)
SR1/SR2 状态寄存器 SB(起始位)、ADDR(地址匹配)、TXE(发送空)、RXNE(接收非空)、BTF(字节传输完成)、AF(应答失败)
CCR 时钟控制寄存器 CCR[11:0](时钟分频系数)、DUTY(占空比)、F/S(快速/标准模式)
TRISE 上升时间寄存器 TRISE[5:0](SCL上升时间,单位:APB时钟周期)

我的习惯:配置I2C时,我从来不会一次性把所有寄存器写完。我会先配CR2设好时钟频率,再配CCR设波特率,然后配TRISE,最后才开CR1的PE位使能外设。顺序错了,有时候外设起不来。

3.3 时钟控制与波特率计算

时钟配置是I2C最容易翻车的地方。你想想看,SCL线上的时钟频率怎么来的?

公式其实很简单:

SCL频率 = APB时钟频率 / (CCR值 × 2)
// 标准模式(100kHz)下,DUTY=0
// 快速模式(400kHz)下,DUTY=1时占空比变为16:9

举个例子,APB1时钟是36MHz,想要100kHz的SCL:

CCR = 36MHz / (100kHz × 2) = 180
// 所以CCR寄存器写0xB4

TRISE寄存器呢?它表示SCL上升沿允许的最大时间。标准模式下,这个值 = APB时钟频率(MHz) + 1。比如36MHz的APB1,TRISE就写37(0x25)。

我曾经踩过的坑:有一次我把CCR算错了,写了个奇数进去。结果SCL波形不对称,从机死活不响应。查了半天才发现——CCR必须是偶数,否则时钟占空比会乱掉。嗯,这个坑我记了三年。

3.4 中断机制

STM32的I2C中断,分三类:

  • 事件中断(ITEVTEN):包括起始位完成(SB)、地址发送完成(ADDR)、数据发送空(TXE)、数据接收非空(RXNE)、字节传输完成(BTF)
  • 缓冲中断(ITBUFEN):TXE和RXNE的使能开关。关了它,你就得轮询DR寄存器
  • 错误中断(ITERREN):总线错误(BERR)、应答失败(AF)、仲裁丢失(ARLO)、上溢/下溢(OVR)

我一般这么配中断优先级:

// 以STM32F4 HAL库为例
__HAL_I2C_ENABLE_IT(&hi2c1, I2C_IT_EVT | I2C_IT_ERR);
// 事件中断处理
void HAL_I2C_EV_IRQHandler(I2C_HandleTypeDef *hi2c) {
    // 检查SR1寄存器状态
    if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_RXNE)) {
        // 读DR寄存器
        uint8_t data = hi2c->Instance->DR;
        // 处理数据...
    }
    if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_TXE)) {
        // 写DR寄存器
        hi2c->Instance->DR = next_byte;
    }
}

关键点:中断服务函数里,读SR1再读DR,或者写DR再读SR1,这个顺序不能乱。因为读SR1会清除某些标志位,顺序错了,中断可能再也进不来了。

3.5 DMA传输机制

大批量数据传输,用中断一个一个字节搬,太浪费CPU了。这时候DMA就派上用场了。

STM32的I2C DMA传输,分两条通道:

  • DMA发送:内存 → I2C的DR寄存器
  • DMA接收:I2C的DR寄存器 → 内存

配置步骤我总结了一下:

  1. 使能I2C的DMA请求(CR2的DMAEN位)
  2. 配置DMA通道:源地址、目标地址、数据长度、循环模式
  3. 设置DMA优先级(一般比普通中断高)
  4. 使能DMA流和通道
  5. 启动I2C传输(主模式发START + 地址)

代码示例:

// 使用DMA发送256字节
uint8_t tx_buffer[256];
HAL_I2C_Master_Transmit_DMA(&hi2c1, slave_addr, tx_buffer, 256);

// DMA传输完成回调
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c) {
    // 发送完成,可以发STOP了
    HAL_I2C_Master_Sequence_End(hi2c, I2C_STOP_SOFT);
}

我建议:DMA传输时,别忘了处理STOP条件。很多新手配完DMA发现总线一直拉低,就是因为DMA传完了数据,但没发STOP信号。HAL库的HAL_I2C_Master_Sequence_End就是干这个的。

3.6 主模式与从模式配置对比

配置项 主模式 从模式
时钟源 自身产生SCL 接收外部SCL
起始条件 软件写START位 检测总线上的START
地址匹配 发送从机地址 匹配自身OAR寄存器
应答控制 自动检测ACK/NACK 软件控制ACK位
时钟拉伸 一般不使能 常用NOSTRETCH=0
典型应用 读传感器、写EEPROM 作为MCU间通信的从机

从模式有个细节——时钟拉伸。当从机来不及处理数据时,可以拉低SCL,让主机等一等。这个功能在STM32里默认是开启的。如果你不需要,记得把CR1的NOSTRETCH位置1。

注意:有些从机设备不支持时钟拉伸。如果你把STM32配成从机,又开了时钟拉伸,主机那边可能会超时报错。我遇到过一块OLED屏,主机是ESP32,从机是STM32,开了时钟拉伸后ESP32一直报"bus busy",关了就好了。

3.7 常见配置陷阱与避坑

最后,我把自己这些年踩过的坑整理一下:

  • GPIO配置错误:I2C的SCL和SDA必须是开漏输出(Open-Drain),不能是推挽输出。推挽输出会把总线拉死。
  • 上拉电阻忘加:内部上拉太弱(约40kΩ),外部必须加4.7kΩ或10kΩ上拉。400kHz快速模式建议用2.2kΩ。
  • 中断优先级冲突:I2C中断优先级不要和SysTick、DMA等高优先级中断冲突,否则容易丢数据。
  • DMA传输未完成就发STOP:一定要在DMA传输完成回调里发STOP,别在主循环里乱发。
  • 总线空闲检测:发START之前,最好检查一下总线是否空闲(SR2的BUSY位)。我见过有人在总线忙的时候硬发START,结果总线直接挂了。

嗯,这一章的内容就到这儿。硬件I2C外设的寄存器、时钟、中断、DMA,说白了就是一套完整的状态机系统。你只要把每个模块的职责搞清楚,配置起来就不会手忙脚乱。