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

198с. Мини-проект: приборная панель по CAN — обороты, скорость и температура из шины машины

Сканер из 198р спрашивает блок двигателя и ждёт ответа. Но обороты, скорость и температура и так ходят по шине машины: блоки рассылают их сами, по кругу, десятки раз в секунду, — щиток приборов их просто слушает. Здесь мы делаем второй щиток: STM32F407 молча слушает шину, разбирает кадры по настоящему файлу DBC (19е), рисует стрелку оборотов и полосы температуры и топлива на цветном экране ILI9341 и зажигает предупреждения: перегрев, лампу переключения передачи, мало топлива. Если знакомых кадров на шине нет, прибор сам переходит на опрос OBD-II, как сканер 198р. Третий источник — гоночная машина на блоке rusEFI (19з): у него своя рассылка.

Машины под рукой не было. Ядро (dash_core.c) собрано gcc на компьютере и прогнано на трёх искусственных, но правильных по DBC потоках кадров: VW MQB на 60 с со сбоями CRC, потерями кадров и пропажей сообщения, rusEFI на 20 с и машина, которая молчит, — там прибор уходит в опрос OBD. Прошивка для STM32F407 собрана arm-none-eabi-gcc; на плате и в машине она не запускалась.

Опоры: RM0090 (глава bxCAN: режим Silent, 32.5.1; поля CAN_BTR, таблица 184; DMA2, таблица распределения запросов; CCM) из пака Keil STM32F4xx_DFP 2.17.1 и RM0440 (FDCAN: Bus monitoring mode, бит CCCR.MON) из STM32G4xx_DFP 1.6.0. Сигналы — opendbc/dbc/vw_mqb.dbc, алгоритм CRC и его константы — opendbc/car/volkswagen/mqbcan.py, настоящие кадры — тесты opendbc; рассылка rusEFI — can_verbose.cpp и rusEFI_CAN_verbose.dbc (коммит 79ceeb3); команды ILI9341 — драйвер qp_ili9341.c из QMK; формулы OBD — python-obd. Лицензия: opendbc — MIT (Comma.ai), из него перенесены 16 байт константы CRC сообщения ESP_21; rusEFI — GPL-3.0, QMK — GPL-2.0-or-later, python-obd — GPL-2.0: их код не скопирован, сверены только форматы и числа. Разбор DBC — 19е, OBD-II — 19г и 198р, rusEFI — 19з, трансивер и терминаторы — 19.2, защита входа — 173.

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

Оригинал Перевод
Silent mode (bxCAN), Bus monitoring mode (FDCAN), listen-only режим «только слушать»: принимает, но на шину выдаёт только рецессивные биты
acknowledge (ACK) slot поле подтверждения: любой приёмник пишет туда доминантный бит
broadcast / cyclic message кадр, который блок рассылает сам, по кругу
identifier list mode, 16-bit scale фильтр-список, 16-битный: 4 точных идентификатора в банке
rolling counter, CRC счётчик кадров и контрольная сумма в самом кадре (19е.4)
instrument cluster (Kombi) щиток приборов
shift light лампа «пора переключать передачу»
exponential moving average (EMA) экспоненциальное сглаживание
dirty rectangle прямоугольник, который надо перерисовать
CASET, PASET, RAMWR команды ILI9341: окно по столбцам, по строкам, запись в память

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

Прибор ничего не спрашивает, пока может этого не делать. Кадры приходят через трансивер в bxCAN, аппаратный фильтр пропускает семь нужных идентификаторов, ядро разбирает их по DBC, проверяет CRC и счётчик, сглаживает и 25 раз в секунду отдаёт экрану список прямоугольников для перерисовки. Экран получает их по SPI через DMA кусками по 8 КБ.

Схема приборной панели: шина машины (выводы 6 и 14 разъёма OBD или провода за щитком) через трансивер SN65HVD230 без терминатора на bxCAN1 в режиме SILM, BTR 0x811A0005, точка выборки 85,7 процента; фильтры из двух банков, список 16 бит, пропускают 7 идентификаторов из 2048; три источника данных: VW MQB (0xA8, 0xFD с CRC и счётчиком, 0x640, 0x6B8), rusEFI с базой 0x200 (0x201 и 0x203 раз в 50 мс) и запасной путь OBD (после 1 с тишины выход из SILM, запросы 0x7DF, ответы 0x7E8, слот 25 мс); ядро dash_core со сглаживанием 160, 360 и 1440 мс до 90 процентов и предупреждениями по сырому значению; SPI1 21 МГц и DMA2 поток 3 кусками по 8 КБ на ILI9341 320 на 240, весь экран 58,51 мс; внизу лампа переключения 4 мс против 74 мс по сглаженному, самый тяжёлый такт экрана 9,198 мс из 40 и прошивка 5224 байта флеш-памяти и 17 036 байт ОЗУ

Перечень деталей — примерный, цены не приводятся:

Что Какое Зачем
плата STM32F407 (или STM32G474 с FDCAN, 198с.2) bxCAN, SPI с DMA, 128 КБ ОЗУ для DMA, FPU для геометрии стрелки
трансивер модуль на SN65HVD230 питание 3,3 В, как у микроконтроллера (19.2)
экран модуль 2,4…2,8″ на ILI9341, 320 × 240, SPI 16 бит на точку
защита и питание предохранитель, диод, ограничитель, импульсный стабилизатор как в 198р.2 и 173.4
подключение вилка OBD-II или отвод от проводов CAN за щитком 198с.2

Выводы STM32F407: CAN1 — PB8 (RX) и PB9 (TX), функция AF9; SPI1 — PA5 (SCK) и PA7 (MOSI), AF5; выбор экрана — PA4, данные/команда — PA3, сброс — PA2. Такт — кварц 8 МГц, 168 МГц ядра, APB1 42 МГц (bxCAN), APB2 84 МГц (SPI1).


198с.2 Куда подключаться и почему только слушать

Где взять шину. Проще всего — разъём OBD-II: выводы 6 и 14 (CAN-H и CAN-L), 16 — плюс аккумулятора, 4 и 5 — земля (раскладка из 198р.2). Но на многих машинах разъём отделён шлюзом (общеизвестно; на машине не проверено), и широковещательные кадры силовой шины туда не выходят — видны только ответы на диагностические запросы. В VW видно и по DBC: у ESP_21 в vw_mqb.dbc отправитель — Gateway_MQB, то есть в описанной сети кадр выдаёт шлюз, а не сам блок ESP. Тогда подключаются к проводам CAN за щитком приборов: там всё, что нужно щитку, есть заведомо. Какие именно кадры выходят на разъём конкретной машины, без машины не проверить — поэтому в приборе и есть запасной путь через OBD (198с.4).

Терминатора нет. Как и сканер 198р, панель — ответвление от шины, на которой два терминатора по 120 Ом уже стоят на концах (19.2).

Почему только слушать. Шина машины — чужая. Любой узел в обычном режиме выходит на шину, даже если сам ничего не передаёт: подтверждает каждый кадр доминантным битом в поле ACK и шлёт кадр ошибки, если ему что-то не понравилось. Прибор с неверной скоростью шины в обычном режиме будет рвать чужие кадры своими кадрами ошибок.

В режиме Silent (RM0090, 32.5.1) bxCAN принимает правильные кадры, но на шину выдаёт только рецессивные биты и сам начать передачу не может. Если ему надо послать доминантный бит — подтверждение, флаг перегрузки, активный флаг ошибки, — бит заворачивается внутрь, в собственный приёмник, а шина остаётся рецессивной. Режим включает бит SILM (бит 31 CAN_BTR); менять его можно только в режиме инициализации. В ядре та же мысль проведена выше железа: пока прибор слушает, у dash_poll() просто нет пути к передаче, а в опыте с VW и rusEFI передано ноль кадров.

Та же мысль у FDCAN на STM32G474: Bus monitoring mode, бит MON (бит 5) регистра FDCAN_CCCR. По RM0440 его можно поставить, только когда стоят оба бита CCE и INIT; поведение то же — принимает правильные кадры, передачу начать не может, доминантные биты заворачивает внутрь. Прошивка этого раздела — для F407; FDCAN разобран в 19а.


198с.3 bxCAN: 500 кбит/с от 42 МГц, SILM и фильтр-список

Скорость. Шина силовых блоков — 500 кбит/с; это то же допущение, что в 198р, у конкретной машины его надо проверить. От APB1 42 МГц ровно 500 кбит/с дают 33 сочетания полей, из них 5 — с точкой выборки от 80 до 90 % (перебор модели dash_model.py):

