3、I2C从机模式驱动开发:从机模式初始化、从机地址配置、从机接收/发送中断处理、从机状态机设计

好,咱们进入I2C从机模式。主机模式大家可能更熟悉——发起通信、控制时钟、决定什么时候收什么时候发。但从机模式呢?说白了,从机是被动的。它得时刻准备着,等主机来叫它。这个“等”的过程,就是状态机的核心。

我记得刚做第一个I2C从机项目时,以为只要配好地址、开个中断就完事了。结果呢?从机经常“死机”,主机发了几次数据它都没反应。后来才发现,状态机设计出了问题——从机在某个状态卡住了,没有超时保护。嗯,这里要注意,从机模式比主机模式更容易出“软死”的问题。

3.1 从机模式初始化

初始化从机,比主机多一个步骤:你要告诉硬件,我是从机,不是主机。很多MCU的I2C外设默认是主机模式,得显式切换。

我个人习惯的初始化流程是这样的:

  1. 使能时钟——I2C外设时钟和GPIO时钟都得开。
  2. 配置GPIO——SDA和SCL设为开漏输出,带上拉(内部或外部)。
  3. 复位I2C外设——确保寄存器处于已知状态。
  4. 设置从机地址——可以是7位或10位,后面细说。
  5. 使能中断——从机全靠中断驱动,轮询不现实。
  6. 使能I2C外设——开始监听总线。

关键点:初始化顺序不能乱。尤其是GPIO配置必须在I2C使能之前完成,否则总线状态可能不对,导致第一个ACK就失败。

来看一段实际的初始化代码(以STM32为例,但思路通用):

void i2c_slave_init(uint8_t own_address)
{
    // 1. 使能时钟
    RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;

    // 2. 配置GPIO:PB6=SCL, PB7=SDA,开漏输出
    GPIOB->MODER &= ~(GPIO_MODER_MODER6 | GPIO_MODER_MODER7);
    GPIOB->MODER |= (GPIO_MODER_MODER6_1 | GPIO_MODER_MODER7_1);  // 复用功能
    GPIOB->OTYPER |= (GPIO_OTYPER_OT6 | GPIO_OTYPER_OT7);         // 开漏
    GPIOB->PUPDR |= (GPIO_PUPDR_PUPD6_0 | GPIO_PUPDR_PUPD7_0);    // 上拉

    // 3. 复位I2C
    I2C1->CR1 |= I2C_CR1_SWRST;
    I2C1->CR1 &= ~I2C_CR1_SWRST;

    // 4. 配置从机地址(7位模式)
    I2C1->OAR1 = (own_address << 1) | I2C_OAR1_OA1EN;

    // 5. 使能中断:接收、发送、错误、停止
    I2C1->CR2 |= I2C_CR2_ITBUFEN | I2C_CR2_ITEVTEN | I2C_CR2_ITERREN;

    // 6. 使能I2C外设
    I2C1->CR1 |= I2C_CR1_PE;
}

小技巧:复位那一步很多人会省略。但我建议别省——尤其是在调试阶段,外设可能处于未知状态,复位一下更安心。

3.2 从机地址配置

地址配置看似简单,其实有几个坑。7位地址和10位地址的配置方式不同,而且有些MCU支持双地址(响应两个不同的从机地址)。

特性 7位地址 10位地址
地址范围 0x00 ~ 0x7F 0x000 ~ 0x3FF
保留地址 0x00(广播)、0x01~0x07 无特殊保留
配置寄存器 OAR1的低7位 OAR1 + OAR2配合
中断处理差异 无 需要额外处理头部字节

我在项目中遇到过一个问题:给从机配了地址0x55,结果它老是响应0x2A。查了半天,发现是地址左移了一位没处理。很多MCU的地址寄存器要求左移1位存放,因为最低位是读写标志位。你想想看,如果直接把0x55写进去,实际地址就变成了0x2A。

避坑指南:我曾经因为地址配置错误,导致两个从机响应同一个地址,总线冲突了整整两天才找到原因。配置地址后,一定要用逻辑分析仪抓一下,确认从机确实在正确的地址上ACK。

另外,如果你需要支持多个从机地址(比如一个用于常规通信,一个用于配置),可以开启双地址模式。但要注意,双地址会增加中断处理的复杂度,状态机里得区分是哪个地址被匹配了。

3.3 从机接收/发送中断处理

从机的中断处理,是驱动开发的核心。主机发来数据,从机得知道什么时候收、什么时候发。这些事件都通过中断通知CPU。

常见的从机中断事件包括:

  • 地址匹配(ADDR)——主机发出了我的地址,我要准备响应了。
  • 接收缓冲区非空(RXNE)——主机给我发了一个字节,快来读。
  • 发送缓冲区空(TXE)——主机要读数据,快把下一个字节填进去。
  • 停止条件(STOP)——主机结束了本次通信。
  • 错误(ERR)——总线错误、仲裁丢失、溢出等。

我个人习惯把中断服务函数写成这样:

void I2C1_EV_IRQHandler(void)
{
    uint32_t status = I2C1->SR1;

    // 地址匹配事件
    if (status & I2C_SR1_ADDR) {
        // 读取SR1和SR2清除ADDR标志
        (void)I2C1->SR1;
        (void)I2C1->SR2;

        // 判断主机是读还是写
        if (I2C1->SR2 & I2C_SR2_TRA) {
            // 主机读模式:从机要发送数据
            slave_state = SLAVE_STATE_TX;
            tx_idx = 0;
        } else {
            // 主机写模式:从机要接收数据
            slave_state = SLAVE_STATE_RX;
            rx_idx = 0;
        }
        return;
    }

    // 接收数据事件
    if (status & I2C_SR1_RXNE) {
        rx_buffer[rx_idx++] = I2C1->DR;
        return;
    }

    // 发送数据事件
    if (status & I2C_SR1_TXE) {
        I2C1->DR = tx_buffer[tx_idx++];
        return;
    }

    // 停止条件事件
    if (status & I2C_SR1_STOPF) {
        // 清除STOPF标志
        I2C1->CR1 |= I2C_CR1_PE;
        slave_state = SLAVE_STATE_IDLE;
        // 通知应用层:一帧数据接收完成
        frame_complete_flag = 1;
        return;
    }
}

注意:ADDR标志的清除方式很特殊——必须读SR1再读SR2。顺序不能反,也不能只读一个。很多新手在这里卡住,从机死活不响应后续中断。

3.4 从机状态机设计

状态机是从机驱动的灵魂。没有状态机,中断处理就是一盘散沙。你想想看,主机可能随时发起读或写,从机得知道自己当前在干什么、下一步该干什么。

我常用的从机状态机包含以下几个状态:

状态 含义 进入条件 退出条件
IDLE 空闲,等待地址匹配 上电/停止条件 地址匹配中断
RX 接收模式,等待主机写数据 地址匹配 + 主机写 停止条件/重启条件
TX 发送模式,等待主机读数据 地址匹配 + 主机读 停止条件/主机NACK
ERROR 错误状态,等待恢复 总线错误/溢出 超时/复位

下面这张图展示了从机状态机的完整流转:

I2C从机状态机流转图 IDLE 等待地址匹配 RX 接收主机数据 TX 发送数据给主机 ERROR 错误处理 地址匹配 + 主机写 地址匹配 + 主机读 停止条件 停止条件 / NACK 溢出/错误 总线错误 超时/复位 关键:每个状态都必须有超时保护,防止从机“卡死”在某个中间状态

状态机的实现,我建议用switch-case,别用if-else嵌套。代码可读性差不说,还容易漏状态。

typedef enum {
    SLAVE_STATE_IDLE,
    SLAVE_STATE_RX,
    SLAVE_STATE_TX,
    SLAVE_STATE_ERROR
} slave_state_t;

void slave_state_machine(void)
{
    switch (slave_state) {
        case SLAVE_STATE_IDLE:
            // 啥也不干,等中断
            break;

        case SLAVE_STATE_RX:
            // 数据已经在中断里收进了rx_buffer
            // 这里可以检查是否收完一帧
            if (frame_complete_flag) {
                process_received_frame();
                frame_complete_flag = 0;
                slave_state = SLAVE_STATE_IDLE;
            }
            break;

        case SLAVE_STATE_TX:
            // 数据已经在中断里发出去了
            // 检查是否发完
            if (tx_idx >= tx_len) {
                slave_state = SLAVE_STATE_IDLE;
            }
            break;

        case SLAVE_STATE_ERROR:
            // 错误恢复
            i2c_slave_reset();
            slave_state = SLAVE_STATE_IDLE;
            break;

        default:
            slave_state = SLAVE_STATE_IDLE;
            break;
    }
}

设计建议:状态机里一定要加超时。我曾经遇到过主机发了地址后突然掉电,从机一直卡在RX状态,再也响应不了新通信。加个超时定时器,比如10ms没动静就强制回IDLE,问题就解决了。

3.5 从机驱动的几个实战要点

最后,分享几个我在项目中积累的经验:

  • 时钟延展(Clock Stretching):有些从机处理速度慢,需要拉低SCL让主机等。如果你的MCU支持硬件时钟延展,记得使能;如果不支持,得确保中断响应够快,别让主机等超时了。
  • NACK处理:主机读数据时,如果读到最后一个字节后发了NACK,从机得释放SDA线。有些MCU会自动处理,有些需要手动配置。
  • 多主机环境:如果总线上有多个主机,从机得支持仲裁。说白了,就是检测到总线被其他设备占用时,主动释放总线。
  • 调试手段:我习惯在状态机里加一个调试引脚,每进入一个状态就翻转一次。用示波器一看,就知道状态机跑没跑对。

避坑指南:我曾经在一个项目里,从机状态机没加超时,结果主机异常复位后,从机还卡在TX状态。主机重新发地址时,从机还在往外吐数据,总线直接乱掉。从那以后,我每个状态都加了超时保护——哪怕IDLE状态也加一个看门狗喂狗操作。