Survive I2C slaves that stretch the clock - #266
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
readBytessplits a transfer at the RX FIFO boundary, but unlikewriteBytesit never recorded a wait stage for the command it started. Nothing waited for or cleared the command's END interrupt, so the next chunk (orendTransaction/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:
The fix records the same data-transfer wait stage
writeBytesuses, 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 allowedus_limit + 1024µs and the END wait allowed512 << 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_waitalso 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.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 nextbeginTransactionstarts from a recovered bus.Measured stretch tolerance after the change:
0xFFFFF)In both cases the failure is reported as
connection_lostand 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_errevaluation 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.