Skip to content

fix(i2c): three I2C receive-path fixes (RX buffer sizing + NUL terminator + short-read resync) - #44

Open
ACEFGI wants to merge 3 commits into
Seeed-Studio:mainfrom
inanimate-tech:fix/i2c-rx-buffer-overflow
Open

fix(i2c): three I2C receive-path fixes (RX buffer sizing + NUL terminator + short-read resync)#44
ACEFGI wants to merge 3 commits into
Seeed-Studio:mainfrom
inanimate-tech:fix/i2c-rx-buffer-overflow

Conversation

@ACEFGI

@ACEFGI ACEFGI commented Jun 29, 2026

Copy link
Copy Markdown

This PR contains three fixes to the I2C receive path in src/Seeed_Arduino_SSCMA.cpp. Fixes 1 and 2 are heap corruption (a trampled neighbouring allocation); fix 3 is a permanent response-framing desync that freezes a free-running consumer. They share the same file and receive path, so they're bundled here.


Fix 1 — grow the TwoWire RX buffer to hold a full response packet

Problem

SSCMA::i2c_read() reads the module response in MAX_PL_LEN (250-byte) chunks:

_wire->requestFrom(_address, MAX_PL_LEN);            // 250 bytes
_wire->readBytes(data + i * MAX_PL_LEN, MAX_PL_LEN);

On ESP32 the Arduino TwoWire RX buffer defaults to 128 bytes (I2C_BUFFER_LENGTH), and requestFrom() does not clamp the requested size to the buffer capacity. So any module response larger than 128 bytes (e.g. a populated invoke() result, or even the ID()/name() strings read during begin()) overruns the internal rxBuffer by up to 122 bytes — writing JSON response text past the end of the buffer and trampling the adjacent heap block.

On a sparse heap the overrun usually lands in free space and goes unnoticed. On denser firmware it corrupts a live allocation and the device boot-loops with Guru Meditation Error (LoadProhibited).

How to reproduce

Arduino-ESP32 ships ESP-IDF "light" heap poisoning (every allocation carries head/tail canaries), so heap_caps_check_integrity_all() walks the heap and reports any trampled canary. A minimal bring-up sketch bracketed with an integrity check surfaces it:

#include <Wire.h>
#include <Seeed_Arduino_SSCMA.h>
#include <esp_heap_caps.h>

SSCMA ai;

void setup() {
  Serial.begin(115200);
  Wire.begin(/*SDA=*/8, /*SCL=*/9);
  ai.begin(&Wire);
  Serial.println(heap_caps_check_integrity_all(true) ? "heap ok" : "HEAP CORRUPT");
}

void loop() {
  ai.invoke(1, false, false);           // a populated result is a >128 B response
  if (!heap_caps_check_integrity_all(true)) {
    Serial.println("[heap] CORRUPTION after invoke");
  }
  delay(200);
}

With a model loaded (so responses exceed 128 bytes), the integrity check trips — the corrupted block's canary holds ASCII fragments of the JSON response, confirming the Wire RX buffer is the source.

Workaround (caller side)

Growing the RX buffer before begin() keeps the read in bounds. This is what callers currently have to do — e.g. in a Grove Vision AI V2 bring-up project:

wire.begin(sda_pin, scl_pin);
// SSCMA::i2c_read() issues requestFrom(addr, 250) for every chunk, but the
// arduino-esp32 Wire RX buffer defaults to 128 bytes (I2C_BUFFER_LENGTH).
// Grow it to hold a full chunk so the read stays in bounds. 250 == MAX_PL_LEN.
wire.setBufferSize(256);
ai.begin(&wire);

Fix

Move that workaround into the library so callers don't need to know the internal chunk size: begin() calls setBufferSize(PACKET_SIZE) (HEADER_LEN + MAX_PL_LEN + CHECKSUM_LEN = 256, already defined in the header). Guarded to ARDUINO_ARCH_ESP32, since setBufferSize() is not part of the generic Arduino TwoWire API and ESP32 is where requestFrom() fails to self-clamp.

With the fix in place, the reproduction above reports heap ok on every iteration where it previously detected corruption.

A complementary core-side fix — making TwoWire::requestFrom() clamp the request to the buffer instead of overrunning — is proposed at espressif/arduino-esp32#12723.


Fix 2 — reserve a byte for the rx-buffer NUL terminator

Problem

