PAYATHON 2026

ESP32 之间通过 I2C 通信,为何下一次 requestFrom() 才收到数据?

排障赵师傅

结论

每次都收到上一轮数据,应先检查 ESP32 从机装载发送缓冲的时机。最符合日志的解释是:requestEvent() 中的 Wire.write() 为下一次读取准备数据,而当前读取取走的仍是旧缓冲中的内容。

从机完成计算后,还需要把结果从 TransmittedBytes 装入 I2C 外设的发送缓冲。这是两个步骤。主机多等 2 秒,只能给计算和保存结果留出时间。

问题中没有给出具体芯片型号和 Arduino-ESP32 版本,因此不能仅凭日志认定是驱动缺陷。原始 ESP32 与其他 ESP32 系列芯片的从机实现有差异,处理方式需要按实际型号选择。

接线也需要检查:普通、非隔离的 I2C 必须有共同地参考。发送缓冲和接地是两个问题,需要分别处理。

为什么阻塞调用仍然会读到旧数据

Wire.requestFrom() 会阻塞,直到本次 I2C 读取完成或失败,但不会判断收到的字节是否对应刚才发送的命令。

即使没有超时,并且完整返回了 7 个字节,这些字节也可能是上一条命令的结果。I2C 本身不包含“这是第几条命令的响应”这样的业务信息。

原代码在收到读取请求后才执行:

void requestEvent()
{
    Wire.write(TransmittedBytes, 7);
}

如果所用实现要求在主机读取前预装发送数据,就可能出现与日志一致的固定一轮偏移:

第 1 次读取:取走旧数据;回调装入响应 A
第 2 次读取:取走响应 A;回调装入响应 B
第 3 次读取:取走响应 B;回调装入响应 C

测试分支也有同样的问题:

uint8_t bytes[] = {byteTest, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06};

这组数据直到进入 requestEvent() 才生成。主机在请求前等得再久,也不会让回调提前执行。

第一次出现的 F4 FF FF FF FF FF FF 则无法仅凭串口输出确定来源,不能直接认定为某种初始化值或硬件故障。

先检查接线和运行环境

按以下顺序排查:

  1. 确认具体芯片型号和安装的 Arduino-ESP32 版本。
  2. 确认两端 SDA、SCL 配置一致,并有可靠的共同地参考。
  3. 暂时移除业务处理和额外任务,用固定 7 字节响应验证第一次读取。
  4. 固定响应正常后,再恢复命令处理和同步协议。

两块板分别供电时,一般不需要连接两路 3.3V 输出,也不应随意并联两个稳压器的输出。普通 I2C 仍需要连接信号地。

如果两块板的 USB 都接到同一台电脑,它们可能已经通过 USB 间接共地。因此,没有连接板间 GND 线,不一定代表没有共同地参考,需要实际检查。

如果接地确实引发故障,应先查清供电和 USB 接地路径,必要时使用合适的 USB 隔离或 I2C 隔离方案。省略地线不能解决普通 I2C 的参考电位问题,SDA、SCL 上的 4.7 kΩ 上拉也不能代替地线。

对需要预装发送数据的 ESP32 使用 slaveWrite()

如果使用的是原始 ESP32,且当前 Arduino-ESP32 版本提供 Wire.slaveWrite(),可以先测试预装发送数据的方式。

测试时,从机启动后预装 7 个字节,主机等从机初始化完成后只读取一次:

#include <Wire.h>

constexpr uint8_t SLAVE_ADDRESS = 0x28; // 示例地址,两端必须一致
uint8_t reply[7] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06};

void setup()
{
    Serial.begin(115200);

    if (!Wire.begin(SLAVE_ADDRESS)) {
        Serial.println("I2C slave init failed");
        return;
    }

    size_t queued = Wire.slaveWrite(reply, sizeof(reply));
    Serial.printf("Queued %u bytes\n", static_cast<unsigned>(queued));
}

void loop()
{
    delay(1);
}

