Embedded HandbookApps
All sections › XXV. Mini-projects
STM32F103, STM32G474 · level intermediate · 28 min read
Open in the interactive handbook

198е. Mini-project: a CAN sniffer on STM32 — the SLCAN protocol over USB CDC

Section 19 took bxCAN apart from the inside, 19а covered FDCAN, 19б the CANopen protocol on top of it. A finished instrument for the bus was not there. It appears here: a sniffer that listens to the bus and hands every frame to the computer as a line of text. The language of those lines is SLCAN (also called the LAWICEL protocol): the commands S6, O, t1238… are understood by python-can and other CAN programs, so you do not need to write your own program on the computer side.

The instrument is built in two variants. On the BluePill (F103) it uses bxCAN and USART1 through a USB–UART adapter: this chip's own USB cannot live together with CAN. On the G474 it uses FDCAN2 and USB CDC from section 20.7. The protocol core is one and the same; it is also built on the computer and checked against a model and against python-can.

References: RM0008 (the bxCAN chapter: silent mode, filters, receive FIFO, figure 234 with the frame length; the memory shared by USB and CAN) and RM0440 (the FDCAN chapter: message RAM, bus monitoring mode) from the Keil packs, the datasheet DS12288 (the FDCAN2 and BOOT0 pins of the G474). The format was not checked against a formal description of SLCAN: the lines were checked against the slcan.py module of python-can 4.6.1 (the Zephyr environment). The frame length and bit stuffing come from the model in 19б.

Terms used in the documentation

Term Meaning
silent mode (SILM) the listen-only mode of bxCAN
bus monitoring mode (MON) the same mode in FDCAN
identifier mask mode a filter in mask mode: only the mask bits are compared
receive FIFO (FMP0, RFOM0, FOVR0) receive queue: how many frames, release a frame, overrun
bus idle: 11 consecutive recessive bits the bus is free: 11 recessive bits in a row
shared USB/CAN SRAM the buffer memory shared by USB and CAN in the F103
message RAM the FDCAN message memory
SLCAN, LAWICEL protocol a text protocol of a CAN adapter over a serial port

198е.1 What the instrument does and what it is built from

The sniffer connects to the bus through a transceiver and stays silent: it does not acknowledge frames, does not send error frames, transmits nothing at all. Each frame it receives goes into a ring, is turned into a line and handed to the computer. Commands from the computer set the bit rate and open the channel.

The path of a frame in the sniffer: the CAN bus and the transceiver, F103 bxCAN in listen-only mode with an accept-all filter, the core ring of 128 frames, the main loop builds an SLCAN line, USART1 at 921600 baud and a USB–UART adapter to python-can; at the bottom, the data rate at full load and a note on the memory shared by USB and CAN in the F103

The BluePill variant:

Why not the F103's own USB is explained in 19.8, and RM0008 states it precisely: USB and CAN share a dedicated 512-byte memory at address 0x4000 6000 and cannot work at the same time. Hence the adapter. In the G474 the USB memory (0x4000 6000) and the FDCAN message RAM (0x4000 A400) are separate, so the second variant sends the same lines straight to USB CDC.


198е.2 The SLCAN protocol: lines and replies

All communication is ASCII lines, each ending with a CR character (\r). The reply "done" is CR, a refusal is BEL (\a, code 7). Our core understands these commands:

Command What it does Reply
S0…S8 bit rate 10, 20, 50, 100, 125, 250, 500, 750 kbit/s, 1 Mbit/s CR; on an open channel, BEL
O / L open the channel: normal mode / listen-only CR; without a bit rate, BEL
C close the channel CR
t T r R transmit a frame: 11 or 29 bits, data or remote request CR; in L mode, BEL
F error flags, two hex digits F00\r …
V / N version / adapter number V0101\r, N0198\r
Z0 / Z1 turn timestamps off / on CR

A frame is written the same way in both directions: a letter, the identifier (three digits for 11 bits, eight for 29), one DLC digit and the data, two digits per byte. Remote requests r and R have no data. Lines from a run of the core:

t12388081828384858687\r          11 бит, 0x123, 8 байт
T1ABCDEF08A8ABAEB1B4B7BABD\r     29 бит, 0x1ABCDEF0, 8 байт
t123880818283848586871008\r      то же с меткой Z1: 0x1008 мс

The timestamp is four hex digits of milliseconds that wrap around. This is our own convention: adapters from different vendors count the timestamp differently, and python-can does not read the tail after the data at all. The bits of the F reply are ours too: 0x01 means the ring overflowed, 0x02 means the bxCAN receive FIFO overflowed, 0x04 means the node is error passive, 0x08 means it went bus-off.

The longest frame line is T with eight bytes and a timestamp: 31 characters including CR. The line buffer in the core is 32 characters.


198е.3 Throughput: where the bottleneck is

The worst case for a sniffer is a bus loaded to 100 % with the shortest frames. Frame length per RM0008 (fig. 234): standard 44 + 8n bits, extended 64 + 8n, plus 3 bits of intermission. Bit stuffing only makes a frame longer, so without it the number of frames per second is the highest. At 500 kbit/s:

Frame Bits without stuffing / worst Frames/s Characters per line (with Z1) Characters/s (with Z1)
t, DLC 0 47 / 55 10638 6 (10) 63830 (106383)
t, DLC 8 111 / 135 4505 22 (26) 99099 (117117)
T, DLC 0 67 / 80 7463 11 (15) 82090 (111940)
T, DLC 8 131 / 160 3817 27 (31) 103053 (118321)

The peak is 103053 characters per second without timestamps and 118321 with them. Now the channels. A UART character is 10 bits (start, 8 data, stop):

The bottleneck on the F103 is the UART: even 921600 baud falls a little short of a fully loaded 500 kbit/s bus. The ring smooths out bursts: with 128 frames, at full load with t DLC 8 frames it fills up in 415 ms at 921600 baud and in 32 ms at 115200. After that frames are lost, and the core raises flag 0x01 in F.


198е.4 F103 firmware: bxCAN listens, the interrupt puts frames into the ring

bxCAN is clocked from APB1 = 36 MHz. A bit is divided into time quanta: one synchronization quantum, TS1 and TS2 (19.4). The bit rate table in the firmware checks itself: _Static_assert will not let you build a bit rate that does not divide exactly.

Command Bit rate Prescaler Total quanta (TS1 + TS2 in brackets; one more is the sync quantum) Sample point
S0…S7 10…750 kbit/s 450…6 8 (6 + 1) 87.5 %
S8 1 Mbit/s 4 9 (7 + 1) 88.9 %

Opening the channel means entering initialization mode, setting the bit timing and the filter, and leaving:

    CAN1->BTR = (listen ? CAN_BTR_SILM : 0u)    /* «только слушать»: на шину — ни одного доминантного бита */
              | ((uint32_t)(bt[speed].ts2 - 1u) << 20)
              | ((uint32_t)(bt[speed].ts1 - 1u) << 16)
              | (uint32_t)(bt[speed].brp - 1u);  /* SJW = 1 квант */

    CAN1->FMR |= CAN_FMR_FINIT;                 /* фильтр 0: маска из нулей — принимать всё */
    CAN1->FA1R &= ~1u;
    CAN1->FS1R |= 1u;
    CAN1->FM1R &= ~1u;
    CAN1->sFilterRegister[0].FR1 = 0;
    CAN1->sFilterRegister[0].FR2 = 0;
    CAN1->FFA1R &= ~1u;
    CAN1->FA1R |= 1u;
    CAN1->FMR &= ~CAN_FMR_FINIT;

The SILM bit is the key thing for a sniffer. According to RM0008, in silent mode bxCAN receives frames but drives only recessive bits onto the bus: the acknowledge bit and the error flag are looped back internally. Such a node does not interfere with the bus even if the bit rate is chosen wrongly.

The filter is one 32-bit filter in mask mode: both the identifier and the mask are zeros, so not a single bit is compared. Without a filter bxCAN receives nothing at all (19.5).

Reception happens in the FIFO 0 interrupt (vector 20, shared with USB). The frame is taken out of the mailbox, the mailbox is released at once, and the frame goes into the core ring:

void USB_LP_CAN1_RX0_IRQHandler(void)
{
    while (CAN1->RF0R & CAN_RF0R_FMP0) {
        uint32_t rir = CAN1->sFIFOMailBox[0].RIR;
        uint32_t lo = CAN1->sFIFOMailBox[0].RDLR, hi = CAN1->sFIFOMailBox[0].RDHR;
        slcan_frame f;
        f.flags = (uint8_t)(((rir & CAN_RI0R_IDE) ? SLCAN_EXT : 0u) | ((rir & CAN_RI0R_RTR) ? SLCAN_RTR : 0u));
        f.id = (rir & CAN_RI0R_IDE) ? (rir >> 3) : (rir >> 21);
        f.dlc = (uint8_t)(CAN1->sFIFOMailBox[0].RDTR & 0xFu);
        f.ts = (uint16_t)ms;
        for (unsigned i = 0; i < 4u; i++) {
            f.data[i] = (uint8_t)(lo >> (8u * i));
            f.data[4u + i] = (uint8_t)(hi >> (8u * i));
        }
        CAN1->RF0R = CAN_RF0R_RFOM0;    /* отпустить ящик: следующий кадр очереди */
        slcan_rx_push(&f);
    }

Writing RF0R = RFOM0 is safe: the FULL0 and FOVR0 flags are cleared by writing a one, and zeros have no effect on them.

The main loop does two things: it hands command bytes from USART1 to the core one at a time, and, if there is a frame in the ring, writes its line to the port. The whole firmware (startup code, sniffer, core) is 2260 bytes of flash and 2188 bytes of RAM, of which 2048 is the ring of 128 frames of 16 bytes each.

The G474 variant. FDCAN2 on PB12 and PB13 (AF9): PA11 and PA12 are taken by USB, and PB8 on the G474 is the BOOT0 pin. Listen-only mode is the MON bit in CCCR; there are no filters at all (RXGFC = 0: everything goes to FIFO 0). FIFO 0 of FDCAN2 sits in the message RAM at offset 0x350 + 0xB0: the block of one FDCAN is 212 words, before the FIFO come 28 filters for 11 bits and 8 for 29 bits, an element is 18 words. From 170 MHz every bit rate divides exactly except S7: 170000000 / 750000 = 226.67, and to O after S7 the core replies BEL. S1…S8 use 17 quanta, S0 uses 34 (the prescaler is at most 512), the sample point is 88.2 % everywhere. Lines go out through CDC_Transmit_FS with two buffers: while one is being sent, the other is being filled.


198е.5 The core: ring, lines, commands

The ring has one writer (the CAN interrupt) and one reader (the main loop). Each moves only its own counter, so disabling interrupts is not needed. When there is no room, a frame does not overwrite an old one but is counted as lost:

void slcan_rx_push(const slcan_frame *f)
{
    if (head - tail >= SLCAN_RING) {    /* места нет: считаем, но не затираем */
        lost++;
        lost_flag = 1;
        return;
    }
    ring[head % SLCAN_RING] = *f;
    if (ring[head % SLCAN_RING].dlc > 8u)
        ring[head % SLCAN_RING].dlc = 8u;   /* DLC 9…15 в классическом CAN — те же 8 байт */
    head++;                             /* после данных: цикл не увидит недописанный кадр */
}

A frame line is built without printf, from a table of 16 digits:

unsigned slcan_format(const slcan_frame *f, char *out)
{
    char *p = out;
    int ext = (f->flags & SLCAN_EXT) != 0;
    *p++ = (f->flags & SLCAN_RTR) ? (ext ? 'R' : 'r') : (ext ? 'T' : 't');
    p = hexn(p, f->id, ext ? 8u : 3u);
    *p++ = (char)('0' + f->dlc);
    if (!(f->flags & SLCAN_RTR))
        for (unsigned i = 0; i < f->dlc; i++)
            p = hexn(p, f->data[i], 2u);
    if (stamps)
        p = hexn(p, f->ts, 4u);         /* Z1: четыре цифры миллисекунд */
    *p++ = '\r';
    return (unsigned)(p - out);
}

Commands are parsed by slcan_command: strictly by length (a t line must have exactly as many data digits as the DLC demands), the identifier is no greater than 0x7FF or 0x1FFFFFFF. A line longer than 32 characters is rejected entirely, and the LF character is skipped: a terminal with CR LF line endings is not an error.


198е.6 Testing on the computer: the same core against a model and python-can

The core (slcan.c) is built with gcc in a container with -Wall -Wextra -Werror -pedantic together with a harness: it feeds the core bytes "from the computer", frames "from the bus" and prints everything the core outputs. Next to it is a model in Python, written separately, not as a translation of the C code.

Core against model. 3386 steps: random and edge-case lines (valid, almost valid, garbage, longer than 32 characters, with CR LF), frames with DLC up to 15, a failed open, overflow. All 3386 matched: 3004 replies to commands, 287 frame lines, 273 calls to the board. After 130 frames without output both the core and the model counted 2 lost.

python-can against the core. python-can 4.6.1 writes to a fake port, the bytes go to the C core, and python-can reads the replies:

The firmware was not run on hardware. sniffer_f103.c, start_f103.c, slcan.c and can_g474.c were built by armclang without warnings; the whole F103 image was linked by arm-none-eabi-gcc with -Werror, which is where the sizes in 198е.4 come from.


198е.7 Pitfalls

You connected the sniffer and errors appeared on the bus. A node in normal mode acknowledges every frame of other nodes, and at a wrong bit rate it sends error frames. A sniffer needs listen-only mode: L instead of O, the SILM bit (MON in FDCAN).

CAN and USB on the F103 do not work together. The 512-byte memory is shared (RM0008). The way out is to send the lines through a USART and an adapter, or to take a G474, where the memories are separate.

After remapping CAN the debugger disappeared. The SWJ_CFG bits in AFIO_MAPR are write-only: according to RM0008 their value is undefined when read. A read-modify-write of MAPR can write garbage there and turn SWD off. That is why the firmware writes MAPR as a whole: CAN_REMAP = 10, SWJ_CFG = 000.

O replies BEL although the bit rate is set. Leaving initialization waits for 11 recessive bits in a row on CAN_RX. With no transceiver or no bus the INAK bit does not clear, and plat_can_open gives up on a timeout.

python-can sends L twice. In version 4.6.1 set_bitrate opens the channel itself, and then the constructor opens it once more. The core rejects the second L on an open channel, python-can does not check the reply, and everything works. Better not to let your own core fall over on a repeated open.

A frame with DLC 9 arrives as 12 bytes. In classic CAN, DLC 9…15 means the same 8 bytes, but python-can reads the DLC digit using the CAN FD table. So the core replaces such a DLC with 8 already in the ring.

At 115200 baud frames are lost. The channel carries 11.2 % of the peak, and the ring of 128 frames is full after 32 ms. Flag 0x01 in the F reply tells you there were losses.

Commands sometimes vanish, reception "hangs". In the USART1 interrupt you have to read SR and then DR: only this pair clears the ORE overrun flag (RM0008). If you read DR alone, the ORE interrupt may not clear.


198е.8 Summary

Full source code

slcan.c — the SLCAN core: frame ring, lines, commands.

/* Мини-проект «CAN-сниффер»: ядро SLCAN — кольцо кадров, строки, команды. */
#include "slcan.h"

#define OK   '\r'                       /* команда выполнена */
#define BEL  '\a'                       /* отказ */

static volatile slcan_frame ring[SLCAN_RING];
static volatile uint32_t head;          /* пишет только прерывание CAN */
static volatile uint32_t tail;          /* пишет только главный цикл */
static volatile uint32_t lost;          /* кадры, которым не хватило места */
static volatile uint8_t  lost_flag;

static uint8_t speed = 0xFFu;           /* S0…S8; 0xFF — скорость не задана */
static uint8_t is_open, listen_only, stamps;

static const char HEX[] = "0123456789ABCDEF";

/* ── кольцо: один писатель, один читатель ── */
void slcan_rx_push(const slcan_frame *f)
{
    if (head - tail >= SLCAN_RING) {    /* места нет: считаем, но не затираем */
        lost++;
        lost_flag = 1;
        return;
    }
    ring[head % SLCAN_RING] = *f;
    if (ring[head % SLCAN_RING].dlc > 8u)
        ring[head % SLCAN_RING].dlc = 8u;   /* DLC 9…15 в классическом CAN — те же 8 байт */
    head++;                             /* после данных: цикл не увидит недописанный кадр */
}

int slcan_rx_pop(slcan_frame *f)
{
    if (tail == head)
        return 0;
    *f = ring[tail % SLCAN_RING];
    tail++;
    return 1;
}

uint32_t slcan_lost(void)
{
    return lost;
}

/* ── кадр → строка: t123 8 1122…  T12345678 8 …  r123 0  R12345678 0 ── */
static char *hexn(char *p, uint32_t v, unsigned digits)
{
    while (digits--)
        *p++ = HEX[(v >> (4u * digits)) & 0xFu];
    return p;
}

unsigned slcan_format(const slcan_frame *f, char *out)
{
    char *p = out;
    int ext = (f->flags & SLCAN_EXT) != 0;
    *p++ = (f->flags & SLCAN_RTR) ? (ext ? 'R' : 'r') : (ext ? 'T' : 't');
    p = hexn(p, f->id, ext ? 8u : 3u);
    *p++ = (char)('0' + f->dlc);
    if (!(f->flags & SLCAN_RTR))
        for (unsigned i = 0; i < f->dlc; i++)
            p = hexn(p, f->data[i], 2u);
    if (stamps)
        p = hexn(p, f->ts, 4u);         /* Z1: четыре цифры миллисекунд */
    *p++ = '\r';
    return (unsigned)(p - out);
}

/* ── строка → кадр (команды передачи) ── */
static int hexval(char c)
{
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
    return -1;
}

static int gethex(const char *s, unsigned digits, uint32_t *v)
{
    *v = 0;
    for (unsigned i = 0; i < digits; i++) {
        int h = hexval(s[i]);
        if (h < 0)
            return -1;
        *v = (*v << 4) | (uint32_t)h;
    }
    return 0;
}

int slcan_parse(const char *s, unsigned n, slcan_frame *f)
{
    int ext = (s[0] == 'T' || s[0] == 'R');
    int rtr = (s[0] == 'r' || s[0] == 'R');
    unsigned idl = ext ? 8u : 3u;
    uint32_t v;
    if (n < 2u + idl || gethex(s + 1, idl, &f->id) != 0)
        return -1;
    if (f->id > (ext ? 0x1FFFFFFFu : 0x7FFu))
        return -1;
    if (s[1 + idl] < '0' || s[1 + idl] > '8')
        return -1;
    f->dlc = (uint8_t)(s[1 + idl] - '0');
    f->flags = (uint8_t)((ext ? SLCAN_EXT : 0u) | (rtr ? SLCAN_RTR : 0u));
    f->ts = 0;
    if (n != 2u + idl + (rtr ? 0u : 2u * f->dlc))
        return -1;                      /* длина строки обязана совпасть с DLC */
    for (unsigned i = 0; i < 8u; i++)
        f->data[i] = 0;
    for (unsigned i = 0; !rtr && i < f->dlc; i++) {
        if (gethex(s + 2 + idl + 2 * i, 2u, &v) != 0)
            return -1;
        f->data[i] = (uint8_t)v;
    }
    return 0;
}

/* ── команды: ответ '\r' — выполнено, '\a' — отказ ── */
unsigned slcan_command(const char *s, unsigned n, char *reply)
{
    slcan_frame f;
    char *p = reply;
    if (n == 0u) {                      /* пустая строка: python-can шлёт её после S */
        *p++ = OK;
        return 1u;
    }
    switch (s[0]) {
    case 'S':                           /* S0…S8 — скорость, только на закрытом канале */
        if (n != 2u || s[1] < '0' || s[1] > '8' || is_open)
            break;
        speed = (uint8_t)(s[1] - '0');
        *p++ = OK;
        return 1u;
    case 'O':                           /* открыть: обычный режим */
    case 'L':                           /* открыть: только слушать */
        if (n != 1u || is_open || speed > 8u || plat_can_open(speed, s[0] == 'L') != 0)
            break;
        is_open = 1;
        listen_only = (s[0] == 'L');
        *p++ = OK;
        return 1u;
    case 'C':                           /* закрыть; на закрытом — тоже «выполнено» */
        if (n != 1u)
            break;
        if (is_open)
            plat_can_close();
        is_open = 0;
        *p++ = OK;
        return 1u;
    case 't': case 'T': case 'r': case 'R':
        if (!is_open || listen_only || slcan_parse(s, n, &f) != 0 || plat_can_send(&f) != 0)
            break;                      /* «только слушать» передавать не умеет */
        *p++ = OK;
        return 1u;
    case 'F': {                         /* флаги: F + две цифры */
        if (n != 1u)
            break;
        uint8_t fl = (uint8_t)(plat_status() | (lost_flag ? SLCAN_F_LOST : 0u));
        lost_flag = 0;
        *p++ = 'F';
        p = hexn(p, fl, 2u);
        *p++ = OK;
        return (unsigned)(p - reply);
    }
    case 'V':                           /* версия: железо 01, программа 01 */
        if (n != 1u)
            break;
        for (const char *v = "V0101\r"; *v; v++)
            *p++ = *v;
        return (unsigned)(p - reply);
    case 'N':                           /* номер адаптера */
        if (n != 1u)
            break;
        for (const char *v = "N0198\r"; *v; v++)
            *p++ = *v;
        return (unsigned)(p - reply);
    case 'Z':                           /* метки времени: Z0 — выкл, Z1 — вкл */
        if (n != 2u || (s[1] != '0' && s[1] != '1'))
            break;
        stamps = (uint8_t)(s[1] == '1');
        *p++ = OK;
        return 1u;
    default:
        break;
    }
    *p++ = BEL;
    return 1u;
}

/* ── сборка строки по байту: разделитель — '\r', '\n' пропускается ── */
unsigned slcan_feed(char c, char *reply)
{
    static char line[SLCAN_LINE];
    static unsigned len;
    static uint8_t too_long;
    if (c == '\n')
        return 0u;                      /* терминал с CR LF — не ошибка */
    if (c != '\r') {
        if (len < SLCAN_LINE)
            line[len++] = c;
        else
            too_long = 1;               /* хвост выбрасываем, строку потом отвергнем */
        return 0u;
    }
    unsigned n = len, bad = too_long;
    len = 0;
    too_long = 0;
    if (bad) {
        reply[0] = BEL;
        return 1u;
    }
    return slcan_command(line, n, reply);
}

sniffer_f103.c — the BluePill firmware: bxCAN and USART1.

/* Мини-проект «CAN-сниффер»: BluePill (STM32F103C8), регистры через CMSIS.
 *   PB8/PB9 — CAN_RX/CAN_TX после ремапа (PA11/PA12 заняты разъёмом USB);
 *   PA9/PA10 — USART1 к переходнику USB–UART, 921 600 бод.
 * USB самого F103 здесь не годится: его буферы и bxCAN делят одни 512 байт
 * памяти (RM0008, 23.2 и 24.2) — вместе они не работают.
 * Такт: кварц 8 МГц × 9 = 72 МГц, APB1 = 36 МГц — от него тактуется bxCAN. */
#include "stm32f10x.h"
#include "slcan.h"

#define FCPU   72000000u
#define FCAN   36000000u                /* APB1 */
#define BAUD   921600u
#define UART_BRR ((FCPU + BAUD / 2u) / BAUD)
_Static_assert(UART_BRR == 78u, "72 МГц / 78 = 923 077 бод, +0,16 %");

/* S0…S8: скорость, делитель, TS1, TS2 (в квантах, без «минус один») */
#define SPEEDS(X) \
    X(10000u, 450u, 6u, 1u) X(20000u, 225u, 6u, 1u) X(50000u, 90u, 6u, 1u)   \
    X(100000u, 45u, 6u, 1u) X(125000u, 36u, 6u, 1u) X(250000u, 18u, 6u, 1u)  \
    X(500000u, 9u, 6u, 1u)  X(750000u, 6u, 6u, 1u)  X(1000000u, 4u, 7u, 1u)
#define ROW(rate, brp, ts1, ts2) { brp, ts1, ts2 },
#define EXACT(rate, brp, ts1, ts2) \
    _Static_assert(FCAN % ((brp) * (1u + (ts1) + (ts2))) == 0u \
                   && FCAN / ((brp) * (1u + (ts1) + (ts2))) == (rate), "скорость не делится ровно");
SPEEDS(EXACT)
static const struct { uint16_t brp; uint8_t ts1, ts2; } bt[9] = { SPEEDS(ROW) };

static volatile uint32_t ms;            /* SysTick: миллисекунды — для меток Z1 */
static volatile uint8_t  fovr;          /* очередь приёма переполнялась */
static volatile uint8_t  rxq[64];       /* байты от компьютера */
static volatile uint32_t rxh, rxt;

void SysTick_Handler(void)
{
    ms++;
}

void USART1_IRQHandler(void)
{
    uint32_t sr = USART1->SR;           /* SR, затем DR — снимает и RXNE, и ORE */
    uint8_t c = (uint8_t)USART1->DR;
    if ((sr & USART_SR_RXNE) && rxh - rxt < sizeof rxq)
        rxq[rxh++ % sizeof rxq] = c;
}

/* кадр из очереди 0 bxCAN — в кольцо ядра; очередь освобождается сразу */
void USB_LP_CAN1_RX0_IRQHandler(void)
{
    while (CAN1->RF0R & CAN_RF0R_FMP0) {
        uint32_t rir = CAN1->sFIFOMailBox[0].RIR;
        uint32_t lo = CAN1->sFIFOMailBox[0].RDLR, hi = CAN1->sFIFOMailBox[0].RDHR;
        slcan_frame f;
        f.flags = (uint8_t)(((rir & CAN_RI0R_IDE) ? SLCAN_EXT : 0u) | ((rir & CAN_RI0R_RTR) ? SLCAN_RTR : 0u));
        f.id = (rir & CAN_RI0R_IDE) ? (rir >> 3) : (rir >> 21);
        f.dlc = (uint8_t)(CAN1->sFIFOMailBox[0].RDTR & 0xFu);
        f.ts = (uint16_t)ms;
        for (unsigned i = 0; i < 4u; i++) {
            f.data[i] = (uint8_t)(lo >> (8u * i));
            f.data[4u + i] = (uint8_t)(hi >> (8u * i));
        }
        CAN1->RF0R = CAN_RF0R_RFOM0;    /* отпустить ящик: следующий кадр очереди */
        slcan_rx_push(&f);
    }
    if (CAN1->RF0R & CAN_RF0R_FOVR0) {
        CAN1->RF0R = CAN_RF0R_FOVR0;    /* флаг снимается записью единицы */
        fovr = 1;
    }
}

int plat_can_open(uint8_t speed, int listen)
{
    uint32_t t;
    CAN1->MCR = CAN_MCR_INRQ | CAN_MCR_NART;    /* из сна — в инициализацию; без автоповтора */
    for (t = 100000u; !(CAN1->MSR & CAN_MSR_INAK); t--)
        if (t == 0u)
            return 1;
    CAN1->BTR = (listen ? CAN_BTR_SILM : 0u)    /* «только слушать»: на шину — ни одного доминантного бита */
              | ((uint32_t)(bt[speed].ts2 - 1u) << 20)
              | ((uint32_t)(bt[speed].ts1 - 1u) << 16)
              | (uint32_t)(bt[speed].brp - 1u);  /* SJW = 1 квант */

    CAN1->FMR |= CAN_FMR_FINIT;                 /* фильтр 0: маска из нулей — принимать всё */
    CAN1->FA1R &= ~1u;
    CAN1->FS1R |= 1u;
    CAN1->FM1R &= ~1u;
    CAN1->sFilterRegister[0].FR1 = 0;
    CAN1->sFilterRegister[0].FR2 = 0;
    CAN1->FFA1R &= ~1u;
    CAN1->FA1R |= 1u;
    CAN1->FMR &= ~CAN_FMR_FINIT;

    CAN1->IER = CAN_IER_FMPIE0;                 /* прерывание: в очереди 0 есть кадр */
    NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
    CAN1->MCR &= ~CAN_MCR_INRQ;                 /* ждёт 11 рецессивных бит на RX */
    for (t = 100000u; CAN1->MSR & CAN_MSR_INAK; t--)
        if (t == 0u)
            return 2;
    return 0;
}

void plat_can_close(void)
{
    CAN1->MCR |= CAN_MCR_INRQ;
    NVIC_DisableIRQ(USB_LP_CAN1_RX0_IRQn);
}

int plat_can_send(const slcan_frame *f)
{
    if (!(CAN1->TSR & CAN_TSR_TME0))
        return 1;                               /* ящик 0 занят — отказ, повторит компьютер */
    CAN1->sTxMailBox[0].TDTR = f->dlc;
    CAN1->sTxMailBox[0].TDLR = (uint32_t)f->data[0] | ((uint32_t)f->data[1] << 8)
                             | ((uint32_t)f->data[2] << 16) | ((uint32_t)f->data[3] << 24);
    CAN1->sTxMailBox[0].TDHR = (uint32_t)f->data[4] | ((uint32_t)f->data[5] << 8)
                             | ((uint32_t)f->data[6] << 16) | ((uint32_t)f->data[7] << 24);
    CAN1->sTxMailBox[0].TIR = ((f->flags & SLCAN_EXT) ? ((f->id << 3) | CAN_TI0R_IDE) : (f->id << 21))
                            | ((f->flags & SLCAN_RTR) ? CAN_TI0R_RTR : 0u) | CAN_TI0R_TXRQ;
    return 0;
}

uint8_t plat_status(void)
{
    uint32_t esr = CAN1->ESR;
    uint8_t s = (uint8_t)((fovr ? SLCAN_F_OVERRUN : 0u) | ((esr & CAN_ESR_EPVF) ? SLCAN_F_PASSIVE : 0u)
                          | ((esr & CAN_ESR_BOFF) ? SLCAN_F_BUSOFF : 0u));
    fovr = 0;
    return s;
}

static void clock_72mhz(void)
{
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY)) {
    }
    FLASH->ACR = FLASH_ACR_PRFTBE | FLASH_ACR_LATENCY_2;
    RCC->CFGR = RCC_CFGR_PLLSRC | RCC_CFGR_PLLMULL9 | RCC_CFGR_PPRE1_DIV2;
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY)) {
    }
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
    }
}

static void put(const char *s, unsigned n)
{
    for (unsigned i = 0; i < n; i++) {
        while (!(USART1->SR & USART_SR_TXE)) {
        }
        USART1->DR = (uint8_t)s[i];
    }
}

int main(void)
{
    char out[SLCAN_OUT];
    slcan_frame f;
    clock_72mhz();
    RCC->APB2ENR |= RCC_APB2ENR_AFIOEN | RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN | RCC_APB2ENR_USART1EN;
    RCC->APB1ENR |= RCC_APB1ENR_CAN1EN;
    AFIO->MAPR = AFIO_MAPR_CAN_REMAP_REMAP2;   /* PB8 RX, PB9 TX; SWJ_CFG = 000 — отладчик жив */
    GPIOB->CRH = (GPIOB->CRH & ~0xFFu) | 0x4u | (0xBu << 4);   /* PB8 вход, PB9 — AF двухтактный */
    GPIOA->CRH = (GPIOA->CRH & ~0xFF0u) | (0xBu << 4) | (0x4u << 8);   /* PA9 TX, PA10 RX */
    USART1->BRR = UART_BRR;
    USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;
    NVIC_EnableIRQ(USART1_IRQn);
    SysTick->LOAD = FCPU / 1000u - 1u;
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;

    for (;;) {
        while (rxt != rxh) {                    /* команды — по байту в ядро */
            unsigned n = slcan_feed((char)rxq[rxt % sizeof rxq], out);
            rxt++;
            if (n)
                put(out, n);
        }
        if (slcan_rx_pop(&f))                   /* кадры — по строке за проход */
            put(out, slcan_format(&f, out));
    }
}

can_g474.c — the same core on the STM32G474: FDCAN2 and USB CDC.

/* Мини-проект «CAN-сниффер»: то же ядро SLCAN на STM32G474 — FDCAN2 + USB CDC.
 *   PB12/PB13 — FDCAN2_RX/TX (AF9): PA11/PA12 заняты USB, а PB8 (FDCAN1_RX) — это BOOT0;
 *   USB CDC — из CubeMX (раздел 20.7): CDC_Receive_FS зовёт sniffer_cdc_rx(),
 *   главный цикл — sniffer_poll(). Такт, USB и HAL настраивает CubeMX.
 * У G474 память USB (0x4000 6000) и память сообщений FDCAN (0x4000 A400)
 * разные — CDC и CAN работают одновременно (RM0440, карта памяти). */
#include "stm32g474xx.h"
#include "slcan.h"

extern uint8_t  CDC_Transmit_FS(uint8_t *buf, uint16_t len);   /* 0 = USBD_OK */
extern uint32_t HAL_GetTick(void);

#define FCAN 170000000u                 /* ядро FDCAN от PCLK1 (FDCANSEL = 10) */

/* S0…S8: скорость, делитель, TSEG1, TSEG2 в квантах; 0 — ровно не делится */
#define SPEEDS(X) \
    X(10000u, 500u, 29u, 4u) X(20000u, 500u, 14u, 2u) X(50000u, 200u, 14u, 2u)  \
    X(100000u, 100u, 14u, 2u) X(125000u, 80u, 14u, 2u) X(250000u, 40u, 14u, 2u) \
    X(500000u, 20u, 14u, 2u) X(750000u, 0u, 0u, 0u) X(1000000u, 10u, 14u, 2u)
#define ROW(rate, brp, s1, s2) { brp, s1, s2 },
#define EXACT(rate, brp, s1, s2) \
    _Static_assert((brp) == 0u || (FCAN % ((brp) * (1u + (s1) + (s2))) == 0u \
                   && FCAN / ((brp) * (1u + (s1) + (s2))) == (rate) && (brp) <= 512u), "скорость");
SPEEDS(EXACT)
static const struct { uint16_t brp; uint8_t s1, s2; } bt[9] = { SPEEDS(ROW) };

/* память сообщений: FDCAN2 — второй блок по 0x350, очередь 0 — со смещения 0xB0,
 * элемент — 18 слов (2 слова заголовка + 64 байта данных) */
#define RAM2    (SRAMCAN_BASE + 0x350u)
#define F0SA    (RAM2 + 0x0B0u)
_Static_assert(28u * 4u + 8u * 8u == 0x0B0u, "фильтры 11 и 29 бит перед очередью 0");
_Static_assert(212u * 4u == 0x350u, "блок одного FDCAN — 212 слов");

void FDCAN2_IT0_IRQHandler(void)
{
    while (FDCAN2->RXF0S & FDCAN_RXF0S_F0FL) {
        uint32_t gi = (FDCAN2->RXF0S >> FDCAN_RXF0S_F0GI_Pos) & 3u;
        const volatile uint32_t *e = (const volatile uint32_t *)(F0SA + gi * 18u * 4u);
        uint32_t r0 = e[0], r1 = e[1], lo = e[2], hi = e[3];
        slcan_frame f;
        f.flags = (uint8_t)(((r0 >> 30) & 1u ? SLCAN_EXT : 0u) | ((r0 >> 29) & 1u ? SLCAN_RTR : 0u));
        f.id = (r0 >> 30) & 1u ? (r0 & 0x1FFFFFFFu) : ((r0 >> 18) & 0x7FFu);
        f.dlc = (uint8_t)((r1 >> 16) & 0xFu);  /* 9…15 ядро превратит в 8 */
        f.ts = (uint16_t)HAL_GetTick();
        for (unsigned i = 0; i < 4u; i++) {
            f.data[i] = (uint8_t)(lo >> (8u * i));
            f.data[4u + i] = (uint8_t)(hi >> (8u * i));
        }
        FDCAN2->RXF0A = gi;                    /* элемент прочитан */
        slcan_rx_push(&f);
    }
    FDCAN2->IR = FDCAN_IR_RF0N;
}

int plat_can_open(uint8_t speed, int listen)
{
    if (bt[speed].brp == 0u)
        return 1;                              /* S7 от 170 МГц ровно не делится */
    RCC->CCIPR = (RCC->CCIPR & ~RCC_CCIPR_FDCANSEL) | RCC_CCIPR_FDCANSEL_1;
    RCC->APB1ENR1 |= RCC_APB1ENR1_FDCANEN;
    RCC->AHB2ENR |= RCC_AHB2ENR_GPIOBEN;
    GPIOB->AFR[1] = (GPIOB->AFR[1] & ~(0xFFu << 16)) | (9u << 16) | (9u << 20);
    GPIOB->MODER = (GPIOB->MODER & ~(0xFu << 24)) | (0xAu << 24);   /* PB12, PB13 — AF */

    FDCAN2->CCCR |= FDCAN_CCCR_INIT;
    while (!(FDCAN2->CCCR & FDCAN_CCCR_INIT)) {
    }
    FDCAN2->CCCR |= FDCAN_CCCR_CCE | (listen ? FDCAN_CCCR_MON : 0u);  /* MON — «только слушать» */
    FDCAN2->NBTP = ((uint32_t)(bt[speed].brp - 1u) << FDCAN_NBTP_NBRP_Pos)
                 | ((uint32_t)(bt[speed].s1 - 1u) << FDCAN_NBTP_NTSEG1_Pos)
                 | ((uint32_t)(bt[speed].s2 - 1u) << FDCAN_NBTP_NTSEG2_Pos);  /* SJW = 1 */
    FDCAN2->RXGFC = 0;                         /* без фильтров: всё — в очередь 0 (так и после сброса) */
    FDCAN2->IE = FDCAN_IE_RF0NE;
    FDCAN2->ILE = FDCAN_ILE_EINT0;
    NVIC_EnableIRQ(FDCAN2_IT0_IRQn);
    FDCAN2->CCCR &= ~FDCAN_CCCR_INIT;
    return 0;
}

void plat_can_close(void)
{
    FDCAN2->CCCR |= FDCAN_CCCR_INIT;
    NVIC_DisableIRQ(FDCAN2_IT0_IRQn);
    FDCAN2->CCCR = (FDCAN2->CCCR | FDCAN_CCCR_CCE) & ~FDCAN_CCCR_MON;
}

int plat_can_send(const slcan_frame *f)
{
    (void)f;
    return 1;                                  /* этот вариант только слушает */
}

uint8_t plat_status(void)
{
    uint32_t psr = FDCAN2->PSR;
    return (uint8_t)(((psr & FDCAN_PSR_EP) ? SLCAN_F_PASSIVE : 0u) | ((psr & FDCAN_PSR_BO) ? SLCAN_F_BUSOFF : 0u));
}

/* ── USB CDC: приём — в кольцо байтов, передача — двумя буферами ── */
static volatile uint8_t  rxq[256];
static volatile uint32_t rxh, rxt;
static uint8_t  txb[2][256];
static unsigned txn, cur;

void sniffer_cdc_rx(const uint8_t *buf, uint32_t len)    /* из CDC_Receive_FS */
{
    for (uint32_t i = 0; i < len && rxh - rxt < sizeof rxq; i++)
        rxq[rxh++ % sizeof rxq] = buf[i];
}

static void out(const char *s, unsigned n)
{
    for (unsigned i = 0; i < n; i++)
        txb[cur][txn++] = (uint8_t)s[i];
}

void sniffer_poll(void)                                  /* из главного цикла */
{
    char line[SLCAN_OUT];
    slcan_frame f;
    while (rxt != rxh && txn + SLCAN_OUT <= sizeof txb[0]) {
        unsigned n = slcan_feed((char)rxq[rxt % sizeof rxq], line);
        rxt++;
        out(line, n);
    }
    while (txn + SLCAN_OUT <= sizeof txb[0] && slcan_rx_pop(&f))
        out(line, slcan_format(&f, line));
    /* отданный буфер не трогать до конца передачи: «принято» — значит прошлый ушёл */
    if (txn && CDC_Transmit_FS(txb[cur], (uint16_t)txn) == 0u) {
        cur ^= 1u;
        txn = 0;
    }
}

Next: section 198ж "UDP sensor: broadcast, network discovery and a custom protocol" — from the CAN bus to the network: the device announces itself over UDP.

Open in the interactive handbook