Skip to content

SERCOM async/DMA for Wire, SPI, and UART - #385

Closed
crabel99 wants to merge 45 commits into
adafruit:masterfrom
crabel99:sercom-async-dma
Closed

SERCOM async/DMA for Wire, SPI, and UART#385
crabel99 wants to merge 45 commits into
adafruit:masterfrom
crabel99:sercom-async-dma

Conversation

@crabel99

@crabel99crabel99 commented Feb 17, 2026

Copy link
Copy Markdown

Related Issue: #382

SERCOM Async/DMA API Comparison: sercom-async-dma vs Master

Executive Summary

Motivation

The SAMD21/SAMD51 SERCOM peripherals support hardware-accelerated DMA transfers, but the Arduino core's synchronous blocking APIs don't expose this capability. This creates performance bottlenecks in applications that need to communicate with multiple peripherals efficiently. The master branch Wire library had internal async/DMA support, but the API remained entirely synchronous, and the patterns weren't extended to SPI or UART.

This branch extends transparent async/DMA operation across all three major SERCOM interfaces (Wire/I2C, SPI, UART) while maintaining 100% backward compatibility with existing synchronous code.

Design Intent

Primary Goals:

  1. Zero Breaking Changes: All existing synchronous code must work identically without modification
  2. Transparent DMA: Hardware acceleration should be internal - no exposed DMA/fallback helper functions
  3. Unified API Pattern: All three interfaces should follow the same async design pattern
  4. Opt-in Async: Applications can choose async operation by providing callbacks; default behavior is synchronous
  5. Transaction Pooling: Match SERCOM hardware queue depth (8 transactions) for optimal throughput

Key Design Decision:

  • SERCOM APIs are entirely async-only; synchronous behavior is provided only at the protocol level (Wire/SPI/UART) via callback defaults.

Non-Goals:

  • Exposing low-level DMA control to users
  • Creating separate async-only APIs (e.g., separate writeAsync() methods)
  • Changing the behavioral contract of existing APIs

Philosophy

"Seamless by default, async by choice"

The API design follows a simple principle: when a callback is provided (!= nullptr), the operation is asynchronous and returns immediately; when no callback is provided (== nullptr), the operation is synchronous and blocks until complete. This allows:

  • Legacy code: Works unchanged with zero modifications
  • Gradual migration: Applications can adopt async selectively, one call at a time
  • Clean interfaces: No API surface area explosion with separate methods for each mode
  • Internal optimization: DMA acceleration happens transparently when USE_ZERODMA is defined

The transaction pool architecture (8 transactions matching SERCOM queue depth) enables efficient pipelining of operations without exposing queue management to applications.


Hardware Testing Status

✅ Tested Configurations

InterfaceModeDeviceTest CoverageStatus
SPIMasterTMC5130A Stepper Driver6/6 tests passing✅ Hardware validated
UARTN/AHardware loopback5/5 tests passing✅ Hardware validated
Wire (I2C)MasterMCP9600 Temperature SensorSync/async/DMA/non-DMA mixed transactions + loader unit tests✅ Hardware validated

⚠️ Untested Configurations

InterfaceMode/FeatureReason
Wire (I2C)SlaveNo hardware test setup available
Wire (I2C)High-speed mode (Hs-mode)No Hs-mode capable device available for full end-to-end testing
Wire (I2C)10-bit addressingNo 10-bit address device available for testing
SPISlaveNo hardware test setup available
UARTN/AUART is peer-to-peer (no master/slave concept)

Testing Notes:

  • SPI master mode has been validated with a real TMC5130A stepper motor driver, covering read/write/bulk transfer operations
  • UART has been tested with hardware loopback configuration, validating both sync and async read/write paths
  • Wire (I2C) has been fully validated with an MCP9600 temperature sensor, including:
    • Synchronous blocking operations (legacy API compatibility)
    • Asynchronous callback-based operations
    • DMA-accelerated transfers (USE_ZERODMA enabled)
    • Non-DMA fallback paths (USE_ZERODMA disabled)
    • Mixed transaction scenarios (sync/async interleaved)
    • Loader transaction builder unit tests
    • SCLSM (SCL stretch mode) operation validated
  • No slave mode testing has been performed for any interface
  • Hs-mode (High-speed I2C): SCLSM flag validated but full Hs-mode communication not tested (requires Hs-mode capable device)
  • 10-bit addressing: API support added but not hardware validated (no 10-bit device available)
  • All tests use the Unity framework and run on SAMD21 hardware (test_simio_m0 environment)
  • DMA acceleration (USE_ZERODMA) has been tested alongside fallback paths (without DMA library)

Recommended Pre-Merge Validation

Before merging to master, reviewers should consider:

  1. API compatibility: All existing synchronous code patterns work unchanged (verified)
  2. I2C device testing: Wire tested with MCP9600 sensor covering sync/async/DMA/non-DMA paths
  3. ⚠️Slave mode validation: Test Wire and SPI slave modes if these are supported use cases
  4. ⚠️Multi-SERCOM stress testing: Validate concurrent async operations across multiple SERCOM instances
  5. ⚠️Production workload: Test with real application workloads beyond synthetic tests

Known Limitations & Future Development