这个测试不注册 Wire.onRequest(),也不在循环里反复装入相同数据,用来确认提前装入发送缓冲后,第一次读取能否得到正确内容。

Wire.slaveWrite() 是 ESP32 平台相关接口,不能假定所有芯片和 Arduino 核心都支持。如果当前环境没有这个接口,应检查对应型号和版本的从机发送实现,不能直接用循环里的 Wire.write() 代替。

将预装操作放到业务处理完成之后

固定响应验证通过后,在原代码完成数据处理的位置装入结果,例如:

// 放在从机处理命令完成的位置。
// ReceivedBytesLength、TransmittedBytes 等沿用现有业务变量。
HandleByteData(ReceivedBytesLength);

if (TransmitLength == 7) {
    size_t queued = Wire.slaveWrite(TransmittedBytes, 7);

    if (queued == 7) {
        // 此时再通知主机:当前响应已装入发送缓冲。
    } else {
        // 按发送失败处理,不要通知主机响应已就绪。
    }
}

采用预装方式时,应移除原来在 onRequest() 中再次写入同一响应的逻辑,避免重复装载。也不要每次运行 loop() 都调用 slaveWrite(),因为发送队列是否追加、何时消耗,取决于具体实现。

如果当前芯片和驱动支持在本次读取期间通过 onRequest() 提供数据,可以保留回调发送方式。回调应只复制已经准备好的完整响应,不要在其中进行耗时计算、延时或大量串口输出。

用就绪信号和序号同步主从机

固定 delay(2000) 适合临时排查。正式通信时,需要让主机明确知道响应已经就绪。

可以采用以下流程:

  1. 主机发送带有序号的命令。
  2. 从机接收命令,在任务或 loop() 中处理。
  3. 从机将完整响应装入发送缓冲,然后通过额外的 READY GPIO 通知主机。
  4. 主机确认这是本次命令的就绪通知,再读取响应。
  5. 主机检查响应序号与命令序号是否一致。

READY 信号需要明确的复位和确认规则,避免主机把上一轮仍保持有效的电平当成本轮通知。响应序号用于识别旧数据、重复响应或丢失响应。

不增加 GPIO 也可以设计状态查询协议,但状态字节同样要遵守驱动的发送缓冲规则。只增加一个软件 ready 变量,不能保证主机及时读到它。

增加序号、状态或长度字段后,帧长也要相应调整。如果响应必须保持 7 字节,就需要在这 7 字节内重新分配字段。

主机检查返回长度,从机保护共享数据

主机至少应检查 endTransmission() 的状态和 requestFrom() 实际返回的字节数。固定读取 7 字节可以写成:

bool readReply(uint8_t address, uint8_t (&reply)[7])
{
    size_t received =
        Wire.requestFrom(address, static_cast<size_t>(sizeof(reply)), true);

    if (received != sizeof(reply)) {
        while (Wire.available()) {
            Wire.read();
        }
        return false;
    }

    for (size_t i = 0; i < sizeof(reply); ++i) {
        int value = Wire.read();
        if (value < 0) {
            return false;
        }
        reply[i] = static_cast<uint8_t>(value);
    }

    return true;
}

这段代码只能检查读取长度。响应是否对应当前命令,仍要靠序号判断。

从机还需要处理两处隐患:

  • receiveEvent() 写入 ReceivedBytes 前必须检查数组容量,并记录实际保存的长度。过长命令应丢弃或明确报错。
  • 回调与 loop() 共享的标志、长度和数组,需要使用适合当前执行上下文的同步方式,例如队列或受保护的缓冲区。仅加 volatile 不能保证整帧数据一致,也不能防止新命令覆盖正在处理的数据。

先确认固定响应能在第一次读取时正确返回,再加入命令序号和就绪通知,判断每次收到的数据属于哪条命令。连续请求两次可以暴露缓冲时序问题,但不适合作为长期同步方案。

备注:内容仅供参考。