Делитель Квантов в бите TS1 + TS2 Точка выборки
4 21 16 + 4 81,0 %
6 14 11 + 2 85,7 %
7 12 9 + 2 83,3 %
12 7 5 + 1 85,7 %
14 6 4 + 1 83,3 %

Взят второй: квант 142,9 нс, SJW — 2 кванта. В регистре 0x011A0005, а с битом SILM — 0x811A0005. Переключение режима — через инициализацию:

static void can_mode(int active)                       /* 0 — SILM (только слушать), 1 — обычный */
{
    CAN1->MCR |= CAN_MCR_INRQ;
    while (!(CAN1->MSR & CAN_MSR_INAK)) {
    }
    CAN1->BTR = CAN_BTR_VAL | (active ? 0u : CAN_BTR_SILM);
    CAN1->MCR &= ~CAN_MCR_INRQ;
    while (CAN1->MSR & CAN_MSR_INAK) {
    }
}

Фильтр. Нужны точные идентификаторы, а не диапазон, как у сканера (198р.3): четыре кадра VW, два кадра rusEFI и ответ OBD 0x7E8 — семь. В 16-битном режиме списка один банк хранит четыре идентификатора: STID в битах 15…5, RTR — бит 4, IDE — бит 3. Два банка — восемь мест, последнее занято тем же 0x7E8:

    fr[0] = F16(VW_ESP_21) << 16 | F16(VW_MOTOR_12);       /* банк 0, FR1 */
    fr[1] = F16(VW_KOMBI_03) << 16 | F16(VW_MOTOR_07);     /* банк 0, FR2 */
    fr[2] = F16(RE_BASE3) << 16 | F16(RE_BASE1);           /* банк 1, FR1 */
    fr[3] = F16(OBD_RESP_ID) << 16 | F16(OBD_RESP_ID);     /* банк 1, FR2: ответ OBD дважды */

Выходит 1FA01500, D700C800, 40604020, FD00FD00. Опыт прогнал через модель этого фильтра все 2048 стандартных идентификаторов: прошли ровно 7 — 0x0A8, 0x0FD, 0x201, 0x203, 0x640, 0x6B8, 0x7E8; запросов данных и 29-битных кадров — ни одного. В модели VW из 57 849 кадров до ядра дошло 9849: остальные 48 000 — шестнадцать чужих сообщений раз в 20 мс — отбросило железо, прерывание на них не просыпается.


198с.4 Сигналы: VW MQB по DBC, рассылка rusEFI и запасной OBD

VW MQB. Четыре сообщения из vw_mqb.dbc, все — Intel (@1, 19е.2):

Кадр Сообщение Сигнал Биты Масштаб Служебные поля
0x0A8 Motor_12 MO_Drehzahl_01 48|16 × 0,25 об/мин CRC (байт 0), счётчик Motor_12_BZ 8|4
0x0FD ESP_21 ESP_v_Signal 32|16 × 0,01 км/ч CHECKSUM 0|8, COUNTER 8|4
0x640 Motor_07 MO_Kuehlmittel_Temp 24|8 × 0,75 − 48 °C —
0x6B8 Kombi_03 KBI_Tankfuellstand_Prozent 18|7 × 1 % 126 «Init», 127 «Fehler»

Разбор написан руками — сдвиги и маски, как у генератора 19е.5, — и CRC проверяется до того, как значение попадёт на экран:

    case VW_MOTOR_12:
    case VW_ESP_21: {
        int k = f->id == VW_ESP_21;
        if (vw_mqb_crc(f->id, d) != d[0]) {
            dash.crc_err++;
            return;
        }
        if (!counter_ok(k, d[1] & 0x0Fu))
            return;
        lock_src(SRC_VW);
        if (k)
            set_val(SIG_SPEED, (int32_t)(d[4] | d[5] << 8), now);              /* 32|16, ×0,01 км/ч */
        else
            set_val(SIG_RPM, (int32_t)(d[6] | d[7] << 8) * 25, now);           /* 48|16, ×0,25 об/мин */
        break;
    }

CRC — тот же CRC-8 с многочленом 0x2F и «секретным» байтом по счётчику, что в 19е.4. В таблице констант opendbc 37 сообщений; ESP_21 там есть, а Motor_12 (0xA8) — нет. Для него функция opendbc считает CRC только по байтам 1…7, так же сделано и здесь; совпадёт ли это с настоящим блоком двигателя, не проверено. На 16 настоящих кадрах ESP_21 из тестов opendbc ядро приняло все 16 (счётчики 0…15 подряд, потерь 0) и показало скорости от 0,00 до 16,16 км/ч; первый кадр с одним изменённым битом дал CRC 0xCB вместо 0x66 — отброшен.

Счётчик ловит две беды: пропуск (разность больше 1) и «зависший» передатчик (тот же счётчик ещё раз — кадр отбрасывается). Значение 126 или 127 в KBI_Tankfuellstand_Prozent — не проценты, а «ещё не готов» и «ошибка» (VAL_ 1720 в DBC): такие кадры отбрасываются.

rusEFI. Своя рассылка включается флагом enableVerboseCanTx («CAN broadcast using custom rusEFI protocol»): sendCanVerbose() в can_verbose.cpp шлёт 12 кадров подряд, от базы до базы + 11. База по умолчанию — CAN_DEFAULT_BASE 0x200, период — canSleepPeriodMs, по умолчанию 50 мс. Кадр — структура C, скопированная как есть (младший байт первым), и rusEFI_CAN_verbose.dbc описывает её так же:

Кадр Структура Сигнал Биты Масштаб
0x201 Speeds RPM 0|16 × 1 об/мин
0x201 Speeds VehicleSpeed 48|8 × 1 км/ч
0x203 Sensors1 CoolantTemp 16|8 × 1 − 40 °C
0x203 Sensors1 FuelLevel 56|8 × 0,5 %

Ни счётчика, ни CRC здесь нет: остаётся только следить, не пропал ли кадр.

Запасной OBD. Если за 1 с не пришло ни одного знакомого кадра, ядро поднимает флаг active, прошивка переводит bxCAN из SILM в обычный режим, и дальше — опрос, как в 198р.5: слот 25 мс, таблица из восьми слотов, обороты через слот, ожидание ответа 150 мс. PID 0C, 0D, 05 — формулы 198р.4; уровень топлива — PID 2F, A · 100 / 255 % (python-obd, FUEL_LEVEL, декодер percent). Поиск блоков, карты PID и составные ответы здесь не повторяются — они в 198р; панель спрашивает общим адресом 0x7DF и слушает только 0x7E8. Это единственный случай, когда прибор выходит на шину, и включать его имеет смысл только там, где машина иначе молчит.


198с.5 Экран: стрелка, сглаживание, предупреждения и SPI через DMA

Сколько стоит весь экран. 320 × 240 точек по 16 бит — 153 600 байт. SPI1 работает от APB2 84 МГц с делителем 4 — 21 МГц. Весь экран уходит за 58,51 мс, то есть не больше 17,1 кадра в секунду; на 42 МГц — 29,26 мс (допустимую частоту записи по даташиту ILI9341 раздел не сверял). Хранить кадр целиком негде: ОЗУ, которое видит DMA, — 128 КБ (131 072 байта в скрипте компоновки), а 64 КБ CCM (RM0090: «accessible only by the CPU») DMA не видит. Отсюда устройство вывода:

  1. Перерисовывать только то, что изменилось. На каждом такте (40 мс) ядро сравнивает нарисованное с новым: конец стрелки, число скорости, длину полос, флаги предупреждений — и отдаёт список прямоугольников. Для стрелки это рамка вокруг оси, старого и нового конца.
  2. Рисовать кусками. dash_render() считает цвет каждой точки прямоугольника заново, по 4096 точек (8192 байта) за раз. У DMA счётчик NDTR — до 65535, кусок в него помещается с запасом.
  3. Два буфера. Пока DMA2 (поток 3, канал 3 — SPI1_TX по таблице RM0090) шлёт один кусок, процессор рисует следующий:
        for (int y = 0; y < r[i].h; y += rows) {
            int h = y + rows > r[i].h ? r[i].h - y : rows;
            dash_render(&r[i], y, h, lcd_buf[k]);     /* рисуем, пока DMA шлёт прошлый кусок */
            dma_wait();
            lcd_dma(lcd_buf[k], (uint32_t)(h * r[i].w * 2));
            k ^= 1;
        }

Перед каждым прямоугольником — окно: 2A (столбцы, 4 байта), 2B (строки, 4 байта), 2C (запись) — 11 байт команд. Команды и 16 бит на точку (3A с 0x05), поворот на 90° (36 с 0x68: BGR, MX, MV) взяты из драйвера QMK qp_ili9341.c.

