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е): кадры принимает, но на шину не выдаёт ни одного доминантного бита — не подтверждает, не шлёт кадров ошибки. Для чёрного ящика это обязательно: прибор, влезший в шину машины, не имеет права её тревожить.
Путь кадра:
- Прерывание очереди 0 забирает кадр из ящика bxCAN вместе с меткой времени и кладёт в кольцо в памяти. Больше ничего: никаких строк, никакой карты в прерывании.
- Главный цикл вынимает кадры из кольца и превращает их в строку журнала candump или в запись двоичного блока (198т.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 |
Два формата журнала:
- Строка candump — то, что пишет
candump -l:(1790000000.001249) can0 086#0000000000000000. Время — секунды UNIX и шесть цифр микросекунд, интерфейс, кадр в виде аргументаcansend. Кадр с восемью байтами — 46 символов с переводом строки. Читается глазами, can-utils понимают его сразу. - Двоичный блок — наш формат. Блок 512 байт: заголовок 24 байта
(сигнатура
CLG1, номер сеанса, номер блока, время первого кадра в микросекундах, число записей, 16 бит CRC-32 от тела) и записи по 9 + DLC байт: 4 байта — микросекунды от начала блока, 4 — идентификатор с флагами как у SocketCAN, 1 — DLC. Запись не пересекает границу блока, поэтому записей с восемью байтами данных в блок входит 28.
На полной шине блоки дают в 2,52…3,16 раза меньше байтов в секунду, чем строки. Но главное в двоичном формате — не размер. Каждый блок сам себя проверяет, и читатель после обрыва питания точно знает, где кончаются целые данные (198т.5).
Даже худший поток — 319 149 байт/с — это меньше 3 % от 11,8 МБ/с, которые SDIO на четырёх линиях и 24 МГц даёт по шине (41а.1). Скорость карты — не узкое место. Узкое место — её паузы.
198т.4 Кольцо и паузы карты: сколько кадров держать
Карта отвечает «принято», а потом держит линию D0 занятой, пока пишет (41а.6). Обычно это доли миллисекунды, но время от времени карта уходит на уборку внутри себя, и запись стоит заметно дольше. Замеров задержек настоящих карт здесь нет, поэтому это модель, а не данные карты:
- каждая команда записи — 1 мс занятости плюс 43,4 мкс на сектор по шине (41а.1);
- после каждых 512 КиБ записанного — пауза 250 мс.
Пока карта стоит, главный цикл стоит вместе с ней (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. На прогоне:
parse_canframeизlib.c(так читает журналcanplayer) разобрал все 359 819 строк без единой ошибки; хэш идентификаторов, длин и данных совпал с хэшем поездки;log2ascперевёл журнал в формат ASC Vector: 359 819 строкRx;- двоичный журнал, разобранный своим читателем, совпал со строковым кадр в кадр вместе со временем.
Первый кадр поездки в обоих видах — строка журнала и она же после 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 Итоги
- Метка bxCAN (TTCM) — 16 бит тактов бита в точке выборки SOF; круг на 500 кбит/с — 131 072 мкс; продление разностями с якорем по TIM2 при паузе больше 65 536 мкс. У FDCAN — счётчик с шагом 1…16 тактов или TIM3.
- Полная шина 500 кбит/с — до 10 638 кадров/с; строками это 319 149 байт/с, блоками — 100 867; SDIO даёт 11,8 МБ/с, узкое место — паузы карты.
- Поездка 10 минут: 359 819 кадров, 599,7 кадра/с, загрузка 14,79 %; журнал строками — 16 503 698 байт, блоками — 6 579 712, в 2,51 раза меньше.
- Кольцо без потерь — от 153 кадров строками и 152 блоками при паузе 250 мс; для полной шины кадрами по 8 байт — 1133 кадра (27 192 байта).
- Файл заранее через
f_expandиf_sync: 16 служебных секторов за поездку вместо 1612; обрыв стоит 148…152 мс строк против 448…1071 мс приf_syncраз в секунду. - can-utils разобрали все 359 819 строк;
log2ascдал 359 819 строкRx; DBC-расшифровка расходится с профилем не больше чем на полшага. - Прошивка F407: ядро — 1127 байт кода, обвязка bxCAN — 634 байта, FatFS — 6580 байт; ОЗУ обвязки — 36 052 байта, из них кольцо на 1280 кадров — 30 720.
Дальше: раздел 198у «Мини-проект: свой блок на шине CAN — датчик, DBC, диагностика UDS и прошивка по шине» — до сих пор блоки на шине были чужими: их спрашивали, слушали и записывали; теперь свой блок — со своим сообщением в DBC, своими кодами неисправностей и прошивкой по шине.