MCP2515-Based Two-Board Arbitration Experiment — ESP32-C6
To deliberately and repeatably trigger CAN bus arbitration between two identical ESP32-C6 + MCP2515 boards using a shared trigger mechanism; to prove — both with software counters and with a logic analyzer capture — that the lower ID (MASTER, 0x123) genuinely wins arbitration against the higher ID (SLAVE, 0x456); and to diagnose and minimize the side effects observed along the way (signal corruption, delayed/incomplete transmissions).
| Component | Detail |
|---|---|
| MCU | ESP32-C6 (×2 — MASTER: COM19, SLAVE: COM20) |
| CAN Controller | MCP2515 + TJA1050 integrated module (×2) |
| Level Shifter | TXS0108E (SPI bus, 3.3V↔5V) |
| Shared trigger | Single push-button, wired in parallel to both boards' GPIO3 |
| Debounce | Several values were tried; 22pF gave a clean, noise-free trigger, while larger values (e.g. 10µF) introduced noise |
| Bitrate | CAN_500KBPS, MCP_8MHZ crystal |
| SPI pins | GPIO7 (MOSI), GPIO6 (SCK), GPIO2 (MISO), GPIO10 (CS) |
src/master/main.cpp and src/slave/main.cpp, split into two PlatformIO environments (env:master, env:slave) via build_src_filter in a shared platformio.ini.attachInterrupt), each board sends a CAN frame with its own ID (MASTER: 0x123, SLAVE: 0x456) back-to-back, BURST_COUNT times.extraDelayUs) between trigger and transmission can be added/removed from the serial terminal via +/-, in 2µs steps — used to pull the two boards' bus-access moments closer together or push them further apart.TXB0CTRL register (address 0x30), not exposed by the library (autowp-mcp2515), is read via raw SPI commands; the MLOA (Message Lost Arbitration, bit 5), TXERR (bit 4), and ABTF (bit 6) bits are checked immediately after every transmission.sendMessage() return value (RES, ERROR_FAILTX=4 also covers arbitration loss) and the MLOA bit are treated as two independent pieces of evidence, cross-checked against each other.DB0-DB1) — receiving the same SEQ more than once on the other side is used as direct proof that the hardware silently retried the same message in the background.| # | Issue | Diagnosis | Fix |
|---|---|---|---|
| 1 | undefined reference to setup()/loop() build error | The master/, slave/ subfolders didn't match the build_src_filter pattern | Correct folder layout: src/master/main.cpp, src/slave/main.cpp |
| 2 | No CAN signal at all visible on the logic analyzer | SPI.begin() was called with no arguments, so ESP32-C6's default SPI pins were used — not connected to the actual wiring (GPIO7/6/2/10) | Pins explicitly mapped via SPI.begin(SCK_PIN, MISO_PIN, MOSI_PIN, CS_PIN); speed/object made explicit with MCP2515 mcp2515(CS_PIN, 10000000, &SPI); filters explicitly set to "accept all" |
| 3 | Transmissions kept firing on their own, without any button press | The shared trigger line (GPIO3) was physically floating/loose, and picked up spurious FALLING edges from ambient noise (likely CAN switching activity) | A local capacitor (hardware debounce) was added between GPIO3 and GND; several values were tried — 22pF gave a clean result, while larger values (e.g. 10µF) introduced noise. A single capacitor was sufficient since it was one shared electrical node |
| 4 | One board was receiving the other's ID (RXWRONG matched its own ID) | The wrong firmware had been flashed to the wrong port (MASTER/SLAVE roles swapped) | Confirmed the correct firmware was on the correct board by checking the startup banner (TX_ID=...) |
| 5 | Frames sent back-to-back on a single button press always arrived sequentially; a genuine collision was never captured | The natural jitter between the two boards' bus-access moments (tens to hundreds of µs) was far larger than the window a real arbitration event requires (~1 bit time, 2µs) | Added a gradually adjustable pre-send delay (2µs resolution) to SLAVE (then MASTER) to search for the crossover point of "who starts first"; also increased the number of back-to-back transmissions per trigger (10–100) to raise the number of attempts |
| 6 | Occasional corrupted/garbage ID reads (e.g. 0x85218D04, with the IDE bit incorrectly set) | Four hypotheses were tested in sequence: (a) writing new data into the same TX buffer (TXB0) while the previous message was still retrying → fixed with waitForTxReady(), which confirms the TXREQ bit has actually cleared; (b) RX buffer overflow (RX0OVR) → drainIncoming() was added inside the burst loop, but EFLG was always 0x00, RX0OVR/RX1OVR never set — this theory was conclusively ruled out, and this change actually increased corruption frequency by adding more SPI traffic; (c) matching our custom register reads (1MHz) to the library's own SPI clock (10MHz) was tried → no visible improvement; (d) the actual fix: keeping our custom readMcpRegister() at 1MHz while leaving the library's own object at 10MHz — i.e. not matching the two, but deliberately keeping a speed mismatch. Likely cause: through the TXS0108E level shifter, back-to-back operations (our extra register read immediately following one of the library's sendMessage() calls) leave very little timing margin at 10MHz; slowing our extra read down to 1MHz relaxes that margin | The final fix has two parts: (1) readMcpRegister() fixed at 1MHz (library kept at 10MHz); (2) a fixed delay between transmissions (FIXED_INTERBURST_DELAY_US) was added/increased. The second measure both reduced corrupted/incomplete transmissions and (as expected) reduced the likelihood of arbitration, since it pushes the two boards' bus-access moments further apart — revealing a direct trade-off between test sensitivity and signal integrity |
In Round #25, on the SLAVE side, two consecutive transmissions (#25.1, #25.2)
confirmed arbitration loss with two independent indicators agreeing at once:
MLOA=1 — the MCP2515's hardware "I lost arbitration" flag (TXB0CTRL register)RES=4 (ERROR_FAILTX) — the library's sendMessage() return valueThis test round used BURST_COUNT=4 (each board sent 4 frames per trigger).
[SLAVE] #9.1 SEQ=33 ... RES=0 MLOA=0 ... >>> Transmission successful (no collision)
[SLAVE] #9.2 SEQ=34 ... RES=0 MLOA=0 ... >>> Transmission successful (no collision)
[SLAVE] #9.3 SEQ=35 ... RES=0 MLOA=0 ... >>> Transmission successful (no collision)
[SLAVE] #9.4 SEQ=36 ... RES=4 MLOA=1 TXERR=1 ... >>> ARBITRATION LOST
(expected behavior - MASTER/lower ID won)
[SLAVE] ===== Round #9 SUMMARY: 1/4 transmissions had MLOA=1 (arbitration loss) =====
[SLAVE] RX id=0x85218D04 SEQ=0 round=0 burst=0 EFLG=0x40 RX0OVR=1 RX1OVR=0
*** REPEAT (same SEQ, likely a hardware auto-retry) ***
On the 4th of 4 attempts, both MLOA=1 and RES=4 (ERROR_FAILTX) fired together —
two independent pieces of evidence for a genuine arbitration loss.
The logic analyzer capture corresponding to this event:
Detailed walkthrough of the capture (following the numbered annotations):
BURST_COUNT=4 setting used for this round.0x456".0x123. There are 4 rounds of mutual transmission in total; the gaps between rounds come from the fixed delay (FIXED_INTERBURST_DELAY_US) configured in software.0x476 instead of 0x456. In this zoomed view, the top waveform is the correct signal, the bottom one is the corrupted signal. The first circle marks a bit difference affecting the ID value; the second circle shows the DLC (data length) field being read as longer than 8 when it should be exactly 8.0x123 and is in fact completely healthy — as a new frame; instead, it treats it as a continuation of 0x476 and swallows it (drops it from the decode). In other words, there was no error at the MCP2515/bus level here — the error is entirely due to PulseView's own decode algorithm losing sync after the earlier corrupted read.0x123 (MASTER) wins, 0x456 (SLAVE) backs off, waits, and then retransmits its message with a delay — which matches both the MLOA=1/RES=4 lines in the terminal log and the bus behavior visible at this point in the capture.In the capture, the CAN-L line looks like an ordinary, completed frame — the moment the losing side (SLAVE) backs off in the middle of the ID field was not visually distinguishable. This is not a zoom/resolution issue — even a single-bit difference already shows up on CAN-L as a visible line/edge. The real problem is that CAN-L is the composite of both sides' signals — looking at the bus line alone, we can only ever see the "winning" bit; we cannot tell which side released it.
To distinguish this, each board's own TX and RX pins (the transceiver's digital logic-level outputs, separate from CAN-L, specific to each board) would need to be monitored alongside CAN-L. At that moment, SLAVE's own TX pin would go recessive (back off) while MASTER's TX pin remains dominant — this distinction can only be seen by comparing per-board TX/RX pins, not by looking at CAN-L alone. A standalone transceiver such as the SN65HVD230 offers a practical advantage here, since it provides easier probe access to these TX/RX pins.
In a new series recorded after the SPI-speed fix (see Section 4, Issue 6),
arbitration was this time caught right at the start of the series (#1.1).
Five separate images document the event at different time scales.
Image 1 — SLAVE terminal output:
In #1.1, both RES=4 and MLOA=1 fired — arbitration occurred on the very first
attempt. #1.2-1.4 are clean. Round summary: 1/4. One more notable detail: in the
final line, RX id=0x123 ... EFLG=0x40 RX0OVR=1, even though RX0OVR=1 is set,
the ID that was actually read is completely correct (0x123) — this once again
confirms the "RX overflow = corrupted ID" theory ruled out in Section 4: overflow
can occasionally occur, but it does not corrupt the content of the message that is
read; it can only cause some other, in-between message to be lost.
Image 2 — Overview before zooming in (D6 trigger line):
The button was held for roughly 168ms. A small noise blip is visible as the button is released; this could likely be smoothed out by slightly increasing the debounce capacitor value (22pF), but this noise did not cause a new trigger (debounce did its job) — the blip itself is likely a side effect related to the timing of the fixed-delay loops in the code at that moment.
Image 3 — All 4 rounds, sequential view:
The order of who starts first varies from round to round (sometimes 456 starts
first, sometimes 123 does), but arbitration makes the lower ID (123) win every
single round — i.e. "who starts first" varies, "who actually wins" never does.
This matches expected CAN behavior exactly.
Image 4 — SPI zoom, the moment MASTER wins:
On the SPI bus: MOSI: 03 30 00 → MISO: FF FF 28. Decoding this: 0x03 = READ
instruction, 0x30 = the TXB0CTRL register address, and on the third byte
(clocked out with a 0x00 dummy) the actual register value comes back:
0x28 = 0b00101000 = bit5 (MLOA=1) + bit3 (TXREQ=1). In terms of raw
content, these bytes have no overlap with 0x456's ID or data bytes — the ID
(TXB0SIDH/SIDL) and data bytes (TXB0D0-D7) live at different addresses in the
register map; 0x28 is purely a control/status value. But structurally there is
a direct link: TXB0CTRL (0x30) is exactly the control register of the same
TX buffer (TXB0) that holds 0x456's ID and data. The TXREQ=1 bit means "the
message in this buffer (0x456's data) is still queued, still waiting to go out on
the bus / still retrying" — so this read is directly tied not to the 0x123
packet currently on the bus, but to the state of its own pending 0x456
message sitting in that buffer.
Image 5 — SPI zoom, SLAVE's delayed retransmission:
The same FF FF 28 read is shown again here in a small reference inset. This SPI
transaction has no direct effect on the 0x456 packet, but it is very
meaningful temporally: it proves that, up to that point, SLAVE's message had still
been sitting in the MCP2515's TX buffer. The ID: 1110 (0x456) frame seen right
after is not a new message — it is exactly that pending/lost message, finally
succeeding after the hardware automatically retried it. Images 4 and 5 together
provide SPI-level visual proof of the theory discussed earlier: a message that
loses arbitration is not discarded — the hardware keeps retrying it in the
background.
The most important methodological finding of the whole test was:
As the fixed delay between transmissions increases, both signal corruption/incomplete transmissions decrease AND the probability of capturing arbitration decreases.
These two goals (clean/reliable data ↔ frequently catching collisions) are directly in tension — improving one worsens the other. This is an inherent limitation of the experiment, and future attempts may need to deliberately tune this balance (e.g. varying the delay depending on the test phase).
MLOA=1 + RES=4); the SPI-level images from the second series additionally provided direct proof that the hardware automatically retries a message that lost arbitration in the background.The Round #9 capture (see Section 5) is the clearest arbitration capture obtained so far — the button press, the 4-frame burst, the SPI commands, and the mutual bus transmissions are all visible together in a single capture.
Idea: In the current setup (MCP2515 + integrated TJA1050 module), the exact moment of arbitration loss — the point in the ID field where SLAVE backs off after the first differing bit — could not be clearly distinguished in the capture; the CAN-L line simply looks like an ordinary, completed frame. This is not a resolution issue — since CAN-L is the composite of both boards' signals, it cannot by itself show which side backed off. In a setup using a standalone transceiver such as the SN65HVD230, monitoring each board's own TX and RX pins alongside CAN-L would let us directly observe, via each board's own dedicated signal, which board's TX pin went recessive (backed off) at that bit. This is an approach planned for testing in a future attempt.