IS31FL3733 Async DMA I2C Architecture
Overview
This document describes an elegant, fully asynchronous DMA-driven I2C communication architecture for the IS31FL3733 LED matrix driver. The design eliminates blocking I2C operations by leveraging SERCOM (hardware I2C controller) callbacks, double-buffered row updates, and a lock-free ring buffer for pending transactions.
ABM support is intentionally abandoned in this architecture.
Problem Statement
Traditional I2C LED driver libraries block the main program during register writes, especially when updating multiple rows or the entire 12×16 matrix. This architecture solves that by:
- Moving all I2C traffic to a background async engine
- Using DMA for efficient data transfers
- Maintaining lock-free data structures to avoid CPU overhead
- Supporting real-time color updates without blocking
Architecture Components
Class Structure
class IS31FL3733 {
public:
bool begin(TwoWire &wire, uint8_t addr = 0x50, ColorOrder order = GRB, uint8_t sdbPin = 0xFF, uint8_t irqPin = 0xFF);
void DeviceOn();
void DeviceOff();
void SetPixelPWM(uint8_t row, uint8_t col, uint8_t value = 0);
void SetPixelColor(uint8_t row, uint8_t col, uint32_t rgb = 0);
void SetRowPWM(uint8_t row, uint8_t value = 0);
void SetRowColor(uint8_t row, uint32_t rgb = 0);
void Fill(uint32_t rgb = 0);
private:
SERCOM *_hw;
SercomTxn _txn;
uint8_t _txPtr[17];
uint8_t _pwm_matrix[12][17];
RingBufferN<12> _rowRingBuffer;
Enqueued _enqueued;
ColorOrder _colorOrder;
uint8_t _sdbPin;
uint8_t _irqPin;
bool _pwmLocked;
uint8_t _ledOpen[24];
uint8_t _ledShort[24];
uint8_t _ledOn[24];
SercomTxn _cmdTxn[4];
uint8_t _crwlTx[2] = {0xFE, 0xC5};
uint8_t _pgSelTx[2];
uint8_t _cmdTx[25];
uint8_t _cmdRx[24];
void _sendRow();
void _selectPage(uint8_t page);
void _unlockPwm();
void _onService();
static void _txnCallback(void *user, int status);
static void _irqCallback();
static void _cmdCallback(void *user, int status);
};
1. PWM Matrix: 12×17 Row Buffer
uint8_t _pwm_matrix[12][17]
- Private member holding all LED PWM state
- Byte 0 is the Page 1 row address (0x00, 0x10, ..., 0xB0)
- Bytes 1–16 are PWM intensity (0–255) for one row
- The same row buffer doubles as the transaction payload source
2. SERCOM Transaction Descriptor
struct SercomTxn {
uint16_t config;
uint16_t address;
size_t length;
const uint8_t *txPtr;
uint8_t *rxPtr;
void (*onComplete)(void *user, int status);
void *user;
};
SercomTxn _txn;
uint8_t _txPtr[17];
- Private members
- Each transaction is 17 bytes: Page 1 row address + 16 PWM values
_txn.txPtr points to _txPtr when a transaction is active, nullptr when idle
3. Row Ring Buffer
RingBufferN<12> _rowRingBuffer;
- Private member
- Holds row numbers (0–11) that need updating
- Main program enqueues row number when a row is modified
- Background callback dequeues and transmits
- Repeated writes to the same row update
_pwm_matrix but do not add duplicate ring entries
4. Enqueued Bitfield
struct Enqueued {
uint16_t data;
inline uint8_t get(uint8_t n) { return (data >> n) & 1; }
inline void set(uint8_t n) { data |= (1 << n); }
inline void clear(uint8_t n) { data &= ~(1 << n); }
};
Enqueued _enqueued;
- Private member
- Set when a row is enqueued
- Cleared when a row is dequeued for transmission
- If a row is modified while in-flight, it is re-enqueued via this bitfield
5. Async/Sync I/O Helpers
Unified infrastructure for register access with configurable callback behavior:
inline void _asyncWrite(uint16_t pagereg, const uint8_t *data, uint8_t len,
void (*callback)(void *, int) = nullptr, void *user = nullptr);
inline void _asyncRead(uint16_t pagereg, uint8_t *dest, uint8_t len,
void (*callback)(void *, int) = nullptr, void *user = nullptr);
Design Features:
- Page encoding: High byte = page (0-3) or common register (0xFE, 0xF0, 0xF1)
- Auto page-select: Pages 0-3 trigger
_selectPage(), common registers skip it
- Callback support: Optional callback for async completion notification
- Reuses command chain: Always uses
_cmdTxn[2] for write, _cmdTxn[3] for read
- Inline for performance: Minimal overhead in ISR context
Usage Patterns:
_asyncRead((uint16_t)ISR << 8, &_lastISR, 1, isrCallback, this);
_asyncWrite(LEDONOFF, _ledOn, 24, nullptr, nullptr);
auto syncCallback = [](void *user, int status) {
volatile bool *flag = (volatile bool *)user;
*flag = true;
};
volatile bool syncComplete = false;
_asyncWrite(CR, &cr_value, 1, syncCallback, (void *)&syncComplete);
while (!syncComplete);
6. Initialization
bool IS31FL3733Driver::begin(TwoWire &wire, uint8_t addr, ColorOrder order, uint8_t sdbPin, uint8_t irqPin)
{
_hw = wire.getSercom();
if (_hw == nullptr)
return false;
_colorOrder = order;
_sdbPin = sdbPin;
_irqPin = irqPin;
_rowRingBuffer.flush();
for (uint8_t row = 0; row < 12; row++)
_pwm_matrix[row][0] = row * 0x10;
_cmdTxn[0].config = I2C_CFG_STOP;
_cmdTxn[0].address = addr;
_cmdTxn[0].length = 2;
_cmdTxn[0].txPtr = _crwlTx;
_cmdTxn[0].user = this;
_cmdTxn[1].config = I2C_CFG_STOP;
_cmdTxn[1].address = addr;
_cmdTxn[1].length = 2;
_cmdTxn[1].txPtr = _pgSelTx;
_cmdTxn[1].user = this;
_cmdTxn[2].config = I2C_CFG_STOP;
_cmdTxn[2].address = addr;
_cmdTxn[2].txPtr = _cmdTx;
_cmdTxn[2].user = this;
_cmdTxn[3].config = I2C_CFG_READ | I2C_CFG_STOP;
_cmdTxn[3].address = addr;
_cmdTxn[3].txPtr = _cmdRx;
_cmdTxn[3].user = this;
_txn.config = I2C_CFG_STOP;
_txn.address = addr;
_txn.length = 17;
_txn.txPtr = nullptr;
_txn.onComplete = _txnCallback;
_txn.user = this;
if (_sdbPin != 0xFF) {
pinMode(_sdbPin, OUTPUT);
digitalWrite(_sdbPin, HIGH);
}
if (_irqPin != 0xFF) {
pinMode(_irqPin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(_irqPin), _irqCallback, FALLING);
}
auto syncCallback = [](void *user, int status) {
volatile bool *flag = (volatile bool *)user;
*flag = true;
};
auto syncWrite = [this, &syncComplete, &syncCallback](uint16_t pagereg,
const uint8_t *data, uint8_t len) {
syncComplete = false;
_asyncWrite(pagereg, data, len, syncCallback, (void *)&syncComplete);
while (!syncComplete);
};
auto syncRead = [this, &syncComplete, &syncCallback](uint16_t pagereg,
uint8_t *dest, uint8_t len) {
syncComplete = false;
_asyncRead(pagereg, dest, len, syncCallback, (void *)&syncComplete);
while (!syncComplete);
};
_selectPage(1);
Fill();
return true;
}
}
### 7. Interrupt and Fault Detection (Open/Short)
When `_irqPin` is provided, the driver enables open/short detection (OSD) and registers a GPIO ISR.
The ISR schedules a pre-staged transaction chain that reads fault status and updates cached arrays
without blocking the main application.
**Page Management:**
- Default page is set to Page 1 (PWM) in `begin()` and stays there for all normal PWM writes
- PWM row writes (17-byte transactions) run bare on Page 1 without page switching overhead
**Preemption rule:**
- `_pwmLocked` is set by the GPIO ISR (or any non-PWM operation) before scheduling alternate chains
- Normal PWM send path checks `_pwmLocked` and exits before copying into `_txPtr`
- The final callback of any non-PWM chain calls `_unlockPwm()`, which:
1. Restores Page 1 via `_selectPage(1)`
2. Clears `_pwmLocked`
3. Kicks `_sendRow()` to resume PWM traffic
**Register setup in `begin()`:**
- Enable OSD in CR (Page 3, register 0x00): `CR |= CR_OSD`
- Unmask interrupts in IMR (Page 3, register 0x10): `IMR |= IMR_IO | IMR_IS`
- Initialize LED On/Off (Page 0, 0x00..0x17) to all ON
- Initialize PWM (Page 1, 0x00..0xB0 rows) to zero via `Fill()`
**Transaction chain (Open/Short fault):**
1. GPIO ISR fires → `_irqCallback()` → `_onService()`
2. `_onService()` locks PWM: `_pwmLocked = true`
3. **Read ISR (Async)**: `_asyncRead(ISR << 8, &_lastISR, 1, isrCallback, this)`
- Reads Interrupt Status Register (0xF1, 1 byte) into `_lastISR` cache
- ISR callback dispatches based on `_lastISR` bits when read completes
4. **Dispatch based on ISR value**:
- **OB/SB (bits 0x03)**: Merged open/short fault handling
- Determines fault register: `pagereg = (isr & ISR_OB) ? LEDOPEN : LEDSHORT`
- Determines destination: `dest = (isr & ISR_OB) ? _ledOpen : _ledShort`
- Reads fault register: `_asyncRead(pagereg, dest, 24, _osbCallback, this)`
- **ABM1/ABM2/ABM3 (bits 0x1C)**: Auto-breath completion interrupts
- Currently just unlocks PWM (TODO: implement ABM handling)
- **Unknown**: Unlocks PWM
5. **On fault read completion**: `_osbCallback()`
- Updates LED mask: `_ledOn[i] = _ledOn[i] & ~(_ledOpen[i] | _ledShort[i])`
- Writes updated mask: `_asyncWrite(LEDONOFF, _ledOn, 24, nullptr, nullptr)`
- Unlocks PWM: `_unlockPwm()` restores Page 1 and resumes PWM writes
**Key Design Points**:
- All I/O uses unified `_asyncWrite`/`_asyncRead` inline helpers
- ISR reading is fully asynchronous with callback-based dispatch
- OB/SB paths merged into single branch (fault type determined from ISR bit)
- `_lastISR` cache enables lightweight ISR callback (no lambdas in ISR context)
- `_osbCallback` is static method for fault completion handling
All of the above uses the dedicated `_cmdTxn[4]` chain so it does not interfere with the normal row PWM `_txn` pipeline.
**Command transaction sizing:**
- `_cmdTxn[0]`: Unlock (PSWL write: 2 bytes)
- `_cmdTxn[1]`: Page select (PSR write: 2 bytes)
- `_cmdTxn[2..3]`: Read/write ops (max 25 TX, 24 RX bytes)
State Machine: Transaction Lifecycle
Write API (Public, Called by Main Program)
void IS31FL3733Driver::SetPixelPWM(uint8_t row, uint8_t col, uint8_t value)
{
uint8_t idx = (row - 1) & 0b1111;
_pwm_matrix[idx][col] = value;
if (!_enqueued.get(idx)) {
_enqueued.set(idx);
_rowRingBuffer.store(idx);
}
if (_txn.txPtr == nullptr)
_sendRow();
}
void IS31FL3733Driver::SetPixelColor(uint8_t row, uint8_t col, uint32_t rgb)
{
row = (row - 1) & 0b11;
uint8_t r = (rgb >> 16) & 0xFF;
uint8_t g = (rgb >> 8) & 0xFF;
uint8_t b = rgb & 0xFF;
uint8_t baseRow = row * 3 + 1;
switch (_colorOrder) {
case ColorOrder::RGB:
SetPixelPWM(baseRow + 0, col, r);
SetPixelPWM(baseRow + 1, col, g);
SetPixelPWM(baseRow + 2, col, b);
break;
case ColorOrder::GRB:
SetPixelPWM(baseRow + 0, col, g);
SetPixelPWM(baseRow + 1, col, r);
SetPixelPWM(baseRow + 2, col, b);
break;
case ColorOrder::RBG:
SetPixelPWM(baseRow + 0, col, r);
SetPixelPWM(baseRow + 1, col, b);
SetPixelPWM(baseRow + 2, col, g);
break;
case ColorOrder::BRG:
SetPixelPWM(baseRow + 0, col, b);
SetPixelPWM(baseRow + 1, col, r);
SetPixelPWM(baseRow + 2, col, g);
break;
case ColorOrder::GBR:
SetPixelPWM(baseRow + 0, col, g);
SetPixelPWM(baseRow + 1, col, b);
SetPixelPWM(baseRow + 2, col, r);
break;
case ColorOrder::BGR:
SetPixelPWM(baseRow + 0, col, b);
SetPixelPWM(baseRow + 1, col, g);
SetPixelPWM(baseRow + 2, col, r);
break;
}
}
Send API (Private, Called from Write API or Callback)
void IS31FL3733Driver::_sendRow()
{
if (_pwmLocked)
return;
if (_rowRingBuffer.available() == 0)
return;
uint8_t row = _rowRingBuffer.read_char();
_enqueued.clear(row);
memcpy(_txPtr, _pwm_matrix[row], 17);
_txn.txPtr = _txPtr;
_hw->enqueueWIRE(_txn);
}
void IS31FL3733Driver::_selectPage(uint8_t page)
{
page = page & 0b11;
_hw->enqueueWIRE(_cmdTxn[0]);
uint8_t pageSel[2] = {0xFD, page};
memcpy(_pgSelTx, _pageSel, 2);
_hw->enqueueWIRE(_cmdTxn[1]);
}
void IS31FL3733Driver::_unlockPwm()
{
_selectPage(1);
_pwmLocked = false;
_sendRow();
}
SERCOM Callback (Private, Called from ISR on Transaction Complete)
void IS31FL3733Driver::_txnCallback(void *user, int status)
{
IS31FL3733 *self = (IS31FL3733 *)user;
self->_txn.txPtr = nullptr;
if (self->_rowRingBuffer.available() > 0)
self->_sendRow();
}
Flow Summary
- Main program writes → Updates
_pwm_matrix[row][col] → Sets _enqueued bit → Enqueues row → Kicks _sendRow() if idle
- **
_sendRow()** → Dequeues row → Clears _enqueued bit → Copies to _txPtr → Enqueues SERCOM transaction
- Callback fires → Sets
_txn.txPtr = nullptr → Calls _sendRow() if more rows pending
- While in-flight: If row is updated again,
_enqueued bit is re-set and row is re-queued
Initialization
See Architecture Components - Initialization above for full begin() implementation.
Public API (Non-blocking)
driver.SetPixelColor(row, col, uint32_t rgb);
driver.SetRowRgb(row, r_pwm, g_pwm, b_pwm);
driver.Fill(uint32_t rgb);
driver.UpdatePwm(const uint8_t pwm[12][17]);
All return immediately. No blocking I2C.
Benefits
| Aspect | vs. Blocking I2C | This Architecture |
| Main program blocked? | Yes, during writes | No, fully async |
| CPU overhead | Proportional to data size | Minimal (memcpy + pointer swaps in callback) |
| Throughput | Sequential | DMA handles transmission in background |
| Latency | Unpredictable (I2C waits) | Predictable (buffered) |
| Real-time capable? | No | Yes (animations, PWM fading) |
Thread Safety
- No locks needed: Ring buffer +
_enqueued bitfield prevents duplicate queueing
- Main program reads/writes
_pwm_matrix freely
- Single in-flight transaction uses
_txPtr as the stable DMA source
- Clearing
_enqueued bit before transmission allows row to be re-queued while in-flight
- All data structures are atomic (single bytes/pointers on ARM)
_txnCallback runs in ISR context; _sendRow() is ISR-safe (no re-entrancy issues)
Example: Animating a Bar Graph
for (int step = 0; step < 256; step++) {
for (int row = 0; row < 4; row++) {
for (int col = 0; col < 16; col++) {
uint8_t brightness = (step + row * 64) % 256;
driver.SetPixelColor(row, col, brightness);
}
}
delay(10);
}
The I2C engine processes pending rows in the background while the main program continues.
Future Enhancements
- Gamma correction during send: Apply gamma curve while populating
txPtr
- HSV support: Convert HSV → RGB in the async path
- Interrupt statistics: Track transaction completion rate, queue depth for performance monitoring
References
- IS31FL3733B datasheet (see
docs/IS31FL3733B_DS.pdf)
- SERCOM I2C DMA reference (SAMD21 or target MCU docs)
- ColorUtils: Gamma correction and HSV→RGB (see
src/is31fl3733_color_utils.hpp)