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

198т. Mini-project: a CAN bus logger to an SD card — recording a trip and analysing it on a computer

The sniffer from 198е hands frames to a computer while one is connected. The scanner from 198р queries the nodes itself, and the dashboard from 198с shows what it hears. Here the device has no screen and no computer: a black box that switches on with the ignition, listens to the bus silently and writes every frame with a precise time to an SD card. After the trip the card is taken out, and the log is analysed on a computer with the tools from 19ж and a DBC file from 19е.

The difficulty is not in CAN or in the card on their own, but where they meet. Frames arrive without a break, while the card now and then goes quiet to do housekeeping. Power in a car disappears without warning. So the main questions of this section are how many frames to keep in memory, how to write to the card and what remains in the file after a power loss.

Tested without a car and without a card. The logger core (canlog.c) was built with gcc on a computer and run through the real FatFS R0.15 on a card image in memory: a synthetic 10-minute trip, frames from vw_mqb.dbc, card delays from a model, power losses in the middle of writing. The log was checked with a line parser and log2asc from can-utils and with the DBC parser from 19е. The F407 firmware was built with arm-none-eabi-gcc; it has not been run on a board or in a car.

References: RM0090 (the bxCAN chapter: TTCM mode, section 32.7.2; the TIME field in CAN_RDTxR), RM0440 (FDCAN: the timestamp counter, FDCAN_TSCC, the RXTS field) from the Keil packs. FatFS R0.15 (ChaN) — a copy from the ESP-IDF 5.4.1 image. Licences: FatFS has its own permissive licence (keep the notice in the sources), can-utils (lib.c, canframelen.c, log2asc.c) — GPL-2.0-only or BSD-3-Clause at your choice, opendbc — MIT; copies of the files are kept in the check together with their licences. bxCAN and the sniffer — 19 and 198е, SDIO and the disk layer — 41а, FatFS and power loss — 42.6, a log with a marker last — 173а.4, DBC — 19е, can-utils — 19ж.5.

Terms used in the documentation

Term Meaning
time triggered communication mode (TTCM) the bxCAN mode with timestamps
message time stamp, TIME[15:0] the frame timestamp: a 16-bit counter of bit times
sample point of the Start Of Frame bit the sample point of the first bit of a frame (SOF)
timestamp counter, TSCC.TCP, TSCC.TSS the FDCAN timestamp counter, its prescaler and its source
RXTS the timestamp in an FDCAN Rx FIFO element
contiguous allocation, f_expand reserve space for a file in one piece in advance
f_sync write the data and the directory entry to the medium without closing the file
log file (candump -l / -L) the can-utils log: time, interface, frame in cansend form
ASC log the Vector text log; log2asc converts to it

198т.1 What the device does and what it is built from

The device is an F407 with a transceiver (19.2) and a microSD socket on four SDIO lines (41а). bxCAN runs in "listen only" mode (the SILM bit, as in 198е): it receives frames but does not drive a single dominant bit onto the bus — it does not acknowledge and does not send error frames. For a black box this is mandatory: a device that has been plugged into a car's bus has no right to disturb it.

The path of a frame in the F407 logger: a 500 kbit/s CAN bus and a transceiver, bxCAN in SILM and TTCM modes with an accept-all filter, the FIFO 0 interrupt takes the TIME stamp and the TIM2 time, a ring of 24-byte frames, the main loop makes a candump line or a binary record, a 4096-byte chunk of 8 sectors, FatFS R0.15 with a file reserved in advance through f_expand for 64 MiB, SDIO on 4 lines at 24 MHz — 43.4 µs per sector, an SD card with a delay model of 1 ms per write and 250 ms once every 512 KiB; at the bottom the result of a 10-minute trip and the sizes of a line and a binary record

The path of a frame:

  1. The FIFO 0 interrupt takes the frame out of the bxCAN mailbox together with its timestamp and puts it into a ring in memory. Nothing else: no lines, no card in the interrupt.
  2. The main loop takes frames out of the ring and turns them into a candump log line or a binary block record (198т.3).
  3. The accumulator collects bytes into a 4096-byte chunk — eight sectors — and passes it to f_write whole. FatFS writes such a chunk straight from the buffer, without its own window, in a single command.

The ring sits between the bus, which does not wait, and the card, which makes you wait. Its size is worked out in 198т.4.


198т.2 The timestamp: TTCM on bxCAN and the FDCAN counter

The frame time is needed to within microseconds: on the computer it is used to reconstruct the order of events and the period of each message. Reading a timer in the interrupt is imprecise: the time between the end of a frame and entering the interrupt varies, and the start of the frame is another 47…160 bits before its end.

bxCAN, TTCM mode. According to RM0090 (32.7.2), the TTCM bit in CAN_MCR enables the internal bxCAN counter. It adds one on every bit time and is captured at the sample point of the SOF bit — both on reception and on transmission. The value is in the upper 16 bits of CAN_RDTxR, the TIME[15:0] field. At 500 kbit/s the bit time is 2 µs, and the counter wraps around in 65536 × 2 = 131072 µs.

The counter itself cannot be read: bxCAN has no register holding the "current" value. So the timestamp is extended with differences. Neighbouring frames on a live bus come more often than once per half turn, and the difference of two timestamps modulo 2¹⁶ is the exact number of bit times between their SOFs. The coarse time from the 32-bit TIM2 (1 MHz) is needed only to notice a pause longer than half a turn and re-anchor to it:

uint64_t cl_tsx_bx(cl_tsx *x, uint32_t coarse_us, uint16_t hw)
{
    uint32_t dc = coarse_us - x->last_coarse;   /* TIM2 32 бита: разность верна и через переполнение */
    if (!x->started || dc > x->max_gap_us) {
        x->t_us = coarse_us;                    /* пауза длиннее полукруга: якорь по грубому времени */
        if (x->started)
            x->reanchors++;
        x->started = 1;
    } else {
        uint16_t d = (uint16_t)(hw - x->last_hw);   /* тактов бита с прошлого SOF */
        x->t_us += (uint64_t)d * x->us_per_tick;
    }
    x->last_coarse = coarse_us;
    x->last_hw = hw;
    return x->t_us;
}

The threshold is half a turn, 65536 µs: between two interrupts the coarse time moves forward by the interval between the SOFs plus the difference in interrupt entry delays, and a margin of half a turn covers that difference. The anchor timestamp is offset from the true SOF by the frame length and the interrupt latency — in the run this is 249 and 271 µs for two segments, while the intervals within a segment are exact to one bit time.

FDCAN on the G474. According to RM0440, FDCAN has its own 16-bit timestamp counter. The TCP field in FDCAN_TSCC sets a step of 1…16 bit times, the TSS field sets the source: 01 — the internal counter, 10 — the TIM3 counter. The value is captured at the start of the frame and goes into the RXTS field of the Rx FIFO element. For CAN FD, RM0440 explicitly requires an external counter: the bit time in the data phase is different, and the internal counter does not give a uniform scale. The TIM3 option is more convenient for classic CAN too: with TIM3 at 1 MHz the timestamp is directly in microseconds, and the counter can be read, so you can extend the timestamp from the current TIM3 value, without guessing pauses.


198т.3 How many frames and how many bytes: the bus and the card

The worst case is a bus at 100 % load with short frames. The length of a standard frame without stuff bits is 47 + 8n bits including the 3-bit gap, the same as in 198е.3 and in can_frame_length() from can-utils. At 500 kbit/s:

Frame Bits Frames/s candump line Bytes/s as lines Binary record Bytes/s as blocks
DLC 0 47 10638 30 319149 9 100867
DLC 8 111 4505 46 207207 17 82368

Two log formats:

On a full bus, blocks give 2.52…3.16 times fewer bytes per second than lines. But size is not the main point of the binary format. Each block checks itself, and after a power loss the reader knows exactly where the intact data ends (198т.5).

Even the worst stream — 319149 bytes/s — is less than 3 % of the 11.8 MB/s that SDIO on four lines at 24 MHz gives on the bus (41а.1). Card speed is not the bottleneck. The bottleneck is its pauses.


198т.4 The ring and card pauses: how many frames to keep

The card answers "accepted" and then holds the D0 line busy while it writes (41а.6). Usually this is a fraction of a millisecond, but now and then the card goes off to do internal housekeeping, and a write takes noticeably longer. There are no measurements of the delays of real cards here, so this is a model, not card data:

While the card is stalled, the main loop is stalled with it (f_write waits), and the interrupt keeps putting frames into the ring. Only the interrupt writes the ring, only the loop reads it — no interrupt disabling is needed, as in 198е.5:

int cl_push(cl_ring *r, const cl_frame *f)
{
    uint32_t used = r->head - r->tail;
    if (used >= r->size) {              /* места нет: старое не затираем, считаем потерю */
        r->lost++;
        return 0;
    }
    r->buf[r->head % r->size] = *f;
    r->head++;                          /* после данных: читатель не увидит недописанный кадр */
    if (used + 1u > r->peak)
        r->peak = used + 1u;
    return 1;
}

The test trip — 10 minutes from vw_mqb.dbc: steering LWI_01, engine speed Motor_12 and two more messages every 10 ms, vehicle speed ESP_21 and two more every 20 ms, temperature Motor_07 and three more every 100 ms, plus an OBD-II query every 200 ms: request 0x7DF and response 0x7E8 3 ms later (19г). The profile: standing, accelerating to 90 km/h, highway, city, stop; the engine warms up from 20 to 90 °C. At second 5 the bus is silent for 300 ms — engine start. The periods and the profile are model parameters, not a recording of a real car. Frames go out according to arbitration, and the length of each one includes real bit stuffing.

Generator result: 359819 frames, 13 identifiers, 599.7 frames/s, bus load 14.79 %; of 44382312 bits, 4634307 are stuff bits. can-utils (can_frame_length, exact mode) counted the same 44382312 bits; without stuffing — 39748005, by the worst-case estimate — 48335685. The longest delay of a frame by arbitration is 500 µs.

The run. First a ring of 65536 frames — to find the peak, then a ring of exactly the peak and of one less:

Format Ring peak Ring = peak One frame less Card writes 250 ms pauses Card busy
candump lines 153 0 lost 1 lost 4046 32 13194 ms
binary blocks 152 0 lost 1 lost 1625 13 5183 ms

153 frames of 24 bytes — 3672 bytes of RAM. That is what 600 frames/s needs. For a full bus the same model gives a simple calculation: a 250 ms pause plus writing a chunk (1 ms + 8 × 43.4 µs) is 251.35 ms, during which 1133 frames of 8 bytes arrive (27192 bytes) or 2674 empty frames (64176 bytes). The firmware has a ring of 1280 frames — 30720 bytes: a full bus of 8-byte frames passes in the model, empty ones do not. If the card actually stalls for longer than 250 ms, the ring has to be recalculated for its pause: the formula is the same — frames per second times the worst pause.


198т.5 The card: chunks, a file reserved in advance and power loss

Chunks of 4096 bytes. In the model each command costs 1 ms, so writing one sector at a time is expensive. A chunk of 8 sectors at an address that is a multiple of 4 (for DMA, 41а.6) fits a 32 KiB cluster exactly, and f_write passes it in a single command: for 16503698 bytes of line log — 4046 writes.

A file reserved in advance. f_expand reserves space for the file in one piece and sets the size at once. FatFS R0.11 from the STM32Cube package for F1 does not have this function yet; R0.15 does:

    if (f_expand(&fil, 64u * 1024u * 1024u, 1) != FR_OK || f_sync(&fil) != FR_OK)
        return -2;                          /* место — сразу одним куском, размер — сразу в каталог */

After that, writes go into clusters that are already allocated: the FAT and the directory do not change during the trip. In the run, on top of 32234 data sectors the card received 16 metadata sectors — all of them when the file was closed. For comparison, the usual way: the file grows, and f_sync writes the directory and the FAT once a second. The same log takes 5642 writes instead of 4046, 1612 metadata sectors, and the card is busy for 15359 ms instead of 13194.

The second call on that line — f_sync right after f_expand — is not redundant. Without it the new size stays only in the FIL structure in memory, and after a power loss the directory shows a file of length 0. A run without this call ended exactly that way: power loss after write 1300 — 0 bytes read.

Power loss. In the experiment the card "dies" after write N: nothing after that gets through. Then the image is mounted again, as at the next power-up, and the file is read from the beginning:

Method Power losses Lost at the end What was read is the start of the full log
lines, file reserved in advance 5 148…152 ms yes, all 5
binary blocks, file reserved in advance 2 375 ms yes, both
lines, growing file + f_sync once a second 5 448…1071 ms yes, all 5

With a file reserved in advance only the unwritten chunk is lost: 4096 bytes is 149 ms of lines and 374 ms of blocks at this trip's data rate. For blocks the loss is longer because a chunk of the same 4096 bytes holds more frames. With f_sync everything written after the last sync is lost: the directory does not know about the new data.

After a power loss, a file reserved in advance has its full size, and past the last intact data lies the old content of the card. The line reader cuts off at the first incomplete line or zero byte. The binary reader is more reliable: it walks through the blocks as long as the signature, the session number, consecutive block numbers and the CRC all match — the same as the log with a marker in 173а.4. A chunk smaller than 4096 bytes need not be lost at all if you manage to finish writing it on the PVD signal (173а.2): writing 8 sectors on the bus takes 0.35 ms, but how long the card will stay busy after that cannot be said without its datasheet.


198т.6 Analysis on a computer: can-utils and DBC

The line log is the native can-utils format. In the run:

The first frame of the trip in both forms — the log line and the same frame after log2asc:

(1790000000.001249) can0 086#0000000000000000
   0.000000 1  86              Rx   d 8 00 00 00 00 00 00 00 00

On a real computer with SocketCAN the log can be played back onto a bus: canplayer -I trip.log (19ж.5). In the experiment canplayer was not run: it needs a vcan interface in the kernel.

Decoding with vw_mqb.dbc by the parser from 19е produced a CSV with vehicle speed, engine speed, steering angle and temperature, and it agreed with the trip profile:

Signal Message DBC step Maximum Deviation from profile
ESP_v_Signal, km/h ESP_21 0x0FD 0.01 100.00 0.0050
MO_Drehzahl_01, rpm Motor_12 0x0A8 0.25 3400.00 0.1250
LWI_Lenkradwinkel, deg LWI_01 0x086 0.1 35.00 0.0500
MO_Kuehlmittel_Temp, °C Motor_07 0x640 0.75 90.00 0.3741

Each deviation is no more than half a step — this is rounding during packing, not a log error. The OBD responses for engine speed (PID 0x0C, formula (256A + B)/4 from 19г) — 1499 of them — differ from the Motor_12 engine speed in the log by no more than 0.25 rpm. One log, two independent sources of the same quantity, and they agree — this is how a real recording is checked as well.


198т.7 The complete logger code

The logger is four files of our own: the core header canlog.h, the hardware-independent core canlog.c, the F407 glue log_f407.c (bxCAN with timestamps, the interrupt, the file on the card) and the FatFS configuration ffconf.h. All of them are given below in full. The pieces from 198т.2, 198т.4 and 198т.5 are repeated here inside their functions.

FatFS R0.15 itself (ChaN, its own permissive licence: keep the notice in the sources) is not here — it is third-party code; ff.c, ff.h and diskio.h are taken from it unchanged. The disk_* disk layer on SDIO, the startup code with the vector table, the clock setup and main() calling logger_main() are not in the check sources: the disk layer is from 41а, and the firmware is built only to object files, without linking.

The build uses the same commands as the check. CMSIS_F4 is the Drivers/CMSIS/Device/ST/STM32F4xx/Include directory of the Keil STM32F4xx_DFP 2.17.1 pack (it contains stm32f4xx.h), CMSIS_CORE is CMSIS/Core/Include from CMSIS 5.9.0 (github.com/ARM-software/CMSIS_5), and the fatfs directory holds the FatFS files and ffconf.h:

CC="arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -Os -std=c11 -ffunction-sections -fdata-sections"
$CC -Wall -Wextra -Werror -pedantic -c canlog.c -o canlog.o
$CC -Wall -Wextra -Werror -DSTM32F407xx -I. -Ifatfs -I$CMSIS_F4 -I$CMSIS_CORE -c log_f407.c -o log_f407.o
$CC -w -Ifatfs -c fatfs/ff.c -o ff.o
arm-none-eabi-size canlog.o log_f407.o ff.o

The core header. A 24-byte frame, the ring, timestamp extension, the candump line, the binary block and the chunk accumulator. No registers and no HAL: the same file is built with gcc on a computer and with arm-none-eabi-gcc for the F407.

/* Раздел 198т: ядро регистратора CAN — кольцо кадров, продление метки времени,
 * строка candump, двоичные блоки по 512 байт и накопитель порций для карты.
 * Без регистров и без HAL: одно и то же собирается gcc на ПК и arm-none-eabi-gcc. */
#ifndef CANLOG_H
#define CANLOG_H
#include <stdint.h>

#define CL_EFF 0x80000000u          /* 29-битный идентификатор, как CAN_EFF_FLAG в SocketCAN */
#define CL_RTR 0x40000000u          /* запрос, как CAN_RTR_FLAG */

typedef struct {
    uint64_t t_us;                  /* время начала кадра, мкс от включения */
    uint32_t id;                    /* идентификатор и флаги CL_EFF, CL_RTR */
    uint8_t dlc;
    uint8_t data[8];
} cl_frame;                         /* 24 байта с выравниванием */

/* Кольцо: пишет только прерывание, читает только главный цикл. */
typedef struct {
    cl_frame *buf;
    uint32_t size;
    volatile uint32_t head, tail;
    uint32_t lost, peak;
} cl_ring;

void cl_ring_init(cl_ring *r, cl_frame *buf, uint32_t size);
int cl_push(cl_ring *r, const cl_frame *f);      /* 0 — места нет, кадр потерян */
int cl_pop(cl_ring *r, cl_frame *f);             /* 0 — пусто */

/* Метка bxCAN (TTCM): 16-битный счётчик тактов бита, снятый в точке выборки SOF.
 * Счётчик не читается, поэтому время продлевается разностями соседних меток,
 * а грубое время (TIM2, мкс) нужно, только чтобы поймать паузу длиннее круга. */
typedef struct {
    uint64_t t_us;
    uint32_t last_coarse;
    uint16_t last_hw;
    uint8_t started;
    uint8_t us_per_tick;            /* 2 мкс на 500 кбит/с */
    uint32_t max_gap_us;            /* меньше круга счётчика: 65 536 тактов */
    uint32_t reanchors;
} cl_tsx;

void cl_tsx_init(cl_tsx *x, unsigned us_per_tick);
uint64_t cl_tsx_bx(cl_tsx *x, uint32_t coarse_us, uint16_t hw);

/* Строка журнала candump -L: "(1436509052.249713) can0 7E8#0441...\n" */
unsigned cl_fmt_candump(const cl_frame *f, uint64_t epoch_us, char *out);

/* Двоичный формат: блоки по 512 байт, в каждом заголовок 24 байта и записи
 * 9 + dlc байт; запись не пересекает границу блока. */
#define CL_BLK 512u
#define CL_HDR 24u
#define CL_MAGIC 0x31474C43u        /* "CLG1" */
typedef struct {
    uint8_t blk[CL_BLK];
    unsigned used;
    uint32_t seq, session;
    uint64_t base_us;
    uint16_t n;
} cl_bin;

uint32_t cl_crc32(const uint8_t *p, unsigned n);
void cl_bin_init(cl_bin *b, uint32_t session);
/* 1 — блок заполнен и готов (cl_bin_take), кадр ещё не положен: повторить вызов */
int cl_bin_add(cl_bin *b, const cl_frame *f);
const uint8_t *cl_bin_take(cl_bin *b);           /* закрыть блок: CRC и номер */

/* Накопитель порции: копит байты до chunk (кратно 512) и отдаёт порцию целиком. */
typedef int (*cl_write_fn)(void *ctx, const uint8_t *p, unsigned n);
typedef struct {
    uint8_t *buf;
    unsigned chunk, fill;
    cl_write_fn write;
    void *ctx;
    uint32_t chunks, errors;
} cl_sink;

void cl_sink_init(cl_sink *s, uint8_t *buf, unsigned chunk, cl_write_fn w, void *ctx);
void cl_sink_put(cl_sink *s, const void *p, unsigned n);
void cl_sink_flush(cl_sink *s);                  /* хвост при остановке, добит до 512 нулями */

#endif

The ring. Head and tail are frame counters, the array index is the remainder of division by the size, and the used space is their difference:

/* Раздел 198т: ядро регистратора CAN. */
#include "canlog.h"
#include <string.h>

void cl_ring_init(cl_ring *r, cl_frame *buf, uint32_t size)
{
    r->buf = buf;
    r->size = size;
    r->head = r->tail = 0;
    r->lost = r->peak = 0;
}

int cl_push(cl_ring *r, const cl_frame *f)
{
    uint32_t used = r->head - r->tail;
    if (used >= r->size) {              /* места нет: старое не затираем, считаем потерю */
        r->lost++;
        return 0;
    }
    r->buf[r->head % r->size] = *f;
    r->head++;                          /* после данных: читатель не увидит недописанный кадр */
    if (used + 1u > r->peak)
        r->peak = used + 1u;
    return 1;
}

int cl_pop(cl_ring *r, cl_frame *f)
{
    if (r->head == r->tail)
        return 0;
    *f = r->buf[r->tail % r->size];
    r->tail++;
    return 1;
}

The timestamp. The threshold is half a turn of the TTCM counter: 65536 ticks × 2 µs / 2 = 65536 µs (198т.2):

void cl_tsx_init(cl_tsx *x, unsigned us_per_tick)
{
    memset(x, 0, sizeof *x);
    x->us_per_tick = (uint8_t)us_per_tick;
    x->max_gap_us = 65536u * us_per_tick / 2u;   /* половина круга счётчика */
}

uint64_t cl_tsx_bx(cl_tsx *x, uint32_t coarse_us, uint16_t hw)
{
    uint32_t dc = coarse_us - x->last_coarse;   /* TIM2 32 бита: разность верна и через переполнение */
    if (!x->started || dc > x->max_gap_us) {
        x->t_us = coarse_us;                    /* пауза длиннее полукруга: якорь по грубому времени */
        if (x->started)
            x->reanchors++;
        x->started = 1;
    } else {
        uint16_t d = (uint16_t)(hw - x->last_hw);   /* тактов бита с прошлого SOF */
        x->t_us += (uint64_t)d * x->us_per_tick;
    }
    x->last_coarse = coarse_us;
    x->last_hw = hw;
    return x->t_us;
}

The candump line. Without printf: the time digits and the frame bytes are written directly into the line buffer. dec() pads the number with zeros on the left to the given width — six digits of microseconds:

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

static char *dec(char *p, uint64_t v, unsigned width)
{
    char tmp[20];
    unsigned n = 0;
    do {
        tmp[n++] = (char)('0' + v % 10u);
        v /= 10u;
    } while (v || n < width);
    while (n)
        *p++ = tmp[--n];
    return p;
}

unsigned cl_fmt_candump(const cl_frame *f, uint64_t epoch_us, char *out)
{
    char *p = out;
    uint64_t t = epoch_us + f->t_us;
    uint32_t id = f->id & ((f->id & CL_EFF) ? 0x1FFFFFFFu : 0x7FFu);
    int n = (f->id & CL_EFF) ? 8 : 3;
    *p++ = '(';
    p = dec(p, t / 1000000u, 10u);
    *p++ = '.';
    p = dec(p, t % 1000000u, 6u);
    memcpy(p, ") can0 ", 7);
    p += 7;
    while (n--)
        *p++ = HEX[(id >> (4 * n)) & 0xFu];
    *p++ = '#';
    if (f->id & CL_RTR) {
        *p++ = 'R';
    } else {
        for (unsigned i = 0; i < f->dlc && i < 8u; i++) {
            *p++ = HEX[f->data[i] >> 4];
            *p++ = HEX[f->data[i] & 0xFu];
        }
    }
    *p++ = '\n';
    return (unsigned)(p - out);
}

The binary block. A CRC-32 with the reflected polynomial 0xEDB88320 is computed over the block body, and its lower 16 bits go into the header together with the number of records. All numbers are little-endian. cl_bin_add() returns 1 when the frame does not fit: in the run on the computer the loop while (cl_bin_add(&bin, &f)) passes the block closed by cl_bin_take() to the accumulator and puts the frame into the next one:

uint32_t cl_crc32(const uint8_t *p, unsigned n)
{
    uint32_t c = 0xFFFFFFFFu;
    while (n--) {
        c ^= *p++;
        for (int k = 0; k < 8; k++)
            c = (c >> 1) ^ (0xEDB88320u & (0u - (c & 1u)));
    }
    return ~c;
}

static void put32(uint8_t *p, uint32_t v)
{
    p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24);
}

void cl_bin_init(cl_bin *b, uint32_t session)
{
    memset(b, 0, sizeof *b);
    b->session = session;
    b->used = CL_HDR;
}

int cl_bin_add(cl_bin *b, const cl_frame *f)
{
    unsigned len = 9u + ((f->id & CL_RTR) ? 0u : f->dlc);
    uint8_t *p;
    if (b->n == 0)
        b->base_us = f->t_us;                   /* время блока — по первому кадру */
    if (b->used + len > CL_BLK || f->t_us - b->base_us > 0xFFFFFFFFu)
        return 1;                               /* не влезает: сначала закрыть блок */
    p = b->blk + b->used;
    put32(p, (uint32_t)(f->t_us - b->base_us)); /* мкс от начала блока */
    put32(p + 4, f->id);
    p[8] = f->dlc;
    memcpy(p + 9, f->data, len - 9u);
    b->used += len;
    b->n++;
    return 0;
}

const uint8_t *cl_bin_take(cl_bin *b)
{
    uint8_t *h = b->blk;
    memset(b->blk + b->used, 0, CL_BLK - b->used);
    put32(h, CL_MAGIC);
    put32(h + 4, b->session);
    put32(h + 8, b->seq);
    put32(h + 12, (uint32_t)b->base_us);
    put32(h + 16, (uint32_t)(b->base_us >> 32));
    put32(h + 20, (uint32_t)b->n | ((cl_crc32(b->blk + CL_HDR, CL_BLK - CL_HDR) & 0xFFFFu) << 16));
    b->seq++;
    b->used = CL_HDR;
    b->n = 0;
    return b->blk;
}

The chunk accumulator. It collects bytes up to 4096 and passes the chunk in one call; the tail at shutdown is padded with zeros to a whole sector:

void cl_sink_init(cl_sink *s, uint8_t *buf, unsigned chunk, cl_write_fn w, void *ctx)
{
    s->buf = buf;
    s->chunk = chunk;
    s->fill = 0;
    s->write = w;
    s->ctx = ctx;
    s->chunks = s->errors = 0;
}

void cl_sink_put(cl_sink *s, const void *p, unsigned n)
{
    const uint8_t *q = p;
    while (n) {
        unsigned k = s->chunk - s->fill;
        if (k > n)
            k = n;
        memcpy(s->buf + s->fill, q, k);
        s->fill += k;
        q += k;
        n -= k;
        if (s->fill == s->chunk) {              /* порция полна: целыми секторами на карту */
            if (s->write(s->ctx, s->buf, s->chunk))
                s->errors++;
            s->chunks++;
            s->fill = 0;
        }
    }
}

void cl_sink_flush(cl_sink *s)
{
    unsigned n = (s->fill + CL_BLK - 1u) / CL_BLK * CL_BLK;
    if (!n)
        return;
    memset(s->buf + s->fill, 0, n - s->fill);
    if (s->write(s->ctx, s->buf, n))
        s->errors++;
    s->fill = 0;
}

F407 glue: TIM2 and bxCAN. TIM2 is the coarse time in microseconds, bxCAN is in "listen only" mode with TTCM timestamps, and filter 0 with an all-zero mask accepts all frames into FIFO 0:

/* Раздел 198т: регистратор на STM32F407 — bxCAN молчит и ставит метки (TTCM),
 * прерывание кладёт кадр в кольцо, главный цикл пишет журнал на карту через FatFS.
 * Слой диска (disk_*) — SDIO из раздела 41а, здесь не повторяется. */
#include "stm32f4xx.h"
#include "canlog.h"
#include "ff.h"

#define RING_N 1280u                        /* 30 КиБ: полная шина кадрами по 8 байт, 198т.3 */
static cl_frame ring_buf[RING_N];
static cl_ring ring;
static cl_tsx tsx;
static uint8_t chunk[4096] __attribute__((aligned(4)));   /* порция: 8 секторов, адрес кратен 4 для DMA */
static FATFS fs;
static FIL fil;
static cl_sink sink;

void can_listen_500k(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_CAN1EN | RCC_APB1ENR_TIM2EN;
    TIM2->PSC = 84u - 1u;                   /* таймеры APB1 84 МГц / 84 = 1 МГц: грубое время в мкс */
    TIM2->ARR = 0xFFFFFFFFu;
    TIM2->EGR = TIM_EGR_UG;
    TIM2->CR1 = TIM_CR1_CEN;

    CAN1->MCR = CAN_MCR_INRQ | CAN_MCR_TTCM;    /* инициализация; метка — такт бита в SOF */
    while (!(CAN1->MSR & CAN_MSR_INAK)) { }
    CAN1->BTR = CAN_BTR_SILM                    /* только слушать: на шину ни одного доминантного бита */
              | ((2u - 1u) << 20) | ((11u - 1u) << 16) | (6u - 1u);   /* 42 МГц / 6 / 14 кв. = 500 кбит/с */
    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;
    CAN1->MCR &= ~CAN_MCR_INRQ;
    while (CAN1->MSR & CAN_MSR_INAK) { }
    NVIC_EnableIRQ(CAN1_RX0_IRQn);
}

The receive interrupt. The TIME stamp is the upper 16 bits of RDTR; the identifier is converted to the SocketCAN form, and the data bytes are taken from RDLR and RDHR least significant first:

void CAN1_RX0_IRQHandler(void)
{
    while (CAN1->RF0R & CAN_RF0R_FMP0) {
        uint32_t coarse = TIM2->CNT;
        uint32_t rir = CAN1->sFIFOMailBox[0].RIR, rdt = CAN1->sFIFOMailBox[0].RDTR;
        uint32_t lo = CAN1->sFIFOMailBox[0].RDLR, hi = CAN1->sFIFOMailBox[0].RDHR;
        cl_frame f;
        f.t_us = cl_tsx_bx(&tsx, coarse, (uint16_t)(rdt >> 16));   /* TIME[15:0] из RDTR */
        f.id = (rir & CAN_RI0R_IDE) ? (CL_EFF | (rir >> 3)) : (rir >> 21);
        if (rir & CAN_RI0R_RTR)
            f.id |= CL_RTR;
        f.dlc = (uint8_t)((rdt & 0xFu) > 8u ? 8u : (rdt & 0xFu));
        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;
        cl_push(&ring, &f);
    }
}

The main loop. A 64 MiB file reserved in advance, then: a frame from the ring, a candump line, the accumulator. The firmware writes a line log; the binary variant is exercised only by the check on the computer:

static int fs_write(void *ctx, const uint8_t *p, unsigned n)
{
    UINT bw;
    (void)ctx;
    return f_write(&fil, p, n, &bw) != FR_OK || bw != n;
}

int logger_main(uint64_t epoch_us)
{
    static char line[48];
    cl_frame f;
    cl_ring_init(&ring, ring_buf, RING_N);
    cl_tsx_init(&tsx, 2u);
    if (f_mount(&fs, "", 1) != FR_OK || f_open(&fil, "TRIP0001.LOG", FA_CREATE_ALWAYS | FA_WRITE) != FR_OK)
        return -1;
    if (f_expand(&fil, 64u * 1024u * 1024u, 1) != FR_OK || f_sync(&fil) != FR_OK)
        return -2;                          /* место — сразу одним куском, размер — сразу в каталог */
    cl_sink_init(&sink, chunk, sizeof chunk, fs_write, 0);
    can_listen_500k();
    for (;;) {
        if (cl_pop(&ring, &f))
            cl_sink_put(&sink, line, cl_fmt_candump(&f, epoch_us, line));
    }
}

FatFS configuration. FF_USE_EXPAND = 1 enables f_expand, there are no long names (FF_USE_LFN = 0), the sector is exactly 512 bytes, the file system has no clock (FF_FS_NORTC = 1); f_mkfs is enabled only for the run on the computer (CL_HOST), where the card image is formatted before each experiment:

/* Раздел 198т: настройки FatFS R0.15 для регистратора: f_expand включён, короткие имена, сектор 512; f_mkfs только на ПК. */
#define FFCONF_DEF  80286
#define FF_FS_READONLY  0
#define FF_FS_MINIMIZE  0
#define FF_USE_FIND     0
#ifdef CL_HOST
#define FF_USE_MKFS     1
#else
#define FF_USE_MKFS     0
#endif
#define FF_USE_FASTSEEK 0
#define FF_USE_EXPAND   1
#define FF_USE_CHMOD    0
#define FF_USE_LABEL    0
#define FF_USE_FORWARD  0
#define FF_USE_STRFUNC  0
#define FF_PRINT_LLI    0
#define FF_PRINT_FLOAT  0
#define FF_STRF_ENCODE  3
#define FF_CODE_PAGE    437
#define FF_USE_LFN      0
#define FF_MAX_LFN      255
#define FF_LFN_UNICODE  0
#define FF_LFN_BUF      255
#define FF_SFN_BUF      12
#define FF_FS_RPATH     0
#define FF_VOLUMES      1
#define FF_STR_VOLUME_ID    0
#define FF_VOLUME_STRS      "RAM"
#define FF_MULTI_PARTITION  0
#define FF_MIN_SS       512
#define FF_MAX_SS       512
#define FF_LBA64        0
#define FF_MIN_GPT      0x10000000
#define FF_USE_TRIM     0
#define FF_FS_TINY      0
#define FF_FS_EXFAT     0
#define FF_FS_NORTC     1
#define FF_NORTC_MON    1
#define FF_NORTC_MDAY   1
#define FF_NORTC_YEAR   2025
#define FF_FS_NOFSINFO  0
#define FF_FS_LOCK      0
#define FF_FS_REENTRANT 0
#define FF_FS_TIMEOUT   1000
#define FF_USE_DYN_BUFFER 0

198т.8 Pitfalls

The file has length 0 after a power loss. f_expand changes the size in memory, and it gets into the directory on f_sync or f_close. Call f_sync right after f_expand.

The time jumps on bus pauses. The TTCM counter wraps around in 131 ms at 500 kbit/s, and a single timestamp difference cannot reveal a pause longer than a full turn. You need a second, coarse source — TIM2 — and a half-turn threshold; with FDCAN it is simpler to use TIM3 through TSS = 10.

The TTCM timestamp is not UNIX time. The bxCAN counter is not tied to the RTC or to the timers. Time of day comes only by anchoring: the RTC clock at start-up plus microseconds from the anchor.

A "just in case" half-second ring still overflowed. The card pause is a property of the card, not of the firmware. The 250 ms figure here is a model. For the card you choose, the pause has to be measured: write a stream and watch the ring peak, as the run does.

The chunk is not a multiple of 512 or the buffer is not aligned. FatFS then writes through its own window one sector at a time, with read-modify-write, and SDIO DMA will not accept an address that is not a multiple of 4 (41а.5).

A line is cut off in the middle. After a power loss the last line of the log may be incomplete. Parse only lines that end with a newline.

The logger affected the bus. Without SILM bxCAN acknowledges other nodes' frames, and at a wrong bit rate it sends error frames (198е.7). In a car — only "listen only" mode.


198т.9 Summary

Next: section 198у "Mini-project: your own unit on the CAN bus — sensor, DBC, UDS diagnostics and flashing over the bus" — so far the nodes on the bus belonged to someone else: they were queried, listened to and recorded; now comes a node of your own — with its own message in the DBC, its own diagnostic trouble codes and firmware updates over the bus.

Open in the interactive handbook