Current Limitations (SAMD21/SAMD51 Silicon Errata):

  • Hs-mode restrictions: High-speed I2C requires SCLSM=1, which prevents reliable STOP/RESTART commands in interrupt-driven byte mode. Therefore, Hs-mode is DMA-only and STOP-only (no repeated starts)
  • QCEN restriction: Quick Command Enable (QCEN) must not be enabled when SCLSM=1 (causes bus errors per silicon errata)
  • DMA completion window: Wire transactions experience ~350 processor cycles after DMA completion where the hardware is in an unstable state. Initiating a new DMA transaction during this window causes hardware faults. The implementation handles this through appropriate completion signaling and transaction scheduling
  • These restrictions are documented in Wire.h and handled by the implementation

Future Development Roadmap:

  1. Hardware CRC Integration (Requires DMA):

    • SAMD21/SAMD51 peripherals support hardware CRC calculation during DMA transfers
    • Integration planned for protocols requiring CRC (e.g., SPI with CRC checksums)
    • Will leverage existing DMA infrastructure added in this branch
    • Target use case: High-reliability communication with industrial sensors/actuators
  2. Additional Testing:

    • Hs-mode I2C with compatible devices
    • 10-bit I2C addressing validation
    • Slave mode for Wire and SPI
    • Multi-SERCOM concurrent stress testing
  3. Performance Optimization:

    • Benchmark DMA vs non-DMA paths with various transfer sizes
    • Transaction pool tuning for specific workloads
    • Memory footprint optimization
  4. SAMD51 Clock Selection Enhancement:

    • Automatic SERCOM clock selection based on requested bus speed
    • Direct SERCOM API for clock configuration (currently abstracted)
    • Optimize power consumption by selecting appropriate clock sources
    • Improve precision for non-standard baud rates
  5. Strict I2C Pad Validation:

    • Enforce SDA/SCL pad pairing rules from datasheet pinmux tables
    • Provide clearer diagnostics when invalid SERCOM/pad combinations are requested
  6. Optional Companion Libraries (Future):

    • SerialRTT: Lightweight RTT-based serial transport Stream for low-overhead debug I/O
    • DebugUtils: Common debug helpers for native and embedded testing plus Unity test support, pre-test scripts, and example PlatformIO configs

API Change Summary

Quick reference of what changed across the three interfaces:

InterfaceSync API ChangesNew Async CapabilitiesBackward Compatible?
WireNone (defaults preserved)endTransmission() + requestFrom() now accept callbacks✅ Yes (callbacks default to nullptr)
UARTNone (existing methods unchanged)NEW: read(buffer, size, callback) and write(buffer, size, callback)✅ Yes (additions only)
SPINone (defaults preserved)transfer() now accepts callbacks✅ Yes (callbacks default to nullptr)

Detailed API Comparison

Wire API Changes

Master Branch (Original)

classTwoWire : publicStream {
public:TwoWire(SERCOM *s, uint8_t pinSDA, uint8_t pinSCL);
voidbegin();
voidbegin(uint8_t, bool enableGeneralCall = false);
voidend();
voidsetClock(uint32_t);
voidbeginTransmission(uint8_t);
uint8_tendTransmission(bool stopBit);
uint8_tendTransmission(void);
uint8_trequestFrom(uint8_t address, size_t quantity, bool stopBit);
uint8_trequestFrom(uint8_t address, size_t quantity);
size_twrite(uint8_t data);
size_twrite(constuint8_t * data, size_t quantity);
virtualintavailable(void);
virtualintread(void);
virtualintpeek(void);
virtualvoidflush(void);
voidonReceive(void(*)(int));
voidonRequest(void(*)(void));
inlinesize_twrite(unsignedlong n) { returnwrite((uint8_t)n); }
inlinesize_twrite(long n) { returnwrite((uint8_t)n); }
inlinesize_twrite(unsignedint n) { returnwrite((uint8_t)n); }
inlinesize_twrite(int n) { returnwrite((uint8_t)n); }
using Print::write;
voidonService(void);
};

Note: Master branch Wire already had some async operation support through internal transaction mechanisms, but the API was entirely synchronous (blocking).

sercom-async-dma Branch (Enhanced)

classTwoWire : publicStream {
public:TwoWire(SERCOM *s, uint8_t pinSDA, uint8_t pinSCL);
voidbegin();
voidbegin(uint16_t, bool enableGeneralCall = false, uint8_t speed = 0x0, bool enable10Bit = false);
voidbegin(uint8_t, bool enableGeneralCall = false);
voidend();
voidsetClock(uint32_t);
voidbeginTransmission(uint8_t);
// MODIFIED: Added async callback support// If onComplete is nullptr, blocks for legacy sync behavior// If onComplete is non-null, enqueues and returns immediately (async)uint8_tendTransmission(bool stopBit = true,
void (*onComplete)(void* user, int status) = nullptr,
void* user = nullptr);
// MODIFIED: Added async callback support + external buffer support// If onComplete is nullptr, blocks for legacy sync behavior// If onComplete is non-null, enqueues and returns immediately (async)// If rxBuffer is nullptr, internal buffer is used; otherwise rxBuffer is useduint8_trequestFrom(uint8_t address, size_t quantity, bool stopBit = true,
uint8_t* rxBuffer = nullptr,
void (*onComplete)(void* user, int status) = nullptr,
void* user = nullptr);
size_twrite(uint8_t data);
// MODIFIED: Added setExternal parameter for zero-copy async// When setExternal=true, data is used directly (zero-copy) and// quantity is treated as both length and capacitysize_twrite(constuint8_t * data, size_t quantity, bool setExternal = false);
virtualintavailable(void);
virtualintread(void);
virtualintpeek(void);
virtualvoidflush(void);
voidonReceive(void(*)(int));
voidonRequest(void(*)(void));
// NEW: External buffer support for zero-copy async operationsvoidsetRxBuffer(uint8_t* buffer, size_t length);
voidsetTxBuffer(uint8_t* buffer, size_t length);
voidclearRxBuffer(void);
voidresetRxBuffer(void);
uint8_t* getRxBuffer(void);
size_tgetRxLength(void) const;
inlinesize_twrite(unsignedlong n) { returnwrite((uint8_t)n); }
inlinesize_twrite(long n) { returnwrite((uint8_t)n); }
inlinesize_twrite(unsignedint n) { returnwrite((uint8_t)n); }
inlinesize_twrite(int n) { returnwrite((uint8_t)n); }
using Print::write;
inlinevoidonService(void);
};

