4. STM32 I2C外设详解:从框图到寄存器配置
I2C总线在嵌入式系统里太常见了。WM8960的配置、音量调节、EQ设置,全得靠它。说白了,I2C就是音频芯片的“控制通道”。
我个人习惯把I2C外设分成三层来理解:物理层(两根线怎么拉)、协议层(起始条件、应答这些时序)、应用层(你要发什么数据)。STM32的I2C硬件把前两层都包了,你只需要关心寄存器怎么配。
本章核心:搞懂STM32 I2C模块的硬件结构,掌握主发送器和主接收器两种工作模式,学会用中断和DMA搬运数据,最后能自己写出寄存器配置流程。
4.1 STM32 I2C模块框图解析
先看整体结构。STM32的I2C模块内部其实挺复杂的,但我们可以抓住几个关键部分。
这个框图里,我重点说几个关键模块:
- 时钟控制(CCR):决定SCL的频率。标准模式100kHz,快速模式400kHz。配错了,通信就乱套。
- 数据寄存器(DR):你写数据就往这里写,读数据也从这里读。但注意,它背后连着移位寄存器。
- 状态寄存器(SR1/SR2):这是你查“活干完了没”的地方。比如SB位(起始位发送完成)、ADDR位(地址匹配)、TXE位(发送缓冲区空)、RXNE位(接收缓冲区非空)。
- 自身地址寄存器(OAR1/OAR2):当STM32作为从机时用。咱们控制WM8960时STM32是主机,这个暂时用不上。
我的经验:调试I2C时,最常用的就是查SR1寄存器。如果TXE一直不置位,说明数据没发出去。如果RXNE一直不置位,说明从机没响应。我习惯在关键操作后读一下SR1,打印出来看,比示波器还快。
4.2 主发送器模式:怎么把数据发出去
控制WM8960,大部分时间你都在用主发送器模式。你要写配置到某个寄存器地址,流程是这样的:
- 产生起始条件:设置START位,硬件自动拉低SDA,再拉低SCL。
- 发送从机地址 + 写位:7位地址左移1位,最低位是0(写)。比如WM8960的地址是0x1A,那你要发0x34。
- 等待应答:从机拉低SDA表示ACK。如果没收到ACK,说明从机不在线上。
- 发送寄存器地址:你要写的WM8960内部寄存器编号。
- 等待应答:同上。
- 发送数据:你要写入寄存器的值。
- 等待应答:同上。
- 产生停止条件:设置STOP位,硬件自动释放SCL,再释放SDA。
代码实现其实不复杂。我贴一段核心的发送流程:
void I2C_WriteByte(uint8_t slaveAddr, uint8_t regAddr, uint8_t data)
{
// 1. 产生起始条件
I2C->CR1 |= I2C_CR1_START;
while(!(I2C->SR1 & I2C_SR1_SB)); // 等待起始位发送完成
// 2. 发送从机地址 + 写位
I2C->DR = slaveAddr << 1; // 左移1位,最低位为0
while(!(I2C->SR1 & I2C_SR1_ADDR)); // 等待地址发送完成
(void)I2C->SR2; // 读SR2清除ADDR位,这个坑我踩过
// 3. 发送寄存器地址
while(!(I2C->SR1 & I2C_SR1_TXE)); // 等待发送缓冲区空
I2C->DR = regAddr;
// 4. 发送数据
while(!(I2C->SR1 & I2C_SR1_TXE));
I2C->DR = data;
// 5. 等待发送完成
while(!(I2C->SR1 & I2C_SR1_BTF)); // 字节发送完成标志
// 6. 产生停止条件
I2C->CR1 |= I2C_CR1_STOP;
}
注意:ADDR位清除后,SR2寄存器必须读一次。我曾经忘了读SR2,结果ADDR位一直挂着,后续的TXE标志怎么都不对。查了两小时,最后发现是手册里写了“ADDR is cleared by reading SR1 followed by SR2”。
4.3 主接收器模式:从WM8960读数据
有时候你需要读WM8960的寄存器,比如查当前音量、查状态。主接收器模式和发送模式类似,但多了个“重复起始条件”。
流程是这样的:
- 产生起始条件,发送从机地址+写位,发送寄存器地址(告诉从机你要读哪个寄存器)。
- 产生重复起始条件,发送从机地址+读位。
- 从机开始发数据,主机接收。
- 接收最后一个字节前,主机发NACK(告诉从机别发了)。
- 产生停止条件。
为什么要有重复起始条件?你想想看,如果先发停止条件再重新开始,总线就被释放了,别的设备可能插进来。重复起始条件保证这次操作是原子的。
uint8_t I2C_ReadByte(uint8_t slaveAddr, uint8_t regAddr)
{
uint8_t data;
// 第一阶段:写寄存器地址
I2C->CR1 |= I2C_CR1_START;
while(!(I2C->SR1 & I2C_SR1_SB));
I2C->DR = slaveAddr << 1; // 写地址
while(!(I2C->SR1 & I2C_SR1_ADDR));
(void)I2C->SR2;
while(!(I2C->SR1 & I2C_SR1_TXE));
I2C->DR = regAddr;
while(!(I2C->SR1 & I2C_SR1_BTF));
// 第二阶段:重复起始 + 读数据
I2C->CR1 |= I2C_CR1_START; // 重复起始
while(!(I2C->SR1 & I2C_SR1_SB));
I2C->DR = (slaveAddr << 1) | 0x01; // 读地址
while(!(I2C->SR1 & I2C_SR1_ADDR));
(void)I2C->SR2;
// 接收前配置NACK
I2C->CR1 &= ~I2C_CR1_ACK; // 最后一个字节前关ACK
while(!(I2C->SR1 & I2C_SR1_RXNE));
data = I2C->DR;
I2C->CR1 |= I2C_CR1_STOP; // 停止
return data;
}
4.4 中断与DMA传输
轮询方式简单,但浪费CPU。你想想看,while循环在那傻等,CPU啥也干不了。实际项目中,我几乎不用纯轮询。
4.4.1 中断方式
STM32的I2C中断事件很多,常用的就几个:
| 中断事件 | 标志位 | 触发时机 |
|---|---|---|
| 起始位完成 | SB | 起始条件发送成功 |
| 地址发送完成 | ADDR | 从机地址发送完成并收到ACK |
| 发送缓冲区空 | TXE | DR寄存器空,可以写下一个数据 |
| 接收缓冲区非空 | RXNE | DR寄存器有数据可读 |
| 字节传输完成 | BTF | DR和移位寄存器都空,一帧完成 |
| 停止条件 | STOPF | 停止条件发送完成(从机模式) |
中断方式的思路:开启对应中断使能位,在中断服务函数里查状态机。我习惯用一个全局状态变量来跟踪当前处于哪个阶段。
// 中断服务函数示例(简化)
void I2C1_EV_IRQHandler(void)
{
switch(i2c_state)
{
case I2C_STATE_START:
if(I2C->SR1 & I2C_SR1_SB) {
I2C->DR = (slaveAddr << 1);
i2c_state = I2C_STATE_ADDR;
}
break;
case I2C_STATE_ADDR:
if(I2C->SR1 & I2C_SR1_ADDR) {
(void)I2C->SR2; // 清ADDR
i2c_state = I2C_STATE_DATA;
}
break;
case I2C_STATE_DATA:
if(I2C->SR1 & I2C_SR1_TXE) {
if(tx_count < tx_len) {
I2C->DR = tx_buffer[tx_count++];
} else {
I2C->CR1 |= I2C_CR1_STOP;
i2c_state = I2C_STATE_IDLE;
}
}
break;
}
}
4.4.2 DMA方式
如果你要连续发很多数据(比如批量配置WM8960的几十个寄存器),DMA是最爽的。CPU只需要配好DMA通道,然后就可以去干别的事了。
配置要点:
- I2C的TX DMA请求映射到DMA1的某个通道(具体看芯片型号)。
- DMA源地址是你的数据缓冲区,目标地址是I2C->DR寄存器。
- 传输完成后,DMA会产生完成中断,你在中断里发停止条件。
我踩过的坑:DMA传输完成后,I2C可能还没发完最后一个字节。因为DMA把数据写到DR后,移位寄存器还在往外送。所以DMA完成中断里不能立刻发停止条件,要等BTF标志。我一般会在DMA完成中断里开一个I2C的BTF中断,在BTF中断里发停止条件。
4.5 寄存器配置流程
配I2C外设,我总结了一个“五步法”:
- 使能时钟:RCC->APB1ENR里开I2C时钟,RCC->AHB1ENR里开GPIO时钟(F4系列)。
- 配GPIO:SCL和SDA引脚配成开漏输出,复用功能。注意要配上拉电阻。
- 复位I2C:设置I2C->CR1的SWRST位,等一会再清掉。这个步骤很多人跳过,但我建议别省。
- 配时序:设置I2C->CR2的时钟频率(比如APB1是42MHz,就写42),设置I2C->CCR的SCL频率(标准100kHz或快速400kHz),设置I2C->TRISE的最大上升时间。
- 使能I2C:设置I2C->CR1的PE位。
具体代码:
void I2C_Init(void)
{
// 1. 使能时钟
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;
// 2. 配GPIO:PB6=SCL, PB7=SDA
GPIOB->MODER &= ~(0xF << 12); // 清零
GPIOB->MODER |= (0xA << 12); // 复用功能
GPIOB->AFR[0] &= ~(0xFF << 24);
GPIOB->AFR[0] |= (0x44 << 24); // AF4 = I2C1
GPIOB->OTYPER |= (0x3 << 6); // 开漏输出
GPIOB->PUPDR &= ~(0xF << 12);
GPIOB->PUPDR |= (0x5 << 12); // 上拉
// 3. 复位I2C
I2C1->CR1 |= I2C_CR1_SWRST;
delay_us(10);
I2C1->CR1 &= ~I2C_CR1_SWRST;
// 4. 配时序
I2C1->CR2 = 42; // APB1时钟42MHz
I2C1->CCR = 210; // 100kHz: 42MHz / (2*210) = 100kHz
I2C1->TRISE = 43; // 100kHz最大上升时间1000ns, 43 * (1/42MHz) ≈ 1024ns
// 5. 使能I2C
I2C1->CR1 |= I2C_CR1_PE;
}
关键参数计算:
- 标准模式100kHz:CCR = APB1时钟 / (2 * 目标频率)。比如42MHz / 200kHz = 210。
- 快速模式400kHz:CCR = APB1时钟 / (3 * 目标频率)(占空比2:1时)。
- TRISE = 最大上升时间(ns) / (1/APB1时钟(MHz)) + 1。100kHz时最大上升时间1000ns,所以TRISE = 1000 / (1000/42) + 1 ≈ 43。
4.6 调试经验与避坑指南
最后分享几个我实际项目中遇到的坑:
- SCL被拉死:如果SCL一直低,说明从机在时钟拉伸。检查从机是否正常工作,或者复位一下I2C总线。
- SDA一直低:可能是从机没释放总线。发9个时钟脉冲可以恢复,我写过专门的恢复函数。
- ACK丢失:地址不对、从机没上电、总线电容太大。我遇到过WM8960的地址搞错,0x1A和0x34傻傻分不清。
- 中断优先级:I2C中断优先级别设太高,否则可能打断其他关键中断。我习惯设成中等优先级。
- DMA与缓存对齐:DMA传输的缓冲区要保证地址对齐,不然会触发硬件错误。
重要提醒:调试I2C时,示波器是你的好朋友。但我更推荐先看状态寄存器。如果SR1的AF位(应答失败)置位,说明从机没响应。如果OVR位(溢出)置位,说明你读数据太慢了。这些标志位比示波器更直接。