Embedded HandbookПриложения
Все разделы › XXV. Мини-проекты
STM32F407, STM32G474 · уровень средний · 28 мин чтения
Открыть в интерактивном справочнике

198т. Мини-проект: регистратор шины CAN на карту SD — запись поездки и разбор на компьютере

Сниффер из 198е отдаёт кадры компьютеру, пока тот подключён. Сканер из 198р спрашивает блоки сам, панель из 198с показывает то, что слышит. Здесь прибор без экрана и без компьютера: чёрный ящик, который включается с зажиганием, молча слушает шину и пишет каждый кадр с точным временем на карту SD. После поездки карту вынимают, а журнал разбирают на компьютере инструментами из 19ж и файлом DBC из 19е.

Трудность не в CAN и не в карте по отдельности, а в стыке. Кадры идут без остановки, а карта время от времени замолкает на уборку. Питание в машине пропадает без предупреждения. Поэтому главное в разделе — сколько кадров держать в памяти, как писать на карту и что останется в файле после обрыва.

Проверено без машины и без карты. Ядро регистратора (canlog.c) собрано gcc на компьютере и прогнано через настоящий FatFS R0.15 на образе карты в памяти: синтетическая поездка на 10 минут, кадры по vw_mqb.dbc, задержки карты по модели, обрывы питания посреди записи. Журнал проверили разборщик строк и log2asc из can-utils и разборщик DBC из 19е. Прошивка F407 собрана arm-none-eabi-gcc; на плате и в машине она не запускалась.

Опоры: RM0090 (глава bxCAN: режим TTCM, раздел 32.7.2; поле TIME в CAN_RDTxR), RM0440 (FDCAN: счётчик меток, FDCAN_TSCC, поле RXTS) из паков Keil. FatFS R0.15 (ChaN) — копия из образа ESP-IDF 5.4.1. Лицензия: FatFS — своя свободная (сохранять уведомление в исходниках), can-utils (lib.c, canframelen.c, log2asc.c) — GPL-2.0-only или BSD-3-Clause на выбор, opendbc — MIT; копии файлов лежат в проверке вместе с лицензиями. bxCAN и сниффер — 19 и 198е, SDIO и слой диска — 41а, FatFS и отключение питания — 42.6, журнал с меткой последней — 173а.4, DBC — 19е, can-utils — 19ж.5.

Как это называется в документации

Оригинал Перевод
time triggered communication mode (TTCM) режим с метками времени у bxCAN
message time stamp, TIME[15:0] метка кадра: 16-битный счётчик тактов бита
sample point of the Start Of Frame bit точка выборки первого бита кадра (SOF)
timestamp counter, TSCC.TCP, TSCC.TSS счётчик меток FDCAN, его делитель и источник
RXTS метка в элементе очереди приёма FDCAN
contiguous allocation, f_expand выделить файлу место одним куском заранее
f_sync записать на носитель данные и каталог, не закрывая файл
log file (candump -l / -L) журнал can-utils: время, интерфейс, кадр в виде cansend
ASC log текстовый журнал Vector, в него переводит log2asc

198т.1 Что делает прибор и из чего он собран

Прибор — F407 с трансивером (19.2) и гнездом карты microSD на четырёх линиях SDIO (41а). bxCAN включён в режиме «только слушать» (бит SILM, как в 198е): кадры принимает, но на шину не выдаёт ни одного доминантного бита — не подтверждает, не шлёт кадров ошибки. Для чёрного ящика это обязательно: прибор, влезший в шину машины, не имеет права её тревожить.

Путь кадра в регистраторе на F407: шина CAN 500 кбит/с и трансивер, bxCAN в режимах SILM и TTCM с фильтром принимать всё, прерывание очереди 0 берёт метку TIME и время TIM2, кольцо кадров по 24 байта, главный цикл делает строку candump или двоичный блок, порция 4096 байт из 8 секторов, FatFS R0.15 с файлом, выделенным заранее через f_expand на 64 МиБ, SDIO на 4 линиях и 24 МГц — 43,4 мкс на сектор, карта SD с моделью задержек 1 мс на запись и 250 мс раз в 512 КиБ; внизу итог поездки на 10 минут и размеры строки и двоичной записи

Путь кадра:

  1. Прерывание очереди 0 забирает кадр из ящика bxCAN вместе с меткой времени и кладёт в кольцо в памяти. Больше ничего: никаких строк, никакой карты в прерывании.
  2. Главный цикл вынимает кадры из кольца и превращает их в строку журнала candump или в запись двоичного блока (198т.3).
  3. Накопитель собирает байты в порцию на 4096 байт — восемь секторов — и отдаёт её в f_write целиком. FatFS пишет такую порцию прямо из буфера, без своего окна, одной командой.

Кольцо стоит между шиной, которая не ждёт, и картой, которая ждать заставляет. Его размер считает 198т.4.


198т.2 Метка времени: TTCM у bxCAN и счётчик FDCAN

Время кадра нужно с точностью до микросекунд: по нему на компьютере восстанавливают порядок событий и период каждого сообщения. Прочитать таймер в прерывании — неточно: между концом кадра и входом в прерывание проходит разное время, а начало кадра отстоит от конца ещё на 47…160 бит.

bxCAN, режим TTCM. По RM0090 (32.7.2) бит TTCM в CAN_MCR включает внутренний счётчик bxCAN. Он прибавляет единицу каждый такт бита и снимается в точке выборки бита SOF — и при приёме, и при передаче. Значение лежит в старших 16 битах CAN_RDTxR, поле TIME[15:0]. На 500 кбит/с такт бита — 2 мкс, и счётчик проходит круг за 65 536 × 2 = 131 072 мкс.

Сам счётчик не читается: регистра со значением «сейчас» у bxCAN нет. Поэтому метка продлевается разностями. Соседние кадры на живой шине идут чаще, чем раз в полкруга, и разность двух меток по модулю 2¹⁶ — точное число тактов между их SOF. Грубое время от 32-битного TIM2 (1 МГц) нужно только чтобы заметить паузу длиннее полукруга и заново привязаться к нему:

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;
}

Порог — полкруга, 65 536 мкс: между двумя прерываниями грубое время уходит вперёд на интервал между SOF плюс разницу задержек входа, и запас в полкруга эту разницу покрывает. Метка якоря сдвинута от истинного SOF на длину кадра и задержку прерывания — на прогоне это 249 и 271 мкс для двух участков, а интервалы внутри участка точны до такта бита.

FDCAN у G474. По RM0440 у FDCAN свой 16-битный счётчик меток. Поле TCP в FDCAN_TSCC задаёт шаг в 1…16 тактов бита, поле TSS — источник: 01 — свой счётчик, 10 — счётчик TIM3. Значение снимается в начале кадра и ложится в поле RXTS элемента очереди приёма. Для CAN FD RM0440 прямо требует внешний счётчик: такт бита в фазе данных другой, и свой счётчик не даёт ровной шкалы. Вариант с TIM3 удобнее и для классического CAN: TIM3 на 1 МГц — метка сразу в микросекундах, а счётчик читается, так что продлевать её можно по текущему значению TIM3, без угадывания пауз.


198т.3 Сколько кадров и сколько байт: шина и карта

Худший случай — шина на 100 % короткими кадрами. Длина стандартного кадра без вставок — 47 + 8n бит вместе с паузой в 3 бита, так же как в 198е.3 и в can_frame_length() из can-utils. На 500 кбит/с:

Кадр Бит Кадров/с Строка candump Байт/с строками Двоичная запись Байт/с блоками
DLC 0 47 10 638 30 319 149 9 100 867
DLC 8 111 4505 46 207 207 17 82 368

Два формата журнала:

На полной шине блоки дают в 2,52…3,16 раза меньше байтов в секунду, чем строки. Но главное в двоичном формате — не размер. Каждый блок сам себя проверяет, и читатель после обрыва питания точно знает, где кончаются целые данные (198т.5).

Даже худший поток — 319 149 байт/с — это меньше 3 % от 11,8 МБ/с, которые SDIO на четырёх линиях и 24 МГц даёт по шине (41а.1). Скорость карты — не узкое место. Узкое место — её паузы.


198т.4 Кольцо и паузы карты: сколько кадров держать

Карта отвечает «принято», а потом держит линию D0 занятой, пока пишет (41а.6). Обычно это доли миллисекунды, но время от времени карта уходит на уборку внутри себя, и запись стоит заметно дольше. Замеров задержек настоящих карт здесь нет, поэтому это модель, а не данные карты:

Пока карта стоит, главный цикл стоит вместе с ней (f_write ждёт), а прерывание продолжает класть кадры в кольцо. Кольцо пишет только прерывание, читает только цикл — запрет прерываний не нужен, как в 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;
}

Поездка для опыта — 10 минут по vw_mqb.dbc: руль LWI_01, обороты Motor_12 и ещё два сообщения раз в 10 мс, скорость ESP_21 и ещё два раз в 20 мс, температура Motor_07 и ещё три раз в 100 мс, плюс опрос OBD-II каждые 200 мс: запрос 0x7DF и ответ 0x7E8 через 3 мс (19г). Профиль: стоянка, разгон до 90 км/ч, трасса, город, остановка; двигатель прогревается с 20 до 90 °C. На 5-й секунде шина молчит 300 мс — пуск двигателя. Периоды и профиль — параметры модели, не запись настоящей машины. Кадры выходят по арбитражу, длина каждого — с настоящей вставкой битов.

Итог генератора: 359 819 кадров, 13 идентификаторов, 599,7 кадра/с, загрузка шины 14,79 %; из 44 382 312 бит 4 634 307 — вставленные. can-utils (can_frame_length, точный режим) насчитала те же 44 382 312 бит; без вставок — 39 748 005, по худшей оценке — 48 335 685. Самая долгая задержка кадра арбитражем — 500 мкс.

Прогон. Сначала кольцо на 65 536 кадров — узнать пик, потом кольцо ровно на пик и на единицу меньше:

Формат Пик кольца Кольцо на пик На кадр меньше Записей на карту Пауз по 250 мс Карта занята
строки candump 153 0 потерь 1 потеря 4046 32 13 194 мс
двоичные блоки 152 0 потерь 1 потеря 1625 13 5183 мс

153 кадра по 24 байта — 3672 байта ОЗУ. Столько нужно на 600 кадров/с. Для полной шины та же модель даёт простой счёт: пауза 250 мс плюс запись порции (1 мс + 8 × 43,4 мкс) — это 251,35 мс, за которые придёт 1133 кадра по 8 байт (27 192 байта) или 2674 пустых кадра (64 176 байт). В прошивке кольцо на 1280 кадров — 30 720 байт: полная шина кадрами по 8 байт в модели проходит, пустыми — нет. Если карта на деле замирает дольше 250 мс, кольцо надо пересчитать под её паузу: формула та же — кадров в секунду на худшую паузу.


198т.5 Карта: порции, файл заранее и обрыв питания

Порции по 4096 байт. В модели каждая команда стоит 1 мс, поэтому писать по одному сектору дорого. Порция в 8 секторов с адресом, кратным 4 (для DMA, 41а.6), ложится на кластер 32 КиБ ровно, и f_write отдаёт её одной командой: на 16 503 698 байт журнала строками — 4046 записей.

Файл заранее. f_expand выделяет файлу место одним куском и сразу ставит размер. В FatFS R0.11 из пакета STM32Cube для F1 этой функции ещё нет, в R0.15 она есть:

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

После этого запись идёт в уже занятые кластеры: таблица FAT и каталог по ходу поездки не меняются. На прогоне сверх 32 234 секторов данных карта получила 16 служебных — всё при закрытии файла. Для сравнения обычный путь: файл растёт, а f_sync раз в секунду записывает каталог и таблицу. Тот же журнал — 5642 записи вместо 4046, 1612 служебных секторов, карта занята 15 359 мс вместо 13 194.

Второй вызов в строке — f_sync сразу после f_expand — не лишний. Без него новый размер остаётся только в структуре FIL в памяти, и после обрыва питания каталог покажет файл длиной 0. Прогон без этого вызова так и закончился: обрыв после 1300-й записи — прочитано 0 байт.

Обрыв питания. В опыте карта «умирает» после N-й записи: всё дальнейшее не доходит. Затем образ монтируется заново, как при следующем включении, и файл читается с начала:

Способ Обрывов Потеряно в конце Прочитанное — начало полного журнала
строки, файл заранее 5 148…152 мс да, все 5
двоичные блоки, файл заранее 2 375 мс да, оба
строки, рост файла + f_sync раз в секунду 5 448…1071 мс да, все 5

С файлом заранее теряется только несписанная порция: 4096 байт — это 149 мс строками и 374 мс блоками при потоке этой поездки. Для блоков потеря дольше потому, что порция из тех же 4096 байт вмещает больше кадров. С f_sync теряется всё, что записано после последней синхронизации: каталог о новых данных не знает.

После обрыва файл, выделенный заранее, имеет полный размер, а за последними целыми данными лежит старое содержимое карты. Строки читатель обрезает на первой неполной строке или нулевом байте. Двоичный читатель надёжнее: он идёт по блокам, пока сходятся сигнатура, номер сеанса, номер блока подряд и CRC — так же, как журнал с меткой в 173а.4. Порцию меньше 4096 байт можно не терять вовсе, если успеть дописать её по сигналу PVD (173а.2): на запись 8 секторов по шине уходит 0,35 мс, но сколько карта будет занята после этого, без её паспорта не сказать.


198т.6 Разбор на компьютере: can-utils и DBC

Журнал строками — родной формат can-utils. На прогоне:

Первый кадр поездки в обоих видах — строка журнала и она же после log2asc:

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

На живом компьютере с SocketCAN журнал проигрывается обратно на шину: canplayer -I trip.log (19ж.5). В опыте canplayer не запускался: нужен интерфейс vcan в ядре.

Расшифровка по vw_mqb.dbc разборщиком из 19е дала CSV со скоростью, оборотами, углом руля и температурой и сошлась с профилем поездки:

Сигнал Сообщение Шаг в DBC Наибольшее Расхождение с профилем
ESP_v_Signal, км/ч ESP_21 0x0FD 0,01 100,00 0,0050
MO_Drehzahl_01, об/мин Motor_12 0x0A8 0,25 3400,00 0,1250
LWI_Lenkradwinkel, град LWI_01 0x086 0,1 35,00 0,0500
MO_Kuehlmittel_Temp, °C Motor_07 0x640 0,75 90,00 0,3741

Каждое расхождение не больше половины шага — это округление при упаковке, а не ошибка журнала. Ответы OBD на обороты (PID 0x0C, формула (256A + B)/4 из 19г) — их 1499 — отличаются от оборотов Motor_12 в журнале не больше чем на 0,25 об/мин. Один журнал, два независимых источника одной величины, и они согласны — так проверяют и настоящую запись.


198т.7 Полный код регистратора

Регистратор — четыре своих файла: заголовок ядра canlog.h, ядро без железа canlog.c, обвязка F407 log_f407.c (bxCAN с метками, прерывание, файл на карте) и настройки FatFS ffconf.h. Ниже все они целиком. Куски из 198т.2, 198т.4 и 198т.5 здесь повторяются внутри своих функций.

Самой FatFS R0.15 (ChaN, своя свободная лицензия: уведомление в исходниках сохранять) здесь нет — это чужой код, из неё берутся ff.c, ff.h и diskio.h без правок. Слоя диска disk_* на SDIO, стартового кода с таблицей векторов, настройки тактов и main() с вызовом logger_main() в исходниках проверки нет: слой диска — из 41а, а прошивка собирается только до объектных файлов, без компоновки.

Сборка — те же команды, что в проверке. CMSIS_F4 — каталог Drivers/CMSIS/Device/ST/STM32F4xx/Include пака Keil STM32F4xx_DFP 2.17.1 (в нём stm32f4xx.h), CMSIS_CORE — CMSIS/Core/Include из CMSIS 5.9.0 (github.com/ARM-software/CMSIS_5), в каталоге fatfs — файлы FatFS и 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

Заголовок ядра. Кадр на 24 байта, кольцо, продление метки, строка candump, двоичный блок и накопитель порций. Ни регистров, ни HAL: тот же файл собирается gcc на компьютере и arm-none-eabi-gcc под 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

Кольцо. Голова и хвост — счётчики кадров, индекс в массиве — остаток от деления на размер, занятое место — их разность:

/* Раздел 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;
}

Метка времени. Порог — полкруга счётчика TTCM: 65 536 тактов × 2 мкс / 2 = 65 536 мкс (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;
}

Строка candump. Без printf: цифры времени и байты кадра пишутся прямо в буфер строки. dec() добивает число нулями слева до заданной ширины — шесть цифр микросекунд:

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);
}

Двоичный блок. CRC-32 с отражённым полиномом 0xEDB88320 считается по телу блока, в заголовок идут её младшие 16 бит вместе с числом записей. Все числа — младшим байтом вперёд. cl_bin_add() возвращает 1, когда кадр не влезает: в прогоне на компьютере цикл while (cl_bin_add(&bin, &f)) отдаёт закрытый cl_bin_take() блок в накопитель и кладёт кадр в следующий:

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;
}

Накопитель порций. Копит байты до 4096 и отдаёт порцию одним вызовом; хвост при остановке добивается нулями до целого сектора:

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: TIM2 и bxCAN. TIM2 — грубое время в микросекундах, bxCAN — «только слушать» с метками TTCM, фильтр 0 с маской из нулей принимает все кадры в очередь 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);
}

Прерывание приёма. Метка TIME — старшие 16 бит RDTR; идентификатор приводится к виду SocketCAN, байты данных — из RDLR и RDHR младшим вперёд:

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);
    }
}

Главный цикл. Файл на 64 МиБ заранее, дальше — кадр из кольца, строка candump, накопитель. В прошивке — журнал строками; двоичный вариант прогоняет только проверка на компьютере:

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. FF_USE_EXPAND = 1 включает f_expand, длинных имён нет (FF_USE_LFN = 0), сектор — ровно 512 байт, часов у файловой системы нет (FF_FS_NORTC = 1); f_mkfs включена только для прогона на компьютере (CL_HOST), где образ карты размечается перед каждым опытом:

/* Раздел 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 Грабли

Файл после обрыва — длиной 0. f_expand меняет размер в памяти, а в каталог он попадёт при f_sync или f_close. Вызывать f_sync сразу после f_expand.

Время скачет на паузах шины. Счётчик TTCM проходит круг за 131 мс на 500 кбит/с, и по одной разности меток паузу длиннее круга не увидеть. Нужен второй, грубый источник — TIM2 — и порог в полкруга; у FDCAN проще брать TIM3 через TSS = 10.

Метка TTCM — не время UNIX. Счётчик bxCAN не связан ни с RTC, ни с таймерами. Время суток — только привязкой: часы RTC при старте плюс микросекунды от якоря.

Кольцо в полсекунды «на всякий случай» всё равно переполнилось. Пауза карты — свойство карты, а не прошивки. Цифра 250 мс здесь — модель. Под выбранную карту паузу надо мерить: писать поток и смотреть пик кольца, как делает прогон.

Порция не кратна 512 или буфер не выровнен. FatFS тогда пишет через своё окно по сектору, с чтением и дозаписью, а DMA SDIO не примет адрес, не кратный 4 (41а.5).

Строка обрезана посередине. После обрыва последняя строка журнала бывает недописанной. Разбирать только строки, заканчивающиеся переводом строки.

Регистратор повлиял на шину. Без SILM bxCAN подтверждает чужие кадры, а при неверной скорости шлёт кадры ошибки (198е.7). В машине — только режим «только слушать».


198т.9 Итоги

Дальше: раздел 198у «Мини-проект: свой блок на шине CAN — датчик, DBC, диагностика UDS и прошивка по шине» — до сих пор блоки на шине были чужими: их спрашивали, слушали и записывали; теперь свой блок — со своим сообщением в DBC, своими кодами неисправностей и прошивкой по шине.

Открыть в интерактивном справочнике