Skip to content

esp32: migrate i2c drivers to the new I2C master driver (i2c_master.h)#2349

Draft
harmon25 wants to merge 3 commits into
atomvm:release-0.7from
harmon25:i2c_upgrade
Draft

esp32: migrate i2c drivers to the new I2C master driver (i2c_master.h)#2349
harmon25 wants to merge 3 commits into
atomvm:release-0.7from
harmon25:i2c_upgrade

Conversation

@harmon25

@harmon25 harmon25 commented Jul 1, 2026

Copy link
Copy Markdown

Summary

Migrates AtomVM's ESP32 I2C stack off the legacy driver/i2c.h API (end-of-life as of IDF v6.0, scheduled for removal in v7.0) onto the new driver/i2c_master.h API. Both existing implementations are migrated:

  • the "i2c" port driver (i2c_driver.c, default i2c:open/1 path)
  • the NIF-resource driver (i2c_resource.c, opt-in via {use_nif, true})

No changes to the Erlang-level i2c API (libs/avm_esp32/src/i2c.erl, libs/eavmlib/src/i2c_hal.erl) or to STM32/RP2 drivers.

Design

  • Shared i2c_bus_manager helper (new i2c_bus_manager.c/include/i2c_bus_manager.h): owns bus open/close and per-address device handling. Because AtomVM's i2c API takes the target address on every call rather than at open time (e.g. i2c_scanner.erl probes arbitrary addresses on one open bus), while the new driver requires a persistent per-address device handle for any transaction, this helper adds a transient device handle immediately before each transaction and removes it immediately after.
  • Byte-buffer accumulation for streaming transmissions: the legacy driver deferred the actual bus transaction until i2c_master_cmd_begin() via a command link built up across begin_transmission/write_byte(s)/end_transmission calls. The new driver has no command-link equivalent, so a small growable I2CTxBuffer accumulates the bytes instead, flushed as a single i2c_master_transmit() call from end_transmission.
  • i2c_driver_acquire/i2c_driver_release (native C extension point for other drivers to share the port driver's bus) now hand out i2c_master_bus_handle_t instead of i2c_port_t. This header was already explicitly marked ATOMVM_ESP32_I2C_OLD_API/"will be changed in future," and has no in-tree callers.
  • NACK error-code normalization: the new driver reports a NACK'd transaction as ESP_ERR_INVALID_STATE (IDF <= 5.5) or ESP_ERR_INVALID_RESPONSE (IDF >= 6.0), whereas the legacy driver always returned ESP_FAIL. Both codes are normalized back to ESP_FAIL in i2c_bus_manager.c to preserve the {error, esp_fail} contract that existing applications and test_i2c.erl rely on, across all supported IDF versions.
  • Build: esp_driver_i2c is linked explicitly for IDF >= 6.0 (where the legacy driver component drops its public dependency on it), mirroring the existing esp_driver_dac/tinyusb pattern already used in avm_builtins/CMakeLists.txt.

Testing

No ESP-IDF toolchain was available locally, so I pulled the same espressif/idf Docker images used by CI and built libavm_builtins.a directly:

  • IDF v5.2.7 (minimum CI-tested version) — esp32 target — OK, no warnings
  • IDF v5.5.4 (latest CI-tested version) — esp32 target — OK, no warnings
  • IDF v5.5.4 — esp32c3 (RISC-V) target — OK, no warnings

I also manually traced test_i2c.erl (BMP180 register read/write, split transactions, einprogress and NACK error paths) and i2c_scanner.erl (arbitrary-address probing) against the new implementation to confirm behavioral compatibility. I was not able to run the Wokwi-simulated hardware tests locally (requires a WOKWI_CLI_TOKEN), so CI coverage for test_i2c via esp32-simtest.yaml / esp32-build.yaml is relied on to validate actual bus transactions.

Opening as draft pending CI results, since I could not execute the full on-target test suite locally.

@harmon25 harmon25 changed the base branch from main to release-0.7 July 1, 2026 22:31
Migrate both the "i2c" port driver and the i2c NIF-resource driver
from the legacy driver/i2c.h API to the new driver/i2c_master.h API.
The legacy driver is end-of-life as of IDF v6.0 and scheduled for
removal in v7.0.

- Add a shared i2c_bus_manager helper used by both implementations for
  bus open/close and per-address device add/transact/remove, since the
  new driver requires a persistent per-address device handle while
  AtomVM's i2c API takes the target address on every call (e.g. the
  i2c_scanner example probes arbitrary addresses on one open bus).
