3. I2C从机驱动开发:从机状态机设计、中断处理、寄存器读写实现、多主机仲裁处理

各位,今天我们来啃一块硬骨头——I2C从机驱动。说实话,从机比主机难写。主机掌握主动权,什么时候发、什么时候收,都是自己说了算。从机呢?你得时刻准备着,随时响应主机的召唤。我刚开始做I2C从机时,就被状态机折腾得够呛。今天我把这些经验掰开揉碎了讲给你听。

3.1 从机状态机设计——核心中的核心

I2C从机的灵魂是什么?状态机。没有状态机,你的从机就是一盘散沙。为什么这么说?你想想看,主机随时可能发起Start、发送地址、读写数据、发送Stop,从机必须在每个时刻都知道自己该干什么。

一个标准的I2C从机状态机,至少包含以下几个状态:

状态 含义 触发条件
IDLE 空闲状态,等待Start 总线空闲或收到Stop
ADDR_RCVD 已收到地址,等待R/W位 收到匹配的从机地址
DATA_RX 接收数据中(主机写从机) R/W=0,且收到数据字节
DATA_TX 发送数据中(主机读从机) R/W=1,且主机继续提供SCL
STOP_RCVD 收到Stop条件 总线出现Stop信号

我个人习惯用枚举类型来定义这些状态,清晰明了:

typedef enum {
    I2C_SLAVE_STATE_IDLE,
    I2C_SLAVE_STATE_ADDR_RCVD,
    I2C_SLAVE_STATE_DATA_RX,
    I2C_SLAVE_STATE_DATA_TX,
    I2C_SLAVE_STATE_STOP_RCVD
} i2c_slave_state_t;

状态切换的逻辑,说白了就是跟着SCL和SDA的边沿走。每次SCL上升沿采样数据,下降沿准备下一个bit。这里有个坑——状态切换必须在SCL低电平期间完成,否则时序就乱了。

关键设计原则:状态机每个状态的入口和出口都要有明确的动作。比如进入ADDR_RCVD时,要清除地址匹配标志;进入DATA_TX时,要准备好第一个要发送的字节。

3.2 中断处理——别让CPU空转

轮询方式写从机?我劝你别这么干。I2C的时序要求很严格,轮询很容易丢数据。中断才是正道。

I2C从机的中断事件通常包括:

  • 地址匹配中断:主机发送的地址与从机地址一致
  • 数据接收中断:收到一个完整字节
  • 数据发送中断:上一个字节已发送完毕,可以准备下一个
  • Stop条件中断:总线释放
  • 仲裁丢失中断:多主机冲突

我在项目中遇到过一个问题:中断服务函数里做了太多事情,导致下一个SCL时钟到来时还没处理完。结果呢?数据错位,整个通信乱套。后来我学乖了——中断里只做最轻量的事情,比如把数据塞进FIFO,设置标志位,真正的处理放到主循环或任务里。

我的经验:中断服务函数里不要调用printf、不要做复杂计算、不要操作慢速外设。就做三件事:读状态寄存器、读写数据寄存器、清中断标志。其他事情,出去再说。

来看一个典型的中断处理框架:

void I2C1_IRQHandler(void) {
    uint32_t status = I2C->SR1;  // 读状态寄存器
    
    if (status & I2C_SR1_ADDR) {      // 地址匹配
        i2c_slave_state = I2C_SLAVE_STATE_ADDR_RCVD;
        // 清除ADDR标志(读SR1再读SR2)
        (void)I2C->SR2;
    }
    
    if (status & I2C_SR1_RXNE) {      // 收到数据
        rx_buffer[rx_index++] = I2C->DR;
        i2c_slave_state = I2C_SLAVE_STATE_DATA_RX;
    }
    
    if (status & I2C_SR1_TXE) {       // 可以发送
        I2C->DR = tx_buffer[tx_index++];
        i2c_slave_state = I2C_SLAVE_STATE_DATA_TX;
    }
    
    if (status & I2C_SR1_STOPF) {     // Stop条件
        i2c_slave_state = I2C_SLAVE_STATE_STOP_RCVD;
        // 清除STOPF标志
        I2C->CR1 |= I2C_CR1_ACK;
    }
}

3.3 寄存器读写实现——从机的“内存”怎么暴露

I2C从机通常要暴露一组寄存器给主机访问。这组寄存器就是主机和从机之间的“共享内存”。设计得好,通信效率高;设计得烂,调试起来想砸板子。

寄存器读写的典型流程是这样的:

  1. 主机先写一个字节,指定要访问的寄存器地址(这叫“指针设置”)
  2. 主机再发起读操作,从机就把该寄存器的值发回去
  3. 或者主机连续写多个字节,从机自动递增寄存器地址

