3. 从机状态机设计:IDLE、ADDR、DATA_ACK、DATA_NACK 状态转移

状态机,说白了就是 I2C 从机的「大脑」。它决定了芯片什么时候该听、什么时候该说、什么时候该闭嘴。我见过不少新手工程师,上来就对着寄存器手册一顿配置,结果从机要么不响应,要么乱发数据——十有八九是状态机没理清楚。

这一章,我们就来拆解从机状态机的四个核心状态:IDLE、ADDR、DATA_ACK、DATA_NACK。搞懂了它们之间的转移关系,你写代码时心里就有底了。

3.1 状态机总览:四个状态,一条主线

先看整体框架。从机状态机其实不复杂,它只处理两件事:谁在跟我说话? 和 他要我干什么?

核心原则: 从机永远是被动的。它不能主动发起通信,只能响应主机的 SCL 时钟和 SDA 数据。

下面这张图,是我自己画的状态转移图。你把它存下来,写代码时对照着看,能省不少 debug 时间。

I2C 从机状态机转移图 I2C 从机状态机转移图 IDLE 等待 START 条件 ADDR 地址匹配检测 DATA_ACK 发送/接收数据 + ACK DATA_NACK 发送/接收数据 + NACK START 条件 地址不匹配 匹配 + W=0 匹配 + R=1 继续传输 主机 NACK STOP 条件 STOP 条件 注意:从机不能主动离开 DATA_ACK 状态,必须等待主机发送 STOP 或 NACK

3.2 状态详解:每个状态在干什么?

3.2.1 IDLE 状态——等待的起点

IDLE 是默认状态。从机上电复位后,或者收到 STOP 条件后,都会回到这里。在这个状态下,从机什么都不做,只是盯着 SDA 线,等待 START 条件出现。

我的习惯: 在 IDLE 状态里,我会把内部数据指针复位到初始位置。这样每次通信开始,数据都是从第一个字节开始读写,不会出现「上次读到一半,这次接着读」的 bug。

IDLE 状态下,从机要做的检查很简单:

  • SDA 从高变低,同时 SCL 为高 → 检测到 START 条件 → 跳转到 ADDR 状态
  • 否则 → 保持 IDLE

3.2.2 ADDR 状态——你是谁?

进入 ADDR 状态后,从机开始接收主机发送的 7 位地址(或 10 位地址的第一字节)和读写位。这是从机唯一一次「自我介绍」的机会。

地址匹配的逻辑是这样的:

条件 结果 下一个状态
接收到的地址 == 本机地址 拉低 SDA 发送 ACK DATA_ACK
接收到的地址 != 本机地址 释放 SDA(高阻) IDLE
接收到广播地址 (0x00) 取决于是否使能广播响应 DATA_ACK 或 IDLE
我曾经踩过的坑: 地址比较的时序窗口非常窄。有一次我在 ADDR 状态里做了太多处理——比如读 EEPROM、更新状态标志——结果 SCL 第 9 个脉冲来了,我还没准备好拉 ACK。从那以后,我规定自己:ADDR 状态只做地址比较和 ACK/NACK 决策,其他事情一律放到 DATA_ACK 里做。

3.2.3 DATA_ACK 状态——正常收发数据

这是从机最忙的状态。主机发数据,从机收;主机要数据,从机发。每次传输一个字节(8 位),然后从机在第 9 个 SCL 脉冲拉低 SDA 表示 ACK。

在 DATA_ACK 状态下,从机需要区分两种场景:

  1. 主机写从机(W=0): 从机接收数据字节,存入内部寄存器或 FIFO,然后发送 ACK。
  2. 主机读从机(R=1): 从机从内部寄存器读取数据,放到 SDA 上,然后等待主机发送 ACK。

这里有个容易搞混的地方:ACK 是谁发的?

  • 主机写从机 → 从机发 ACK
  • 主机读从机 → 主机发 ACK

你想想看,如果读操作时从机发 ACK,那 SDA 就被从机拉住了,主机根本没法发数据。所以方向一定要分清楚。

3.2.4 DATA_NACK 状态——异常或结束信号

DATA_NACK 状态通常意味着两件事之一:

  • 主机不想继续读了: 主机在读操作的第 9 个时钟释放 SDA(不发 ACK),告诉从机「够了,别发了」。
  • 从机不想继续收了: 从机在写操作的第 9 个时钟释放 SDA,告诉主机「我满了,别发了」。

