This is an Arduino library for the built-in CAN (TWAI) controller(s) on ESP32 family chips. It provides a high-level, Arduino-friendly API that works across all ESP-IDF versions from v4.x through v5.5+ and across all ESP32 variants — from the original single-controller ESP32 to the three-controller ESP32-P4 and the CAN-FD-capable ESP32-C5.
Check out the CANipulator as an example of a device with dual-CAN transceivers.
A real-world implementation of the device and this library can be found here. In this example, we take certain messages from an incompatible ECU's CAN Bus, modify (translate) them to known required ones for the body or powertrain CAN Bus on the other side.
This library is based on collin80/esp32_can,
which is itself part of a family of CAN libraries sharing the
collin80/can_common abstract base class.
That base class defines the CAN_COMMON interface and the CAN_FRAME / CAN_FRAME_FD
frame types used throughout.
The key differences introduced in this fork:
IDF 5.5+ support (TWAI_TIER_NEW). The original library targets IDF ≤ 5.4 and uses
twai_driver_install / twai_receive (blocking). IDF 5.5 replaced the entire driver with
esp_driver_twai (twai_new_node_onchip, callback-based async RX). This fork adds a
complete third implementation tier for the new driver, selected automatically at compile time.
CAN-FD support. On chips where SOC_TWAI_SUPPORT_FD is defined (currently ESP32-C5),
setDataBaudrate() enables the FD data phase and sendFrameFD() / get_rx_buffFD()
handle frames up to 64 bytes. The FD path is compiled away entirely on non-FD chips.
Three-tier build system. Rather than a single #if guard, the library now selects
one of three complete implementations at compile time based on ESP_IDF_VERSION:
TWAI_TIER_V1 → IDF < 5.2 legacy single-controller API
TWAI_TIER_V2 → IDF 5.2–5.4 legacy _v2 handle-based API
TWAI_TIER_NEW → IDF ≥ 5.5 new esp_driver_twai component
Existing sketches written for the original library compile and run unchanged.
| Chip | TWAI controllers | CAN-FD | CAN0 |
CAN1 |
CAN2 |
|---|---|---|---|---|---|
| ESP32 (original) | 1 | No | ✅ | — | — |
| ESP32-S2 | 1 | No | ✅ | — | — |
| ESP32-S3 | 1 | No | ✅ | — | — |
| ESP32-C3 | 1 | No | ✅ | — | — |
| ESP32-C6 | 2 | No | ✅ | ✅ | — |
| ESP32-C5 | 2 | Yes | ✅ | ✅ | — |
| ESP32-P4 | 3 | No | ✅ | ✅ | ✅ |
The library selects the correct number of
CANglobals at compile time usingSOC_TWAI_CONTROLLER_NUM, so no code changes are needed when switching targets.
- collin80/can_common — provides
CAN_COMMON,CAN_FRAME, andCAN_FRAME_FD. Install via the Arduino Library Manager or manually.
- Install can_common via Arduino Library Manager or clone into your
libraries/folder. - Clone or download this repository into
Arduino/libraries/esp32_can/. - Select your ESP32 board in the Arduino IDE — the correct IDF tier is picked automatically.
#include <esp32_can.h>
void setup() {
Serial.begin(115200);
CAN0.setCANPins(GPIO_NUM_16, GPIO_NUM_17); // RX, TX — adjust to your board
CAN0.begin(500000); // 500 kbps
CAN0.watchFor(); // accept all IDs
}
void loop() {
CAN_FRAME frame;
if (CAN0.available() && CAN0.read(frame)) {
Serial.printf("ID: %08X DLC: %d Data: %02X %02X %02X %02X\n",
frame.id, frame.length,
frame.data.byte[0], frame.data.byte[1],
frame.data.byte[2], frame.data.byte[3]);
}
// Transmit
CAN_FRAME tx;
tx.id = 0x123;
tx.extended = false;
tx.length = 4;
tx.data.byte[0] = 0xDE;
tx.data.byte[1] = 0xAD;
tx.data.byte[2] = 0xBE;
tx.data.byte[3] = 0xEF;
CAN0.sendFrame(tx);
}// Accept only standard IDs 0x100–0x1FF (mask = 0x700, id = 0x100)
CAN0.watchFor(0x100, 0x700);
// Accept a single extended ID
CAN0.watchForRange(0x18DA00F1, 0x18DA00F1);
// Per-filter callback — fires when a matching frame arrives
CAN0.setCallback(0, [](CAN_FRAME *frame) {
Serial.printf("Filter 0 hit: ID %08X\n", frame->id);
});CAN0.setListenOnlyMode(true);
CAN0.begin(500000);
CAN0.watchFor();// Probes common speeds from 1 Mbps down to 20 kbps.
// Returns the detected speed, or 0 if none found.
uint32_t detected = CAN0.beginAutoSpeed();
Serial.printf("Detected: %lu bps\n", detected);Both controllers use the same API. CAN1 is available automatically when
SOC_TWAI_CONTROLLER_NUM >= 2.
#include <esp32_can.h>
void setup() {
// CAN0 — controller 0
CAN0.setCANPins(GPIO_NUM_0, GPIO_NUM_1);
CAN0.begin(500000);
CAN0.watchFor();
// CAN1 — controller 1
CAN1.setCANPins(GPIO_NUM_2, GPIO_NUM_3);
CAN1.begin(500000);
CAN1.watchFor();
}
void loop() {
CAN_FRAME frame;
// Forward CAN0 → CAN1
if (CAN0.available() && CAN0.read(frame)) {
CAN1.sendFrame(frame);
}
// Forward CAN1 → CAN0
if (CAN1.available() && CAN1.read(frame)) {
CAN0.sendFrame(frame);
}
}CAN2 is available when SOC_TWAI_CONTROLLER_NUM >= 3. Usage is identical to CAN0/CAN1.
#include <esp32_can.h>
void setup() {
CAN0.setCANPins(GPIO_NUM_0, GPIO_NUM_1);
CAN0.begin(500000);
CAN0.watchFor();
CAN1.setCANPins(GPIO_NUM_4, GPIO_NUM_5);
CAN1.begin(500000);
CAN1.watchFor();
CAN2.setCANPins(GPIO_NUM_8, GPIO_NUM_9);
CAN2.begin(500000);
CAN2.watchFor();
}Note: The ESP32-P4 does not support CAN-FD. Its three TWAI controllers are classic CAN 2.0B only. FD frames on the bus will be interpreted as errors. Each controller requires its own external transceiver (e.g. TJA1051).
CAN-FD is only available on chips where SOC_TWAI_SUPPORT_FD is defined.
The FD data-phase baud rate must be set before calling begin().
#include <esp32_can.h>
void setup() {
// Set FD data phase BEFORE begin()
CAN0.setCANPins(GPIO_NUM_4, GPIO_NUM_5);
CAN0.setDataBaudrate(2000000); // 2 Mbps data phase
CAN0.begin(500000); // 500 kbps arbitration phase
CAN0.watchFor();
CAN1.setCANPins(GPIO_NUM_6, GPIO_NUM_7);
CAN1.setDataBaudrate(2000000);
CAN1.begin(500000);
CAN1.watchFor();
}
void loop() {
// Receive FD frame
CAN_FRAME_FD rxFD;
if (CAN0.readFD(rxFD) && rxFD.fdMode) {
Serial.printf("FD frame ID: %08X DLC: %d\n", rxFD.id, rxFD.length);
for (int i = 0; i < rxFD.length; i++) {
Serial.printf(" %02X", rxFD.data.uint8[i]);
}
Serial.println();
}
// Transmit FD frame
CAN_FRAME_FD txFD;
txFD.id = 0x123;
txFD.extended = false;
txFD.fdMode = 1; // mark as FD frame
txFD.length = 16; // up to 64 bytes
for (int i = 0; i < 16; i++) txFD.data.uint8[i] = i;
CAN0.sendFrameFD(txFD);
// Classic frames still work normally on a CAN-FD bus
CAN_FRAME classic;
classic.id = 0x456;
classic.length = 4;
classic.data.byte[0] = 0xAA;
CAN0.sendFrame(classic);
}Mixed-bus note: Classic CAN nodes and CAN-FD nodes can coexist on the same bus. Classic nodes will correctly acknowledge FD frames during the arbitration phase but will not participate in the higher-speed data phase.
| Method | Description |
|---|---|
setCANPins(rx, tx) |
Set GPIO pins — call before begin() |
setDataBaudrate(bps) |
Enable CAN-FD data phase — call before begin() (FD chips only) |
begin(bps) |
Start the controller at the given bit rate |
beginAutoSpeed() |
Auto-detect bus speed; returns detected bps or 0 |
set_baudrate(bps) |
Change speed on a running controller (triggers restart) |
setListenOnlyMode(bool) |
Receive-only, no TX or ACK |
setNoACKMode(bool) |
Transmit without requiring acknowledgement (self-test) |
setRXBufferSize(n) |
Set RX queue depth before begin() |
| Method | Description |
|---|---|
watchFor() |
Accept all frames (clears filter table) |
watchFor(id, mask) |
Accept frames matching (id & mask) == (rxId & mask) |
watchForRange(low, high) |
Accept IDs in the given inclusive range |
setCallback(filter, fn) |
Register a callback for a specific filter slot |
setGeneralCallback(fn) |
Register a callback for all accepted frames |
| Method | Description |
|---|---|
available() |
Number of frames waiting in the RX queue |
read(frame) |
Read the oldest frame from the queue into frame |
readFD(frame) |
Read an FD frame (FD chips only) |
| Method | Description |
|---|---|
sendFrame(frame) |
Transmit a classic CAN frame; returns true on success |
sendFrameFD(frame) |
Transmit a CAN-FD frame (FD chips only) |
| Method | Description |
|---|---|
resetIfStale(stallMs) |
Call from loop() if you want explicit sketch-side stall recovery |
| Method | Description |
|---|---|
getControllerStats() |
Exposes the controller's hardware error counters straight from the IDF driver |
These are the same values the controller's internal error-confinement state machine uses to drive bus-off recovery — TEC and REC follow CAN 2.0B / ISO 11898 semantics (increment on errors, decrement on success; cross 128 → error-passive, cross 256 → bus-off).
#include <esp32_can.h>
void loop() {
CANControllerStats stats;
if (CAN0.getControllerStats(stats)) {
Serial.printf("TEC=%u REC=%u bus=%u rx_overrun=%u\n",
stats.txErrorCount,
stats.rxErrorCount,
stats.busErrorCount,
stats.rxOverrunCount);
}
delay(1000);
}Returns true on success; false if the controller hasn't been
initialised yet or the underlying IDF call fails. On failure the contents
of stats are undefined — always check the return value before reading.
| Field | Description |
|---|---|
txErrorCount |
TEC. Increments on TX errors, decrements on successful TX. Reaches 128 → error-passive; 256 → bus-off. |
rxErrorCount |
REC. Same scheme for RX errors. |
busErrorCount |
Cumulative bus errors observed since boot or last bus-off recovery. |
rxOverrunCount |
Frames discarded because the RX queue was full. Indicates either bus rate > what the driver can keep up with, or a starved RX task. |
All four fields are saturated to
uint16_t(max 65535). The V1/V2 drivers internally useuint32_t, but for the diagnostic use cases these counters are intended for, the upper bytes are noise — anything above a few hundred is already deep into pathological territory.
Note: The TIER_NEW (
esp_driver_twai, IDF 5.5+) driver does not expose the RX overrun counter — there's no public accessor as of IDF 5.5 even though the hardware counter still increments, sogetControllerStats()always returns 0 in that field rather than removing it from the struct for consistency across all three tiers. If a future IDF release adds the accessor, this field will be updated to return the true value.
- TEC and REC both 0, no bus errors growing: healthy bus, no faults observed.
- TEC climbing alone: TX side of the bus is unhealthy — bad termination on the local end, a node not acknowledging, or wiring fault.
- REC climbing alone: RX side noise — likely EMI, ground bounce, or another node sending malformed frames.
- Both climbing in lockstep: bus-wide problem — clock mismatch between nodes (wrong bit rate somewhere), severe noise, or shorted line.
rxOverrunCountnon-zero (V1/V2 only): RX task can't keep up. Either lower the bus rate, raise the RX queue size withsetRXBufferSize(), or check that your loop is not blocking.- Controller in
TWAI_ERROR_BUS_OFF(visible via state callbacks or the library's automatic recovery): TEC reached 256. The library's watchdog will recover automatically; check counters afterward to find the root cause.
Linker errors referencing twai_* functions — you are likely mixing IDF versions or
have a stale build cache. In Arduino IDE select Sketch → Clean Build Folder then rebuild.
Frames received but callbacks never fire — confirm you called watchFor() after begin(),
and that the filter ID/mask matches your traffic. The filter table uses exact-match logic:
(receivedId & mask) == (filterId & mask).
Bus-off recovery — the internal watchdog task polls every 200 ms and automatically restarts the controller after a bus-off event. No action needed in your sketch.
CAN-FD: setDataBaudrate() has no effect — it must be called before begin().
Calling it afterwards requires CAN0.disable(); CAN0.enable(); to take effect.
ESP32-P4 with three controllers — each controller needs its own transceiver chip and
its own pair of GPIO pins. GPIO assignment is entirely flexible via setCANPins(); there
are no fixed TWAI GPIO pins on the P4.
CANipulator_test_loopback
Verifies that both on-chip CAN controllers AND both external transceivers are working, and that they can communicate with each other over a shared physical bus by sending messages back and forth. Connect CAN-L to CAN-L, CAN-H to CAN-H. Ground is likely already common. A 120 Ω termination resistor between CAN-H and CAN-L at each end of the bus is recommended (two resistors total). Without termination, short test jumpers may still work, but a real bus needs them.
CANipulator_test_forwarder
This example simply forwards all traffic from CAN0 to CAN1 a vice-versa. Great for debugging which messages originate from a certain node.
CANipulator_C5_test_forwarder
Same as the previous, but with FD support.
Credits:
Collin80/esp32_can - Original esp32_can library
outlandnish/esp32_can - Majority of twai_v2 library updates
sans-ltd/esp32_can - Bugfixes
Implements a CAN driver for the built-in CAN hardware on an ESP32. The builtin CAN is called CAN0, and also CAN1 if there is a second CAN port on the ESP32 chip in use. This library requires the can_common library. That library is a common base that other libraries can be built off of to allow a more universal API for CAN.
The needed can_common library is found here: https://github.com/collin80/can_common Some examples use the SmartLeds library found here: https://github.com/RoboticsBrno/SmartLeds
This library and any associated hardware are intended solely for development, testing, educational, and off-road applications. Users are responsible for ensuring compliance with all applicable laws and regulations. The software and associated hardware are provided as-is and are not certified for use in road-going vehicles and should not be used to modify, control, or interfere with vehicle systems while operating on public roads.
/i/26064/products/2024-07-07T01:54:25.239Z-0001.jpg)