我曾经见过一个设计:寄存器地址和寄存器值混在一起,没有明确的协议分层。结果主机读回来的数据总是对不上。后来我总结了一套寄存器访问的“黄金法则”:

寄存器访问设计要点:

  • 寄存器地址从0开始连续编址,方便地址递增
  • 每个寄存器固定宽度(通常是8位或16位)
  • 读操作不改变寄存器值(除非是“读清除”类型)
  • 写操作要检查地址合法性,非法地址直接NACK

代码实现上,我习惯用一个数组来模拟寄存器空间:

#define REG_MAP_SIZE  256
static uint8_t reg_map[REG_MAP_SIZE];
static uint8_t reg_addr_ptr;  // 当前指针位置

// 主机写从机时,第一个字节是寄存器地址
void i2c_slave_handle_write(uint8_t data) {
    if (i2c_slave_state == I2C_SLAVE_STATE_ADDR_RCVD) {
        // 第一个数据字节 = 寄存器地址
        reg_addr_ptr = data;
    } else {
        // 后续数据字节 = 写入寄存器
        if (reg_addr_ptr < REG_MAP_SIZE) {
            reg_map[reg_addr_ptr++] = data;
        } else {
            // 地址越界,发送NACK
            i2c_slave_send_nack();
        }
    }
}

// 主机读从机时,从机返回当前指针指向的寄存器值
uint8_t i2c_slave_handle_read(void) {
    uint8_t data = 0;
    if (reg_addr_ptr < REG_MAP_SIZE) {
        data = reg_map[reg_addr_ptr++];
    }
    return data;
}

注意:寄存器地址指针的递增要谨慎。如果主机只读一个字节,指针要不要递增?这取决于你的协议设计。我建议:读操作也递增,这样主机可以连续读取多个寄存器。但一定要在数据手册里写清楚。

3.4 多主机仲裁处理——别让两个主机打起来

多主机仲裁是I2C协议里最精妙的设计之一。说白了,就是多个主机同时抢总线时,谁先发现SDA被拉低,谁就主动退出。这个机制完全靠硬件实现,但作为从机驱动开发者,你也要理解其中的门道。

为什么?因为从机也可能参与仲裁——当从机作为发送方时,它实际上就是总线上的“临时主机”。如果另一个主机也在发送数据,冲突就发生了。

我画了一张图,帮你理清多主机仲裁的流程:

I2C多主机仲裁流程图 主机1 开始发送 主机2 开始发送 仲裁:谁发送了低电平? 主机1 仲裁失败 主机2 仲裁成功 释放总线,转为从机 继续发送剩余数据 仲裁丢失中断触发,软件需清除标志并重新等待地址

从机驱动里怎么处理仲裁?关键在于仲裁丢失中断。当硬件检测到SDA电平与自己发送的不一致时,会触发这个中断。你的处理逻辑应该是:

void i2c_slave_handle_arbitration_lost(void) {
    // 1. 清除仲裁丢失标志
    I2C->SR1 &= ~I2C_SR1_ARLO;
    
    // 2. 复位状态机到IDLE
    i2c_slave_state = I2C_SLAVE_STATE_IDLE;
    
    // 3. 释放总线(硬件自动完成,但软件要确保不再驱动SDA)
    I2C->CR1 &= ~I2C_CR1_START;
    
    // 4. 重新使能从机地址识别
    I2C->CR1 |= I2C_CR1_ACK;
    
    // 5. 清空FIFO,丢弃未完成的数据
    rx_index = 0;
    tx_index = 0;
}

避坑指南:我曾经在仲裁丢失后忘记重新使能ACK,结果从机再也不响应地址了。调试了整整一个下午才发现。记住:仲裁丢失后,从机要像刚上电一样重新初始化接收逻辑。

3.5 实战经验总结——从机开发的“三板斧”

说了这么多,我最后给你总结三条从机开发的核心原则:

  • 状态机要健壮:每个状态都要考虑异常情况,比如收到非法地址、数据长度超限、意外Stop等。不要假设主机总是按规矩来。
  • 中断要轻量:中断里只做最必要的事,把数据搬运和协议解析放到主循环。我见过太多人把中断写成“重量级函数”,结果系统响应越来越慢。
  • 寄存器访问要安全:地址越界检查、写保护、读清除标志位,这些机制一个都不能少。你的从机可能被各种奇奇怪怪的主机访问,保护自己很重要。

嗯,从机驱动开发就是这样。看起来简单,但每个细节都可能让你栽跟头。把这些基础打牢了,后面学SPI和UART的从机模式就会轻松很多。