I learned the CAN bus by never touching one. Linux gives you a virtual CAN
interface one ip link add away, and the whole toolchain — SocketCAN plus
candump, cansend and friends — behaves exactly as it does on a car. That is how
I got from “CAN is the automotive thing” to reading frames, filtering them and
understanding arbitration, on a laptop with no transceiver and no ECU.
Every command and byte of output below came out of a throwaway container on my dev
box (ubuntu:24.04, --cap-add=NET_ADMIN, no --privileged). can-utils and
iproute2 were not installed; I installed them inside the container, and they are
gone now that the container is. The one place where I used a model instead of a live
bus is labelled as such.
What CAN is: a bus with no addresses
CAN (Controller Area Network) is a broadcast, multi-master bus for vehicles and industrial control. Three properties of the specification matter more than the rest:
- No addresses. A frame has a message ID and up to eight bytes of payload, and every node on the bus receives it. There are no node IDs.
- Message IDs carry priority. IDs name the message, not the sender, and a lower ID means higher priority.
- Access is CSMA/CR. When two masters talk at once the contenders resolve it bitwise instead of destroying the frame. Nothing is lost and no retry timer is needed — the loser falls silent and retries after the current frame ends.
Physically it is a differential pair (CAN_H/CAN_L) carrying a dominant 0 and a recessive 1, which is why it survives the electrical environment of a car. Classic CAN runs at 500 kbit/s or 1 Mbit/s, and 120Ω terminators at both ends keep reflections down.
The frame: 11-bit and 29-bit IDs, DLC and data
There are two frame formats: CAN 2.0A (“standard”) uses an 11-bit ID, 0x000–0x7FF, and CAN 2.0B (“extended”) uses 29 bits. Both carry a DLC (data length code, 0–8) and up to eight bytes of payload, plus a remote-request variant that asks a node to send a given ID.
Bring the virtual interface up and send one:
$ ip link add dev vcan0 type vcan
$ ip link set up vcan0
$ ip -details link show vcan0
3: vcan0: <NOARP,UP,LOWER_UP> mtu 2060 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/can promiscuity 0 allmulti 0 minmtu 0 maxmtu 0
vcan addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535 tso_max_size 65536 tso_max_segs 65535 gro_max_size 65536
The link/can is the tell: this is a SocketCAN interface, and everything you’d do
to a real can0 works here. Now a standard frame, and an extended one:
$ cansend vcan0 123#DEADBEEF
$ cansend vcan0 12345678#DEADBEEF
$ candump vcan0
vcan0 12345678 [4] DE AD BE EF
vcan0 123 [4] DE AD BE EF
123#DEADBEEF is an 11-bit ID 0x123, DLC 4, payload DE AD BE EF.
12345678#... is a 29-bit ID. (candump right-aligns the ID, which is how you can
tell at a glance that one of them is extended.) A remote frame is a special case:
$ cansend vcan0 123#R
$ candump vcan0
vcan0 123 [0] remote request
Send a handful in an order that is deliberately not sorted, and candump -L — the
log-file format on stdout — gives you the same frames with timestamps and the raw
wire representation, which is what you paste into a bug report:
$ candump -L vcan0
(1791967959.294730) vcan0 456#01
(1791967959.295476) vcan0 123#02
(1791967959.296132) vcan0 7DF#03
(1791967959.296800) vcan0 000#04
Why the ID is not an address
Because every node receives every frame, an ID is a subject line, not a
destination. A body controller that cares about door switches listens for 0x1A0;
an engine controller that does not simply ignores it. This is what trips up people
coming from TCP/IP or Modbus: nothing routes your frame to a node, and the sender
does not know or care whether anyone is listening.
Filtering is where the bus stays cheap, and it happens in the controller (an
MCP2515 does it in hardware with masks and filters over two receive buffers), in the
kernel per socket, or in your process. The same receive filter as a few lines of C,
this one accepting only 0x123:
struct can_filter rfilter[1];
rfilter[0].can_id = 0x123;
rfilter[0].can_mask = CAN_SFF_MASK; /* all 11 bits must match */
setsockopt(s, SOL_CAN_RAW, CAN_RAW_FILTER, rfilter, sizeof(rfilter));
Frames 456, 123, 7DF, 456, 123 were sent while that program was blocked in
read(), and it woke on exactly one of them:
received can_id=0x123 can_dlc=3 data=C0 FF EE
candump has the same ability on the command line, which is the quick way to
narrow a busy bus to one message:
$ candump vcan0,123:7FF
vcan0 123 [1] AB
vcan0 123 [1] CD
123:7FF means “accept ID 0x123, mask the entire 11-bit ID field”. The 456 and
7DF frames that were sent at the same time never appear.
Arbitration: why a lower ID wins
CSMA/CR works like this. Everybody listens before and while transmitting (carrier sense). The ID field goes out most-significant bit first. A node that writes a 1 but reads back a 0 detects that someone else is writing the more important bit, stops transmitting, and waits for the next idle. The first bit at which two IDs differ decides.
Two senders on my vcan0, hammering 456#01 and 123#02 at the same instant,
look like this in the dump — and the counts afterwards are the giveaway:
$ grep -c '456#01' /tmp/arbitration.log
6591
$ grep -c '123#02' /tmp/arbitration.log
6597
$ head -4 /tmp/arbitration.log
(1791967965.300812) vcan0 456#01
(1791967965.300898) vcan0 123#02
(1791967965.301452) vcan0 456#01
(1791967965.301610) vcan0 123#02
And here is the honest part: vcan does not arbitrate. There is no physical bus,
no dominant and recessive levels, so there is nothing to resolve — the kernel hands
frames to the interface’s transmit queue in the order they arrive, which is why the
two IDs interleave roughly evenly instead of 0x123 starving 0x456. To see the
rule itself, I wrote the bus’s decision procedure down in sixty lines of C (a model,
not a capture):
$ ./arb 456 123
A id=0x456 10001010110
B id=0x123 00100100011
at bit 10: A sends 1, B sends 0 -> B drives dominant, A loses
loser goes silent and retries after the current frame ends
=> the frame now on the bus is id=0x123
$ ./arb 7DF 1A0
at bit 10: A sends 1, B sends 0 -> B drives dominant, A loses
=> the frame now on the bus is id=0x1A0
That is the whole of CAN’s quality of service: the lowest ID always gets through. A node that spams a low ID can starve everyone else — a real and common bus failure. High-rate messages get low IDs, and past roughly 60–70% bus load you get latency spikes rather than dropped frames. An ID is not a name; it is a ticket into the bus queue, so choose it deliberately.
vcan: the same bus with no wires
vcan is a virtual CAN bus in the kernel: frames you transmit are delivered to the
same interface’s receive queue immediately, and nowhere else. Nothing is lost,
acked, errored or arbitrated — and that is what makes it useful. You get the real
SocketCAN ABI, the real frame parsing, the real can-utils, and none of the
physics. My whole test rig was this:
$ docker run -d --name vcan-lab --cap-add=NET_ADMIN ubuntu:24.04 sleep 1200
$ docker exec vcan-lab apt-get install -y iproute2 can-utils
$ docker exec vcan-lab ip link add dev vcan0 type vcan
$ docker exec vcan-lab ip link set up vcan0
Gone the moment the container exits. It is the right environment for developing a
decoder, replaying a log from a field service call (canplayer), running a CI test
against a mock ECU, or teaching a workshop where the budget for transceivers is
zero.
The tools: candump, cansend, cansniffer and friends
can-utils is small enough to know completely. candump reads the bus (-L adds
timestamps, <id>,<id>:<mask> filters), cansend writes it, and cansniffer gives
you a live per-ID view — one line per message, sorted by ID, with frames-per-window
counters, which is how you find the ID you want on an unknown bus (I stripped the
terminal escape sequences so it survives pasting). cangen, canplayer and the
cangw gateway complete the set; the gateway forwards frames between interfaces,
which is how you stitch a test rig out of two vcan interfaces and a real adapter.
$ cansniffer vcan0
00001 | 123 | DE AD BE EF ....
00001 | 1A0 | 01 02 03 04 05 06 07 08 ........
00001 | 456 | CA FE ..
For how busy the bus is, canbusload estimates bits-on-the-wire against the nominal
bitrate — 27% of a hypothetical 500 kbit/s vcan0 while the sender loop was
running. The columns are received frames, used bits in total, and used bits for
payload; you must pass the bitrate, because a virtual interface does not have one.
$ canbusload vcan0@500000
vcan0@500000 1430 135850 45760 0 27%
The interface’s own counters are plain link statistics, identical in shape to
eth0:
$ ip -s link show vcan0
RX: bytes packets errors dropped missed mcast
32004 17795 0 0 0 0
TX: bytes packets errors dropped carrier collsns
32004 17795 0 0 0 0
CAN-FD: same socket, bigger payload
CAN-FD (Flexible Data rate) keeps the arbitration phase at the classic speed, runs the data phase faster, and raises the payload from 8 to 64 bytes. Remote frames are gone and the DLC now denotes length directly. For anything that speaks ISO-TP — UDS, OBD-II requests bigger than 7 bytes — one FD frame replaces a multi-frame burst, which matters more than the extra bitrate.
On Linux CAN-FD is the same SocketCAN stack; the interface is simply declared
FD-capable, and the vcan0 above is not:
$ cansend vcan0 123##1DEADBEEF
CAN interface is not CAN FD capable - sorry.
The check is the interface MTU: CAN-FD frames need CANFD_MTU (72) rather than
CAN_MTU (16). Set it on a down interface and the same frame goes out:
$ ip link set vcan0 down
$ ip link set vcan0 mtu 72
$ ip link set vcan0 up
$ cansend vcan0 123##1DEADBEEF
$ cansend vcan0 12345678##0DEADBEEFCAFEBABE1122334455667788
$ cansend vcan0 456#0102030405
$ candump vcan0
vcan0 123 [04] DE AD BE EF
vcan0 12345678 [16] DE AD BE EF CA FE BA BE 11 22 33 44 55 66 77 88
vcan0 456 [5] 01 02 03 04 05
A 16-byte payload is impossible in classic CAN, and the classic 3-hex-digit and extended 8-hex-digit IDs are unchanged: FD is a framing upgrade, not a new network. On real hardware, capability comes from the controller (an MCP2517 or MCP2518 rather than an MCP2515) and the driver’s bit timing, not from an MTU set by hand.
From vcan0 to a real can0
A real bus is three additions: a chip that speaks the protocol, a transceiver that drives the differential pair, and termination. After the hardware is wired, the Linux side is unremarkable:
$ ip link set can0 type can bitrate 500000
$ ip link set up can0
$ candump can0
can0 comes from a driver — mcp251x for an MCP2515 off SPI, peak_usb for a PCAN
dongle, a built-in controller on a board — and from that moment your code does not
know or care which one it talks to. ip -details link show can0 then shows the real
bitrate, sample point and controller mode, which vcan0 does not have.
For an ESP32 the cheap end of the spectrum already has the peripheral: the ESP32,
S3 and C3 include a TWAI controller (CAN 2.0B) in silicon, exposed in ESP-IDF as
twai_driver_install, twai_start and twai_message_t — wire a transceiver such as
an SN65HVD230 to two pins and you have a real node. An MCP2515 over SPI works too,
at the cost of extra pins, an IRQ line, and driver code where the TWAI needs none.
Same trade as on the EasyTier node: the protocol
logic is portable, the transport underneath decides how much of your budget the
hardware takes. And if you need to reverse whatever is already on the wire, the
method from the serial-protocol post applies
verbatim, only with candump where a logic analyzer channel used to be.
Two cautions. Put 120Ω at each end of the real bus and only there — two terminators per bus, not one per node. And do not “test” on a vehicle you do not own; everything above ran on a virtual interface inside a container that existed for twenty minutes and is now gone.