4. I2C从机状态机设计:从机空闲态、寻址态、接收态、发送态的状态转换模型

状态机这东西,说白了就是让硬件知道「我现在该干嘛」。I2C从机没有状态机,就像一个人站在十字路口却不知道往哪走——总线上的信号变化它全看见了,但就是不知道该响应谁。

我个人习惯把I2C从机状态机分成四个核心状态:空闲态、寻址态、接收态和发送态。这四种状态构成了从机工作的全部生命周期。你想想看,从机在总线上无非就是四种情况:没人理它、被叫到名字、听主机说话、向主机说话。

4.1 状态定义与转换总览

先看一张整体状态转换图,后面我们再逐个拆解。

I2C从机状态机转换模型 空闲态 (IDLE) 等待START条件 寻址态 (ADDR) 匹配地址 + R/W位 接收态 (RX) 从机接收数据 发送态 (TX) 从机发送数据 START + 地址匹配 R/W = 0 (写) R/W = 1 (读) STOP / 重复START STOP / 重复START 重复START 重复START 关键转换条件: START → 地址匹配 → R/W位决定方向 → 数据阶段 → STOP/重复START回到空闲或寻址

这张图我画了好几个版本才定下来。你看,从空闲态出发,只有START条件加上地址匹配才能进入寻址态。寻址态是个分岔路口——根据R/W位决定是去接收态还是发送态。数据传完了,STOP条件或者重复START条件会把状态机拉回空闲或重新寻址。

4.2 空闲态(IDLE)

空闲态是从机的「默认姿势」。SCL和SDA都是高电平,从机啥也不干,就盯着总线看。

嗯,这里要注意:空闲态不是真的「闲着」。从机硬件一直在监测START条件——SCL高电平时SDA从高到低的跳变。这个检测是纯硬件的,不占用CPU。

属性说明
总线状态SCL高、SDA高
从机行为监测START条件,不参与任何传输
退出条件检测到START条件 + 地址匹配
异常恢复总线超时或复位信号强制回到空闲态
个人经验:我曾经在一个项目中遇到从机莫名其妙卡在非空闲态,查了两天才发现是总线上的毛刺触发了虚假START。从那以后,我习惯在空闲态加一个去抖计数器——连续采样到START条件保持超过3个SCL周期才认为有效。

4.3 寻址态(ADDR)

寻址态是从机最忙的阶段。主机发送了START条件后,紧跟着7位地址和1位R/W位。从机要在这9个SCL周期内完成地址比较,还要决定下一步方向。

为什么说它关键?因为地址匹配的时序窗口非常窄。我记得第一次调I2C从机时,地址比较逻辑写在了中断里,结果中断响应延迟稍微大了点,地址就匹配失败了。后来我把地址比较改成了硬件移位寄存器的实时比较,问题才解决。

寻址态的核心工作流程:

  1. 接收地址位:每个SCL时钟移入一位,7位地址全部接收后与本地地址寄存器比较
  2. 判断匹配:如果匹配,在第9个SCL周期拉低SDA发送ACK;不匹配则保持高电平发NACK
  3. 解析R/W位:第8位是R/W,0表示主机写(从机接收),1表示主机读(从机发送)
  4. 状态跳转:根据R/W位进入接收态或发送态
避坑指南:我曾经在支持10位地址的从机上栽过跟头。10位地址需要两个字节传输,第一个字节的前5位是11110加上地址高位,第二个字节才是地址低位。如果状态机只做了7位地址匹配,遇到10位地址就会直接NACK。所以设计状态机时,一定要先确认你的从机支持哪种地址模式。

4.4 接收态(RX)

进入接收态后,从机开始接收主机发来的数据字节。每个字节8位数据,从机在每第9个SCL周期发送ACK/NACK。

接收态里有个容易被忽略的细节:数据接收的节奏控制。主机是按标准速率发送数据的,但从机内部可能正在处理上一个字节。如果从机没准备好接收下一个字节,怎么办?

标准I2C协议提供了时钟拉伸(Clock Stretching)机制——从机可以在第9个SCL之后拉低SCL,告诉主机「慢点,我还没消化完」。这个功能在RTOS环境下特别有用,因为任务切换和缓冲区处理都需要时间。

/* 接收态状态机伪代码 */
void i2c_slave_rx_state(void) {
    uint8_t byte_count = 0;
    
    while (state == I2C_STATE_RX) {
        /* 等待SCL低电平,准备接收数据 */
        wait_for_scl_low();
        
        /* 接收8位数据 */
        rx_byte = shift_in_8_bits();
        
        /* 处理接收到的字节 */
        if (rx_buffer_full()) {
            /* 缓冲区满了,发NACK并准备停止 */
            send_nack();
            state = I2C_STATE_IDLE;
            break;
        }
        
        /* 发送ACK */
        send_ack();
        
        /* 可选:时钟拉伸,给RTOS任务切换时间 */
        if (need_processing_time()) {
            stretch_clock();  /* 拉低SCL */
            /* 此时可以触发任务通知 */
            notify_rx_task(rx_byte);
            release_clock();  /* 释放SCL */
        }
        
        byte_count++;
    }
}
关键点:接收态中,每收到一个字节都可以触发一次RTOS任务通知。但要注意——不要在中断里做耗时处理,把数据扔给任务队列就赶紧释放总线。

