Skip to content

Fix ESP32 I2C transaction locking - #353

Merged
lovyan03 merged 1 commit into
m5stack:developfrom
ainyan03:i2c_lock_fix
Oct 14, 2026
Merged

lovyan03 merged 1 commit into
m5stack:developfrom
ainyan03:i2c_lock_fix

Conversation

@ainyan03

@ainyan03 ainyan03 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Lock leak on an invalid address. beginTransaction() took the port lock and then let restart() reject an out-of-range address. The state stayed state_disconnect, so the following endTransaction() returned early without unlocking or restoring the saved registers. Every later transfer on that port then blocked forever (the same task deadlocks on the non-recursive mutex). The address is now checked before the lock is taken.

Transaction owner. Rejecting before the lock means a failed begin no longer holds the port, so the cleanup after it must not touch a transfer that another task is running. The hardware port now records the task that owns the open transaction:

  • endTransaction(), restart(), writeBytes() and readBytes() return mode_mismatch when the calling task does not own the transaction. This also covers a task that keeps writing after an error has already released the port.
  • A second beginTransaction() from the owning task returns mode_mismatch instead of deadlocking.
  • transactionWrite() / transactionRead() / transactionWriteRead() no longer call endTransaction() when beginTransaction() failed.

Contract: do not call endTransaction() after a failed beginTransaction().

Atomic bitOn() / bitOff(). writeRegister8() with a mask read and wrote the register in two transactions and released the lock in between, so a concurrent update of the same register from another task could be lost. The port mutex is now recursive and held from the read to the write. The bus sequence is unchanged (a STOP between the read and the write), so devices see the same traffic as before. This serializes callers of this library only. Another driver sharing the bus can still transfer between the two, and software (bit-bang) ports keep the previous behavior.

Checked

  • Hardware, internal I2C bus of each board: beginTransaction(port, 0x800) + endTransaction(), then a register read of an on-board device from another task. "Held" runs the same while another task keeps an open transaction on the port.
Board Chip Port Before: read after the failed begin/end After: idle After: held
Core2 v1.3 ESP32 HP 1 never returns (lock leaked) returns, value correct other task's transaction completes, stray endTransaction() returns mode_mismatch
AtomS3R ESP32-S3 HP 1 — same same
ToughC5 ESP32-C5 LP — same same
Tab5 ESP32-P4 HP 1 — same same

Bus scans, touch, PMIC/RTC and the IO expanders keep working with the change.

  • Builds with Arduino 2.0.x (ESP-IDF 4.4) / 3.x and ESP-IDF 5.1 to 6.1 across ESP32, S3, C3, C5, C6, C61, H2 and P4.

Not tested

  • ESP32-S2, C3, C6 and H2 hardware (the changed code has no chip-specific branch).

An out-of-range address was rejected by restart() after beginTransaction()
had already taken the port lock. The state stayed disconnected, so the
following endTransaction() returned early without unlocking, and every later
transfer on that port blocked forever. The address is now checked before the
lock is taken or the shared bus registers are saved.

Rejecting before the lock means a failed begin no longer holds the port, so
ending it must not touch a transfer owned by another task. The hardware port
now records the task that owns the open transaction:
- endTransaction(), restart(), writeBytes() and readBytes() return
  mode_mismatch when called by a task that does not own the transaction
  (including after an error has already released it).
- A second beginTransaction() from the owning task returns mode_mismatch
  instead of deadlocking.
- transactionWrite/Read/WriteRead() no longer call endTransaction() when
  beginTransaction() failed.
Contract: do not call endTransaction() after a failed beginTransaction().

writeRegister8() with a mask (bitOn/bitOff) read and wrote the register in two
transactions and released the lock in between, so a concurrent update of the
same register from another task could be lost. The port mutex is now
recursive and held across the read and the write. The bus sequence is
unchanged (STOP between the read and the write). This serializes lgfx callers
only; software (bit-bang) ports are not covered.
@lovyan03
lovyan03 merged commit cb11c69 into m5stack:develop Oct 14, 2026
25 checks passed
@ainyan03
ainyan03 deleted the i2c_lock_fix branch October 14, 2026 08:04
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