Separate from the TwoWire buffer above, the library's own response buffer (rx_buf, allocated in set_rx_buffer()) has a one-byte overrun. fetch() and wait() NUL-terminate it after each read:

rx_buf[rx_end] = '\0';

The read clamp permits len + rx_end == rx_len, so rx_end can equal rx_len exactly — and set_rx_buffer() allocates only rx_len bytes. That terminating NUL then lands one byte past the malloc, corrupting the adjacent heap block.

It stays latent in normal request/response use (the buffer drains after every command, so rx_end never reaches rx_len), but is reached under a sustained free-run (invoke(-1)) backlog where the buffer fills to rx_len. We observed it as wake-word TFLite tensor-arena corruption on a neighbouring allocation.

Fix

Allocate size + 1 in set_rx_buffer() so the terminator slot is always in-bounds. rx_len still tracks the usable data capacity, so no other logic changes.


Fix 3 — return bytes actually read so short reads resync

Problem

SSCMA::i2c_read() returned the requested length unconditionally, even when the read came up short:

_wire->requestFrom(_address, MAX_PL_LEN);
_wire->readBytes(data + i * MAX_PL_LEN, MAX_PL_LEN);
...
return length;   // reported as a full read even on a timeout / short read

requestFrom()/readBytes() can return fewer bytes than requested — most commonly a clock-stretched read that times out (ESP_ERR_TIMEOUT, surfaced by the core as i2cRead returned Error 263). The caller (fetch()) advances its receive cursor by this return value:

rx_end += read(rx_buf + rx_end, len);

so an over-reported read walks rx_end over partial/garbage bytes. The \r\n response framing then desyncs permanently: strnstr(RESPONSE_PREFIX/SUFFIX) no longer aligns to real event boundaries, the event callback stops firing, and a free-running consumer (invoke(-1)) that loops until it receives an event spins forever. The device keeps running, but that stream is dead until a reset.

Fix

Accumulate and return the number of bytes actually read, keep writes contiguous (data + total), and stop the read on a short chunk or a failed endTransmission(). rx_buf then holds exactly the bytes received, so the parser can resync on the next complete frame instead of desyncing forever.

ACEFGI added 2 commits June 29, 2026 13:45
i2c_read() reads the module response in MAX_PL_LEN (250-byte) chunks via
requestFrom(). On ESP32 the Arduino TwoWire RX buffer defaults to 128
bytes (I2C_BUFFER_LENGTH) and requestFrom() does not clamp the requested
size to the buffer capacity, so a full-size read overruns rxBuffer and
corrupts the neighbouring heap (manifests as boot-loop Guru Meditation
panics on dense-heap firmware).

Call setBufferSize(PACKET_SIZE) in the I2C begin() path so a full packet
fits. Guarded to ARDUINO_ARCH_ESP32 (the affected core; setBufferSize is
not part of the generic TwoWire API).
fetch() and wait() NUL-terminate the receive buffer with
`rx_buf[rx_end] = '\0'` after each read. The read clamp permits
`len + rx_end == rx_len`, so rx_end can equal rx_len and that write
lands one byte past the malloc'd buffer, corrupting the adjacent heap
block. It stays latent in request/response use (the buffer drains every
command) but is reached under a sustained free-run (invoke(-1)) backlog,
where the buffer fills to rx_len — observed as wake-word tensor-arena
corruption on a neighbouring allocation.

Allocate size + 1 in set_rx_buffer so the terminator slot is always
in-bounds; rx_len remains the usable data capacity. Stacks on the
setBufferSize packet-size fix (both retained until upstream merges).
@ACEFGI ACEFGI changed the title fix(i2c): grow TwoWire RX buffer to hold a full response packet fix(i2c): two RX-buffer heap-corruption fixes (packet sizing + NUL terminator) Jun 30, 2026
i2c_read() returned the requested length unconditionally, even when
requestFrom()/readBytes() timed out or came up short. The caller advances
its rx cursor by this value, so an under-read drove the byte stream out of
sync with the \r\n response framing and never recovered (free-run pose then
froze on a single ESP_ERR_TIMEOUT). Return the real byte count, keep writes
contiguous, and stop on a short chunk so the rx buffer holds exactly the
bytes received and the parser can resync on the next complete frame.
@ACEFGI ACEFGI changed the title fix(i2c): two RX-buffer heap-corruption fixes (packet sizing + NUL terminator) fix(i2c): three I2C receive-path fixes (RX buffer sizing + NUL terminator + short-read resync) Jul 2, 2026
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.

1 participant