Сглаживание. Сырое значение прыгает — округление, дрожь датчика, плеск топлива в баке. Стрелка берёт экспоненциальное среднее: на каждом такте экран проходит долю α оставшегося пути, последний шаг — сразу в цель:

int32_t dash_ema(int32_t shown, int32_t target, unsigned shift)
{
    int32_t step = (target - shown) / (int32_t)(1u << shift);
    return step ? shown + step : target;                   /* последний шаг — сразу в цель */
}
Сигнал α Тактов до 90 % скачка Время Постоянная времени
обороты 1/2 4 160 мс 57,7 мс
скорость, температура 1/4 9 360 мс 139,0 мс
топливо 1/16 36 1440 мс 619,8 мс

Число тактов посчитали и ядро, и модель на Python по формуле ⌈ln 0,1 / ln (1 − α)⌉ — совпали.

Предупреждения — по сырому значению. Лампа переключения, перегрев и топливо смотрят на последнее принятое значение, а не на сглаженное, и включаются в момент приёма кадра, а не на такте экрана. У каждого порога — разнос включения и выключения, чтобы лампа не мигала на границе:

Предупреждение Включается Гаснет
лампа переключения от 6000 об/мин ниже 5800 об/мин
перегрев от 105 °C ниже 100 °C
мало топлива ниже 10 % от 12 %

Сигнал, который не обновлялся 500 мс, считается пропавшим: вместо числа — прочерк, его предупреждения гаснут.


198с.6 Проверка на компьютере: три машины

Опыт sim_dash.c собирает кадры по DBC — масштаб, счётчик, CRC — и пропускает их через модель фильтра и ядро; экран «рисуется» по-настоящему, кусками в буфер, и считаются байты SPI. Периоды кадров VW в модели — наши: в DBC VW их нет (19е.1). Машина A: Motor_12 раз в 10 мс, ESP_21 — 20 мс, Motor_07 — 100 мс, Kombi_03 — 200 мс, плюс 16 чужих сообщений.

Машина A, VW MQB, 60 с. Пуск на 2 с, разгон до 6400 об/мин к 15 с, переключение (скачок до 3500 об/мин), разгон до 120 км/ч, круиз, перегрев до 110 °C и остывание, ускоренный расход топлива с 14 до 8 %. Сбои: пять кадров Motor_12 испорчены после подсчёта CRC (20,00 с), три кадра ESP_21 потеряны (22 с), ESP_21 пропадает на 1 с (35 с), кадр Motor_12 повторён со старым счётчиком (45 с), первую секунду щиток шлёт «Init».

Что Прогон
кадров в секунду до ядра 0x0A8 — 100,0, 0x0FD — 49,1, 0x640 — 10,0, 0x6B8 — 5,0
отброшено по CRC 5
«зависший» счётчик 1
отброшено «Init» 5
передано на шину 0
лампа переключения обороты перешли 6000 на 14,286 с, лампа — 14,290 с (через 4 мс)
та же лампа по сглаженному 14,360 с — на 74 мс позже
скачок 6400 → 3500 на 15 с стрелка прошла 90 % к 15,120 с
отставание на разгоне (10 с) 23 об/мин
перегрев включился на 37,407 с (105,00 °C), погас на 46,607 с (99,75 °C)
мало топлива 45,013 с (9 %)
пропажа ESP_21 последний кадр 34,985 с, прочерк на 35,520 с (через 535 мс), число вернулось на 36,040 с

Скачок оборотов прошёл 90 % за 120 мс, а не 160: кадр пришёл в момент такта, и первый из четырёх шагов сделан сразу. Прочерк — через 535 мс: 500 мс ожидания плюс ожидание ближайшего такта экрана.

Счётчик потерь показал 10: пять кадров Motor_12 с плохим CRC (отправитель их считал), три потерянных ESP_21 и два за секундную пропажу — а пропало 50 кадров. Четырёхбитный счётчик видит потери только по модулю 16: 50 − 3 · 16 = 2. Длинную дыру ловит не счётчик, а таймер 500 мс.

Снимок экрана в разгар перегрева, дословно из прогона:

show overheat t=39000 src=1 rpm=3000 spd=110 clt=107.65 fuel=10.00 warn=-H- stale=0000 needle=63,64

Машина B, rusEFI, 20 с. Все 12 кадров рассылки раз в 50 мс, из них до ядра доходят 0x201 и 0x203 — по 20,0 в секунду, остальные 4000 кадров отбрасывает фильтр. Лампа переключения зажглась через 10 мс после того, как обороты перешли 6000: задержка — до следующего кадра. Перегрев — на 17,003 с.

Машина C, знакомых кадров нет, 10 с. Первую секунду прибор молчит (передано 0 кадров), на 1,000 с уходит в OBD. Дальше 361 запрос и 360 ответов (последний запрос — в конце опыта), таймаутов 0; все 360 значений совпали с тем, что закодировал блок; обороты — 180 раз, не реже чем через 50 мс; топливо показано 54,90 % (A = 140).

Экран. Самый тяжёлый такт у VW — 24 146 байт, 9,198 мс SPI из 40 мс такта; за 60 с SPI занят 2,69 % времени. У rusEFI обороты растут быстрее, стрелка чаще перерисовывается: самый тяжёлый такт — 12,087 мс, в среднем 6,65 %. Первый такт — весь экран, 58,51 мс, — один раз при включении.

Прошивка STM32F407 (dash_f407.c: такты, bxCAN в SILM с фильтрами, прерывание приёма, SPI1 с DMA2, ILI9341, SysTick; ядро то же) собрана arm-none-eabi-gcc 12.2.1 -Os -Wall -Wextra -Werror -pedantic для Cortex-M4 с FPU: 5224 байта флеш-памяти и 17 036 байт ОЗУ, из них 16 384 — два буфера экрана, 512 — очередь из 32 кадров. Само ядро — 3632 байта кода в объектном файле. Проверки _Static_assert в прошивке: 168/42/84 МГц, 500 кбит/с ровно, SPI 21 МГц, кусок в пределах NDTR, SILM — бит 31.


198с.7 Полный код прошивки

Прошивка — пять своих файлов: ядро без железа dash_core.h и dash_core.c, работа с регистрами F407 dash_f407.c, старт start_f407.c и скрипт компоновщика f407.ld, плюс шрифт font5x7.h — тот же файл байт в байт, что в 198р.7, здесь его не повторяем. Ниже все пять целиком: dash_core.c и dash_f407.c разбиты на куски подряд, без пропусков — склеенные через пустую строку, они дают файлы байт в байт. Куски из 198с.3, 198с.4 и 198с.5 повторяются внутри своих функций. Из чужого — только 16 байт константы CRC сообщения ESP_21 из opendbc (MIT). Опыт на компьютере sim_dash.c и модель dash_model.py — стенд проверки, а не часть прибора, их здесь нет.

Сборка — те же команды, что в проверке. 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):

for f in start_f407 dash_f407 dash_core; do
  arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -Os \
    -Wall -Wextra -Werror -pedantic -std=c11 -ffreestanding -ffunction-sections -fdata-sections \
    -fsingle-precision-constant -DSTM32F407xx -I. -I$CMSIS_F4 -I$CMSIS_CORE -c $f.c -o $f.o
done
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
  start_f407.o dash_f407.o dash_core.o -T f407.ld -nostdlib -Wl,--gc-sections -o fw.elf -lgcc

Заголовок ядра. Кадр, адреса и поля DBC, сроки, пороги предупреждений, экран и состояние панели:

/* dash_core.h — раздел 198с: ядро приборной панели по CAN, без привязки к железу.
 * Одно и то же ядро собирается gcc на ПК (опыт sim_dash.c) и arm-none-eabi-gcc для STM32F407 (dash_f407.c). */
#ifndef DASH_CORE_H
#define DASH_CORE_H
#include <stdint.h>

typedef struct {
    uint32_t id;      /* 11 бит; 29-битные кадры панели не нужны */
    uint8_t dlc;
    uint8_t d[8];
} dash_frame;

enum { SIG_RPM, SIG_SPEED, SIG_CLT, SIG_FUEL, SIG_N };
enum { SRC_NONE, SRC_VW, SRC_RUSEFI, SRC_OBD };