4.5 发送态(TX)

发送态是从机向主机发送数据的阶段。主机发完地址和R/W=1后,从机就要把数据放到SDA线上。

发送态和接收态最大的区别在于:数据方向反了。接收态是从机被动采样SDA,发送态是从机主动驱动SDA。这个切换在硬件上要特别注意——SDA线的驱动方向必须在正确的时间点切换,否则会造成总线冲突。

我记得第一次做从机发送时,SDA方向切换的时机没算准,结果在地址阶段结束后SDA还被从机驱动着,直接和主机的SCL信号打架。示波器上一看,波形乱七八糟的。

发送态的工作流程:

  1. 准备数据:从发送缓冲区取出一个字节,或者从寄存器读取
  2. 逐位发送:在每个SCL低电平期间设置SDA数据,高电平期间被主机采样
  3. 等待ACK:第9个SCL周期释放SDA,检测主机是否发送ACK
  4. 判断继续:主机ACK则继续发送下一个字节,NACK则停止发送回到空闲态
步骤SCL周期从机动作主机动作
11-8逐位发送数据采样SDA
29释放SDA,检测ACK拉低SDA表示ACK,释放表示NACK
3(继续)ACK则发下一字节,NACK则停止决定是否继续读取
实用技巧:发送态中,我习惯在发送第一个字节前先检查发送缓冲区是否有数据。如果缓冲区为空,直接发送一个默认值(比如0xFF)并置位错误标志。这样主机读到的数据虽然不对,但至少不会因为SDA浮空导致总线锁死。

4.6 状态转换的边界条件

状态机设计得好不好,关键看边界条件处理得怎么样。我总结了几种常见的边界情况:

  • 重复START:主机在数据阶段结束后不发STOP,而是再发一个START。此时从机要从接收态或发送态回到寻址态,重新匹配地址
  • STOP条件提前:主机在任意时刻发送STOP,从机必须立即回到空闲态,不能卡在当前状态
  • NACK后的行为:接收态中从机发NACK后,主机可能发STOP也可能发重复START,从机要做好两手准备
  • 总线超时:SCL被拉低超过规定时间(通常25ms),从机应复位状态机释放总线
血的教训:我曾经在一个量产项目上,从机状态机没处理重复START场景。结果某个主机在连续读取时用了重复START,从机直接卡死在发送态,整条总线上的其他设备全部瘫痪。那次返修花了我整整两周时间。从那以后,我的状态机里一定会画一条从接收态/发送态回到寻址态的虚线。

4.7 状态机实现的代码骨架

最后给一个实际项目中用过的状态机骨架。这个骨架配合RTOS使用,每个状态对应一个明确的处理函数:

typedef enum {
    I2C_STATE_IDLE,
    I2C_STATE_ADDR,
    I2C_STATE_RX,
    I2C_STATE_TX,
    I2C_STATE_ERROR
} i2c_slave_state_t;

void i2c_slave_state_machine(i2c_slave_state_t current_state, uint8_t bus_event) {
    switch (current_state) {
        case I2C_STATE_IDLE:
            if (bus_event == I2C_EVENT_START) {
                /* 进入寻址态,开始地址匹配 */
                set_state(I2C_STATE_ADDR);
                enable_address_detection();
            }
            break;
            
        case I2C_STATE_ADDR:
            if (bus_event == I2C_EVENT_ADDR_MATCH) {
                /* 地址匹配成功,根据R/W跳转 */
                if (get_rw_bit() == 0) {
                    set_state(I2C_STATE_RX);
                } else {
                    set_state(I2C_STATE_TX);
                    load_first_tx_byte();
                }
            } else if (bus_event == I2C_EVENT_STOP) {
                /* 地址没匹配完就收到STOP */
                set_state(I2C_STATE_IDLE);
            }
            break;
            
        case I2C_STATE_RX:
            if (bus_event == I2C_EVENT_BYTE_RECEIVED) {
                handle_rx_byte();
            } else if (bus_event == I2C_EVENT_STOP ||
                       bus_event == I2C_EVENT_REPEATED_START) {
                set_state(I2C_STATE_IDLE);
            }
            break;
            
        case I2C_STATE_TX:
            if (bus_event == I2C_EVENT_BYTE_SENT) {
                handle_tx_ack();
            } else if (bus_event == I2C_EVENT_NACK) {
                /* 主机不再读取,准备停止 */
                set_state(I2C_STATE_IDLE);
            } else if (bus_event == I2C_EVENT_STOP) {
                set_state(I2C_STATE_IDLE);
            }
            break;
            
        default:
            set_state(I2C_STATE_IDLE);
            break;
    }
}

这个骨架看起来简单,但实际项目中我会在每个状态入口和出口加调试引脚翻转,用逻辑分析仪看状态切换是否和预期一致。调试状态机,说白了就是「看波形、对状态、找偏差」三个步骤循环。