4. STM32 I2C外设详解:从框图到寄存器配置

I2C总线在嵌入式系统里太常见了。WM8960的配置、音量调节、EQ设置,全得靠它。说白了,I2C就是音频芯片的“控制通道”。

我个人习惯把I2C外设分成三层来理解:物理层(两根线怎么拉)、协议层(起始条件、应答这些时序)、应用层(你要发什么数据)。STM32的I2C硬件把前两层都包了,你只需要关心寄存器怎么配。

本章核心:搞懂STM32 I2C模块的硬件结构,掌握主发送器和主接收器两种工作模式,学会用中断和DMA搬运数据,最后能自己写出寄存器配置流程。

4.1 STM32 I2C模块框图解析

先看整体结构。STM32的I2C模块内部其实挺复杂的,但我们可以抓住几个关键部分。

STM32 I2C模块内部结构框图 时钟控制 (CCR) 数据寄存器 (DR) 控制逻辑 (CR1/CR2) 自身地址 (OAR1/OAR2) 状态寄存器 (SR1/SR2) 数据移位寄存器 SCL / SDA 引脚控制与滤波 (GPIO复用 + 施密特触发 + 数字滤波) 中断 / DMA 请求生成 💡 数据移位寄存器才是真正干活的地方。DR寄存器只是缓冲,数据实际是一bit一bit从移位寄存器串行出去的。 我刚开始以为DR直接控制引脚,查了半天时序不对,后来才发现是移位寄存器在背后操作。

这个框图里,我重点说几个关键模块:

  • 时钟控制(CCR):决定SCL的频率。标准模式100kHz,快速模式400kHz。配错了,通信就乱套。
  • 数据寄存器(DR):你写数据就往这里写,读数据也从这里读。但注意,它背后连着移位寄存器。
  • 状态寄存器(SR1/SR2):这是你查“活干完了没”的地方。比如SB位(起始位发送完成)、ADDR位(地址匹配)、TXE位(发送缓冲区空)、RXNE位(接收缓冲区非空)。
  • 自身地址寄存器(OAR1/OAR2):当STM32作为从机时用。咱们控制WM8960时STM32是主机,这个暂时用不上。

我的经验:调试I2C时,最常用的就是查SR1寄存器。如果TXE一直不置位,说明数据没发出去。如果RXNE一直不置位,说明从机没响应。我习惯在关键操作后读一下SR1,打印出来看,比示波器还快。

4.2 主发送器模式:怎么把数据发出去

控制WM8960,大部分时间你都在用主发送器模式。你要写配置到某个寄存器地址,流程是这样的:

  1. 产生起始条件:设置START位,硬件自动拉低SDA,再拉低SCL。
  2. 发送从机地址 + 写位:7位地址左移1位,最低位是0(写)。比如WM8960的地址是0x1A,那你要发0x34。
  3. 等待应答:从机拉低SDA表示ACK。如果没收到ACK,说明从机不在线上。
  4. 发送寄存器地址:你要写的WM8960内部寄存器编号。
  5. 等待应答:同上。
  6. 发送数据:你要写入寄存器的值。
  7. 等待应答:同上。
  8. 产生停止条件:设置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的寄存器,比如查当前音量、查状态。主接收器模式和发送模式类似,但多了个“重复起始条件”。

流程是这样的:

  1. 产生起始条件,发送从机地址+写位,发送寄存器地址(告诉从机你要读哪个寄存器)。
  2. 产生重复起始条件,发送从机地址+读位。
  3. 从机开始发数据,主机接收。
  4. 接收最后一个字节前,主机发NACK(告诉从机别发了)。
  5. 产生停止条件。

为什么要有重复起始条件?你想想看,如果先发停止条件再重新开始,总线就被释放了,别的设备可能插进来。重复起始条件保证这次操作是原子的。

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外设,我总结了一个“五步法”:

  1. 使能时钟:RCC->APB1ENR里开I2C时钟,RCC->AHB1ENR里开GPIO时钟(F4系列)。
  2. 配GPIO:SCL和SDA引脚配成开漏输出,复用功能。注意要配上拉电阻。
  3. 复位I2C:设置I2C->CR1的SWRST位,等一会再清掉。这个步骤很多人跳过,但我建议别省。
  4. 配时序:设置I2C->CR2的时钟频率(比如APB1是42MHz,就写42),设置I2C->CCR的SCL频率(标准100kHz或快速400kHz),设置I2C->TRISE的最大上升时间。
  5. 使能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位(溢出)置位,说明你读数据太慢了。这些标志位比示波器更直接。