/* VW MQB, opendbc/dbc/vw_mqb.dbc */
#define VW_MOTOR_12   0x0A8u   /* MO_Drehzahl_01 48|16@1+ (0.25,0), CRC и счётчик */
#define VW_ESP_21     0x0FDu   /* ESP_v_Signal 32|16@1+ (0.01,0), CRC и счётчик */
#define VW_MOTOR_07   0x640u   /* MO_Kuehlmittel_Temp 24|8@1+ (0.75,-48) */
#define VW_KOMBI_03   0x6B8u   /* KBI_Tankfuellstand_Prozent 18|7@1+ (1,0), 126 Init, 127 Fehler */
/* rusEFI, rusEFI_CAN_verbose.dbc: база CAN_DEFAULT_BASE 0x200 */
#define RE_BASE1      0x201u   /* RPM 0|16@1+ (1,0), VehicleSpeed 48|8@1+ (1,0) */
#define RE_BASE3      0x203u   /* CoolantTemp 16|8@1+ (1,-40), FuelLevel 56|8@1+ (0.5,0) */
/* OBD-II (разделы 19г и 198р) */
#define OBD_REQ_ID    0x7DFu
#define OBD_RESP_ID   0x7E8u

#define DASH_TICK_MS      40u    /* экран обновляется 25 раз в секунду */
#define DASH_STALE_MS     500u   /* сигнал старше — прочерк */
#define DASH_FALLBACK_MS  1000u  /* столько слушаем молча, прежде чем спрашивать по OBD */
#define DASH_OBD_SLOT_MS  25u
#define DASH_OBD_P2_MS    150u

/* пороги предупреждений, в сотых долях единицы; включение и выключение разнесены */
#define WARN_CLT_ON    10500   /* 105 °C */
#define WARN_CLT_OFF   10000   /* 100 °C */
#define WARN_SHIFT_ON  600000  /* 6000 об/мин */
#define WARN_SHIFT_OFF 580000  /* 5800 об/мин */
#define WARN_FUEL_ON   1000    /* меньше 10 % */
#define WARN_FUEL_OFF  1200    /* 12 % и больше */

enum { WARN_HOT = 1, WARN_SHIFT = 2, WARN_FUEL = 4 };

/* экран 320 × 240 (ILI9341, поворот на 90°), 16 бит на точку */
#define LCD_W 320
#define LCD_H 240
#define DASH_CHUNK_PX 4096u      /* кусок, который рисуется и уходит одной передачей DMA */

typedef struct {
    int16_t x, y, w, h;
} dash_rect;

typedef struct {
    int32_t val[SIG_N];          /* последнее значение ×100 */
    int32_t shown[SIG_N];        /* сглаженное значение на экране ×100 */
    uint32_t t_upd[SIG_N];       /* когда пришло */
    uint8_t have[SIG_N];
    uint8_t stale[SIG_N];
    uint8_t src;                 /* откуда данные */
    uint8_t active;              /* 0 — молчим (режим SILM), 1 — можно передавать (OBD) */
    uint8_t warn;
    uint8_t last_cnt[2];         /* счётчики Motor_12 и ESP_21 */
    uint8_t cnt_ok[2];
    uint32_t crc_err, cnt_lost, cnt_frozen, bad_dlc, invalid;
    uint32_t rx_known, obd_tx, obd_rx, obd_timeouts;
    uint32_t t_start, t_obd_sent, next_slot;
    uint8_t obd_wait, obd_pid, poll_i;
    /* что сейчас нарисовано */
    int16_t nx, ny;              /* конец стрелки */
    int16_t speed_txt, bar_clt, bar_fuel;
    uint8_t warn_drawn, full;
} dash_t;

extern dash_t dash;

uint8_t vw_mqb_crc(uint32_t id, const uint8_t *d);
void dash_filter_regs(uint32_t fr[4]);                 /* два банка bxCAN, 16-битный список */
int dash_filter_accept(const uint32_t fr[4], uint32_t id, int ide, int rtr);
void dash_init(uint32_t now);
void dash_rx(const dash_frame *f, uint32_t now);
int dash_poll(uint32_t now, dash_frame *req);          /* 1 — отправить запрос OBD */
int dash_tick(uint32_t now, dash_rect *r, int max);    /* число прямоугольников для перерисовки */
void dash_render(const dash_rect *r, int row0, int rows, uint16_t *buf);
int32_t dash_ema(int32_t shown, int32_t target, unsigned shift);
void dash_needle(int32_t rpm100, int16_t *x, int16_t *y);

#endif

CRC VW. CRC-8 с многочленом 0x2F по байтам 1…7; у ESP_21 в конце — байт из таблицы по счётчику:

/* dash_core.c — раздел 198с: разбор широковещательных кадров, запасной опрос OBD, сглаживание,
 * предупреждения и перерисовка экрана кусками. Железа не касается: кадры приходят в dash_rx(),
 * запрос OBD отдаёт dash_poll(), точки для экрана даёт dash_render(). */
#include "dash_core.h"
#include "font5x7.h"

dash_t dash;

/* ── CRC-8 0x2F VW MQB (AUTOSAR E2E Profile 2), как volkswagen_mqb_meb_checksum() в opendbc ── */
static const uint8_t magic_fd[16] = {0xB4, 0xEF, 0xF8, 0x49, 0x1E, 0xE5, 0xC2, 0xC0,
                                     0x97, 0x19, 0x3C, 0xC9, 0xF1, 0x98, 0xD6, 0x61};   /* ESP_21 */

static uint8_t crc8_2f(uint8_t crc, uint8_t b)
{
    crc ^= b;
    for (int i = 0; i < 8; i++)
        crc = (uint8_t)((crc & 0x80u) ? (unsigned)(crc << 1) ^ 0x2Fu : (unsigned)(crc << 1));
    return crc;
}

uint8_t vw_mqb_crc(uint32_t id, const uint8_t *d)
{
    uint8_t crc = 0xFF;
    for (int i = 1; i < 8; i++)
        crc = crc8_2f(crc, d[i]);
    if (id == VW_ESP_21)                       /* у 0xA8 в таблице opendbc «секретного» байта нет */
        crc = crc8_2f(crc, magic_fd[d[1] & 0x0Fu]);
    return (uint8_t)(crc ^ 0xFFu);
}

Фильтр-список. Четыре регистра двух банков bxCAN и та же проверка на компьютере:

/* ── фильтры bxCAN: 16-битный список, 4 идентификатора на банк (STID в битах 15…5, RTR 4, IDE 3) ── */
#define F16(id) ((uint32_t)(id) << 5)
void dash_filter_regs(uint32_t fr[4])
{
    fr[0] = F16(VW_ESP_21) << 16 | F16(VW_MOTOR_12);       /* банк 0, FR1 */
    fr[1] = F16(VW_KOMBI_03) << 16 | F16(VW_MOTOR_07);     /* банк 0, FR2 */
    fr[2] = F16(RE_BASE3) << 16 | F16(RE_BASE1);           /* банк 1, FR1 */
    fr[3] = F16(OBD_RESP_ID) << 16 | F16(OBD_RESP_ID);     /* банк 1, FR2: ответ OBD дважды */
}

int dash_filter_accept(const uint32_t fr[4], uint32_t id, int ide, int rtr)
{
    uint32_t v = ide ? ((id >> 18) << 5 | 1u << 3 | ((id >> 15) & 7u)) : F16(id);
    v |= rtr ? 1u << 4 : 0u;
    for (int i = 0; i < 4; i++)
        if ((fr[i] & 0xFFFFu) == v || (fr[i] >> 16) == v)
            return 1;
    return 0;
}

Значения. Сглаживание, предупреждения с разнесёнными порогами, счётчик кадров и начальное состояние:

/* ── значения и предупреждения ── */
static const uint8_t EMA_SHIFT[SIG_N] = {1, 2, 2, 4};    /* α = 1/2, 1/4, 1/4, 1/16 */

int32_t dash_ema(int32_t shown, int32_t target, unsigned shift)
{
    int32_t step = (target - shown) / (int32_t)(1u << shift);
    return step ? shown + step : target;                   /* последний шаг — сразу в цель */
}

static void warn_update(int s)
{
    int32_t v = dash.val[s];
    if (s == SIG_CLT) {
        if (v >= WARN_CLT_ON) dash.warn |= WARN_HOT;
        else if (v < WARN_CLT_OFF) dash.warn &= (uint8_t)~WARN_HOT;
    } else if (s == SIG_RPM) {
        if (v >= WARN_SHIFT_ON) dash.warn |= WARN_SHIFT;
        else if (v < WARN_SHIFT_OFF) dash.warn &= (uint8_t)~WARN_SHIFT;
    } else if (s == SIG_FUEL) {
        if (v < WARN_FUEL_ON) dash.warn |= WARN_FUEL;
        else if (v >= WARN_FUEL_OFF) dash.warn &= (uint8_t)~WARN_FUEL;
    }
}

static void set_val(int s, int32_t v, uint32_t now)
{
    if (!dash.have[s])
        dash.shown[s] = v;
    dash.val[s] = v;
    dash.have[s] = 1;
    dash.stale[s] = 0;
    dash.t_upd[s] = now;
    warn_update(s);
}