Wire API Summary

FeatureMastersercom-async-dmaNotes
beginTransmissionbeginTransmission(addr)✅ (unchanged)Start multi-stage transaction
endTransmission (sync)endTransmission(stop)endTransmission(stop=true)Legacy blocking behavior when callback=nullptr
endTransmission (async)❌ N/AendTransmission(stop, callback, user)NEW: Non-blocking with callback
requestFrom (sync)requestFrom(addr, qty, stop)requestFrom(addr, qty, stop=true)Legacy blocking behavior when callback=nullptr
requestFrom (async)❌ N/ArequestFrom(addr, qty, stop, rxBuf, cb, user)NEW: Non-blocking with callback
External RX buffer❌ N/ArequestFrom(rxBuffer=ptr)NEW: Zero-copy async receives
Buffer management❌ N/AsetRxBuffer/setTxBuffer/...NEW: External buffer control
10-bit addressing❌ N/Abegin(addr, ..., enable10Bit)NEW: Enhanced addressing mode
Speed parameter❌ N/Abegin(..., speed)NEW: Direct speed control
Write zero-copy❌ N/Awrite(data, qty, setExternal=true)NEW: External buffer for TX
Default callbacksN/A✅ Both optionalSeamless: sync when nullptr, async when provided

Wire Design Pattern Notes

Wire uses a multi-stage transaction builder pattern:

  1. beginTransmission(address) - Start building
  2. write(...) - Add data to staging buffer (loader transaction)
  3. endTransmission(callback) - Execute the built transaction (sync or async)

Or for reads:

  1. requestFrom(address, quantity, callback) - Execute read transaction directly

This pattern influenced the unified API approach for UART and SPI, but those interfaces use single-call operations rather than multi-stage building.

Key Enhancement: The sercom-async-dma branch adds async callback support to the Wire API while maintaining full backward compatibility with the synchronous blocking behavior. When callbacks are nullptr (default), behavior is identical to master branch.


UART API Changes

Master Branch (Original)

classUart : publicHardwareSerial {
public:intread();
size_twrite(constuint8_t data);
// Inherits: write(str) from Print
};

sercom-async-dma Branch (New)

classUart : publicHardwareSerial {
public:intread();
// NEW: Buffer-based async/sync read with optional callbacksize_tread(uint8_t* buffer, size_t size,
void (*onComplete)(void* user, int status) = nullptr,
void* user = nullptr);
size_twrite(constuint8_t data);
// NEW: Buffer-based async/sync write with optional callback// If callback is nullptr, blocks (sync). Otherwise enqueues and returns (async).size_twrite(constuint8_t* buffer, size_t size,
void (*onComplete)(void* user, int status) = nullptr,
void* user = nullptr);
// Inherits: write(str) from Print
};

UART API Summary

FeatureMastersercom-async-dmaNotes
Single-byte readint read()✅ (unchanged)Traditional API preserved
Single-byte writesize_t write(uint8_t)✅ (unchanged)Traditional API preserved
Buffer read (sync)❌ N/Aread(buf, size)NEW: Ring buffer read
Buffer read (async)❌ N/Aread(buf, size, callback)NEW: DMA-accelerated async read
Buffer write (sync)❌ N/Awrite(buf, size)NEW: DMA or byte-by-byte write
Buffer write (async)❌ N/Awrite(buf, size, callback)NEW: DMA-accelerated async write
Default callbacksN/A✅ Both optionalSeamless: sync when nullptr, async when provided

API Strategy: Single unified method with optional callback parameter (like Wire)


SPI API Changes

Master Branch (Original)

classSPIClass {
public:
byte transfer(uint8_t data);
uint16_ttransfer16(uint16_t data);
voidtransfer(void *buf, size_t count);
// Blocking only, no async supportvoidtransfer(constvoid* txbuf, void* rxbuf, size_t count, bool block = true);
};

sercom-async-dma Branch (New)

classSPIClass {
public:
byte transfer(uint8_t data);
uint16_ttransfer16(uint16_t data);
voidtransfer(void *buf, size_t count);
// MODIFIED: Added async callback parametersvoidtransfer(constvoid* txbuf, void* rxbuf, size_t count,
bool block = true,
void (*onComplete)(void* user, int status) = nullptr,
void* user = nullptr);
voidwaitForTransfer(void);
boolisBusy(void);
};