- Replace the legacy command-link queuing (begin_transmission/
  write_byte(s)/end_transmission) with a small growable byte buffer
  that is flushed as a single i2c_master_transmit() call.
- Redefine i2c_driver_acquire/i2c_driver_release to hand out the new
  i2c_master_bus_handle_t instead of i2c_port_t; this API has no
  in-tree callers and was already documented as subject to change.
- Normalize the new driver's NACK error codes (ESP_ERR_INVALID_STATE
  on IDF <= 5.5, ESP_ERR_INVALID_RESPONSE on IDF >= 6.0) back to
  ESP_FAIL, preserving the {error, esp_fail} contract existing
  applications and the i2c test suite rely on.
- Link esp_driver_i2c explicitly for IDF >= 6.0, matching the existing
  esp_driver_dac/tinyusb pattern in this component.

Verified by building libavm_builtins.a against real ESP-IDF v5.2.7 and
v5.5.4 for both esp32 and esp32c3 targets.

Signed-off-by: harmon25 <dougwright1@gmail.com>
- i2c_bus_manager_transmit: the new driver's i2c_master_transmit()
  rejects zero-length write buffers with ESP_ERR_INVALID_ARG, unlike
  the legacy driver, which happily emitted a bare START/address/STOP
  sequence. This broke the address-only ACK/NACK probe idiom (e.g.
  i2c:write_bytes(I2C, Addr, <<>>), or an empty
  begin_transmission/end_transmission pair), commonly used for device
  presence or busy-polling checks. A write_size == 0 now falls back to
  i2c_master_probe(), normalizing ESP_ERR_NOT_FOUND to ESP_FAIL to
  match the existing {error, esp_fail} contract.
- i2c_resource: close_i2c_resource now always releases the pending
  I2CTxBuffer, even if closing the underlying bus handle fails,
  avoiding a leak of the buffer across the resource's lifetime.
- i2cdriver_write_bytes / nif_i2c_write_bytes: only allocate a scratch
  buffer when a register prefix is present; otherwise the caller's
  data pointer is passed directly to the transmit call, avoiding an
  unnecessary alloc + copy on the common register-less write path.

Verified by building against ESP-IDF v5.5.4 for both esp32 and
esp32s3 targets (no warnings).

Signed-off-by: harmon25 <dougwright1@gmail.com>
@harmon25

harmon25 commented Jul 1, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up commit (d424a5a) addressing a couple of issues found in self-review:

  • Fixed a regression: i2c_master_transmit() rejects zero-length write buffers (ESP_ERR_INVALID_ARG), unlike the legacy driver, which happily emitted a bare START/address/STOP sequence. This broke the address-only ACK/NACK probe idiom (e.g. i2c:write_bytes(I2C, Addr, <<>>), or an empty begin_transmission/end_transmission pair), used for device-presence/busy-polling checks. i2c_bus_manager_transmit now falls back to i2c_master_probe() for write_size == 0, normalizing ESP_ERR_NOT_FOUND to ESP_FAIL to preserve the existing {error, esp_fail} contract.
  • Fixed a minor I2CTxBuffer leak in i2c_resource's close_i2c_resource if the underlying bus-handle close fails.
  • Avoided an unnecessary malloc/copy in write_bytes/nif_i2c_write_bytes for the common case of a register-less write (only the register-prefixed case needs a scratch buffer to make the prefix and data contiguous).

Re-verified by building against ESP-IDF v5.5.4 for both esp32 and esp32s3 targets (clean, no warnings).

Comment thread src/platforms/esp32/components/avm_builtins/i2c_driver.c Fixed
CodeQL's atomvm/term-use-after-gc check flagged make_einprogress_error:
it took the transmitting pid as a 'term owner_pid' parameter, which
callers populated from i2c_data->transmitting_pid *before* the
function's internal memory_ensure_free() call, then used *after* it.
That capture-before-GC/use-after-GC shape is exactly the hazard the
check looks for, since a GC can move/invalidate a previously read
boxed term.

transmitting_pid is in practice always immediate (a local pid or
term_invalid_term()), so this was not an exploitable bug, but the
pattern is fragile and worth fixing structurally rather than
suppressing. make_einprogress_error now takes the owning
'struct I2CData *' instead, and reads i2c_data->transmitting_pid
directly after the GC check, removing the flagged pattern entirely.
All six call sites are updated accordingly.

Addresses the github-advanced-security bot review comment on PR atomvm#2349.

Signed-off-by: harmon25 <dougwright1@gmail.com>
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