Skip to content

Survive I2C slaves that stretch the clock - #266

Merged
lovyan03 merged 2 commits into
m5stack:developfrom
ainyan03:i2c_clock_stretch
Aug 24, 2026
Merged

Survive I2C slaves that stretch the clock#266
lovyan03 merged 2 commits into
m5stack:developfrom
ainyan03:i2c_clock_stretch

Conversation

@ainyan03

Copy link
Copy Markdown
Contributor

This pair of commits makes the ESP32 hardware I2C master survive slaves that stretch the clock, from the microsecond-scale stretches a slave needs to prepare its response up to the tens of milliseconds allowed by SMBus.

The race (first commit)

readBytes splits a transfer at the RX FIFO boundary, but unlike writeBytes it never recorded a wait stage for the command it started. Nothing waited for or cleared the command's END interrupt, so the next chunk (or endTransaction / restart) could reprogram the command registers while the peripheral was still finishing the previous chunk, and a stale END from chunk N defeated the FIFO poll of chunk N+1.

This is not a theoretical window. Measured with a host reading 31 to 128 bytes from a slave (an ESP32-C61 in slave mode) that stretches the clock while preparing its response:

stretch injected before after
none (only the slave's natural stretching) chunked reads (33+ bytes) failed intermittently all lengths pass
500 µs at the 32-byte chunk boundary worse, with corrupted reads all lengths pass

The fix records the same data-transfer wait stage writeBytes uses, so every path that follows waits for and consumes the command completion.

The stall limits (second commit)

The software wait limits gave a stalled transfer roughly 1-2 ms before declaring connection_lost: the FIFO poll allowed us_limit + 1024 µs and the END wait allowed 512 << stage. A slave legally holding SCL longer than that had its transfer killed. The commit raises both limits to a shared constant sized for the SMBus Tlow:sext ceiling (25 ms) and makes the waits, the watchdog, and the error reporting agree on what a stall means:

  • i2c_wait also wakes on TIME_OUT, and treats TIME_OUT or a lost arbitration as fatal even when END is set alongside, matching the SDK's event priority.
  • The STOP wait wakes only on events that actually end a STOP and requires TRANS_COMPLETE: a watchdog bite, a lost arbitration, or the limit expiring with no interrupt at all used to return success from endTransaction. A failed STOP now forces a bus recovery and poisons the context state so the split API refuses to continue the dead transaction; the next beginTransaction starts from a recovered bus.
  • The exponent-encoded SCL-low watchdog moves from 2^31 source clocks (almost a minute, which made it meaningless) to 2^21, past the software allowance on every supported source clock including the 48 MHz XTAL the ESP32-C5 can run, so the software limit is the authority on tolerated stretching.
  • The soft I2C SCL-high wait adopts the same 25 ms allowance.

Measured stretch tolerance after the change:

target tolerated stretch bounded by
ESP32 ≈13 ms (10 ms passes, 15 ms reported) SCL-low watchdog register ceiling (0xFFFFF)
ESP32-S3 25 ms (15 ms passes, 30 ms reported) the software limit

In both cases the failure is reported as connection_lost and the bus recovers cleanly on the next transaction.

NACK handling is unaffected

Probing an absent device does not lean on these limits, so it stays fast. Measured on an ESP32 (Core BASIC): an address NACK raises no error interrupt at all — the command sequence still runs to its END interrupt, and the error only surfaces in the ack_err evaluation after STOP, which is the existing detection path. Absent-address probe times are 103-111 µs both before and after this change (the write path that ends in the STOP evaluation included), and present-device transactions are unchanged.

Verified on ESP32 and ESP32-S3 hosts against a clock-stretching slave, sweeping 31/32/33/64/128-byte reads at 400/100/50 kHz with pattern verification and stretch injections from 0 to 30 ms.

readBytes splits transfers at the hardware RX FIFO boundary. Record the same data-transfer wait stage used by writeBytes after starting each read command, so the next chunk or endTransaction waits for and clears that command completion instead of racing a stale END interrupt.
The software wait limits gave a stalled transfer 1-2ms before declaring
connection_lost: the FIFO poll in readBytes allowed us_limit+1024us and
the END wait in i2c_wait allowed 512<<stage. A slave holding SCL longer
than that - legal under the I2C spec, and up to 25ms under SMBus - had
its transfer killed.

Raise both limits to a shared 25ms constant and make the waits and the
watchdog agree on what it means:

- i2c_wait also wakes on TIME_OUT, and treats TIME_OUT or a lost
  arbitration as fatal even when END is set alongside, matching the
  SDK event priority: the bus state is unknown past either of them.
- The STOP wait requires TRANS_COMPLETE: a watchdog bite, a lost
  arbitration, or the limit expiring with no interrupt at all used to
  return success from endTransaction.
- The exponent-encoded SCL-low watchdog is set to 2^20 source clocks
  (about 26ms at 40MHz), just past the software limit so the software
  limit is the sole authority on tolerated stretching; the previous
  value 31 meant almost a minute. The ESP32 register is at its
  ceiling, about 13ms, so that target tolerates less stretching.
- The soft I2C SCL-high wait adopts the same 25ms allowance.

NACK handling does not lean on these limits, so probing an absent
device stays fast: measured on the ESP32, an address NACK raises no
error interrupt at all - the command sequence still reaches END and
the error only surfaces in the ack_err evaluation after STOP - and
that path is unchanged (absent-address probe: 99-113us before and
after).

Measured with hosts reading 31-128 bytes from a slave that stretches
at the 32-byte chunk boundary: previously failures began at 1ms of
stretch; now the ESP32 passes everything through 10ms and reports
15/30ms as connection_lost (its watchdog ceiling), the ESP32-S3
passes through 15ms and reports 30ms (the 25ms software limit), and
both recover cleanly on the next transaction.
@lovyan03
lovyan03 merged commit 02930d6 into m5stack:develop Aug 24, 2026
27 checks passed
@ainyan03
ainyan03 deleted the i2c_clock_stretch branch August 24, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants