CAN Bus Deep Dive

Differential Bus · Frame Anatomy · SocketCAN · UDS · ECU Dumping
can-utils · SavvyCAN · caringcaribou · ESP32 TWAI · MCP2515 // bench & vcan only // OBD-II to firmware

The Two-Wire Bus

Bench and vcan only Everything on this page assumes a bench setup, a parked vehicle, or ICSim/vcan for practice (see Practice). Injecting onto a live, moving vehicle's bus is not a lab activity -- treat it as out of scope for this reference.

CAN is a 2-wire differential bus: CAN-H and CAN-L, 120ohm termination at each end. There is no source/destination addressing at the frame level and no built-in authentication -- any node physically on the bus can send any ID it wants. That absence of addressing and auth is the entire reason this field exists: possession of the bus is authorization.

OBD-II connector: pins 6, 14, 4/5, 16

On the OBD-II connector (16-pin, SAE J1962, in-car): pin 6 = CAN-H, pin 14 = CAN-L, pins 4/5 = ground, pin 16 = battery+ (permanently powered, not switched by ignition). Most passenger cars run high-speed CAN (ISO 11898-2) at 500 kbps on the OBD-II port; some run a second bus (body/comfort) at 125 kbps.

   OBD-II 16-pin connector (SAE J1962), pins as viewed on the connector face

    +----+----+----+----+----+----+----+----+
    |  1 |  2 |  3 |  4 |  5 |  6 |  7 |  8 |
    |mfr |J850|mfr |chas|sig |CANH|K-ln|mfr |
    +----+----+----+----+----+----+----+----+
    |  9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
    |mfr |J850|mfr |mfr |mfr |CANL|L-ln|+12V|
    +----+----+----+----+----+----+----+----+

      pin 4/5  chassis / signal ground
      pin 6/14 CAN-H / CAN-L differential pair (ISO 15765-4 / J2284), 500 kbps
      pin 16   battery + (~12V), permanently powered

    both ends of the in-vehicle bus already carry 120ohm termination -- a flying-lead
    tap elsewhere on the harness needs its own termination or reflections corrupt frames

Arbitration and payload

A classic CAN frame carries an 11-bit (standard) or 29-bit (extended) arbitration ID plus up to 8 data bytes. Lower ID wins arbitration -- it has priority, and it wins with zero collision or retransmission delay. CAN-FD extends the payload to 64 bytes and adds a bitrate switch; newer vehicles increasingly use it. The bit-level breakdown of a frame, and exactly how arbitration works, is in Frame Anatomy.

How to read this Fresh hardware: Interfaces + ESP32 / MCP2515. Getting a bus up: SocketCAN. Staring at traffic: can-utils + Reverse Eng. Talking to a specific ECU: UDS / OBD-II. Pulling firmware: ECU Dumping. Each tab is independent.

Frame Anatomy

The fundamentals in Intro said "11-bit ID plus up to 8 data bytes." Here's what that looks like at the bit level, per CAN 2.0 / ISO 11898-1.

Standard data frame, field by field

CAN 2.0A data frame, 11-bit standard ID (bit widths, not bytes)

  SOF    ARBITRATION          CONTROL              DATA          CRC          ACK        EOF
  1 bit  ID[10:0] + RTR      IDE + r0 + DLC[3:0]  0-8 bytes    15 bits     slot+dlm  7 bits
         11+1 = 12 bits     1+1+4 = 6 bits       0-64 bits    +1 dlm bit 1+1 bits

  SOF   dominant (0), every node syncs to it -- this is what "start of frame" means on the wire
  RTR   0 = data frame, 1 = remote (request) frame -- remote frames carry no data
  IDE   0 = standard 11-bit ID, 1 = extended -- next field becomes an 18-bit ID extension
  r0    reserved bit, sent dominant
  DLC   4-bit data length code, 0-8 in classic CAN (9-15 reserved / meaningful only in CAN-FD)
  CRC   15-bit checksum + 1 recessive delimiter bit
  ACK   transmitter sends recessive; any receiver that got a valid CRC pulls it dominant
  EOF   7 recessive bits, marks the end -- no other field is ever 7 bits of recessive

Standard vs extended vs CAN-FD

Property Standard (CAN 2.0A) Extended (CAN 2.0B) CAN-FD
ID width11 bits29 bits (11-bit base + 18-bit extension)11 or 29 bits
Max payload8 bytes8 bytes64 bytes
IDE bit010 or 1
Bit ratesingle rate throughoutsingle rate throughoutBRS bit switches to a faster rate for control+data+CRC
Distinguishing bit-- -- FDF (formerly r0) set = FD frame
CRC15-bit15-bit17-bit (payload ≤16B) or 21-bit (>16B)

Arbitration: why the lower ID always wins

CAN uses non-destructive bitwise arbitration. A 0 bit is dominant, a 1 bit is recessive -- if any node drives dominant while another drives recessive, the bus physically reads dominant (a wired-AND on the differential pair). During the arbitration field, every transmitting node sends its ID bit by bit and simultaneously reads back what's actually on the wire. The instant a node sends recessive but reads dominant, it knows a node with a numerically lower ID is also transmitting, and it immediately stops driving and becomes a receiver. The node whose ID never mismatches wins outright -- no collision, no backoff, no retransmission delay. This is what makes CAN suitable for real-time control traffic instead of just a best-effort network.

Priority is a side effect of addressing, not a separate field There's no dedicated priority byte -- whoever designs the DBC picks low IDs for time-critical frames (engine/brake torque, airbag) and high IDs for chatty, non-urgent ones (infotainment, climate). This is why 0x000-range IDs are almost always safety-critical on a real vehicle bus, and it's worth keeping in mind before fuzzing (see Practice).

Interfaces

What actually gets a CAN bus onto a Linux box as a SocketCAN interface, from cheapest to most polished.

Interface What it is SocketCAN story
MCP2515 + TJA1050/TJA1051SPI CAN controller + transceiver, cheap breakout boardsNative kernel driver + device-tree overlay (Pi hat) or bit-banged SPI (ESP32/Arduino); shows up as can0 once the overlay probes
PCAN-USB (Peak-System)Commercial USB-CAN adapter, ~$200Native SocketCAN interface with no extra driver -- the "just works" option
CANtact / candleLight donglesOpen hardware USB-CAN donglesgs_usb driver built into mainline Linux, shows up as can0 directly, no slcand step needed. Cheap clones exist widely
MCP2515+CH340 slcan boardsCheap USB-serial CAN boardSpeaks the slcan (LAWICEL) serial protocol, needs slcand to bridge into SocketCAN
Macchina M2Open source dual/quad CAN board, OBD-II connector form factorPopular in the car-hacking community for in-vehicle use

The MCP2515 is the default first interface for a reason: it's cheap, well documented, and runs directly off an ESP32 or Raspberry Pi SPI bus with no USB layer in between. The register-level detail of talking to it -- and the ESP32's own native CAN controller -- is in ESP32 / MCP2515.

ESP32 TWAI & MCP2515

Two ways to get CAN frames in and out of a microcontroller: the ESP32's built-in TWAI controller (TWAI = Two-Wire Automotive Interface, Espressif's name for their CAN 2.0 implementation), or an MCP2515 driven over SPI from any MCU. Both need an external transceiver -- neither chip drives the differential pair directly.

The ESP32 has the controller, not the PHY TWAI implements the CAN protocol logic, but the SoC pins are single-ended logic level. You still need an external transceiver -- SN65HVD230 (3.3V) or TJA1050/TJA1051 (5V, needs level shifting) -- between the ESP32's TX/RX pins and the actual CAN-H/CAN-L wires.

ESP32 native TWAI (ESP-IDF)

The twai driver in ESP-IDF wraps the peripheral: general config picks GPIO pins and mode, timing config picks bitrate, filter config picks which IDs to accept.

esp-idf twai init + transmit
#include "driver/twai.h"

#define TX_GPIO GPIO_NUM_5
#define RX_GPIO GPIO_NUM_4

void twai_init(void) {
    twai_general_config_t g_config =
        TWAI_GENERAL_CONFIG_DEFAULT(TX_GPIO, RX_GPIO, TWAI_MODE_NORMAL);
    twai_timing_config_t t_config = TWAI_TIMING_CONFIG_500KBITS();
    twai_filter_config_t f_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();

    ESP_ERROR_CHECK(twai_driver_install(&g_config, &t_config, &f_config));
    ESP_ERROR_CHECK(twai_start());
}

void twai_send_frame(uint32_t id, uint8_t *data, uint8_t len) {
    twai_message_t msg = {0};
    msg.identifier = id;
    msg.data_length_code = len;
    msg.flags = TWAI_MSG_FLAG_NONE;   // standard 11-bit ID, data frame
    memcpy(msg.data, data, len);

    twai_transmit(&msg, pdMS_TO_TICKS(100));
}
esp-idf twai receive/dump loop
void twai_rx_loop(void) {
    twai_message_t msg;
    for (;;) {
        if (twai_receive(&msg, pdMS_TO_TICKS(1000)) == ESP_OK) {
            printf("id=0x%03lx dlc=%d data=", msg.identifier, msg.data_length_code);
            for (int i = 0; i < msg.data_length_code; i++)
                printf("%02x ", msg.data[i]);
            printf("\n");
        }
    }
}

MCP2515 over SPI, register level

No HAL, just the Microchip datasheet: SPI command bytes, then key registers for mode control, bit timing, and the TX/RX buffers.

mcp2515 spi opcodes + registers (microchip datasheet)// SPI instruction bytes
#define MCP_RESET        0xC0
#define MCP_READ         0x03
#define MCP_WRITE        0x02
#define MCP_READ_STATUS  0xA0
#define MCP_RX_STATUS    0xB0
#define MCP_BIT_MODIFY   0x05
#define MCP_READ_RXB0    0x90
#define MCP_LOAD_TXB0    0x40
#define MCP_RTS(n)       (0x80 | (1 << (n)))   // n = tx buffer 0-2

// key registers
#define MCP_CANCTRL   0x0F
#define MCP_CANSTAT   0x0E
#define MCP_CNF1      0x2A
#define MCP_CNF2      0x29
#define MCP_CNF3      0x28
#define MCP_CANINTE   0x2B
#define MCP_CANINTF   0x2C

#define MCP_TXB0CTRL  0x30
#define MCP_TXB0SIDH  0x31
#define MCP_TXB0SIDL  0x32
#define MCP_TXB0DLC   0x35
#define MCP_TXB0D0    0x36

#define MCP_RXB0CTRL  0x60
#define MCP_RXB0SIDH  0x61

// CANCTRL[7:5] mode bits (REQOP)
#define MODE_CONFIG      0x80
#define MODE_NORMAL      0x00
#define MODE_LISTENONLY  0x60
#define MODE_LOOPBACK    0x40
#define MODE_SLEEP       0x20
register read/write helpers over spi_device_transmit
static void mcp_write_reg(spi_device_handle_t spi, uint8_t reg, uint8_t val) {
    uint8_t tx[3] = { MCP_WRITE, reg, val };
    spi_transaction_t t = {
        .length = 8 * sizeof(tx),
        .tx_buffer = tx,
    };
    spi_device_transmit(spi, &t);
}

static uint8_t mcp_read_reg(spi_device_handle_t spi, uint8_t reg) {
    uint8_t tx[3] = { MCP_READ, reg, 0x00 };
    uint8_t rx[3] = {0};
    spi_transaction_t t = {
        .length = 8 * sizeof(tx),
        .tx_buffer = tx,
        .rx_buffer = rx,
    };
    spi_device_transmit(spi, &t);
    return rx[2];
}

static void mcp_reset(spi_device_handle_t spi) {
    uint8_t cmd = MCP_RESET;
    spi_transaction_t t = { .length = 8, .tx_buffer = &cmd };
    spi_device_transmit(spi, &t);
}
config mode + bit timing for 500kbps @ 8mhz xtal
static void mcp_set_bitrate_500k_8mhz(spi_device_handle_t spi) {
    // BIT MODIFY CANCTRL: mask REQOP[7:5], set config mode
    uint8_t tx[4] = { MCP_BIT_MODIFY, MCP_CANCTRL, 0xE0, MODE_CONFIG };
    spi_transaction_t t = { .length = 8 * sizeof(tx), .tx_buffer = tx };
    spi_device_transmit(spi, &t);
    while ((mcp_read_reg(spi, MCP_CANSTAT) & 0xE0) != MODE_CONFIG) ;

    // 8MHz crystal, 500kbps -- known-good CNF1-3 values for this combo
    mcp_write_reg(spi, MCP_CNF1, 0x00);
    mcp_write_reg(spi, MCP_CNF2, 0x90);
    mcp_write_reg(spi, MCP_CNF3, 0x02);

    // back to normal mode
    uint8_t tx2[4] = { MCP_BIT_MODIFY, MCP_CANCTRL, 0xE0, MODE_NORMAL };
    spi_transaction_t t2 = { .length = 8 * sizeof(tx2), .tx_buffer = tx2 };
    spi_device_transmit(spi, &t2);
}
load txb0 + request-to-send
void mcp_send_frame(spi_device_handle_t spi, uint16_t id, uint8_t *data, uint8_t len) {
    mcp_write_reg(spi, MCP_TXB0SIDH, id >> 3);
    mcp_write_reg(spi, MCP_TXB0SIDL, (id & 0x07) << 5);
    mcp_write_reg(spi, MCP_TXB0DLC, len & 0x0F);
    for (uint8_t i = 0; i < len; i++)
        mcp_write_reg(spi, MCP_TXB0D0 + i, data[i]);

    // request-to-send on TXB0 -- opcode 0x81 = RTS | (1 << 0)
    uint8_t rts = MCP_RTS(0);
    spi_transaction_t t = { .length = 8, .tx_buffer = &rts };
    spi_device_transmit(spi, &t);
}
Bit timing has to match the crystal CNF1/CNF2/CNF3 encode the sync/propagation/phase segments relative to the MCP2515's oscillator frequency. Get them wrong for your board's crystal (8 MHz vs the also-common 16 MHz) and you don't get a clean error -- you get bus-off, silently mangled frames, or a controller that never leaves config mode. Always match CNF1-3 to the crystal actually on the board.

SocketCAN

SocketCAN is a Linux kernel subsystem, not a library -- a CAN interface behaves like a network interface (ip link, ifconfig) and frames move through a raw socket.

Virtual bus for testing without hardware

vcan setup
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0

Real interface, native SocketCAN device

MCP2515 hat, PCAN-USB, CANtact/gs_usb dongle -- anything that shows up as can0 directly.

bring up can0
sudo ip link set can0 type can bitrate 500000
sudo ip link set can0 up
# undo:
sudo ip link set can0 down

MCP2515 on a Raspberry Pi SPI bus

Needs a device-tree overlay in /boot/config.txt (or /boot/firmware/config.txt on newer Raspberry Pi OS):

/boot/config.txt
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25
dtoverlay=spi-bcm2835-overlay

Then dmesg | grep -i mcp2515 to confirm probe, and bring can0 up as above.

Serial/slcan adapters

LAWICEL protocol -- most cheap USB-CAN dongles speak this over a serial port.

slcand bridge
sudo slcand -o -c -s6 /dev/ttyUSB0 can0   # -s6 = 500kbit, see slcand -h for the -sN table
sudo ip link set can0 up

Raw AF_CAN socket, no library

This is the library-free way to talk SocketCAN, and mirrors exactly what candump does under the hood: open a PF_CAN/SOCK_RAW socket, resolve the interface index, bind, then read/write fixed-size struct can_frames.

raw AF_CAN, linux <linux/can.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <linux/can.h>
#include <linux/can/raw.h>

int main(void) {
    int s = socket(PF_CAN, SOCK_RAW, CAN_RAW);

    struct ifreq ifr;
    strcpy(ifr.ifr_name, "can0");
    ioctl(s, SIOCGIFINDEX, &ifr);

    struct sockaddr_can addr = {0};
    addr.can_family = AF_CAN;
    addr.can_ifindex = ifr.ifr_ifindex;
    bind(s, (struct sockaddr *)&addr, sizeof(addr));

    struct can_frame frame;
    frame.can_id = 0x123;
    frame.can_dlc = 4;
    memcpy(frame.data, "\xDE\xAD\xBE\xEF", 4);
    write(s, &frame, sizeof(frame));

    for (;;) {
        int n = read(s, &frame, sizeof(frame));
        if (n != sizeof(frame)) continue;
        printf("id=0x%03X dlc=%d\n", frame.can_id, frame.can_dlc);
    }
}

can-utils

Install (Debian/Ubuntu): sudo apt install can-utils. Source: linux-can/can-utils on GitHub if you need a newer build -- the isotp tools especially lag in distro packages.

CommandWhat it does
candump can0Live dump, all IDs
candump can0,123:7FFFilter: ID 0x123 exact match (mask 7FF)
candump -l can0Log to candump-<timestamp>.log
candump -L can0Log in ASC-ish long format
cansend can0 123#DEADBEEFSend one frame, ID 0x123, data DE AD BE EF
cangen vcan0 -g 100 -I 1A0 -L 8 -D iGenerate frames every 100ms, ID 1A0, 8 bytes, incrementing data
cansniffer can0Live view, highlights changing bytes -- best RE starting point
canplayer -I candump.logReplay a captured log back onto the bus
canbusload can0@500000Bus utilization %, needs the bitrate for the math
cangw -A -s can0 -d can1 -eGateway/forward frames between two CAN interfaces
log2asc / asc2logConvert between candump log format and Vector ASC
cansniffer is the fastest RE starting point Put the bus in cansniffer, wiggle one thing in the car (indicator, window, throttle), and watch which bytes flip. It's the fastest way to answer "what CAN ID changes when I press this button" -- faster than diffing candump logs by hand for a first pass.

Reverse Engineering

No magic here -- reverse engineering unknown CAN IDs is differential analysis.

  1. Capture a baseline candump -l can0 with nothing happening.
  2. Capture again while triggering exactly one action turn signal on, RPM rising, brake pedal -- one variable at a time.
  3. Diff the two logs or just watch cansniffer live, to isolate the ID/byte that moved.
  4. Narrow further by bisecting does the value scale with RPM (a linear signal), or is it a bitfield (one bit per door, e.g.)?

Tools beyond raw candump/cansniffer

ToolWhat it's for
SavvyCANCross-platform Qt GUI -- graphing, bitfield/signal discovery, comparison between captures, DBC import/export. The standard GUI tool for this.
cabanacomma.ai, part of the openpilot tools repo -- browser-based CAN visualizer/DBC editor, good for correlating signals against a synced video if you have one.
caringcaribouPython framework with dump, send, and diagnostic (UDS/XCP) modules -- useful for scripted, repeatable RE rather than clicking through a GUI.

DBC Signals

A .dbc file maps raw CAN IDs/bytes to named, scaled signals (e.g. EngineRPM). Once you've reverse engineered a signal, write it into a DBC so you -- and tooling -- can decode it by name going forward.

cantools cli
pip install cantools
cantools decode my.dbc < candump.log      # decode a candump-format log using a DBC
cantools dump my.dbc                       # print all messages/signals defined
cantools python api
import cantools
db = cantools.database.load_file('my.dbc')
msg = db.get_message_by_frame_id(0x123)
print(msg.decode(bytes.fromhex('deadbeefdeadbeef')))

For real-world reference DBCs to cross-check IDs against (many common signals are similar across a manufacturer's lineup): commaai/opendbc has a large collection of production DBC files pulled from openpilot-supported vehicles.

Endianness and scaling bite constantly When reverse engineering by hand, a value that looks nonsensical is often just byte-order (Motorola/big-endian vs Intel/little-endian bit layout in the DBC) or a wrong scale/offset, not a wrong signal. Before concluding an ID is unrelated, try the other endianness and a couple of obvious scale factors first.

Practice & Fuzzing

Practicing without a car: ICSim

zombieCraig/ICSim on GitHub -- a fake instrument cluster + control panel that talks over a vcan interface. The standard way to practice replay/fuzzing without touching a real vehicle.

ICSim setup
git clone https://github.com/zombieCraig/ICSim
cd ICSim && make
./icsim vcan0        # dashboard window
./controls vcan0      # keyboard-driven control panel that sends CAN frames

Fuzzing

cangen fuzz
cangen vcan0 -g 50 -I r -L r -D r          # random ID, random length, random data, every 50ms
cangen vcan0 -I 220 -D r -L 8 -g 20        # fixed ID, random payload -- fuzz one message's data

For anything more structured than can-utils gives you (mutation seeded from a real capture, ID-range sweeps, replay-then-fuzz), caringcaribou's dump/send modules or a short custom python-can script are the next step up.

ISO-TP · UDS · OBD-II

ISO-TP (ISO 15765-2)

Segments payloads >8 bytes over CAN -- this is the transport UDS diagnostics rides on. Kernel module is mainline since Linux 5.10; on older kernels build can-isotp from hartkopp/can-isotp.

isotp tools
sudo modprobe can-isotp
isotpsend can0 -s 7e0 -d 7e8      # send, src/dst arbitration IDs
isotprecv can0 -s 7e8 -d 7e0
isotpdump can0 -s 7e0 -d 7e8      # sniff a specific ISO-TP conversation

UDS (ISO 14229) service IDs

Reach for a library rather than hand-rolling frames: python-udsoncan (pylessard/python-udsoncan) + python-can-isotp as the transport layer, or caringcaribou's uds module for quick discovery (cc.py uds discovery can0 sweeps arbitration IDs looking for a diagnostic responder).

SIDService
0x10DiagnosticSessionControl
0x27SecurityAccess (seed/key)
0x22ReadDataByIdentifier
0x2EWriteDataByIdentifier
0x31RoutineControl
0x23ReadMemoryByAddress
0x3DWriteMemoryByAddress
0x34RequestDownload
0x36TransferData
0x37RequestTransferExit

OBD-II

Standard OBD-II PIDs (mode 01) are a tiny, standardized subset of what's actually on the bus -- most interesting signals are manufacturer-specific and require reverse engineering or a factory DBC/UDS spec, not OBD-II.

PIDSignal
0x0CEngine RPM
0x0DVehicle speed
0x05Coolant temperature
python-obd
pip install obd
python-obd query
import obd
conn = obd.OBD("/dev/ttyUSB0")     # auto-detects an ELM327-compatible adapter
print(conn.query(obd.commands.RPM))
ELM327 clones vary wildly If python-obd can't get a clean response, talk to the adapter directly over serial first (ATZ reset, ATE0 echo off, ATSP0 auto-protocol) to rule out the dongle before blaming the code.

ECU Firmware Dumping

Authorized, bench work only Everything below assumes a board on your bench that you own or have explicit authorization to work on -- pulled from a vehicle or a spare part, not probed in-situ on a running car.

Common ECU MCU families

FamilyWhere it shows upDebug interface
Infineon TriCoreTC1766, TC1796, TC2xx -- very common in Bosch/Continental/Delphi ECUsJTAG/DAP historically needs Lauterbach or PLS commercial tools; OpenOCD TriCore support exists but is limited/community-maintained -- check current state before assuming it'll just work
Freescale/NXP/ST MPC55xx / SPC56xPowerPC e200z -- common in older-generation Bosch/Delphi ECUsBDM (background debug mode) or JTAG; P&E Micro Multilink and USBDM are the usual affordable options
ARM Cortex-M (STM32 etc.)Increasingly common in simpler modules (BCM, gateways, some newer ECUs)Standard SWD -- OpenOCD + ST-Link/J-Link/Black Magic Probe just works
Renesas RH850Shows up in newer Denso/Hitachi ECUsVendor debug tooling; check current OpenOCD/probe support

Workflow when you have a board and no documentation

  1. Find the debug pins if unpopulated header/test points exist, a JTAGulator (Grand Idea Studio) brute-forces JTAG and UART pinouts blind -- much faster than probing with a multimeter. A logic analyzer (Saleae, or a Bus Pirate) on candidate pins while the board boots is the fallback.
  2. Try UART first many ECU bootloaders/debug consoles are plain 3.3V or 5V TTL serial. Common baud rates to sweep: 9600, 19200, 38400, 115200. A boot-time console often leaks memory layout, bootloader version, or even a shell -- worth checking before reaching for JTAG.
  3. JTAG/SWD dump once pins and voltage are confirmed, with the debugger matched to the MCU family above.
  4. UDS bootloader route if the ECU is reachable over CAN and security access isn't locked down: $10 diagnostic session, $27 SecurityAccess (seed/key -- may require knowing the OEM's algorithm), then $34/$36/$37 (RequestDownload/TransferData/RequestTransferExit) or $23 (ReadMemoryByAddress) to pull flash contents over the bus with no physical access to the board at all. caringcaribou or a custom python-udsoncan script.
  5. Chip-off, last resort if the flash is a discrete external chip (some simpler modules), desolder and read directly with a CH341A or TL866 programmer. Doesn't work when flash is internal to a BGA MCU -- you're back to the debug interface for those.