进入 DATA_NACK 后,从机应该做什么?

标准做法: 等待 STOP 条件。一旦检测到 STOP,立即回到 IDLE 状态。如果收到重复 START(Sr),则回到 ADDR 状态重新开始。

我个人习惯在 DATA_NACK 状态里加一个超时计数器。如果超过一定时间(比如 10 个 SCL 周期)既没有 STOP 也没有 Sr,就强制复位到 IDLE。这是为了防止主机异常掉电导致总线卡死。

3.3 状态转移的完整流程

把四个状态串起来,一个典型的从机通信流程是这样的:

  1. IDLE → ADDR: 主机发送 START,从机检测到后进入地址匹配阶段。
  2. ADDR → DATA_ACK: 地址匹配成功,从机发送 ACK,准备收发数据。
  3. DATA_ACK → DATA_ACK: 正常传输,每发完一个字节,根据方向决定谁发 ACK,然后继续下一个字节。
  4. DATA_ACK → DATA_NACK: 主机发送 NACK(读操作)或从机发送 NACK(写操作),表示传输结束。
  5. DATA_NACK → IDLE: 主机发送 STOP 条件,从机回到空闲状态。

当然,还有几个特殊情况:

  • 地址不匹配: ADDR → IDLE,从机忽略本次通信。
  • 重复 START: 在任何数据状态下收到 Sr,都要回到 ADDR 状态重新匹配地址。
  • 总线错误: 如果检测到 SDA 被异常拉低超过 9 个时钟,强制进入 IDLE。

3.4 代码实现要点

下面是一个简化的状态机实现框架。实际项目中,你需要在 SCL 的每个上升沿或下降沿触发状态检查。

typedef enum {
    STATE_IDLE,
    STATE_ADDR,
    STATE_DATA_ACK,
    STATE_DATA_NACK
} i2c_slave_state_t;

i2c_slave_state_t current_state = STATE_IDLE;

void i2c_slave_fsm(uint8_t sda_in, uint8_t scl_in) {
    switch (current_state) {
        case STATE_IDLE:
            if (detect_start(sda_in, scl_in)) {
                current_state = STATE_ADDR;
                reset_bit_counter();
            }
            break;
            
        case STATE_ADDR:
            if (bit_counter == 8) {
                if (address_match()) {
                    send_ack();
                    current_state = STATE_DATA_ACK;
                } else {
                    send_nack();
                    current_state = STATE_IDLE;
                }
            }
            break;
            
        case STATE_DATA_ACK:
            if (bit_counter == 8) {
                if (direction == WRITE) {
                    // 从机接收数据,然后发 ACK
                    store_received_byte();
                    send_ack();
                } else {
                    // 从机发送数据,等待主机 ACK
                    load_transmit_byte();
                    if (receive_ack()) {
                        // 主机想继续读
                        current_state = STATE_DATA_ACK;
                    } else {
                        // 主机不想读了
                        current_state = STATE_DATA_NACK;
                    }
                }
            }
            break;
            
        case STATE_DATA_NACK:
            if (detect_stop(sda_in, scl_in)) {
                current_state = STATE_IDLE;
            } else if (detect_repeated_start(sda_in, scl_in)) {
                current_state = STATE_ADDR;
            }
            break;
    }
}
一点经验: 上面的代码为了清晰做了简化。实际项目中,你需要在 SCL 的每个边沿都调用这个函数,并且用边沿检测(而不是电平检测)来判断 START/STOP 条件。否则,总线上的毛刺会让你状态机乱跳。

3.5 常见问题与避坑

最后,分享几个我在调试从机状态机时遇到过的典型问题:

现象 可能原因 解决方法
从机不响应地址 ADDR 状态里处理时间过长,错过了 ACK 窗口 把地址比较和 ACK 生成放在硬件里做,或者用更快的时钟
数据错位 IDLE 状态没有复位位计数器或数据指针 进入 IDLE 时强制复位所有通信相关的指针和计数器
总线卡死 从机在 DATA_NACK 状态没有超时处理,主机异常掉电后一直等待 STOP 增加超时计数器,超时后强制回到 IDLE
重复 START 处理异常 状态机没有在 DATA_ACK 状态下检测 Sr 在所有非 IDLE 状态下都要检测 Sr 条件

嗯,状态机这部分内容就这些。你把它吃透了,I2C 从机开发就成功了一大半。剩下的就是根据具体芯片的寄存器去适配了——但核心逻辑,万变不离其宗。