From SPI-Based MCP2515 to ESP32-C6's Native TWAI Controller
Final result: bit-level arbitration confirmed visuallyThe original test setup used an MCP2515 CAN controller (with an integrated TJA1050
transceiver module) driven over SPI through a TXS0108E level shifter. That setup
successfully proved CAN bus arbitration in software (via the MLOA bit and the
library's return codes), but had two persistent limitations:
To address the second limitation, the project was rebuilt around the SN65HVD230, a standalone 3.3V CAN transceiver with no built-in controller. This required moving the CAN controller logic itself onto the ESP32-C6, using its native TWAI (Two-Wire Automotive Interface) peripheral instead of an external SPI chip.
Because SN65HVD230 operates natively at 3.3V — the same level as the ESP32-C6 — no level shifter is needed at all, and because the CAN controller is now built into the MCU, no SPI bus is needed either. The wiring collapses to just two digital signal lines per board:
| Signal | ESP32-C6 | SN65HVD230 |
|---|---|---|
| TWAI TX → Driver input | GPIO0 | Pin 1 (D) |
| TWAI RX ← Receiver output | GPIO1 | Pin 4 (R) |
| Power | 3.3V | Pin 3 (VCC) |
| Ground | GND | Pin 2 (GND) |
| Slope control | — | Pin 8 (Rs) tied to GND for high-speed mode |
| Reference (unused) | — | Pin 5 (VREF) left floating |
| Bus | — | Pins 6/7 (CANH/CANL) → bus, 120Ω termination at both ends |
| Shared trigger | GPIO3 | — (unchanged from the MCP2515 setup) |
Removed entirely: TXS0108E level shifter, SPI wiring (previously GPIO7/6/2/10), and
the autowp-mcp2515 library dependency.
The firmware structure (shared trigger, adjustable pre-send delay, burst
transmission, sequence numbers for retry detection) stayed the same. What changed
is the CAN layer itself, now built directly on driver/twai.h:
twai_driver_install() / twai_start() replace the MCP2515 library's
reset()/setBitrate()/setNormalMode() calls.twai_transmit() replaces sendMessage().twai_receive() replaces readMessage().TWAI_ALERT_ARB_LOST, read via twai_read_alerts(), replaces the manual
SPI read of the MCP2515's TXB0CTRL register and its MLOA bit.twai_status_info_t.arb_lost_count — a native, hardware-tracked
cumulative counter — replaces the difference-based tracking we previously had to
build ourselves around the MCP2515's TEC register.Since the CAN controller now lives inside the same chip as the application code (rather than behind an external SPI link), there is no more SPI clock/level-shifter interaction to worry about, and the diagnostic data is exposed directly through a proper driver API instead of raw register reads.
twai_transmit() only enqueues a message — it does not wait for the message to
actually finish on the bus. Reading twai_read_alerts() immediately afterwards,
with only a short timeout, could occasionally pick up an alert belonging to a
different queued message than the one just sent, because the hardware processes
the TX queue asynchronously. This produced a visible mismatch: cross-checking the
software log against a direct probe on SLAVE's own TWAI TX pin (D3) showed the
logged arbitration-loss event attributed to burst index #25.3, while the actual
physical bit dropout on D3 occurred one attempt earlier, at #25.2.
Fix: after calling twai_transmit(), the firmware now loops, repeatedly
calling twai_read_alerts() and accumulating the results, until
twai_status_info_t.msgs_to_tx drops back to zero — i.e. until this specific
message has fully left the queue (successfully transmitted, however many retries
that took). Only then are the accumulated alert flags logged. This guarantees each
log line's ARBLOST/TXFAILED fields belong to the message that was actually
just sent, not a neighboring one.
esp_err_t res = twai_transmit(&frame, pdMS_TO_TICKS(50));
uint32_t alerts = 0, accumulatedAlerts = 0;
twai_status_info_t status;
uint32_t waitStart = micros();
do {
twai_read_alerts(&alerts, pdMS_TO_TICKS(5));
accumulatedAlerts |= alerts;
twai_get_status_info(&status);
} while (status.msgs_to_tx > 0 && (uint32_t)(micros() - waitStart) < 20000);
bool arbLost = accumulatedAlerts & TWAI_ALERT_ARB_LOST;
bool txFailed = accumulatedAlerts & TWAI_ALERT_TX_FAILED;
After the fix, a repeat test confirmed correct attribution — the physical
drop-out and the logged ARBLOST=1 line lined up on the same burst index
(#37.1), with the corrected DUR measurement (~486µs) now correctly reflecting
the real bus transmission time (including the back-off and automatic retry)
instead of just the near-instant queue-enqueue time (~11-23µs) that the earlier,
unfixed code had been measuring.
This was the original motivation for moving to SN65HVD230: with a standalone transceiver, each board's own TWAI TX pin (before the transceiver, pure digital logic level) can be probed directly and independently from the composite CAN bus line. The result is exactly what was hoped for — the single bit where SLAVE releases the bus is now directly visible, distinct from the merged bus signal.
D3 in the captures below is SLAVE's own TWAI TX pin. Everywhere else in the frame it toggles in its normal 0/1 pattern; at the exact bit where arbitration is lost, it shows a single, isolated anomaly — SLAVE stops actively driving that bit and goes recessive, precisely because it detected a dominant bit on the bus where it was trying to send a recessive one.
This directly confirms the hypothesis discussed at the end of the MCP2515 phase of the project: the reason the back-off could not be seen on CAN-L alone was never a resolution or zoom problem — CAN-L is inherently a composite of both sides' signals. Only a per-board, pre-transceiver probe point (made practical here by SN65HVD230's exposed digital TX pin) can show which side released the bus.
twai_transmit()'s asynchronous
queuing behavior) was identified by cross-checking software logs against a direct
pin-level capture, and fixed by waiting for msgs_to_tx == 0 before logging a
result.