SPI API Summary

FeatureMastersercom-async-dmaNotes
Single-byte transfer✅ (unchanged)Traditional API preserved
16-bit transfer✅ (unchanged)Traditional API preserved
Buffer transfer (blocking)void transfer(void*)✅ (unchanged)For small transfers
Dual-buffer transfer (sync)transfer(tx, rx, count, true)✅ (unchanged)Explicit blocking
Dual-buffer transfer (async)❌ N/Atransfer(tx, rx, count, true, callback)NEW: Async callback parameter
Default callbacksN/A✅ Optional (nullptr)Sync-only by default for compatibility

API Strategy: Extended existing method signature with optional callback parameters


Wire API (For Reference)

Wire already had async/DMA in master, but documentation for pattern:

classTwoWire {
public:uint8_tsendTransmission(void (*onComplete)(...) = nullptr, ...); // Sync or asyncuint8_trequestFrom(uint8_t address, size_t quantity,
uint8_t* rxBuffer = nullptr,
void (*onComplete)(...) = nullptr, ...); // Sync or async
};

Cross-Interface API Patterns

Design Consistency

PatternWireSPIUART
Callback parameter✅ Optional (default nullptr)✅ Optional (default nullptr)✅ Optional (default nullptr)
Sync/Async duality✅ Single method handles both✅ Single method handles both✅ Single method handles both
Blocks when callback is nullptr
Transaction pool✅ (8-txn)✅ (8-txn)✅ (8-txn)
DMA acceleration
Fallback (non-DMA)✅ Byte-by-byte✅ Byte-by-byte✅ Byte-by-byte
User API changesMinimalExtended (added callback)Extended (added callback)
Backwards compat✅ 100%✅ 100% (default params)✅ 100% (new overloads)

Async Pattern (All Three Interfaces)

// Synchronous: blocks until complete (no callback)
interface.method(data, size);
// Asynchronous: enqueues and returns immediately (with callback)
interface.method(data, size, [](void* user, int status) {
// Transfer complete
}, userData);

Note: For async calls, any user-provided buffers must remain valid until the completion callback fires.


Coverage Analysis

UART Coverage

Before (Master):

  • ✅ HardwareSerial compatibility (single-byte only)
  • ❌ No efficient buffer operations
  • ❌ No async support
  • ❌ No DMA acceleration

After (sercom-async-dma):

  • ✅ HardwareSerial compatibility (preserved)
  • ✅ Efficient buffer operations (both directions)
  • ✅ Async support via callbacks
  • ✅ DMA acceleration when enabled
  • ✅ Automatic fallback to byte-by-byte when DMA unavailable

SPI Coverage

Before (Master):

  • ✅ Single-byte transfers
  • ✅ 16-bit transfers
  • ✅ Buffer transfers (blocking)
  • ✅ Dual-buffer transfers (blocking)
  • ❌ No async callback support
  • ❌ Limited transaction scheduling

After (sercom-async-dma):

  • ✅ All prior functionality preserved
  • ✅ Async callback support (new optional parameters)
  • ✅ Transaction pool for queuing multiple operations
  • ✅ DMA acceleration
  • ✅ Explicit waitForTransfer() check for non-blocking code paths

Fingerprint (Method Signature) Differences

UART: New Overloads Added

// Master: NO OVERLOAD// sercom-async-dma: NEWsize_tread(uint8_t* buffer, size_t size,
void (*onComplete)(void* user, int status) = nullptr,
void* user = nullptr);
// Master: NO OVERLOAD// sercom-async-dma: NEWsize_twrite(constuint8_t* buffer, size_t size,
void (*onComplete)(void* user, int status) = nullptr,
void* user = nullptr);

SPI: Existing Signature Extended

// Mastervoidtransfer(constvoid* txbuf, void* rxbuf, size_t count, bool block = true);
// sercom-async-dma: Added optional callback parameters (backward compatible)voidtransfer(constvoid* txbuf, void* rxbuf, size_t count,
bool block = true,
void (*onComplete)(void* user, int status) = nullptr, // NEW (optional)
void* user = nullptr); // NEW (optional)

Default Parameter Behavior

UART New Methods (Defaults)

read(buffer, size); // Defaults: sync (no callback)read(buffer, size, nullptr, nullptr); // Explicit syncread(buffer, size, myCallback, userData); // Asyncwrite(buffer, size); // Defaults: sync (no callback)write(buffer, size, nullptr, nullptr); // Explicit syncwrite(buffer, size, myCallback, userData); // Async

SPI Extended Method (Defaults)

transfer(tx, rx, count); // Defaults: sync (no callback)transfer(tx, rx, count, true); // Explicit sync blockingtransfer(tx, rx, count, true, nullptr, nullptr); // Explicit synctransfer(tx, rx, count, true, myCallback, data); // Async

Implementation Transparency

AspectMastersercom-async-dmaUser Awareness
Transaction pooling❌ Internal only✅ Internal only❌ None (transparent)
DMA vs fallback❌ N/A✅ Automatic❌ None (transparent)
USE_ZERODMA defineN/A✅ Internal only❌ None (transparent)
Interrupt management✅ Automatic✅ Automatic❌ None (transparent)

Test Coverage