static int counter_ok(int k, uint8_t c)
{
    if (dash.cnt_ok[k]) {
        if (c == dash.last_cnt[k]) {           /* счётчик не растёт — передатчик завис */
            dash.cnt_frozen++;
            return 0;
        }
        dash.cnt_lost += (uint8_t)(c - dash.last_cnt[k] - 1u) & 0x0Fu;
    }
    dash.last_cnt[k] = c;
    dash.cnt_ok[k] = 1;
    return 1;
}

static void lock_src(uint8_t s)
{
    if (dash.src == SRC_NONE)
        dash.src = s;
    dash.rx_known++;
}

void dash_init(uint32_t now)
{
    volatile uint8_t *p = (volatile uint8_t *)&dash;   /* без memset: прошивка собирается без libc */
    for (unsigned i = 0; i < sizeof dash; i++)
        p[i] = 0;
    dash.t_start = now;
    dash.full = 1;
    dash.speed_txt = -1;
    dash.bar_clt = -1;
    dash.bar_fuel = -1;
}

Разбор кадров. VW с CRC и счётчиком, rusEFI, ответы OBD:

void dash_rx(const dash_frame *f, uint32_t now)
{
    const uint8_t *d = f->d;
    if (f->dlc != 8u) {
        dash.bad_dlc++;
        return;
    }
    switch (f->id) {
    case VW_MOTOR_12:
    case VW_ESP_21: {
        int k = f->id == VW_ESP_21;
        if (vw_mqb_crc(f->id, d) != d[0]) {
            dash.crc_err++;
            return;
        }
        if (!counter_ok(k, d[1] & 0x0Fu))
            return;
        lock_src(SRC_VW);
        if (k)
            set_val(SIG_SPEED, (int32_t)(d[4] | d[5] << 8), now);              /* 32|16, ×0,01 км/ч */
        else
            set_val(SIG_RPM, (int32_t)(d[6] | d[7] << 8) * 25, now);           /* 48|16, ×0,25 об/мин */
        break;
    }
    case VW_MOTOR_07:
        lock_src(SRC_VW);
        set_val(SIG_CLT, (int32_t)d[3] * 75 - 4800, now);                      /* 24|8, ×0,75 − 48 °C */
        break;
    case VW_KOMBI_03: {
        uint32_t raw = (uint32_t)(d[2] >> 2) | (uint32_t)(d[3] & 1u) << 6;     /* 18|7 */
        lock_src(SRC_VW);
        if (raw > 100u) {                                                      /* 126 Init, 127 Fehler */
            dash.invalid++;
            return;
        }
        set_val(SIG_FUEL, (int32_t)raw * 100, now);
        break;
    }
    case RE_BASE1:
        lock_src(SRC_RUSEFI);
        set_val(SIG_RPM, (int32_t)(d[0] | d[1] << 8) * 100, now);
        set_val(SIG_SPEED, (int32_t)d[6] * 100, now);
        break;
    case RE_BASE3:
        lock_src(SRC_RUSEFI);
        set_val(SIG_CLT, ((int32_t)d[2] - 40) * 100, now);
        set_val(SIG_FUEL, (int32_t)d[7] * 50, now);
        break;
    case OBD_RESP_ID:
        if (!dash.active || !dash.obd_wait || d[1] != 0x41u || d[2] != dash.obd_pid)
            return;
        dash.obd_wait = 0;
        dash.obd_rx++;
        if (dash.obd_pid == 0x0Cu)
            set_val(SIG_RPM, (int32_t)(d[3] << 8 | d[4]) * 25, now);           /* (256A + B) / 4 */
        else if (dash.obd_pid == 0x0Du)
            set_val(SIG_SPEED, (int32_t)d[3] * 100, now);
        else if (dash.obd_pid == 0x05u)
            set_val(SIG_CLT, ((int32_t)d[3] - 40) * 100, now);
        else
            set_val(SIG_FUEL, (int32_t)d[3] * 10000 / 255, now);               /* A · 100 / 255 */
        break;
    default:
        break;
    }
}

Запасной опрос OBD. Через 1 с тишины — запросы по кругу в слотах 25 мс со сроком ответа 150 мс:

/* ── запасной путь: машина молчит на знакомых адресах — спрашиваем по OBD ── */
static const uint8_t POLL[8] = {0x0C, 0x0D, 0x0C, 0x05, 0x0C, 0x0D, 0x0C, 0x2F};

int dash_poll(uint32_t now, dash_frame *req)
{
    if (!dash.active) {
        if (dash.src == SRC_NONE && now - dash.t_start >= DASH_FALLBACK_MS) {
            dash.active = 1;                   /* платформа выключит SILM, увидев active */
            dash.src = SRC_OBD;
            dash.next_slot = now;
        }
        return 0;                              /* молча слушаем: передавать нечего и нечем */
    }
    if (dash.obd_wait && now - dash.t_obd_sent >= DASH_OBD_P2_MS) {
        dash.obd_wait = 0;
        dash.obd_timeouts++;
    }
    if (dash.obd_wait || (int32_t)(now - dash.next_slot) < 0)
        return 0;
    dash.obd_pid = POLL[dash.poll_i++ & 7u];
    dash.next_slot += DASH_OBD_SLOT_MS;
    req->id = OBD_REQ_ID;
    req->dlc = 8;
    req->d[0] = 2;
    req->d[1] = 0x01;
    req->d[2] = dash.obd_pid;
    for (int i = 3; i < 8; i++)
        req->d[i] = 0xAA;
    dash.obd_wait = 1;
    dash.t_obd_sent = now;
    dash.obd_tx++;
    return 1;
}

Геометрия и что перерисовать. Синус без libm (ряд до x⁹), стрелка, полосы и прямоугольники, которые изменились за такт:

/* ── геометрия экрана 320 × 240 ── */
#define CX 110
#define CY 135
#define NEEDLE 85
#define TICK_IN 88
#define TICK_OUT 100
#define RPM_MAX 800000                         /* 8000 об/мин ×100 — вся шкала */
#define SPD_X 215
#define SPD_Y 40
#define CLT_Y 125
#define FUEL_Y 165
#define BAR_X 215
#define BAR_W 100
#define BAR_H 14
#define WARN_Y 195

static float fsin(float x)                     /* без libm: приведение к [−π/2; π/2] и ряд до x⁹ */
{
    const float pi = 3.14159265f;
    while (x > pi) x -= 2 * pi;
    while (x < -pi) x += 2 * pi;
    if (x > pi / 2) x = pi - x;
    if (x < -pi / 2) x = -pi - x;
    float x2 = x * x;
    return x * (1 - x2 / 6 * (1 - x2 / 20 * (1 - x2 / 42 * (1 - x2 / 72))));
}

static void polar(int32_t rpm100, int r, float *x, float *y)
{
    if (rpm100 < 0) rpm100 = 0;
    if (rpm100 > RPM_MAX) rpm100 = RPM_MAX;
    float a = (225.0f - 270.0f * (float)rpm100 / (float)RPM_MAX) * 3.14159265f / 180.0f;
    *x = CX + r * fsin(a + 3.14159265f / 2);
    *y = CY - r * fsin(a);
}

void dash_needle(int32_t rpm100, int16_t *x, int16_t *y)
{
    float fx, fy;
    polar(rpm100, NEEDLE, &fx, &fy);
    *x = (int16_t)(fx + 0.5f);
    *y = (int16_t)(fy + 0.5f);
}

static int bar_px(int s)
{
    int32_t v = dash.shown[s], lo = s == SIG_CLT ? 4000 : 0, hi = s == SIG_CLT ? 13000 : 10000;
    if (!dash.have[s] || dash.stale[s]) return 0;
    if (v < lo) v = lo;
    if (v > hi) v = hi;
    return (int)((v - lo) * BAR_W / (hi - lo));
}

static int add_rect(dash_rect *r, int n, int max, int x0, int y0, int x1, int y1)
{
    if (x0 < 0) x0 = 0;
    if (y0 < 0) y0 = 0;
    if (x1 > LCD_W) x1 = LCD_W;
    if (y1 > LCD_H) y1 = LCD_H;
    if (n < max && x1 > x0 && y1 > y0)
        r[n++] = (dash_rect){(int16_t)x0, (int16_t)y0, (int16_t)(x1 - x0), (int16_t)(y1 - y0)};
    return n;
}

static int mn(int a, int b) { return a < b ? a : b; }
static int mx(int a, int b) { return a > b ? a : b; }

int dash_tick(uint32_t now, dash_rect *r, int max)
{
    int n = 0;
    for (int s = 0; s < SIG_N; s++) {
        if (dash.have[s] && now - dash.t_upd[s] > DASH_STALE_MS && !dash.stale[s]) {
            dash.stale[s] = 1;
            dash.warn &= (uint8_t)~(s == SIG_CLT ? WARN_HOT : s == SIG_RPM ? WARN_SHIFT : s == SIG_FUEL ? WARN_FUEL : 0);
        }
        if (dash.have[s] && !dash.stale[s])
            dash.shown[s] = dash_ema(dash.shown[s], dash.val[s], EMA_SHIFT[s]);
    }
    int16_t nx, ny;
    dash_needle(dash.have[SIG_RPM] && !dash.stale[SIG_RPM] ? dash.shown[SIG_RPM] : 0, &nx, &ny);
    int spd = dash.have[SIG_SPEED] && !dash.stale[SIG_SPEED] ? (int)((dash.shown[SIG_SPEED] + 50) / 100) : -2;
    int bc = bar_px(SIG_CLT), bf = bar_px(SIG_FUEL);
    if (dash.full) {
        dash.full = 0;
        n = add_rect(r, n, max, 0, 0, LCD_W, LCD_H);
    } else {
        if ((dash.warn ^ dash.warn_drawn) & WARN_SHIFT)
            n = add_rect(r, n, max, 0, 0, LCD_W, 16);
        if (nx != dash.nx || ny != dash.ny)
            n = add_rect(r, n, max, mn(CX, mn(nx, dash.nx)) - 2, mn(CY, mn(ny, dash.ny)) - 2,
                         mx(CX, mx(nx, dash.nx)) + 3, mx(CY, mx(ny, dash.ny)) + 3);
        if (spd != dash.speed_txt)
            n = add_rect(r, n, max, SPD_X, SPD_Y, SPD_X + 90, SPD_Y + 40);
        if (bc != dash.bar_clt)
            n = add_rect(r, n, max, BAR_X, CLT_Y, BAR_X + BAR_W, CLT_Y + BAR_H);
        if (bf != dash.bar_fuel)
            n = add_rect(r, n, max, BAR_X, FUEL_Y, BAR_X + BAR_W, FUEL_Y + BAR_H);
        if ((dash.warn ^ dash.warn_drawn) & (WARN_HOT | WARN_FUEL))
            n = add_rect(r, n, max, BAR_X, WARN_Y, BAR_X + 101, WARN_Y + 24);
    }
    dash.nx = nx;
    dash.ny = ny;
    dash.speed_txt = (int16_t)spd;
    dash.bar_clt = (int16_t)bc;
    dash.bar_fuel = (int16_t)bf;
    dash.warn_drawn = dash.warn;
    return n;
}

Рисование. Цвет каждой точки считается заново, кусок уходит в буфер со старшим байтом вперёд:

/* ── рисование: каждая точка прямоугольника считается заново, буфер — один кусок ── */
#define C_BG 0x0000u
#define C_WHITE 0xFFFFu
#define C_RED 0xF800u
#define C_GREEN 0x07E0u
#define C_AMBER 0xFD20u
#define C_GRAY 0x4208u
#define C_DARK 0x2104u

static int near_seg(float px, float py, float ax, float ay, float bx, float by, float w)
{
    float dx = bx - ax, dy = by - ay, t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy);
    if (t < 0) t = 0;
    if (t > 1) t = 1;
    float ex = ax + t * dx - px, ey = ay + t * dy - py;
    return ex * ex + ey * ey <= w * w;
}

static int text_px(int x, int y, int x0, int y0, int sc, const char *s)
{
    if (y < y0 || y >= y0 + 7 * sc || x < x0) return 0;
    int col = (x - x0) / sc, ch = col / 6, cx = col % 6;
    for (int i = 0; i < ch; i++)
        if (!s[i]) return 0;
    if (!s[ch] || cx >= 5) return 0;
    char c = s[ch];
    if (c < ' ' || c > 'Z') return 0;
    return (FONT5X7[c - ' '][cx] >> ((y - y0) / sc)) & 1u;
}

static uint16_t pixel(int x, int y, const char *spd)
{
    if (y < 16)
        return (dash.warn_drawn & WARN_SHIFT) ? C_RED : C_DARK;
    if (x < 212) {                                          /* круглая шкала оборотов */
        float fx = (float)x, fy = (float)y, ax, ay, bx, by;
        float dx = fx - CX, dy = fy - CY;
        if (dx * dx + dy * dy <= 36.0f) return C_GRAY;       /* ось стрелки */
        if (near_seg(fx, fy, CX, CY, dash.nx, dash.ny, 1.5f)) return C_AMBER;
        if (dx * dx + dy * dy >= (TICK_IN - 1) * (TICK_IN - 1)) {
            for (int k = 0; k <= 8; k++) {
                polar(k * 100000, TICK_IN, &ax, &ay);
                polar(k * 100000, TICK_OUT, &bx, &by);
                if (near_seg(fx, fy, ax, ay, bx, by, 1.2f)) return k >= 6 ? C_RED : C_WHITE;
            }
        }
        return C_BG;
    }
    if (text_px(x, y, SPD_X, SPD_Y, 5, spd)) return C_WHITE;
    if (text_px(x, y, SPD_X + 36, SPD_Y + 42, 2, "KM/H")) return C_GRAY;
    if (text_px(x, y, BAR_X, CLT_Y - 17, 2, "TEMP")) return C_GRAY;
    if (text_px(x, y, BAR_X, FUEL_Y - 17, 2, "FUEL")) return C_GRAY;
    if (x >= BAR_X && x < BAR_X + BAR_W && y >= CLT_Y && y < CLT_Y + BAR_H)
        return x - BAR_X < dash.bar_clt ? ((dash.warn_drawn & WARN_HOT) ? C_RED : C_GREEN) : C_DARK;
    if (x >= BAR_X && x < BAR_X + BAR_W && y >= FUEL_Y && y < FUEL_Y + BAR_H)
        return x - BAR_X < dash.bar_fuel ? ((dash.warn_drawn & WARN_FUEL) ? C_AMBER : C_GREEN) : C_DARK;
    if (y >= WARN_Y && y < WARN_Y + 24 && x >= BAR_X && x < BAR_X + 48)
        return text_px(x, y, BAR_X + 7, WARN_Y + 5, 2, "HOT") ? C_BG : (dash.warn_drawn & WARN_HOT) ? C_RED : C_DARK;
    if (y >= WARN_Y && y < WARN_Y + 24 && x >= BAR_X + 53 && x < BAR_X + 101)
        return text_px(x, y, BAR_X + 55, WARN_Y + 5, 2, "FUEL") ? C_BG : (dash.warn_drawn & WARN_FUEL) ? C_AMBER : C_DARK;
    return C_BG;
}

void dash_render(const dash_rect *r, int row0, int rows, uint16_t *buf)
{
    char spd[4] = "---";
    if (dash.speed_txt >= 0) {
        int v = dash.speed_txt > 999 ? 999 : dash.speed_txt;
        spd[0] = v >= 100 ? (char)('0' + v / 100) : ' ';
        spd[1] = v >= 10 ? (char)('0' + v / 10 % 10) : ' ';
        spd[2] = (char)('0' + v % 10);
    }
    for (int y = row0; y < row0 + rows; y++)
        for (int x = r->x; x < r->x + r->w; x++) {
            uint16_t c = pixel(x, r->y + y, spd);
            *buf++ = (uint16_t)(c >> 8 | c << 8);            /* ILI9341 ждёт старший байт первым */
        }
}

Прошивка: такты и приём. Константы тактов и CAN с проверками при сборке, очередь из 32 кадров, прерывание FIFO 0:

/* dash_f407.c — раздел 198с: прошивка приборной панели для STM32F407 (регистры CMSIS, без HAL).
 * Такты: HSE 8 МГц, SYSCLK 168 МГц, APB1 42 МГц (bxCAN), APB2 84 МГц (SPI1).
 * bxCAN1: PB8 RX, PB9 TX (AF9), 500 кбит/с, режим SILM — только слушать. Два банка фильтров, 16-битный список.
 * ILI9341: SPI1 PA5 SCK, PA7 MOSI (AF5), PA4 CS, PA3 DC, PA2 RESET; SPI1_TX — DMA2 поток 3, канал 3. */
#include "stm32f4xx.h"
#include "dash_core.h"

#define HSE_HZ    8000000u
#define PLL_M     8u
#define PLL_N     336u
#define PLL_P     2u
#define PLL_Q     7u
#define SYSCLK_HZ (HSE_HZ / PLL_M * PLL_N / PLL_P)
#define PCLK1_HZ  (SYSCLK_HZ / 4u)
#define PCLK2_HZ  (SYSCLK_HZ / 2u)
#define CAN_BRP   6u
#define CAN_TS1   11u
#define CAN_TS2   2u
#define CAN_SJW   2u
#define SPI_DIV   4u                                   /* BR = 001: fPCLK / 4 */

_Static_assert(SYSCLK_HZ == 168000000u && PCLK1_HZ == 42000000u && PCLK2_HZ == 84000000u, "такты 168/42/84 МГц");
_Static_assert(PCLK1_HZ / (CAN_BRP * (1u + CAN_TS1 + CAN_TS2)) == 500000u, "CAN 500 кбит/с ровно");
_Static_assert(CAN_TS1 <= 16u && CAN_TS2 <= 8u && CAN_BRP <= 1024u && CAN_SJW <= 4u, "поля CAN_BTR: TS1 4 бита, TS2 3, BRP 10, SJW 2");
_Static_assert(PCLK2_HZ / SPI_DIV == 21000000u, "SPI1 21 МГц");
_Static_assert(DASH_CHUNK_PX * 2u <= 65535u, "кусок влезает в NDTR (до 65535)");
_Static_assert(CAN_BTR_SILM == 0x80000000u, "SILM — бит 31 CAN_BTR");

#define CAN_BTR_VAL ((CAN_SJW - 1u) << 24 | (CAN_TS2 - 1u) << 20 | (CAN_TS1 - 1u) << 16 | (CAN_BRP - 1u))

static volatile uint32_t ms;
static dash_frame rxq[32];
static volatile uint8_t rxh, rxt;
static volatile uint32_t rx_lost;
static uint16_t lcd_buf[2][DASH_CHUNK_PX];

void SysTick_Handler(void) { ms++; }

void CAN1_RX0_IRQHandler(void)
{
    while (CAN1->RF0R & CAN_RF0R_FMP0) {
        CAN_FIFOMailBox_TypeDef *m = &CAN1->sFIFOMailBox[0];
        uint8_t n = (uint8_t)((rxh + 1u) & 31u);
        if (n != rxt) {
            dash_frame *f = &rxq[rxh];
            uint32_t lo = m->RDLR, hi = m->RDHR;
            f->id = m->RIR >> 21;
            f->dlc = (uint8_t)(m->RDTR & 15u);
            for (int i = 0; i < 4; i++) {
                f->d[i] = (uint8_t)(lo >> (8 * i));
                f->d[4 + i] = (uint8_t)(hi >> (8 * i));
            }
            rxh = n;
        } else {
            rx_lost++;
        }
        CAN1->RF0R |= CAN_RF0R_RFOM0;
    }
}

Такты и выводы. HSE 8 МГц → PLL 168 МГц, APB1 42 МГц, APB2 84 МГц:

static void clock_init(void)
{
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY)) {
    }
    FLASH->ACR = FLASH_ACR_ICEN | FLASH_ACR_DCEN | FLASH_ACR_PRFTEN | FLASH_ACR_LATENCY_5WS;
    RCC->CFGR = RCC_CFGR_PPRE1_DIV4 | RCC_CFGR_PPRE2_DIV2;
    RCC->PLLCFGR = PLL_M | PLL_N << RCC_PLLCFGR_PLLN_Pos | (PLL_P / 2u - 1u) << RCC_PLLCFGR_PLLP_Pos
                 | RCC_PLLCFGR_PLLSRC_HSE | PLL_Q << RCC_PLLCFGR_PLLQ_Pos;
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY)) {
    }
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
    }
    SysTick->LOAD = SYSCLK_HZ / 1000u - 1u;
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;
}

static void gpio_af(GPIO_TypeDef *g, unsigned pin, unsigned af)
{
    g->MODER = (g->MODER & ~(3u << (2 * pin))) | 2u << (2 * pin);
    g->OSPEEDR |= 3u << (2 * pin);
    g->AFR[pin >> 3] = (g->AFR[pin >> 3] & ~(15u << (4 * (pin & 7u)))) | af << (4 * (pin & 7u));
}

static void gpio_out(GPIO_TypeDef *g, unsigned pin)
{
    g->MODER = (g->MODER & ~(3u << (2 * pin))) | 1u << (2 * pin);
    g->BSRR = 1u << pin;
}

bxCAN. Режим SILM и обратно, фильтры, передача запроса OBD:

/* ── bxCAN ── */
static void can_mode(int active)                       /* 0 — SILM (только слушать), 1 — обычный */
{
    CAN1->MCR |= CAN_MCR_INRQ;
    while (!(CAN1->MSR & CAN_MSR_INAK)) {
    }
    CAN1->BTR = CAN_BTR_VAL | (active ? 0u : CAN_BTR_SILM);
    CAN1->MCR &= ~CAN_MCR_INRQ;
    while (CAN1->MSR & CAN_MSR_INAK) {
    }
}

static void can_init(void)
{
    uint32_t fr[4];
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;
    RCC->APB1ENR |= RCC_APB1ENR_CAN1EN;
    gpio_af(GPIOB, 8, 9);
    gpio_af(GPIOB, 9, 9);
    CAN1->MCR &= ~CAN_MCR_SLEEP;
    dash_filter_regs(fr);
    CAN1->FMR |= CAN_FMR_FINIT;
    CAN1->FM1R |= 3u;                                  /* банки 0 и 1 — список */
    CAN1->FS1R &= ~3u;                                 /* 16 бит: 4 идентификатора в банке */
    CAN1->FFA1R &= ~3u;                                /* оба — в FIFO 0 */
    CAN1->sFilterRegister[0].FR1 = fr[0];
    CAN1->sFilterRegister[0].FR2 = fr[1];
    CAN1->sFilterRegister[1].FR1 = fr[2];
    CAN1->sFilterRegister[1].FR2 = fr[3];
    CAN1->FA1R |= 3u;
    CAN1->FMR &= ~CAN_FMR_FINIT;
    CAN1->IER = CAN_IER_FMPIE0;
    NVIC_EnableIRQ(CAN1_RX0_IRQn);
    can_mode(0);
}

static void can_send(const dash_frame *f)
{
    if (!(CAN1->TSR & CAN_TSR_TME0))
        return;
    CAN1->sTxMailBox[0].TDTR = f->dlc;
    CAN1->sTxMailBox[0].TDLR = (uint32_t)f->d[0] | (uint32_t)f->d[1] << 8 | (uint32_t)f->d[2] << 16 | (uint32_t)f->d[3] << 24;
    CAN1->sTxMailBox[0].TDHR = (uint32_t)f->d[4] | (uint32_t)f->d[5] << 8 | (uint32_t)f->d[6] << 16 | (uint32_t)f->d[7] << 24;
    CAN1->sTxMailBox[0].TIR = f->id << 21 | CAN_TI0R_TXRQ;
}

ILI9341 по SPI1 и DMA2. Команды, окно, передача куска потоком 3, запуск экрана:

/* ── ILI9341 по SPI1 + DMA2 поток 3 ── */
#define CS_LO() (GPIOA->BSRR = 1u << (4 + 16))
#define CS_HI() (GPIOA->BSRR = 1u << 4)
#define DC_CMD() (GPIOA->BSRR = 1u << (3 + 16))
#define DC_DATA() (GPIOA->BSRR = 1u << 3)

static void delay_ms(uint32_t d)
{
    uint32_t t0 = ms;
    while (ms - t0 < d) {
    }
}

static void spi_wait(void)
{
    while (!(SPI1->SR & SPI_SR_TXE)) {
    }
    while (SPI1->SR & SPI_SR_BSY) {
    }
}

static void dma_wait(void)
{
    if (DMA2_Stream3->CR & DMA_SxCR_EN) {
        while (!(DMA2->LISR & DMA_LISR_TCIF3)) {
        }
    }
    DMA2->LIFCR = DMA_LIFCR_CTCIF3 | DMA_LIFCR_CHTIF3 | DMA_LIFCR_CTEIF3 | DMA_LIFCR_CDMEIF3 | DMA_LIFCR_CFEIF3;
    spi_wait();
}

static void lcd_cmd(uint8_t c, const uint8_t *p, unsigned n)
{
    DC_CMD();
    CS_LO();
    *(volatile uint8_t *)&SPI1->DR = c;
    spi_wait();
    DC_DATA();
    for (unsigned i = 0; i < n; i++) {
        while (!(SPI1->SR & SPI_SR_TXE)) {
        }
        *(volatile uint8_t *)&SPI1->DR = p[i];
    }
    spi_wait();
    CS_HI();
}

