CAN Bus Arbitration Test — SN65HVD230/TWAI Migration

From SPI-Based MCP2515 to ESP32-C6's Native TWAI Controller

Final result: bit-level arbitration confirmed visually
Contents
  1. Background: Why We Migrated
  2. New Hardware: SN65HVD230 Wiring
  3. New Software Architecture: ESP-IDF TWAI Driver
  4. A Subtle Bug: Alert Misattribution
  5. Final Result: Arbitration Seen at the Bit Level
  6. Conclusion

1. Background: Why We Migrated

The 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.

2. New Hardware: SN65HVD230 Wiring

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:

SignalESP32-C6SN65HVD230
TWAI TX → Driver inputGPIO0Pin 1 (D)
TWAI RX ← Receiver outputGPIO1Pin 4 (R)
Power3.3VPin 3 (VCC)
GroundGNDPin 2 (GND)
Slope controlPin 8 (Rs) tied to GND for high-speed mode
Reference (unused)Pin 5 (VREF) left floating
BusPins 6/7 (CANH/CANL) → bus, 120Ω termination at both ends
Shared triggerGPIO3— (unchanged from the MCP2515 setup)

Removed entirely: TXS0108E level shifter, SPI wiring (previously GPIO7/6/2/10), and the autowp-mcp2515 library dependency.

3. New Software Architecture: ESP-IDF TWAI Driver

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:

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.

4. A Subtle Bug: Alert Misattribution

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.

Off-by-one bug: logged event vs actual physical event on D3
The mismatch: D3 shows the physical drop-out during the second (25.2) transmission, while the software log attributed the arbitration loss to the third (25.3) attempt.

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.

5. Final Result: Arbitration Seen at the Bit Level

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.

Zoomed view of the ID field showing the single-bit anomaly on D3
Zoomed into MASTER's winning frame (ID 291 / 0x123). D3 (SLAVE's own TX pin) shows a single isolated dip right within the ID field — the exact bit where SLAVE backed off, distinct from its otherwise regular toggling.
Frame boundary view - D3 quiet during 0x123, active during 0x456
A wider view across the frame boundary: D3 shows the single arbitration-loss anomaly during MASTER's frame (0x123), then resumes full, healthy toggling only once SLAVE's own frame (ID 1110 / 0x456) begins right after.
Two separate arbitration events compared side by side
Two separate arbitration events, captured back to back. In both cases the same signature repeats on D3: a single anomalous bit at the start of the ID field, confirming this is a consistent, reproducible signal — not a one-off artifact.

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.

6. Conclusion