UART Functional Tests (NEW)

  • ✅ SyncWrite_BasicOperation - Single buffer write (sync)
  • ✅ AsyncWrite_CallbackCompletion - Async write with callback
  • ✅ TransactionPool_MultipleQueued - 3+ simultaneous async operations
  • ✅ RingBuffer_Availability - Buffer space tracking
  • ✅ Configuration_Enable - Hardware initialization

SPI Hardware Tests (NEW)

  • ✅ ReadGCONF_Sync - Single register read (sync)
  • ✅ ReadGSTAT_Sync - Status register
  • ✅ ReadIFCNT_Sync - Interface counter (proves queuing work)
  • ✅ ReadXACTUAL_Async - Single async read with callback
  • ✅ WriteThenReadChopconf_Sync - Register write/read cycle
  • ✅ MultipleAsyncTransfersQueued - 3+ queued async operations

Master Branch

  • No async/DMA tests (feature didn't exist)

Summary of Changes

What's New

  1. UART:

    • ✅ New async/DMA read capability via read(buffer, size, callback, user)
    • ✅ New async/DMA write capability via write(buffer, size, callback, user)
    • ✅ Internal transaction pool (8 entries)
    • ✅ Seamless DMA/fallback switching
  2. SPI:

    • ✅ New async callback support via extended transfer() signature
    • ✅ Internal transaction pool (8 entries)
    • ✅ Seamless DMA/fallback switching
    • waitForTransfer() and isBusy() for non-blocking patterns
  3. Both:

    • ✅ 100% backward compatible (old API unchanged)
    • ✅ Unified async pattern across Wire, SPI, UART
    • ✅ Transparent DMA acceleration

What Changed in Existing API

  • ✅ SPI transfer() signature extended with optional parameters
  • ✅ All defaults preserve synchronous blocking behavior
  • ✅ Zero breaking changes

What Stayed the Same

  • ✅ Single-byte operations (UART read/write, SPI transfer)
  • ✅ HardwareSerial inheritance (UART)
  • ✅ SPISetting class and configuration
  • ✅ Transaction-based API (Wire, endTransaction/beginTransaction)
  • ✅ Interrupt handling (automatic)

Migration Guide: Master → sercom-async-dma

UART: No changes required

// Master code works as-is
Serial.write(data);
int b = Serial.read();

Opt-in to new async features

// NEW: Async writeuint8_t buffer[] = {1, 2, 3, 4};
Serial.write(buffer, 4, [](void* user, int status) {
// Transfer complete
}, nullptr);
// NEW: Async read
Serial.read(buffer, 4, [](void* user, int status) {
// Data received
}, nullptr);

SPI: No changes required

// Master code works as-isSPI.transfer(tx, rx, count, true);

Opt-in to new async features

// NEW: Async transferSPI.transfer(tx, rx, count, true, [](void* user, int status) {
// Transfer complete
}, nullptr);

Verdict

API design is clean and consistent:

  • UART and SPI follow the same unified pattern as Wire
  • Optional callback parameter = automatic sync/async selection
  • 100% backward compatible
  • Zero learning curve for existing code
  • Async capability is seamlessly available without cluttering the API

Update 17 Feb 2026: added links to repositories for SerialRTT and DebugUtils

@crabel99

Copy link
Copy Markdown
Author

This example async IS31FL3733 DMA library illustrates how integrating DMA into I2C unlocks true non-blocking LED matrix control on SAMD boards.

  1. Key benefits:
  • Zero blocking delays: CPU continues running application logic while LED frames transmit in the background via DMA
  • Smooth animations + concurrent tasks: Update LED matrices while simultaneously handling sensors, user input, or network activity without frame stuttering
  • Efficient high-refresh scenarios: Drive multiple IS31FL3733 chips or high-frame-rate animations without saturating your main loop
  • Hardware-accelerated throughput: SERCOM + DMA offload byte-by-byte I2C overhead from the processor, maximizing bandwidth for complex lighting effects
  1. Use cases enabled:
  • Real-time audio visualizers that don't drop samples during LED updates
  • Multi-matrix installations with independent refresh cycles
  • LED displays in time-critical applications (robotics, stage control, synchronized effects)
  • This leverages SAMD's DMA capabilities to treat LED matrices like a background peripheral rather than a blocking operation, fundamentally changing what's possible in LED-heavy projects on M0/M4 hardware.

@justin-biolumic

Copy link
Copy Markdown

This branch is working for me using SPI but I'm having trouble using it with an i2c EEPROM. I have created a project to demonstrate the problem. https://bitbucket.org/biolumic/samd-i2c-broken/src/master

In the platformio.ini file you can switch between the master and sercom-async-dma branches. Building and uploading the master branch works but swapping to the sercom-async-dma does not. I have created a fork of this repo with a couple of small changes to make it work with PlatformIO but nothing that should impact the functionality of this branch.

The initial connection works but then the communications stop (timeout). This is what I get on the serial:

src/main.cpp
I2C_EEPROM_VERSION: 1.9.4
isConnected: 1
TEST: determine size
TIME: 7143377
WARNING: Can't determine eeprom size
...

This what it should look like:

src/main.cpp
I2C_EEPROM_VERSION: 1.9.4
isConnected: 1
TEST: determine size
TIME: 56091
SIZE: 16384 Bytes
...

Let me know if I can provide any more information to help identify the cause of this issue.