Once you have a raw dump

first pass on a firmware image
binwalk -e firmware.bin        # carve out embedded filesystems/known signatures
strings -n 8 firmware.bin | less

Then Ghidra (native PowerPC/ARM support; TriCore needs a community processor module -- check what's current) or IDA for actual disassembly/RE.

Gotchas

Bitrate mismatch is silent-ish Wrong bitrate on a real bus doesn't throw a clean error -- you get garbage/no frames or bus-off errors. Check error counters with ip -details -statistics link show can0. Confirm bitrate (usually 500k on the OBD-II port) before assuming the interface or wiring is bad.
Termination A vcan bus needs none of this; a real bus with a flying-lead tap (rather than the OBD-II connector, which already has both ends terminated in-vehicle) can behave badly without correct 120ohm termination at both physical ends.
DBC signal endianness/scaling Bites constantly when reverse engineering by hand -- a value that looks nonsensical is often just byte-order or a wrong scale/offset, not a wrong signal. See DBC Files.
Live-vehicle injection is out of scope Injecting onto a live, moving vehicle's bus is not a lab activity. Everything on this page assumes a bench setup, a parked vehicle, or ICSim/vcan.

Resources

ResourceWhatWhere
opengarages.orgCraig Smith's site; hosts the free Car Hacker's Handbook PDF, still the best single starting reference for this whole fieldopengarages.org
DEF CON Car Hacking VillageYearly talks/CTF, most content ends up on YouTube afterwardcar-hacking-village.com
opendbcLarge real-world DBC file collectiongithub.com/commaai/opendbc
ICSimPractice rig -- fake instrument cluster over vcangithub.com/zombieCraig/ICSim
SavvyCANGUI RE toolgithub.com/collin80/SavvyCAN
can-utilsUpstream source for everything in the can-utils sectiongithub.com/linux-can/can-utils
caringcaribouUDS/XCP diagnostic frameworkgithub.com/CaringCaribou/caringcaribou
python-udsoncanUDS library stackgithub.com/pylessard/python-udsoncan
python-can-isotpISO-TP transport layer for udsoncangithub.com/pylessard/python-can-isotp

GitHub topic/search awesome-vehicle-security and awesome-car-hacking for continuously updated tool lists -- worth a periodic re-check, this space moves fast.

The meta-question to hold Every signal on the bus is just a byte that changed when you did something. If you can't tie a byte to an action, keep sniffing -- there's no field-level metadata to lean on, only correlation.
// frame · hardware · esp32 · socketcan · can-utils · re · dbc · diag · ecu · gotchas //