4. 从机发送与接收:字节级中断处理、NACK/ACK机制

好,我们进入第四章。这一章要啃的,是I2C从机模式里最核心的“肉”——字节级的中断处理,以及那个让无数人头疼的NACK/ACK机制。说白了,就是你的从机芯片怎么跟主机“对话”,每个字节怎么确认,出了问题又该怎么处理。

我个人习惯把I2C从机的通信比作一场“乒乓球赛”。主机发一个字节,就像打过来一个球;从机接住了,回一个ACK(相当于把球打回去);如果从机没接住或者不想接,就回一个NACK(球落地了)。这场球赛的每一个回合,都是在中断服务程序里完成的。

4.1 字节级中断:从机的“心跳”

在I2C从机模式下,中断是驱动一切的核心。没有中断,你的从机就是个“聋子”和“哑巴”。每个字节的发送或接收,都会触发一次中断。你需要在这个中断里,判断当前的状态,然后决定下一步做什么。

不同的MCU,I2C外设的中断标志位可能叫法不同,但逻辑大同小异。我以最通用的状态机模型来讲解,你理解了这套逻辑,换任何芯片都能快速上手。

核心中断事件(从机视角):

  • 地址匹配中断(ADDR): 主机发送的地址与从机地址匹配。此时你需要决定是进入发送模式还是接收模式。
  • 数据接收中断(RXNE): 从机已经接收到一个字节的数据,等待你去读取。
  • 数据发送中断(TXE): 从机已经将一个字节的数据发送出去,等待你填充下一个要发送的字节。
  • 停止条件检测中断(STOP): 主机产生了STOP条件,一次传输结束。

你想想看,如果没有这些中断,你的CPU就得不停地轮询状态寄存器,那得多浪费资源?尤其是在处理高速率(比如400kHz甚至1MHz)的I2C通信时,轮询根本来不及。

4.2 从机发送与接收的“握手”细节

我们分开来看发送和接收。这两种模式下的中断处理逻辑,是完全相反的。

4.2.1 从机接收模式(主机写,从机读)

流程是这样的:主机产生START条件,发送从机地址+写标志位。从机检测到地址匹配,产生ADDR中断。你在ADDR中断里,清除地址标志,然后准备接收数据。

之后,每收到一个字节,就会触发RXNE中断。你的任务就是在这个中断里,把数据寄存器(DR)里的值读出来,存到你的缓冲区里。如果你读慢了,下一个字节来了,就会发生溢出(Overrun)错误。

我的经验: 我曾经在一个项目中,因为中断优先级设置不当,导致RXNE中断被更高优先级的中断长时间抢占。结果I2C从机缓冲区溢出,数据全乱了。后来我把I2C中断优先级提到最高,问题才解决。记住,I2C从机中断的响应时间,直接决定了你的通信可靠性。

4.2.2 从机发送模式(主机读,从机写)

这个模式稍微复杂一点。主机发送从机地址+读标志位后,从机进入发送模式。此时,你需要准备好第一个要发送的字节,写入数据寄存器。主机产生SCL时钟,从机将数据一位一位地移出。

当第一个字节发送完毕,主机如果还想继续读,就会产生ACK;如果不想读了,就产生NACK。从机检测到ACK后,会再次触发TXE中断,让你准备下一个字节。如果检测到NACK,则说明主机不想再要数据了,从机应该释放总线,等待STOP条件。

注意: 很多新手在这里会犯一个错误:在从机发送模式下,当主机发送NACK后,从机仍然尝试发送下一个字节。这会导致总线冲突。正确的做法是:检测到NACK后,立即停止发送,并准备接收下一个START条件。

4.3 NACK/ACK机制:通信的“确认键”

ACK和NACK,是I2C协议里最基础的流量控制手段。它们发生在每个字节(包括地址字节)传输之后的第9个SCL时钟周期。

  • ACK(应答): 接收方在第9个时钟周期将SDA线拉低。表示“我收到了,请继续”。
  • NACK(非应答): 接收方在第9个时钟周期释放SDA线(保持高电平)。表示“我没收到”或“我不想再收了”。