@justin-biolumic

Copy link
Copy Markdown

I've done some more digging and found that requestFrom() times out waiting for txnDone, which should be set by onTxnComplete(). Here is a trace from my logic analyzer.
image

The trace looks complete and correct but for some reason onTxnComplete() never gets called. I've compared this trace to a trace on a working branch and it looks identical.

I'm having trouble finding where onTxnComplete() (via txn->onComplete) should be called from. The only references I can find to this are in stopTransmissionWIRE() but this function doesn't called after requestFrom() even though the stop bit gets set on the wire.

I would appreciate it if someone could point me in the right direction on this.

@justin-biolumic

Copy link
Copy Markdown

In file SERCOM_inline.h, function readDataWIRE() has this at line 80.

if (isMaster) {
if (_wire.txnIndex == (_wire.txnLength - 1)) {
uint8_t cmd = txn->config & I2C_CFG_STOP ? WIRE_MASTER_ACT_STOP : WIRE_MASTER_ACT_NO_ACTION;
sercom->I2CM.CTRLB.reg |= SERCOM_I2CM_CTRLB_ACKACT | SERCOM_I2CM_CTRLB_CMD(cmd); // NACK the last byte and send STOP if requested
}
else
prepareAckBitWIRE(); // ACK bytes otherwise for non-SCLSM mode
}

This is where the stop bit gets set. If I change it to:

if (isMaster) {
if (_wire.txnIndex == (_wire.txnLength - 1)) {
uint8_t cmd = txn->config & I2C_CFG_STOP ? WIRE_MASTER_ACT_STOP : WIRE_MASTER_ACT_NO_ACTION;
sercom->I2CM.CTRLB.reg |= SERCOM_I2CM_CTRLB_ACKACT | SERCOM_I2CM_CTRLB_CMD(cmd); // NACK the last byte and send STOP if requested
if (txn && txn->onComplete)
txn->onComplete(txn->user, static_cast<int>(SercomWireError::SUCCESS));
}
else
prepareAckBitWIRE(); // ACK bytes otherwise for non-SCLSM mode
}

the EEPROM reads succeeds. However, the EEPROM stops working immediately after. I'm not sure if this is related but I've run out of time this week to investigate further.

I realize this is probably not a fix I'm just posting this to hopefully help someone else looking at this understand the issue I having.

@crabel99

Copy link
Copy Markdown
Author

I tweaked the Wire.h

if (sercom->getTxnIndexWIRE() < sercom->getTxnLengthWIRE()) {
bool more = isRead ? sercom->readDataWIRE() : sercom->sendDataWIRE();
awaitingAddressAck = false;
if (!isRead || more) return; // made this a conditional return based on more boolean
}

@justin-biolumic

Copy link
Copy Markdown

@crabel99 commit https://github.com/adafruit/ArduinoCore-samd/pull/385/commits/ff93e56090ae50c8905d8b51f90c953f33a50202 fixes the issue I was having. Why did you revert it?

@crabel99

Copy link
Copy Markdown
Author

@justin-biolumic I was shotgunning my own project to roll back anything that had changed from when it last worked I will go ahead and revert the reversion.

@justin-biolumic

Copy link
Copy Markdown

I have found this branch breaks some of our other SPI drivers. I don't have time to investigate this ATM. I'll report back when I do.

@crabel99

Copy link
Copy Markdown
Author

@justin-biolumic, the issue may be if there are a number of async calls being made during startup. I made the SERCOM buffer relatively small, 8 for each SERCOM. So, for some boards, that is 56 buffer items. I have to be careful when adapting libraries to force sync behavior on begin(). Can you give me the list of libraries so I can try and see what the issue might be?

@justin-biolumic

Copy link
Copy Markdown

@crabel99, actually it's not an issue with an SPI driver. It looks like an interrupt handlers are mixed up somehow.
image

You can see in this image that SERCOM4_Handler points to void SPI_IT_HANDLER(void), but SERCOM4 is the Wire device referred to in the other calls in the stack.

For context, this is happening on a project that uses all 6 SERCOMs.

  • SERCOM0 SPI
  • SERCOM1 SPI
  • SERCOM2 SPI
  • SERCOM3 UART
  • SERCOM4 WIRE
  • SERCOM5 WIRE

I'm unable to share the code so I don't know how helpful this is. The project works fine when I swap back to the master branch though so I don't think it's an issue with how the SERCOMs are set up in my code.

I'll do more testing when I have time.

@crabel99

crabel99 commented Apr 17, 2026

Copy link
Copy Markdown
Author

@justin-biolumic, I committed a change that I think should, hopefully, resolve your issue. Master only supports DMA on SPI. This branch provides full DMA support for all three serial channels and for the chip ADC. The big shift was moving/unifying the DMA architecture into SERCOM.h/cpp and then writing the protocol-specific implementations.

Another major shift is to use the chip's PendSV to execute deferred callbacks outside the ISR context. This is super useful even with regular ISR-type events. I am surprised that this feature was not built into the code from the beginning. This, coupled with the TaskScheduler library, enables a highly efficient code architecture.

@justin-biolumic

Copy link
Copy Markdown

We end up in the dummy handler instead of the wrong ISR now.
image

