Simultaneous CAN bus frame transmission from two microcontroller boards via SN65HVD230 transceivers, and observation of the protocol-level arbitration mechanism.
This experiment aims to observe the bit-level arbitration mechanism — one of the fundamental features of the CAN (Controller Area Network) protocol — on real hardware. When multiple nodes begin transmitting simultaneously on the CAN bus, the node with the lower ID wins by sending a dominant (0) bit; the node with the higher ID detects that its own recessive (1) bit is being read as dominant (0) on the bus and withdraws. This mechanism operates entirely at the hardware level without any central arbitrator.
| Node | ID (hex) | ID (bin) | Role |
|---|---|---|---|
| ESP32 | 0x456 | 100 0101 0110 | Higher ID — loses |
| BBB | 0x123 | 001 0010 0011 | Lower ID — wins |
At the first differing bit (bit10): BBB sends dominant (0), ESP32 sends recessive (1) — BBB wins.
| Parameter | Value |
|---|---|
| CAN speed | 100 kbit/s |
| Bit time | 10 µs |
| Sample point | 75% |
| Sample rate | 2 MHz |
| Trigger | BBB → ESP32 (GPIO15) |
| Component | Model / Spec | Role |
|---|---|---|
| Microcontroller #1 | ESP32 (TWAI driver) | CAN node — ID 0x456, triggered side |
| Microcontroller #2 | BeagleBone Black (SocketCAN) | CAN node — ID 0x123, triggering side |
| Transceiver | SN65HVD230 × 2 | Digital (TTL) ↔ Differential (CAN_H/CAN_L) conversion |
| CAN cable | 2 metres, twisted pair | Physical CAN bus line |
| Termination | 120Ω × 2 (on-board) | Bus impedance: 60Ω |
| Logic Analyzer | Saleae Logic 24MHz 8CH | Signal capture and CAN decoding |
The core approach is for BBB to both trigger ESP32 and immediately transmit its own CAN frame. The delay between the two nodes' SOF (Start of Frame) moments is controlled by the software sweepDelay parameter on the ESP32 side.
BBB sends a 50µs HIGH pulse from P9.15 to ESP32's GPIO15. A hardware interrupt (ISR) on the ESP32 captures this rising edge.
Immediately after the trigger pulse, BBB transmits a frame with ID 0x123 via SocketCAN. Due to kernel network stack latency, this occurs ~250–650µs after the trigger (variable).
After waiting sweepDelay µs from the ISR trigger, the ESP32 sends a frame with ID 0x456 via the TWAI driver. This delay was swept between 100–170µs.
When the two nodes' SOF moments overlap, arbitration occurs. At the first differing ID bit (bit10), BBB sends dominant (0) and ESP32 sends recessive (1) — BBB wins, ESP32 withdraws.
Signals were captured with the logic analyzer; the arb_lost_count (ESP32 TWAI) and arbit-lost (BBB SocketCAN statistics) counters were monitored.
Three layered issues were encountered during the experiment, each requiring resolution before the next could be addressed.
ip link show can0 reported UP and ERROR-ACTIVE, yet BBB's TX signal never reached the physical pin.
On Debian 13.5 / Kernel 6.x, P9.19 and P9.20 default to I2C2 mode. The pinmux register value remains 0x30 (GPIO mode, receiver disabled). The system's existing BB-CAN0-00A0.dtbo overlay was added to uEnv.txt as a filename only (no path prefix):
uboot_overlay_addr4=BB-CAN0-00A0.dtbo # ✓ Correct
uboot_overlay_addr4=/lib/firmware/BB-CAN0-00A0.dtbo # ✗ Wrong (silent failure)
On one of the breakout boards, the 100nF bypass capacitor (C1, between VCC and GND) had been accidentally removed during rework. This left the 3.3V supply noisy, causing corrupted differential output. A 10nF capacitor was soldered in place to complete testing.
Both SN65HVD230 breakout boards already had an on-board 120Ω resistor. Adding one more external resistor connected three in parallel:
120Ω ‖ 120Ω = 60Ω # ✓ Correct (two boards, on-board resistors sufficient)
120Ω ‖ 120Ω ‖ 120Ω = 40Ω # ✗ Wrong (no external resistor should be added)
Sweep experiments revealed which node transmitted first as a function of sweepDelay:
| sweepDelay Range | Observation | Explanation |
|---|---|---|
< 135 µs | ESP32 (0x456) first | ESP32 faster than BBB, finds bus busy |
~135–140 µs | Target arbitration window | Both nodes start SOF at nearly the same time |
> 140 µs | BBB (0x123) first | BBB starts first, ESP32 finds bus busy |
| Node | Marker → Real SOF Latency | Jitter |
|---|---|---|
| ESP32 (TWAI) | ~7 µs (very consistent) | < 2 µs |
| BBB (SocketCAN) | ~73 µs (ortalama) | ±18 µs |
| BBB (Absolute latency) | 250–650 µs | ~400 µs |
Communication established, protocol verified, timing calibration completed. The primary reason bit-level arbitration could not be captured is BBB's structural kernel latency.
Achieved goals:
Unachieved goal:
arbit-lost counter remained zero across 1000 trigger attempts.
When both nodes use the ESP32's TWAI driver, latency will be ~7µs with <2µs jitter. The symmetric latency between the two nodes will allow software-based entry into the arbitration window, and bit-level collision capture becomes a realistic goal.
| Method | Node Latency | Jitter | Arbitration Probability |
|---|---|---|---|
| ESP32 + BBB (this experiment) | 7µs vs 73µs | ±200µs | Very low |
| ESP32 + ESP32 (next) | 7µs vs 7µs | <2µs | High |
The experiment consists of two independent programs developed in separate environments: PlatformIO (VSCode extension) for the ESP32, and a direct GCC compiler + PuTTY terminal for the BeagleBone Black.
Written in C++ on the Arduino framework. Uses Espressif's TWAI (Two-Wire Automotive Interface) driver.
# platformio.ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
Upload: → icon in VSCode status bar · Monitor: 🔌 icon
Written in C on Debian 13.5. Uses SocketCAN (Linux kernel CAN stack) and the libgpiod library.
# Derleme
gcc -o can_arb_trigger \
can_arb_trigger.c -lgpiod
echo "Exit: $?"
# Run
sudo ./can_arb_trigger can0
PuTTY → 192.168.7.2:22 · SSH · beagle/beagle
main.cppReceives trigger via hardware interrupt, waits sweepDelay µs, then transmits a TWAI frame with ID 0x456. On each trigger, TX/RX error counters and arb_lost_count are printed to Serial Monitor.
// ── Pin Definitions ────────────────────────────────────────
#define CAN_TX_PIN GPIO_NUM_17 // SN65HVD230 TXD
#define CAN_RX_PIN GPIO_NUM_16 // SN65HVD230 RXD
#define TRIGGER_PIN GPIO_NUM_15 // BBB P9.15 → ESP32
#define MARKER_PIN GPIO_NUM_4 // D0: timing marker
// ── Sweep Parameters ───────────────────────────────────────
#define DELAY_SWEEP_MIN 100 // µs
#define DELAY_SWEEP_MAX 170 // µs
#define DELAY_SWEEP_STEP 1 // her tetiklemede artar
#define CAN_ID_ESP32 0x456 // higher ID — loses
// ── ISR (Hardware Interrupt) ───────────────────────────────
void IRAM_ATTR onTriggerRising() {
triggerFlag = true;
triggerCount++;
}
// ── Main Loop ──────────────────────────────────────────────
void loop() {
recoverFromBusOffIfNeeded();
if (!triggerFlag) return;
triggerFlag = false;
delayMicroseconds(sweepDelay); // timing adjustment
// Marker HIGH → send frame → Marker LOW
digitalWrite(MARKER_PIN, HIGH);
twai_message_t msg = {.identifier = CAN_ID_ESP32,
.data_length_code = 8, .ss = 1};
memset(msg.data, 0xEE, 8);
twai_transmit(&msg, pdMS_TO_TICKS(10));
digitalWrite(MARKER_PIN, LOW);
// Advance sweep
sweepDelay += DELAY_SWEEP_STEP;
if (sweepDelay > DELAY_SWEEP_MAX) sweepDelay = DELAY_SWEEP_MIN;
// Report results
twai_status_info_t status;
twai_get_status_info(&status);
Serial.printf("delay=%uus | TX err:%lu | RX err:%lu | arb_lost:%lu\n",
sweepDelay, status.tx_error_counter,
status.rx_error_counter, status.arb_lost_count);
}
can_arb_trigger.cSends a trigger pulse from P9.15, then immediately writes a SocketCAN frame with ID 0x123. Reads the arbit-lost counter after each shot; stops when an increase is detected.
// ── Constants ─────────────────────────────────────────────
#define GPIO_CHIP "/dev/gpiochip0"
#define GPIO_LINE 16 // P9.15 — tetik
#define GPIO_LINE_MARKER 17 // P9.23 — timing marker
#define CAN_ID_BBB 0x123 // lower ID — wins
#define TRIGGER_PULSE_US 50 // pulse width
// ── Main Loop (1000 shots, stop on arbitration) ────────────
while (shot < 1000) {
shot++;
// Trigger pulse: HIGH → 50µs → LOW
gpiod_line_request_set_value(request, GPIO_LINE,
GPIOD_LINE_VALUE_ACTIVE);
sleep_us(TRIGGER_PULSE_US);
gpiod_line_request_set_value(request, GPIO_LINE,
GPIOD_LINE_VALUE_INACTIVE);
// Marker + CAN frame
gpiod_line_request_set_value(request, GPIO_LINE_MARKER,
GPIOD_LINE_VALUE_ACTIVE);
write(sock, &frame, sizeof(frame));
gpiod_line_request_set_value(request, GPIO_LINE_MARKER,
GPIOD_LINE_VALUE_INACTIVE);
// arbit-lost check
long current = get_arbit_lost(can_iface);
if (current > arbit_before) {
printf("*** ARBITRATION CAPTURED! +%ld ***\n",
current - arbit_before);
break;
}
sleep_ms(50);
}
The ESP32's TWAI driver uses a 75% sample point. BBB's default is 87.5%. A mismatch inflates the RX error counter — both sides must be set identically:
# Configure CAN interface to match ESP32
sudo ip link set can0 down
sudo ip link set can0 type can \
bitrate 100000 \
sample-point 0.75 \
restart-ms 100
sudo ip link set can0 up
# Verify
ip -details link show can0 | grep sample
→ sample-point 0.750 ✓
# Arbitration statistics
ip -details -statistics link show can0 | grep -A1 re-started
→ re-started bus-errors arbit-lost error-warn error-pass bus-off