为什么会有NACK?常见场景有几种:

场景 原因 谁发出的NACK
地址不匹配 总线上没有设备响应这个地址 所有从机
从机正忙 从机正在处理上一个命令,无法接收新数据 从机
接收缓冲区满 从机的接收缓冲区已满,无法再接收更多数据 从机
主机结束读取 主机已经读够了想要的数据,通知从机停止发送 主机
无效命令 从机不支持主机发送的命令或数据 从机

嗯,这里要注意:NACK并不总是“错误”。比如主机读取数据时,最后一个字节发送NACK,是正常的结束流程。你的从机代码必须能正确处理这种“预期的NACK”。

4.4 实战:一个简单的从机中断处理框架

下面我给出一个伪代码框架,它展示了从机模式下,如何处理发送和接收的中断。这个框架不依赖特定芯片,你只需要把它映射到你用的MCU的寄存器上即可。

// I2C从机中断服务程序
void I2C_Slave_IRQHandler(void) {
    uint32_t status = 读取I2C状态寄存器();

    // 1. 处理地址匹配
    if (status & I2C_FLAG_ADDR) {
        清除ADDR标志();
        // 判断是读还是写
        if (主机要读数据) {
            slave_mode = I2C_MODE_TX;
            tx_index = 0;
            // 准备第一个要发送的字节
            I2C_DR = tx_buffer[tx_index++];
        } else {
            slave_mode = I2C_MODE_RX;
            rx_index = 0;
        }
    }

    // 2. 处理数据接收 (主机写数据给我们)
    if (status & I2C_FLAG_RXNE) {
        if (rx_index < RX_BUFFER_SIZE) {
            rx_buffer[rx_index++] = I2C_DR;
        } else {
            // 缓冲区满了,可以在这里设置一个溢出标志
            // 或者直接丢弃数据
            uint8_t dummy = I2C_DR; // 读出来扔掉
        }
    }

    // 3. 处理数据发送 (主机读我们的数据)
    if (status & I2C_FLAG_TXE) {
        if (tx_index < TX_BUFFER_SIZE) {
            I2C_DR = tx_buffer[tx_index++];
        } else {
            // 数据发完了,如果主机还继续读,我们发0xFF
            I2C_DR = 0xFF;
        }
    }

    // 4. 处理NACK (主机不想再读了)
    if (status & I2C_FLAG_NACK) {
        // 停止发送,重置状态
        slave_mode = I2C_MODE_IDLE;
        tx_index = 0;
        // 注意:这里不需要清除NACK标志,通常硬件会自动处理
    }

    // 5. 处理STOP条件
    if (status & I2C_FLAG_STOP) {
        清除STOP标志();
        // 通知主循环,一次传输完成
        transfer_complete = 1;
    }
}

避坑指南: 我曾经在STOP中断的处理上栽过跟头。有些MCU的I2C外设,在检测到STOP条件后,会自动清除一些状态位。如果你在STOP中断里再去读取数据寄存器,可能会读到错误的值。所以,我的建议是:在STOP中断里,只做“通知”和“重置”的工作,不要做数据解析。

4.5 知识体系:从机通信状态机

为了让你更直观地理解整个流程,我画了一张状态机图。它把从机在发送和接收过程中的状态切换,以及中断触发的时机,都清晰地展示了出来。

I2C从机通信状态机 空闲 地址匹配 (ADDR) 接收模式 (RX) 发送模式 (TX) 发送数据 (TXE) 停止/结束 (STOP/NACK) 说明:从机在空闲状态等待地址匹配。匹配后,根据读写位进入RX或TX模式。 在RX/TX模式下,每个字节都会触发中断,直到主机发送STOP或NACK。 START + 地址 读/写位 RXNE中断 写位 TXE中断 主机发送NACK或STOP

这张图你看懂了吗?从“空闲”状态开始,一旦地址匹配,就根据主机的读写位,兵分两路。左边是接收,右边是发送。每个字节的传输,都在对应的中断里循环。直到主机发出NACK或STOP条件,才回到空闲状态。