This branch works fine on a different project that uses the same I2C EEPROM code. The working project has the SERCOMs set up like this:
SERCOM0 SPI
SERCOM1 SPI
SERCOM2 SPI
SERCOM3 WIRE
SERCOM4 UART
SERCOM5 UART

I don't know if this has anything to do with the issue.

@justin-biolumic

Copy link
Copy Markdown

After some more digging I've found the issue only happens when using Wire1. It doesn't matter what order I bring the interfaces up, only Wire1 fails. If I swap the pins/SERCOMs between the Wire interfaces Wire1 still fails.

Just to be clear, I have two Wire interfaces. Both interfaces work when they are attached to Wire but neither interface works if I attach it Wire1. So something about how Wire1 is setup causes it to end up in the dummy handler when calling endTransmission().

@crabel99

crabel99 commented Apr 20, 2026

Copy link
Copy Markdown
Author

@justin-biolumic, I have a test that reproduces the issue. I'm going to work on isolating it today.

@justin-biolumic

Copy link
Copy Markdown

@crabel99 Have you made any progress on the Wire1 issue?

@crabel99

Copy link
Copy Markdown
Author

@justin-biolumic my day job has been consuming my time, and I haven't had the bandwidth to work on this. I should be back to it next week.

@crabel99

Copy link
Copy Markdown
Author

@justin-biolumic, I made the changes; please let me know if this works. There was a bug in TwoWire::onService(void) that would block completion of the transaction on an erroneous ISR entry (no flags). I tightened the sync async handling as well. I have a dual I2C setup that is working (this works on Wire1).

@justin-biolumic

Copy link
Copy Markdown

@crabel99, no change for me. I still end up in the dummy handler when using Wire1 regardless if this is the first or second I2C interface I bring up.

@crabel99

Copy link
Copy Markdown
Author

@justin-biolumic Can you send me snippets of your variant.h

/* * Serial interfaces*/// Serial
#definePIN_SERIAL_TX (16ul)
#definePIN_SERIAL_RX (17ul)
#definePAD_SERIAL_TX (UART_TX_PAD_0)
#definePAD_SERIAL_RX (SERCOM_RX_PAD_1)
/* * SPI Interfaces*/
#defineSPI_INTERFACES_COUNT1
#definePIN_SPI_MOSI (14u)
#definePIN_SPI_MISO (15u)
#definePIN_SPI_SCK (11u)
#definePIN_SPI_SS (7u)
#definePERIPH_SPI sercom0
#definePAD_SPI_TXSPI_PAD_2_SCK_3
#definePAD_SPI_RXSERCOM_RX_PAD_1staticconstuint8_tSS = PIN_SPI_SS;
staticconstuint8_tMOSI = PIN_SPI_MOSI;
staticconstuint8_tMISO = PIN_SPI_MISO;
staticconstuint8_tSCK = PIN_SPI_SCK;
/* * Wire Interfaces*/// dI2C Interface
#defineWIRE_INTERFACES_COUNT2
#definePIN_WIRE_SDA (9u)
#definePIN_WIRE_SCL (10u)
#definePERIPH_WIRE sercom2
#defineWIRE_ALT_SERCOM (true)
#defineWIRE_IT_HANDLER SERCOM2_Handler
staticconstuint8_tSDA = PIN_WIRE_SDA;
staticconstuint8_tSCL = PIN_WIRE_SCL;
// Component I2C Interface
#definePIN_WIRE1_SDA (18u)
#definePIN_WIRE1_SCL (19u)
#definePERIPH_WIRE1 sercom1
#defineWIRE1_ALT_SERCOM (false)
#defineWIRE1_IT_HANDLER SERCOM1_Handler
staticconstuint8_tSDA1 = PIN_WIRE1_SDA;
staticconstuint8_tSCL1 = PIN_WIRE1_SCL;
/* * USB*/
#definePIN_USB_HOST_ENABLE (30ul)
#definePIN_USB_DM (31ul)
#definePIN_USB_DP (32ul)

The above snippet is from one of my projects for a device with 4 of the 6 used.

@justin-biolumic

Copy link
Copy Markdown

We're using all 6 SERCOMs.

