3. MCU硬件I2C外设详解:以STM32为例
好,咱们今天来啃硬骨头——STM32的硬件I2C外设。
说实话,我刚接触STM32那会儿,也被它的I2C模块搞得有点头疼。尤其是早期标准库版本,那个I2C总线上有个小bug,处理不当就容易卡死。不过现在的HAL库和LL库已经成熟多了,但底层原理你还得懂,不然出了问题根本不知道从哪下手。
3.1 I2C外设的整体架构
STM32的I2C模块,说白了就是一个独立的状态机。它不占用CPU去模拟时序,自己就能完成起始、停止、应答、数据收发这些操作。
核心思想:硬件I2C = 状态机 + 移位寄存器 + 时钟发生器 + 中断/DMA控制器。你只需要配置好寄存器,然后等着中断或DMA告诉你“活干完了”。
我画了一张架构图,你看一眼就明白了:
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寄存器 → 内存
配置步骤我总结了一下:
- 使能I2C的DMA请求(CR2的DMAEN位)
- 配置DMA通道:源地址、目标地址、数据长度、循环模式
- 设置DMA优先级(一般比普通中断高)
- 使能DMA流和通道
- 启动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,说白了就是一套完整的状态机系统。你只要把每个模块的职责搞清楚,配置起来就不会手忙脚乱。