static void lcd_window(const dash_rect *r)
{
    unsigned x1 = (unsigned)(r->x + r->w - 1), y1 = (unsigned)(r->y + r->h - 1);
    uint8_t ca[4] = {(uint8_t)(r->x >> 8), (uint8_t)r->x, (uint8_t)(x1 >> 8), (uint8_t)x1};
    uint8_t pa[4] = {(uint8_t)(r->y >> 8), (uint8_t)r->y, (uint8_t)(y1 >> 8), (uint8_t)y1};
    lcd_cmd(0x2A, ca, 4);                              /* столбцы */
    lcd_cmd(0x2B, pa, 4);                              /* строки */
    DC_CMD();
    CS_LO();
    *(volatile uint8_t *)&SPI1->DR = 0x2C;             /* запись в память; дальше — точки */
    spi_wait();
    DC_DATA();
}

static void lcd_dma(const uint16_t *p, uint32_t bytes)
{
    DMA2_Stream3->CR = 0;
    DMA2_Stream3->PAR = (uint32_t)&SPI1->DR;
    DMA2_Stream3->M0AR = (uint32_t)p;
    DMA2_Stream3->NDTR = bytes;
    DMA2_Stream3->CR = 3u << DMA_SxCR_CHSEL_Pos | DMA_SxCR_MINC | DMA_SxCR_DIR_0 | DMA_SxCR_EN;
}

static void lcd_init(void)
{
    static const uint8_t pix = 0x05, madctl = 0x68;    /* 16 бит на точку; поворот на 90° (BGR|MX|MV), как в QMK */
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_DMA2EN;
    RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
    gpio_af(GPIOA, 5, 5);
    gpio_af(GPIOA, 7, 5);
    gpio_out(GPIOA, 4);
    gpio_out(GPIOA, 3);
    gpio_out(GPIOA, 2);
    SPI1->CR1 = SPI_CR1_MSTR | SPI_CR1_BR_0 | SPI_CR1_SSM | SPI_CR1_SSI;
    SPI1->CR2 = SPI_CR2_TXDMAEN;
    SPI1->CR1 |= SPI_CR1_SPE;
    GPIOA->BSRR = 1u << (2 + 16);
    delay_ms(10);
    GPIOA->BSRR = 1u << 2;
    delay_ms(120);
    lcd_cmd(0x01, 0, 0);                               /* программный сброс */
    delay_ms(120);
    lcd_cmd(0x11, 0, 0);                               /* выход из сна */
    delay_ms(5);
    lcd_cmd(0x3A, &pix, 1);
    lcd_cmd(0x36, &madctl, 1);
    lcd_cmd(0x29, 0, 0);                               /* включить */
    delay_ms(20);
}

Перерисовка и главный цикл. Два буфера: пока DMA шлёт один кусок, ядро рисует следующий:

static void draw(uint32_t now)
{
    dash_rect r[8];
    int n = dash_tick(now, r, 8), k = 0;
    for (int i = 0; i < n; i++) {
        int rows = (int)(DASH_CHUNK_PX / (unsigned)r[i].w);
        dma_wait();
        CS_HI();
        lcd_window(&r[i]);
        for (int y = 0; y < r[i].h; y += rows) {
            int h = y + rows > r[i].h ? r[i].h - y : rows;
            dash_render(&r[i], y, h, lcd_buf[k]);     /* рисуем, пока DMA шлёт прошлый кусок */
            dma_wait();
            lcd_dma(lcd_buf[k], (uint32_t)(h * r[i].w * 2));
            k ^= 1;
        }
    }
    dma_wait();
    CS_HI();
}

int main(void)
{
    uint32_t last = 0;
    int hw_active = 0;
    clock_init();
    dash_init(0);
    lcd_init();
    can_init();
    for (;;) {
        while (rxt != rxh) {
            dash_rx(&rxq[rxt], ms);
            rxt = (uint8_t)((rxt + 1u) & 31u);
        }
        dash_frame q;
        int send = dash_poll(ms, &q);
        if (dash.active && !hw_active) {               /* машина молчит: выходим из SILM и спрашиваем по OBD */
            can_mode(1);
            hw_active = 1;
        }
        if (send && hw_active)
            can_send(&q);
        if (ms - last >= DASH_TICK_MS) {
            last += DASH_TICK_MS;
            draw(ms);
        }
    }
}

Старт. Копия .data, обнуление .bss, включение FPU и таблица векторов с SysTick и CAN1_RX0:

/* start_f407.c — раздел 198с: старт STM32F407: таблица векторов (SysTick и CAN1_RX0), .data, .bss, FPU, main. */
#include <stdint.h>

extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;
int main(void);
void SysTick_Handler(void);
void CAN1_RX0_IRQHandler(void);

void Reset_Handler(void)
{
    uint32_t *s = &_sidata, *d = &_sdata;
    *(volatile uint32_t *)0xE000ED88u |= 0xFu << 20;   /* CPACR: сопроцессоры CP10 и CP11 — FPU */
    while (d < &_edata)
        *d++ = *s++;
    for (d = &_sbss; d < &_ebss;)
        *d++ = 0;
    main();
    for (;;) {
    }
}

static void Fault_Handler(void)
{
    for (;;) {
    }
}

typedef void (*vec_t)(void);
__attribute__((section(".isr_vector"), used))
const vec_t vectors[16 + 21] = {
    [0] = (vec_t)(uintptr_t)&_estack, [1] = Reset_Handler, [2] = Fault_Handler, [3] = Fault_Handler,
    [15] = SysTick_Handler,
    [16 + 20] = CAN1_RX0_IRQHandler,                  /* CAN1_RX0_IRQn = 20 */
};

Скрипт компоновщика f407.ld — 128 КБ ОЗУ, которые видит DMA:

/* Раздел 198с: STM32F407 — 1 МБ флеш-памяти, 128 КБ ОЗУ (SRAM1 + SRAM2, доступны DMA; CCM 64 КБ DMA не видит). */
ENTRY(Reset_Handler)
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
  .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
  .text : { *(.text .text.*) *(.rodata .rodata.*) . = ALIGN(4); } > FLASH
  .ARM.exidx : { *(.ARM.exidx*) } > FLASH
  .data : { _sdata = .; *(.data .data.*) . = ALIGN(4); _edata = .; } > RAM AT > FLASH
  _sidata = LOADADDR(.data);
  .bss (NOLOAD) : { _sbss = .; *(.bss .bss.*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
  end = _ebss;
}

198с.8 Грабли

На столе передатчик повторяет один и тот же кадр. Один передатчик и одна панель в SILM: подтверждения ACK на шине нет никогда — панель его не выдаёт. Для передатчика это ошибка, и bxCAN по умолчанию (NART = 0, RM0090) повторяет кадр, пока тот не уйдёт успешно. В машине подтверждают другие блоки; на столе нужен третий узел в обычном режиме.

Панель ломает шину. Обычный режим и не та скорость — каждый кадр блоков рвётся кадром ошибки. Слушать — только в SILM (bxCAN) или MON (FDCAN), в обычный режим — только ради OBD и только после тишины.

На разъёме OBD тишина. Шлюз не выпускает широковещательные кадры на диагностический разъём (ESP_21 в DBC шлёт Gateway_MQB). Подключаться за щитком или жить на запасном OBD.

Скорость есть, а обороты замерли. Кадр с неверным CRC отброшен — правильно. Но у Motor_12 в таблице opendbc «секретного» байта нет, и если настоящий блок его использует, отброшены будут все кадры. Счётчик ошибок CRC на служебном экране показывает это сразу.

Потеряно 50 кадров, а счётчик говорит 2. Четыре бита — потери по модулю 16. Пропажу сообщения ловит таймер, а не счётчик.

Лампа переключения опаздывает. Если зажигать её по сглаженным оборотам — 74 мс вместо 4 в опыте. Предупреждения — по сырому значению.

Топливо показывает 126 %. Это «Init» щитка, а не проценты (VAL_ 1720).

Экран тормозит приём. Весь кадр — 58,51 мс на 21 МГц, больше такта в 40 мс. Перерисовывать прямоугольники, через DMA и кусками, а приём держать в прерывании с очередью.

DMA шлёт нули. Буфер в CCM (0x1000 0000): DMA его не видит. Буферы экрана — только в SRAM1/SRAM2.

Кадр больше 65535 байт. NDTR 16-битный, 153 600 байт одним заходом не отправить; куски по 8192 байта.


198с.9 Итоги

Дальше: раздел 198т «Мини-проект: регистратор шины CAN на карту SD — запись поездки и разбор на компьютере» — та же шина, но не на экран, а в файл: записать поездку целиком и разобрать её потом.

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