// SPI Interfaces
#define SPI_INTERFACES_COUNT 3
// SPI: (Default) Connected to ADC
#define PIN_SPI_MOSI SPI_MOSI
#define PIN_SPI_MISO SPI_MISO
#define PIN_SPI_SCK SPI_SCK
#define PERIPH_SPI sercom1
#define PAD_SPI_TX SPI_PAD_0_SCK_3
#define PAD_SPI_RX SERCOM_RX_PAD_1
static const uint8_t MOSI = PIN_SPI_MOSI;
static const uint8_t MISO = PIN_SPI_MISO;
static const uint8_t SCK = PIN_SPI_SCK;
// SPI1: Connected to CANBUS driver
#define PIN_SPI1_MOSI CAN_SPI_MOSI
#define PIN_SPI1_MISO CAN_SPI_MISO
#define PIN_SPI1_SS CAN_SPI_SS_CAN
#define PIN_SPI1_SCK CAN_SPI_SCK
#define PERIPH_SPI1 sercom0
#define PAD_SPI1_TX SPI_PAD_0_SCK_3
#define PAD_SPI1_RX SERCOM_RX_PAD_1
static const uint8_t SS1 = PIN_SPI1_SS;
static const uint8_t MOSI1 = PIN_SPI1_MOSI;
static const uint8_t MISO1 = PIN_SPI1_MISO;
static const uint8_t SCK1 = PIN_SPI1_SCK;
// SPI2: Connected to DAC
#define PIN_SPI2_MOSI SPI2_MOSI
#define PIN_SPI2_MISO SPI2_MISO
#define PIN_SPI2_SS SPI2_SS
#define PIN_SPI2_SCK SPI2_SCK
#define PERIPH_SPI2 sercom2
#define PAD_SPI2_TX SPI_PAD_0_SCK_3
#define PAD_SPI2_RX SERCOM_RX_PAD_1
static const uint8_t SS2 = PIN_SPI2_SS;
static const uint8_t MOSI2 = PIN_SPI2_MOSI;
static const uint8_t MISO2 = PIN_SPI2_MISO;
static const uint8_t SCK2 = PIN_SPI2_SCK;
// Wire Interfaces
#define WIRE_INTERFACES_COUNT 2
// Wire: (Default) Off board
#define PIN_WIRE_SDA I2C_SDA
#define PIN_WIRE_SCL I2C_SCL
#define PERIPH_WIRE sercom5
#define WIRE_IT_HANDLER SERCOM5_Handler
static const uint8_t SDA = PIN_WIRE_SDA;
static const uint8_t SCL = PIN_WIRE_SCL;
// Wire1: On board
#define PIN_WIRE1_SDA I2C1_SDA
#define PIN_WIRE1_SCL I2C1_SCL
#define PERIPH_WIRE1 sercom4
#define WIRE_IT_HANDLER1 SERCOM4_Handler
static const uint8_t SDA1 = PIN_WIRE1_SDA;
static const uint8_t SCL1 = PIN_WIRE1_SCL;
// Serial ports
#ifdef __cplusplus
#include "SERCOM.h"
#include "Uart.h"
// Instances of SERCOM
extern SERCOM sercom0;
extern SERCOM sercom1;
extern SERCOM sercom2;
extern SERCOM sercom4;
extern SERCOM sercom5;
// Serial
extern Uart Serial;
#define PIN_SERIAL_TX DEBUG_TX
#define PIN_SERIAL_RX DEBUG_RX
#define PAD_SERIAL_TX UART_TX_PAD_2
#define PAD_SERIAL_RX SERCOM_RX_PAD_3
#endif // __cplusplus
// These serial port names are intended to allow libraries and architecture-neutral
// sketches to automatically default to the correct port name for a particular type
// of use. For example, a GPS module would normally connect to SERIAL_PORT_HARDWARE_OPEN,
// the first hardware serial port whose RX/TX pins are not dedicated to another use.
//
// SERIAL_PORT_MONITOR Port which normally prints to the Arduino Serial Monitor
//
// SERIAL_PORT_USBVIRTUAL Port which is USB virtual serial
//
// SERIAL_PORT_LINUXBRIDGE Port which connects to a Linux system via Bridge library
//
// SERIAL_PORT_HARDWARE Hardware serial port, physical RX & TX pins.
//
// SERIAL_PORT_HARDWARE_OPEN Hardware serial ports which are open for use. Their RX & TX
// pins are NOT connected to anything by default.
//#define SERIAL_PORT_USBVIRTUAL Serial
#define SERIAL_PORT_MONITOR Serial
#define SERIAL_PORT_HARDWARE Serial
#define SERIAL_PORT_HARDWARE_OPEN Serial

@crabel99

Copy link
Copy Markdown
Author

@justin-biolumic

You have:

#defineWIRE_IT_HANDLER1 SERCOM4_Handler

but Wire.cpp expects:

#defineWIRE1_IT_HANDLER SERCOM4_Handler

Because the macro name is incorrect, the core never emits SERCOM4_Handler for Wire1; instead, it emits a literal WIRE1_IT_HANDLER() function. The real SERCOM4_Handler vector is therefore left as the weak dummy handler, which matches the behavior you’re seeing.

crabel99 added 16 commits June 21, 2026 08:08
- Fix misleading indentation in retry logic (lines 847, 857)
- Remove ambiguous overload for Wire.begin() with integer literals
(uint16_t version now requires explicit enableGeneralCall parameter)
- Remove unused variable in SPI.cpp
- Remove redundant unsigned < 0 check in setPending()
- Add __attribute__((weak)) to all SPI interrupt handlers (SERCOM4, SPI1, etc)
This allows variants to override them when SERCOM is used for other
peripherals (e.g., MKR variants use SERCOM4 for Serial2/UART)
- Explicitly cast slave addresses to uint8_t in Wire examples to avoid
any potential overload resolution issues on different compiler versions
@justin-biolumic

Copy link
Copy Markdown

@crabel99 I can confirm that fixing the macro resolved the issue I was having. Thanks.

@crabel99

Copy link
Copy Markdown
Author

@justin-biolumic That is excellent news. I am sorry it took so long to isolate that issue!

@justin-biolumic

Copy link
Copy Markdown

@crabel99 Nothing to be sorry for, it was my mistake. I'm sorry for sending you on a wild goose chase!

@crabel99

Copy link
Copy Markdown
Author

Superseded by #395, which carries the reviewed SERCOM async/DMA work on the current SAME5x integration branch and passes the full 12-job matrix.

Sign up for freeto 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.

3 participants

@crabel99@justin-biolumic@hathach