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

198р. Мини-проект: OBD-II сканер на STM32 — обороты, скорость, температура и коды ошибок

В разделе 19 bxCAN разобран изнутри, в 198е он стал сниффером — прибором, который только слушает шину. Здесь прибор впервые разговаривает с чужими блоками: карманный сканер втыкается в диагностический разъём машины, спрашивает у блока управления двигателем обороты, скорость, температуру охлаждающей жидкости и напряжение, читает коды ошибок и номер кузова (VIN) и показывает всё на маленьком дисплее. Одна кнопка листает экраны, а долгое нажатие стирает коды — с подтверждением и защитой от стирания на ходу.

Проверено всё, что можно проверить без машины. Ядро сканера (obd_core.c) собрано gcc на компьютере и прогнано против модели двух блоков на шине в памяти: поездка на 72 с с пуском, разгоном, круизом и остановкой, два кода у двигателя, три у коробки, VIN, нажатия кнопки по сценарию и обрыв связи. Тот же опыт собран arm-none-eabi-gcc для Cortex-M3 и прогнан в QEMU — вывод совпал строка в строку. Модель на Python своим кодом пересчитала битовый тайминг, фильтр, разбор кадров и загрузку шины. Прошивка для BluePill собрана с -Werror; на плате и в машине она не запускалась.

Опоры: RM0008 (глава bxCAN: битовый тайминг, рис. 233, поля CAN_BTR, фильтры в режиме маски, рис. 229, длина кадра, рис. 234) из пака Keil STM32F1xx_DFP 2.4.1. Стандарты SAE J1979, J1962 и ISO 15765 платные и здесь не сверялись: формулы PID и разбор кодов сверены с python-obd (decoders.py, UnitsAndScaling.py), адреса блоков, функциональный адрес 0x7DF, разбивка ответа на кадры и VIN — с elm327-emulator (obd_message.py, elm.py), значения по умолчанию ISO-TP — с isotp-c и iso14229, команды SSD1306 — с драйвером OLED.cpp из IronOS. Лицензия: python-obd — GPL-2.0, elm327-emulator — CC BY-NC-SA 4.0, isotp-c и iso14229 — MIT, IronOS — GPL-3.0; их код в справочник не скопирован, только сверены числа и форматы. bxCAN — раздел 19, трансивер и терминаторы — 19.2, защита входа питания — 173, сниффер — 198е.

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

Оригинал Перевод
OBD-II, SAE J1979 service (mode) бортовая диагностика; служба (режим) запроса: 01 — текущие данные, 03 — коды, 04 — стереть, 09 — сведения о машине
PID (parameter ID) номер параметра внутри службы 01
DTC (diagnostic trouble code) код неисправности, например P0133
MIL (malfunction indicator lamp) лампа «проверь двигатель»
freeze frame стоп-кадр: параметры в момент появления кода
functional / physical addressing запрос всем блокам (0x7DF) / одному блоку (0x7E0…0x7E7)
ISO-TP (ISO 15765-2): single frame, first frame, consecutive frame, flow control транспорт поверх CAN: одиночный кадр, первый кадр, последующий кадр, управление потоком
block size, STmin сколько кадров слать без нового FC; пауза между кадрами
P2 timeout сколько ждать ответа блока
J1962 connector диагностический разъём на 16 выводов
load dump сброс нагрузки: выброс при отключении генератора под нагрузкой

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

Сканер питается от разъёма машины, сам находит блоки, которые отвечают на OBD-II, выбирает блок двигателя и опрашивает его по кругу. Экранов четыре: живые данные, коды ошибок, VIN и служебный (какие блоки ответили, какие PID они знают, счётчики). Пятый — вопрос перед стиранием кодов.

Сканер OBD-II на BluePill: разъём J1962 с выводами 16 плюс 12 В, 4 и 5 земля, 6 CAN-H и 14 CAN-L; питание через предохранитель, диод и ограничитель на понижающий стабилизатор 3,3 В; линия CAN через трансивер SN65HVD230 без терминатора на bxCAN PB8 и PB9 с тактами BRP 4, TS1 15, TS2 2 и точкой выборки 88,9 %; фильтр-маска пропускает только 0x7E8…0x7EF; ядро ISO-TP и опрос по слотам 25 мс; дисплей SSD1306 по I2C1 на PB6 и PB7 и кнопка на PA0; внизу загрузка шины 2,16 % и размер прошивки

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

Что Какое Зачем
плата BluePill, STM32F103C8 bxCAN, I2C1, 64 КБ флеш-памяти
трансивер модуль на SN65HVD230 питание 3,3 В, как у микроконтроллера (19.2)
разъём вилка OBD-II (J1962) с кабелем выводы 4, 5, 6, 14, 16
защита предохранитель, диод, однонаправленный ограничитель переполюсовка и выбросы (173)
питание понижающий модуль 12 → 3,3 В линейный стабилизатор здесь греется (198р.2)
дисплей модуль 0,96″ на SSD1306, 128 × 64, I2C 8 строк по 21 символу
кнопка тактовая, на землю подтяжка — внутренняя

Выводы BluePill: CAN_RX и CAN_TX — PB8 и PB9 после переназначения (CAN_REMAP = 10, как в 198е), I2C1 — PB6 и PB7, кнопка — PA0. Такт — кварц 8 МГц × 9 = 72 МГц, APB1 — 36 МГц: от него тактуются bxCAN и I2C1.


198р.2 Разъём, питание и трансивер

Разъём J1962. Для CAN-диагностики нужны пять выводов: 16 — плюс аккумулятора, 4 — земля кузова, 5 — сигнальная земля, 6 — CAN-H, 14 — CAN-L. Это общеизвестная раскладка; со стандартом J1962 она не сверена.

Трансивер. Выбран SN65HVD230: он питается от 3,3 В, как и микроконтроллер (таблица в 19.2), и одной шины 3,3 В хватает на всю плату. TJA1050 питается от 5 В — понадобилась бы вторая шина питания, а его выход RXD шёл бы на вход микроконтроллера пятивольтовым уровнем (19.2: «согласователь уровней или толерантные выводы»). Терминатора 120 Ом в сканере нет: на шине машины их уже два, на концах линии (19.2), а сканер — ответвление.

Питание и защита. Порядок — как в 173.4: предохранитель, защита от переполюсовки (диод или транзистор), однонаправленный ограничитель после неё, накопительный конденсатор, затем стабилизатор. Ограничитель для машины выбирают, как сказано в 173.3: рабочее напряжение выше 24 В, чтобы он не открывался при запуске от внешней батареи на 24 В. Сброс нагрузки — выброс на доли секунды с энергией на порядки больше, чем гасит ограничитель у платы (173.3); его защищает цепь в генераторе, а мы только не даём ему сжечь стабилизатор. Чисел для сброса нагрузки здесь нет: со стандартом они не сверялись, это не проверено.

Стабилизатор — импульсный. Линейный падал бы на себе 14,4 − 3,3 = 11,1 В и при токе 50 мА (пример: ток сканера не измерялся) рассеивал бы 0,555 Вт.


198р.3 bxCAN: 500 кбит/с и фильтр на ответы

Скорость. По RM0008 (рис. 233) бит — это один квант синхронизации плюс TS1 + 1 и TS2 + 1 квантов, квант — (BRP + 1) тактов APB1. Поля узкие: TS1 — 4 бита (1…16 квантов), TS2 — 3 бита (1…8), BRP — 10 бит. От 36 МГц 500 кбит/с ровно дают 38 сочетаний, из них 6 — с точкой выборки от 80 до 90 %:

Делитель Квантов в бите TS1 + TS2 Точка выборки
4 18 15 + 2 88,9 %
4 18 14 + 3 83,3 %
6 12 9 + 2 83,3 %
8 9 7 + 1 88,9 %
9 8 6 + 1 87,5 %
12 6 4 + 1 83,3 %

Взят первый: квантов больше всего (квант 111,1 нс), и прыжок подстройки SJW можно дать в 2 кванта — в машине много блоков со своими кварцами. Сниффер 198е брал 9 × 8 квантов (87,5 %) — тоже годится. Какую точку выборки требует конкретная машина, без ISO 15765-4 не проверить. В регистре это 0x011E0003:

    CAN1->BTR = ((CAN_SJW - 1u) << 24) | ((CAN_TS2 - 1u) << 20) | ((CAN_TS1 - 1u) << 16) | (CAN_BRP - 1u);

Фильтр. Блоки отвечают на идентификаторах 0x7E8…0x7EF — восемь соседних, отличаются тремя младшими битами. Значит, хватает одного 32-битного фильтра в режиме маски (RM0008, рис. 229: STID — в битах 31…21, IDE — бит 2, RTR — бит 1). В маску попадают старшие 8 бит идентификатора и ещё IDE и RTR: 29-битные кадры и запросы данных не пройдут.

#define OBD_FR1 ((uint32_t)OBD_RESP_ID << 21)
#define OBD_FR2 (((uint32_t)OBD_RESP_MASK << 21) | 0x4u | 0x2u)   /* IDE = 0 и RTR = 0 — тоже «должно совпасть» */

Выходит FR1 = 0xFD000000, FR2 = 0xFF000006. Опыт прогнал через тот же фильтр все 2048 стандартных идентификаторов: прошли ровно 8, от 0x7E8 до 0x7EF; из запросов данных и 29-битных кадров — ни одного. Остальной трафик машины до прерывания не доходит вовсе.


198р.4 Запросы OBD-II поверх ISO-TP

Запрос — один кадр: байт длины (PCI одиночного кадра, 0x0N), служба, номер PID. Кадр всегда полный, 8 байт; остаток добивается 0xAA — таким байтом добивает isotp-c, а elm327-emulator — нулями. Функциональный запрос уходит на 0x7DF (его слышат все блоки), физический — на 0x7E0…0x7E7, адрес ответа минус 8 (так адреса расписаны в elm327-emulator: двигатель 0x7E0/0x7E8, коробка 0x7E1/0x7E9).

Ответ длиннее 7 байт идёт составным. Первый кадр несёт 12 бит длины и 6 байт; сканер отвечает кадром управления потоком (FC) на физический адрес этого блока, и блок досылает последующие кадры по 7 байт с номерами 1, 2…:

    } else if (type == 1u) {                    /* первый кадр: длина 12 бит, 6 байт данных */
        obd_frame fc;
        tp.len = (uint16_t)(((f->d[0] & 15u) << 8) | f->d[1]);
        if (tp.len < 8u || tp.len > OBD_BUF || f->dlc < 8u) {
            obd.isotp_err++;
            return;
        }
        for (unsigned i = 0; i < 6u; i++)
            tp.buf[i] = f->d[2u + i];
        tp.got = 6;
        tp.sn = 1;
        tp.from = from;
        tp.active = 1;
        fc.id = from - 8u;                      /* управление потоком — на физический адрес этого ЭБУ */
        fc.dlc = 8;
        fc.d[0] = 0x30;                         /* FC: продолжай */
        fc.d[1] = 0;                            /* размер блока 0 — всё без остановок */
        fc.d[2] = 0;                            /* STmin 0 мс */

Начало опыта — первые 207 мс на шине, дословно из прогона (время в мс, идентификатор, 8 байт):

bus t=  1 7DF  02 01 00 AA AA AA AA AA
bus t=  3 7E8  06 41 00 88 18 00 01 AA
bus t=  5 7E9  06 41 00 00 08 00 00 AA
bus t=101 7E0  02 01 20 AA AA AA AA AA
bus t=103 7E8  06 41 20 00 00 00 01 AA
bus t=126 7E0  02 01 40 AA AA AA AA AA
bus t=128 7E8  06 41 40 40 00 00 00 AA
bus t=151 7E0  02 01 01 AA AA AA AA AA
bus t=153 7E8  06 41 01 82 07 65 00 AA
bus t=176 7E0  02 09 02 AA AA AA AA AA
bus t=178 7E8  10 14 49 02 01 57 50 30
bus t=179 7E0  30 00 00 AA AA AA AA AA
bus t=180 7E8  21 5A 5A 5A 39 39 5A 54
bus t=181 7E8  22 53 33 39 30 30 30 30
bus t=201 7DF  01 03 AA AA AA AA AA AA
bus t=203 7E8  06 43 02 01 33 04 20 AA
bus t=205 7E9  10 08 43 03 07 00 07 15
bus t=206 7E1  30 00 00 AA AA AA AA AA
bus t=207 7E9  21 C1 00 AA AA AA AA AA

Что здесь происходит:

  1. 0100 на 0x7DF — «какие PID 01…20 ты знаешь». Ответили двое: 0x7E8 — PID 01, 05, 0C, 0D и 20, 0x7E9 — только 0D. Блок двигателя — тот, кто знает обороты (0C): 0x7E8. Дальше сканер спрашивает только его, на 0x7E0: на 0x7DF скорость (0D) прислала бы и коробка — лишний ответ на каждый такой запрос.
  2. 0120 и 0140 — следующие диапазоны. Последний бит ответа 0100 (PID 20) говорит, что есть ответ на 0120, его последний бит (PID 40) — что есть
  3. PID 0x42 (напряжение) лежит в диапазоне 41…60: одной таблицы 0100 не хватило бы.
  4. 0101 — байт A = 0x82: старший бит — лампа MIL горит, младшие семь — два кода (разбор как в python-obd).
  5. 0902 — VIN: ответ 20 байт (49 02 01 и 17 символов) не лезет в кадр. Первый кадр 10 14 — длина 0x014, FC на 0x7E0 через 1 мс, затем кадры 21 и 22. Собрано WP0ZZZ99ZTS390000 — номер из примера в elm327-emulator.
  6. 03 на 0x7DF — коды. У двигателя два (одиночный кадр, 43 02 01 33 04 20), у коробки три — это 8 байт, составной ответ, FC уже на 0x7E1.

Формулы — те же, что в python-obd (таблица UAS: 0x07 — 0,25 об/мин на единицу, 0x09 — 1 км/ч, 0x0B — 0,001 В):

PID Что Байты Формула
0C обороты A, B (256·A + B) / 4 об/мин
0D скорость A A км/ч
05 охлаждающая жидкость A A − 40 °C
42 напряжение блока A, B (256·A + B) / 1000 В

Код ошибки — два байта. Два старших бита — буква системы (P, C, B, U), следующие два — первая цифра 0…3, остальные 12 бит — три шестнадцатеричные цифры:

    out[0] = sys[code >> 14];
    out[1] = (char)('0' + ((code >> 12) & 3u));                 /* следующие два бита: 0…3 */
    out[2] = hex[(code >> 8) & 15u];
    out[3] = hex[(code >> 4) & 15u];
    out[4] = hex[code & 15u];

Опыт проверил семь кодов: 0x0133 — P0133, 0x0420 — P0420, 0x4123 — C0123 (пример из комментария python-obd), 0x8001 — B0001, 0xC100 — U0100, 0x1234 — P1234, 0x3FFF — P3FFF; модель на Python разобрала их так же.


198р.5 Опрос по кругу, экраны и кнопка

Ядро вызывается раз в миллисекунду. Запрос в полёте всегда один; новый уходит в начале слота 25 мс, если прежний закончен. Физический запрос кончается первым ответом или таймаутом 150 мс (столько по умолчанию ждёт ответа клиент iso14229, UDS_CLIENT_DEFAULT_P2_MS). Функциональный ждёт 100 мс и собирает ответы всех блоков. Три таймаута подряд — надпись NO LINK.

Порядок опроса — таблица из восьми слотов; PID, которых блок не знает, пропускаются:

static const uint8_t POLL[8] = {0x0C, 0x0D, 0x0C, 0x05, 0x0C, 0x0D, 0x0C, 0x42};   /* обороты — каждый второй слот */

Обороты меняются быстрее всего и идут каждые 50 мс (20 раз в секунду), скорость — каждые 100 мс, температура и напряжение — каждые 200 мс. Так и вышло в опыте: на участке 12…48 с между запросами 010C ровно 50 мс (720 запросов), 010D — 100 мс, 0105 и 0142 — по 200 мс.

Загрузка шины. 40 запросов и 40 ответов в секунду — 80 кадров. Кадр с 8 байтами — 111 бит с паузой (RM0008, рис. 234), в худшем случае вставки битов — 135. Это 1,78…2,16 % шины 500 кбит/с. Сканер почти не мешает машине; чаще спрашивать можно, но блок двигателя отвечает не мгновенно, а его время ответа без машины не измерить.

Экраны. Строка — 21 символ шрифта 5 × 7 (6 точек с промежутком, 126 из 128), строк восемь — по странице SSD1306 на строку. Экраны из опыта, дословно:

screen t=20000 accelerating
|OBD-II  ECU 7E8      |
|RPM       1894 RPM   |
|SPEED       44 KM/H  |
|COOLANT     48 C     |
|BATTERY  14.20 V     |
|MIL ON   DTC  2      |
|                     |
|                     |
screen t=5500 dtc
|STORED DTC:  5       |
|P0133  P0420         |
|P0700  P0715         |
|U0100                |
|                     |
|                     |
|HOLD 2S: CLEAR       |
|PRESS: NEXT          |

Надписи латиницей: в прошивке шрифт только на символы от пробела до Z — 59 знаков по 5 байт. На снимке разгона обороты отстают от «правды» поездки на 46 мс — меньше периода их опроса.

Дисплей не должен тормозить опрос. Весь экран 1024 байта по I2C на 400 кГц (9 бит на байт) — 23,1 мс ожидания в главном цикле. Поэтому за вызов уходит один символ: адрес, байт 0x40 и 6 байт столбцов — 0,18 мс, плюс раз на строку три команды позиции. Экран обновляется целиком за 168 мс, а опрос не сдвигается. Команды включения — префикс 0x00 для команд и 0x40 для данных, адрес 0x3C, накачка 8D 14, мультиплекс A8 (высота минус 1), раскладка COM DA (0x02 или 0x12 в зависимости от панели) — сверены с OLED.cpp из IronOS; с даташитом SSD1306 не сверены.

Кнопка и стирание кодов. Короткое нажатие листает экраны. Долгое (2 с, считается по удержанию, а не по отпусканию) на экране кодов открывает вопрос:

screen t=28200 confirm
|CLEAR ALL DTC?       |
|ALSO FREEZE FRAME    |
|AND READINESS DATA   |
|IGNITION ON,         |
|ENGINE OFF ONLY      |
|                     |
|HOLD 2S: YES         |
|PRESS: NO           5|

Ещё одно долгое нажатие в течение 5 с — «да», короткое — «нет», тишина 5 с — тоже «нет». Служба 04 стирает не только коды: в python-obd она названа «Clear DTCs and Freeze data», а ответ 0101 — «Status since DTCs cleared», то есть готовность систем самодиагностики считается от стирания и тоже сбрасывается. А причина неисправности никуда не денется. Поэтому сканер не стирает при работающем двигателе (последние обороты больше нуля) и без связи — пишет ENGINE ON: NOT CLEAR.


198р.6 Проверка на компьютере: поездка на 72 секунды

Модель шины: кадр доходит за 1 мс, блок двигателя отвечает через 2 мс, коробка — через 4 мс; ответ приходит на 3 мс позже запроса. Сценарий:

Время Что происходит Что показал сканер
0…2 с зажигание, двигатель стоит нашёл 2 блока, VIN, 5 кодов
2…10 с пуск, холостой ход 800 об/мин 4 с: 800 об/мин, 21 °C, 14,20 В
10…30 с разгон до 3000 об/мин и 90 км/ч, прогрев 20 с: 1894 об/мин, 44 км/ч, 48 °C
26…31,5 с два долгих нажатия на ходу 31,02 с: отказ ENGINE ON: NOT CLEAR
30…40 с круиз 2200 об/мин, 90 км/ч 38 с: 2200, 90, 78 °C
52 с двигатель заглушен —
54…56,5 с вопрос — и без ответа 61,02 с: вопрос закрыт через 5 с
62…67,5 с вопрос, «да» 67,03 с: оба блока ответили 44; кодов 0, MIL погасла
69…71 с блоки молчат 69,45 с: NO LINK; 71,10 с: связь вернулась

Каждое разобранное значение сверено с тем, что модель блока в этот момент закодировала: обороты — 1388 ответов, скорость — 694, температура — 347, напряжение — 346, расхождений ноль. NO LINK появился через 450 мс тишины — три таймаута по 150 мс. Ошибок ISO-TP и лишних ответов нет.

Тот же опыт собран arm-none-eabi-gcc 12.2.1 для Cortex-M3 и запущен в qemu-system-arm -M netduinoplus2 — вывод совпал с ПК строка в строку. Модель на Python отдельно пересчитала тайминг, фильтр, коды, собрала VIN и ответы коробки из кадров и сверила значения на снимках экрана с «правдой» поездки.

Прошивка BluePill (obd_f103.c: такт, bxCAN, I2C1, кнопка, SysTick; ядро то же) собрана arm-none-eabi-gcc -Os -Wall -Wextra -Werror: 4972 байта флеш-памяти и 680 байт ОЗУ. bxCAN, I2C и дисплей на железе не запускались — CAN в QEMU не моделируется.

Как повторить в машине: сначала экран INFO — видно, кто ответил на 0100 и какие PID знает блок двигателя. Не ответил никто — проверить выводы 6 и 14 и скорость: бывают машины с 29-битными адресами (0x18DB33F1 — их функциональный адрес в elm327-emulator), под них фильтр и адреса другие.


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

Прошивка — шесть своих файлов: ядро без железа obd_core.h и obd_core.c, работа с регистрами F103 obd_f103.c, старт start_f103.c, шрифт font5x7.h и скрипт компоновщика f103.ld. Ниже все они целиком. Куски из 198р.3…198р.5 здесь повторяются внутри своих функций. Чужого кода нет.

Сборка — те же команды, что в проверке. CMSIS_F1 — каталог Device/Include пака Keil STM32F1xx_DFP 2.4.1 (в нём stm32f10x.h), CMSIS_CORE — CMSIS/Core/Include из CMSIS 5.9.0 (github.com/ARM-software/CMSIS_5):

for f in start_f103 obd_f103 obd_core; do
  arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -Os -Wall -Wextra -Werror -std=c11 -ffreestanding \
    -ffunction-sections -fdata-sections -DSTM32F10X_MD -I. -I$CMSIS_F1 -I$CMSIS_CORE -c $f.c -o $f.o
done
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb start_f103.o obd_f103.o obd_core.o \
  -T f103.ld -nostdlib -Wl,--gc-sections -o fw.elf -lgcc

Заголовок ядра. Адреса и фильтр, все сроки, кадр и состояние прибора:

/* Раздел 198р «Мини-проект: OBD-II сканер» — ядро без железа.
 * Запросы OBD-II поверх ISO-TP (одиночный кадр туда, одиночный или составной обратно),
 * расписание опроса, разбор PID и кодов ошибок, кнопка и экраны 21 × 8 символов.
 * Одно и то же ядро собирается gcc на ПК (против модели ЭБУ) и arm-none-eabi-gcc для F103. */
#ifndef OBD_CORE_H
#define OBD_CORE_H
#include <stdint.h>

#define OBD_FUNC_ID   0x7DFu    /* функциональный адрес: «все ЭБУ, кто поддерживает OBD» */
#define OBD_RESP_ID   0x7E8u    /* ответы 0x7E8…0x7EF, запрос к одному ЭБУ — ответ минус 8 */
#define OBD_RESP_MASK 0x7F8u    /* 8 младших идентификаторов — в одном фильтре-маске */

/* фильтр bxCAN, 32 бита, режим маски (RM0008, рис. 229): STID в битах 31:21, IDE — бит 2, RTR — бит 1 */
#define OBD_FR1 ((uint32_t)OBD_RESP_ID << 21)
#define OBD_FR2 (((uint32_t)OBD_RESP_MASK << 21) | 0x4u | 0x2u)   /* IDE = 0 и RTR = 0 — тоже «должно совпасть» */

#define OBD_PAD       0xAAu     /* добивка кадра до 8 байт — как ISO_TP_FRAME_PADDING_VALUE в isotp-c */
#define OBD_SLOT_MS   25u       /* один запрос в слот */
#define OBD_P2_MS     150u      /* ждём ответ — как UDS_CLIENT_DEFAULT_P2_MS в iso14229 */
#define OBD_COLLECT_MS 100u     /* функциональный запрос: собираем ответы всех ЭБУ */
#define OBD_LOST_N    3u        /* столько таймаутов подряд — «NO LINK» */
#define OBD_DEBOUNCE_MS 20u
#define OBD_LONG_MS   2000u     /* долгое нажатие */
#define OBD_CONFIRM_MS 5000u    /* окно подтверждения стирания */
#define OBD_BUF       64u       /* буфер сборки ISO-TP */
#define OBD_MAX_DTC   8u
#define OBD_MAX_ECU   8u

typedef struct {
    uint32_t id;
    uint8_t dlc;
    uint8_t d[8];
} obd_frame;

enum { SCR_LIVE, SCR_DTC, SCR_VIN, SCR_INFO, SCR_CONFIRM, SCR_N };

typedef struct {
    /* что знаем о машине */
    uint32_t pids[3];           /* поддержка PID 01…20, 21…40, 41…60 (ответы 0100, 0120, 0140) */
    uint16_t ecu_id[OBD_MAX_ECU];
    uint8_t n_ecu;
    uint16_t engine;            /* ЭБУ двигателя: ответил 0100 и поддерживает 0C */
    /* последние значения; have_* — пришёл ли хоть один ответ */
    uint16_t rpm_raw;           /* 256·A + B, обороты ×4 */
    uint8_t speed, coolant_raw, have;
    uint16_t volt_mv;
    uint8_t mil, n_dtc_mil;     /* из 0101: лампа и число кодов */
    uint16_t dtc[OBD_MAX_DTC];
    uint8_t n_dtc, dtc_read;
    char vin[18];
    /* счётчики */
    uint32_t tx, rx, timeouts, nrc, extra, isotp_err, cleared;
    uint8_t lost, screen, cleared_ok, refused;
} obd_state;

#define HAVE_RPM 1u
#define HAVE_SPEED 2u
#define HAVE_COOL 4u
#define HAVE_VOLT 8u

extern obd_state obd;

void obd_init(void);
void obd_rx(const obd_frame *f, uint32_t now);  /* кадр, прошедший фильтр */
void obd_poll(uint32_t now, int btn_down);      /* вызывать каждую миллисекунду */
int plat_can_send(const obd_frame *f);          /* даёт платформа; 0 — кадр ушёл в ящик */

/* разбор — отдельно, чтобы сверить с моделью */
unsigned obd_dtc_str(uint16_t code, char out[6]);
int32_t obd_decode(uint8_t pid, const uint8_t *ab);    /* 0C — об/мин ×4, 0D — км/ч, 05 — °C, 42 — мВ */
extern char obd_lines[8][22];                   /* экран: 8 строк по 21 символу */
void obd_render(uint32_t now);

/* журнал для опыта на ПК: что ушло и что разобрано; на плате — пустые функции */
void obd_log_req(uint32_t now, uint16_t id, uint8_t svc, uint8_t pid);
void obd_log_val(uint32_t now, uint8_t pid, int32_t v);
#endif

Ядро: состояние. Один запрос в полёте (job), сборка составного ответа (tp), таблица опроса и состояние кнопки:

/* Раздел 198р «Мини-проект: OBD-II сканер» — ядро: ISO-TP, запросы, разбор, кнопка, экраны. */
#include "obd_core.h"

obd_state obd;
char obd_lines[8][22];

static struct {
    uint8_t busy, func, svc, pid, answered;
    uint16_t to;
    uint32_t t0;
} job;                                          /* один запрос в полёте */

static struct {
    uint8_t active, sn;
    uint16_t from, len, got;
    uint8_t buf[OBD_BUF];
} tp;                                           /* сборка составного ответа ISO-TP */

static uint32_t next_slot;
static uint8_t step, poll_i, want_clear, reread, miss;
static const uint8_t POLL[8] = {0x0C, 0x0D, 0x0C, 0x05, 0x0C, 0x0D, 0x0C, 0x42};   /* обороты — каждый второй слот */

static struct {
    uint8_t raw, stable, long_fired;
    uint32_t t_change, t_press, t_confirm;
} btn;

void obd_init(void)
{
    uint8_t *p = (uint8_t *)&obd;
    for (unsigned i = 0; i < sizeof obd; i++)
        p[i] = 0;
    job.busy = 0;
    tp.active = 0;
    next_slot = 0;
    step = poll_i = want_clear = reread = miss = 0;
    btn.raw = btn.stable = btn.long_fired = 0;
    btn.t_change = btn.t_press = btn.t_confirm = 0;
}

Ядро: запрос. Поддержан ли PID — по картам 0100/0120/0140; кадр запроса — всегда 8 байт с добивкой; физический запрос — на адрес двигателя минус 8:

static int supported(uint8_t pid)
{
    if (pid == 0u || pid > 0x60u)
        return pid == 0u;
    return (obd.pids[(pid - 1u) / 32u] >> (31u - (pid - 1u) % 32u)) & 1u;
}

static void send(uint16_t id, uint8_t n, uint8_t b0, uint8_t b1, uint8_t b2)
{
    obd_frame f;
    f.id = id;
    f.dlc = 8;                                  /* кадр всегда полный: 8 байт с добивкой */
    f.d[0] = n;                                 /* PCI одиночного кадра: 0x0N, N — байт полезных данных */
    f.d[1] = b0;
    f.d[2] = b1;
    f.d[3] = b2;
    for (unsigned i = 1u + n; i < 8u; i++)
        f.d[i] = OBD_PAD;
    if (plat_can_send(&f) == 0)
        obd.tx++;
}

static void request(uint32_t now, int func, uint8_t svc, uint8_t pid, int has_pid)
{
    job.busy = 1;
    job.func = (uint8_t)func;
    job.svc = svc;
    job.pid = pid;
    job.answered = 0;
    job.to = func ? (uint16_t)OBD_FUNC_ID : (uint16_t)(obd.engine - 8u);
    job.t0 = now;
    if (svc == 0x03u)
        obd.n_dtc = 0;
    send(job.to, has_pid ? 2u : 1u, svc, pid, 0);
    obd_log_req(now, job.to, svc, has_pid ? pid : 0xFFu);
}

Ядро: формулы и коды. Значения хранятся целыми: обороты ×4, напряжение в милливольтах:

int32_t obd_decode(uint8_t pid, const uint8_t *ab)
{
    switch (pid) {
    case 0x0C: return (int32_t)((ab[0] << 8) | ab[1]);          /* (256A + B) / 4 об/мин — храним ×4 */
    case 0x0D: return ab[0];                                    /* A км/ч */
    case 0x05: return (int32_t)ab[0] - 40;                      /* A − 40 °C */
    case 0x42: return (int32_t)((ab[0] << 8) | ab[1]);          /* (256A + B) / 1000 В — храним в мВ */
    default: return 0;
    }
}

unsigned obd_dtc_str(uint16_t code, char out[6])
{
    static const char sys[4] = {'P', 'C', 'B', 'U'};           /* два старших бита: система */
    static const char hex[16] = "0123456789ABCDEF";
    out[0] = sys[code >> 14];
    out[1] = (char)('0' + ((code >> 12) & 3u));                 /* следующие два бита: 0…3 */
    out[2] = hex[(code >> 8) & 15u];
    out[3] = hex[(code >> 4) & 15u];
    out[4] = hex[code & 15u];
    out[5] = 0;
    return 5;
}

static void finish(void)
{
    job.busy = 0;
    miss = 0;
    obd.lost = 0;
}

Ядро: разбор сообщения. Отказ 7F, карты поддержки (ЭБУ двигателя — тот, кто знает PID 0C), лампа и число кодов, значения, список кодов, стирание и VIN:

/* целое сообщение от ЭБУ from: p[0] — ответ службы (запрос + 0x40) */
static void message(uint16_t from, const uint8_t *p, unsigned n, uint32_t now)
{
    obd.rx++;
    if (!job.busy) {
        obd.extra++;
        return;
    }
    if (p[0] == 0x7Fu && n >= 2u && p[1] == job.svc) {     /* отрицательный ответ: 7F, служба, причина */
        obd.nrc++;
        if (!job.func)
            finish();
        return;
    }
    if (p[0] != (uint8_t)(job.svc + 0x40u) || (!job.func && from != obd.engine)) {
        obd.extra++;
        return;
    }
    job.answered = 1;
    if (job.svc == 0x01u) {
        if (n < 2u || p[1] != job.pid) {
            obd.extra++;
            return;
        }
        if ((job.pid == 0x00u || job.pid == 0x20u || job.pid == 0x40u) && n >= 6u) {
            uint32_t bm = ((uint32_t)p[2] << 24) | ((uint32_t)p[3] << 16) | ((uint32_t)p[4] << 8) | p[5];
            if (job.pid == 0x00u && obd.n_ecu < OBD_MAX_ECU)
                obd.ecu_id[obd.n_ecu++] = from;
            if (job.pid == 0x00u && obd.engine == 0u && (bm >> (31u - (0x0Cu - 1u))) & 1u)
                obd.engine = from;              /* ЭБУ двигателя — тот, кто знает обороты */
            if (job.pid != 0x00u || from == obd.engine)
                obd.pids[job.pid / 32u] = bm;
        } else if (job.pid == 0x01u) {
            obd.mil = p[2] >> 7;                /* A7 — лампа MIL, A6…A0 — число кодов */
            obd.n_dtc_mil = p[2] & 0x7Fu;
        } else if (n >= 3u) {
            int32_t v = obd_decode(job.pid, p + 2);
            if (job.pid == 0x0Cu) { obd.rpm_raw = (uint16_t)v; obd.have |= HAVE_RPM; }
            if (job.pid == 0x0Du) { obd.speed = (uint8_t)v; obd.have |= HAVE_SPEED; }
            if (job.pid == 0x05u) { obd.coolant_raw = p[2]; obd.have |= HAVE_COOL; }
            if (job.pid == 0x42u) { obd.volt_mv = (uint16_t)v; obd.have |= HAVE_VOLT; }
            obd_log_val(now, job.pid, v);
        }
    } else if (job.svc == 0x03u) {              /* 43, число кодов, по два байта на код */
        for (unsigned i = 2; i + 1u < n; i += 2u) {
            uint16_t c = (uint16_t)((p[i] << 8) | p[i + 1u]);
            if (c != 0u && obd.n_dtc < OBD_MAX_DTC)
                obd.dtc[obd.n_dtc++] = c;
        }
        obd.dtc_read = 1;
    } else if (job.svc == 0x04u) {
        obd.cleared++;
        obd.cleared_ok = 1;
    } else if (job.svc == 0x09u && n >= 20u && p[1] == 0x02u) {
        for (unsigned i = 0; i < 17u; i++)      /* 49 02 01, затем 17 символов VIN */
            obd.vin[i] = (char)p[3u + i];
        obd.vin[17] = 0;
    }
    if (!job.func)
        finish();
}

Ядро: ISO-TP на приёме. Одиночный кадр — сразу в разбор; первый кадр — управление потоком на адрес этого ЭБУ; последующие — по номерам 1…15, 0, 1…:

void obd_rx(const obd_frame *f, uint32_t now)
{
    uint8_t type = f->d[0] >> 4;
    uint16_t from = (uint16_t)f->id;
    if (type == 0u) {                           /* одиночный кадр */
        unsigned n = f->d[0] & 15u;
        if (n == 0u || n > 7u || n + 1u > f->dlc) {
            obd.isotp_err++;
            return;
        }
        message(from, f->d + 1, n, now);
    } else if (type == 1u) {                    /* первый кадр: длина 12 бит, 6 байт данных */
        obd_frame fc;
        tp.len = (uint16_t)(((f->d[0] & 15u) << 8) | f->d[1]);
        if (tp.len < 8u || tp.len > OBD_BUF || f->dlc < 8u) {
            obd.isotp_err++;
            return;
        }
        for (unsigned i = 0; i < 6u; i++)
            tp.buf[i] = f->d[2u + i];
        tp.got = 6;
        tp.sn = 1;
        tp.from = from;
        tp.active = 1;
        fc.id = from - 8u;                      /* управление потоком — на физический адрес этого ЭБУ */
        fc.dlc = 8;
        fc.d[0] = 0x30;                         /* FC: продолжай */
        fc.d[1] = 0;                            /* размер блока 0 — всё без остановок */
        fc.d[2] = 0;                            /* STmin 0 мс */
        for (unsigned i = 3; i < 8u; i++)
            fc.d[i] = OBD_PAD;
        if (plat_can_send(&fc) == 0)
            obd.tx++;
    } else if (type == 2u) {                    /* последующий кадр: номер 1…15, 0, 1… */
        if (!tp.active || from != tp.from || (f->d[0] & 15u) != tp.sn) {
            obd.isotp_err++;
            tp.active = 0;
            return;
        }
        for (unsigned i = 1; i < 8u && tp.got < tp.len; i++)
            tp.buf[tp.got++] = f->d[i];
        tp.sn = (tp.sn + 1u) & 15u;
        if (tp.got == tp.len) {
            tp.active = 0;
            message(from, tp.buf, tp.len, now);
        }
    }
}

Ядро: кнопка. Дребезг 20 мс; короткое нажатие — следующий экран, долгое (2 с, по удержанию) — вопрос о стирании и ответ на него:

static void on_short(void)
{
    obd.refused = 0;
    obd.screen = (obd.screen == SCR_CONFIRM) ? (uint8_t)SCR_DTC : (uint8_t)((obd.screen + 1u) % 4u);
}

static void on_long(uint32_t now)
{
    if (obd.screen == SCR_DTC) {
        obd.screen = SCR_CONFIRM;
        obd.refused = 0;
        obd.cleared_ok = 0;
        btn.t_confirm = now;
    } else if (obd.screen == SCR_CONFIRM) {
        obd.screen = SCR_DTC;
        if (obd.lost || ((obd.have & HAVE_RPM) && obd.rpm_raw > 0u))
            obd.refused = 1;                    /* двигатель работает или связи нет — не стираем */
        else
            want_clear = 1;
    }
}

static void button(uint32_t now, int down)
{
    if ((uint8_t)(down != 0) != btn.raw) {
        btn.raw = (uint8_t)(down != 0);
        btn.t_change = now;
    }
    if (btn.raw != btn.stable && now - btn.t_change >= OBD_DEBOUNCE_MS) {
        btn.stable = btn.raw;
        if (btn.stable) {
            btn.t_press = now;
            btn.long_fired = 0;
        } else if (!btn.long_fired) {
            on_short();
        }
    }
    if (btn.stable && !btn.long_fired && now - btn.t_press >= OBD_LONG_MS) {
        btn.long_fired = 1;                     /* долгое — по удержанию, не по отпусканию */
        on_long(now);
    }
    if (obd.screen == SCR_CONFIRM && now - btn.t_confirm >= OBD_CONFIRM_MS)
        obd.screen = SCR_DTC;
}

Ядро: расписание и таймауты. Сначала опознание (0100, 0120, 0140, 0101, VIN, коды), потом опрос по кругу; obd_poll() зовётся раз в миллисекунду:

static void schedule(uint32_t now)
{
    if (step == 0u) { request(now, 1, 0x01, 0x00, 1); step = 1; return; }
    if (step == 1u) { step = 2; if (obd.engine && supported(0x20)) { request(now, 0, 0x01, 0x20, 1); return; } }
    if (step == 2u) { step = 3; if (obd.engine && supported(0x40)) { request(now, 0, 0x01, 0x40, 1); return; } }
    if (step == 3u) { step = 4; if (obd.engine && supported(0x01)) { request(now, 0, 0x01, 0x01, 1); return; } }
    if (step == 4u) { step = 5; if (obd.engine) { request(now, 0, 0x09, 0x02, 1); return; } }
    if (step == 5u) { step = 6; request(now, 1, 0x03, 0, 0); return; }
    if (!obd.engine) {                          /* никто не ответил на 0100 — начать сначала */
        step = 0;
        return;
    }
    if (want_clear) { want_clear = 0; reread = 2; request(now, 1, 0x04, 0, 0); return; }
    if (reread == 2u) { reread = 1; request(now, 0, 0x01, 0x01, 1); return; }
    if (reread == 1u) { reread = 0; request(now, 1, 0x03, 0, 0); return; }
    for (unsigned k = 0; k < 8u; k++) {         /* по кругу, пропуская то, чего ЭБУ не знает */
        uint8_t pid = POLL[poll_i];
        poll_i = (uint8_t)((poll_i + 1u) & 7u);
        if (supported(pid)) {
            request(now, 0, 0x01, pid, 1);
            return;
        }
    }
}

void obd_poll(uint32_t now, int btn_down)
{
    button(now, btn_down);
    if (job.busy) {
        uint32_t dt = now - job.t0;
        if (job.func && dt >= OBD_COLLECT_MS) {
            if (job.answered)
                finish();
            else
                job.busy = 0;
        }
        if (job.busy && !job.func && dt >= OBD_P2_MS)
            job.busy = 0;
        if (!job.busy && !job.answered) {       /* никто не ответил */
            obd.timeouts++;
            tp.active = 0;
            if (++miss >= OBD_LOST_N)
                obd.lost = 1;
        }
    }
    if (!job.busy && (int32_t)(now - next_slot) >= 0) {
        next_slot = ((int32_t)(now - next_slot) >= (int32_t)OBD_SLOT_MS) ? now + OBD_SLOT_MS : next_slot + OBD_SLOT_MS;
        schedule(now);
    }
}

Ядро: экраны. Четыре экрана и вопрос о стирании — в буфер 8 строк по 21 символу; на дисплей его выводит платформа:

/* ── экраны ─────────────────────────────────────────────────────────────── */
static void put(unsigned row, unsigned col, const char *s)
{
    while (*s && col < 21u)
        obd_lines[row][col++] = *s++;
}

static void num(unsigned row, unsigned end, int32_t v)   /* число вправо до столбца end */
{
    char b[12];
    unsigned n = 0, neg = v < 0;
    uint32_t u = neg ? (uint32_t)(-v) : (uint32_t)v;
    do {
        b[n++] = (char)('0' + u % 10u);
        u /= 10u;
    } while (u);
    if (neg)
        b[n++] = '-';
    for (unsigned i = 0; i < n && end >= i; i++)
        obd_lines[row][end - i] = b[i];
}

static void hexn(unsigned row, unsigned col, uint32_t v, unsigned n)
{
    static const char hex[16] = "0123456789ABCDEF";
    for (unsigned i = 0; i < n; i++)
        obd_lines[row][col + i] = hex[(v >> (4u * (n - 1u - i))) & 15u];
}

void obd_render(uint32_t now)
{
    for (unsigned r = 0; r < 8u; r++) {
        for (unsigned c = 0; c < 21u; c++)
            obd_lines[r][c] = ' ';
        obd_lines[r][21] = 0;
    }
    if (obd.screen == SCR_LIVE) {
        put(0, 0, "OBD-II");
        if (obd.engine) {
            put(0, 8, "ECU");
            hexn(0, 12, obd.engine, 3);
        }
        put(1, 0, "RPM");
        put(2, 0, "SPEED");
        put(3, 0, "COOLANT");
        put(4, 0, "BATTERY");
        if (obd.have & HAVE_RPM) num(1, 13, obd.rpm_raw / 4u); else put(1, 11, "---");
        if (obd.have & HAVE_SPEED) num(2, 13, obd.speed); else put(2, 11, "---");
        if (obd.have & HAVE_COOL) num(3, 13, (int32_t)obd.coolant_raw - 40); else put(3, 11, "---");
        if (obd.have & HAVE_VOLT) {
            uint32_t cv = (obd.volt_mv + 5u) / 10u;     /* до сотых вольта */
            num(4, 10, (int32_t)(cv / 100u));
            obd_lines[4][11] = '.';
            obd_lines[4][12] = (char)('0' + cv / 10u % 10u);
            obd_lines[4][13] = (char)('0' + cv % 10u);
        } else {
            put(4, 11, "---");
        }
        put(1, 15, "RPM");
        put(2, 15, "KM/H");
        put(3, 15, "C");
        put(4, 15, "V");
        put(5, 0, obd.mil ? "MIL ON" : "MIL OFF");
        put(5, 9, "DTC");
        num(5, 14, obd.n_dtc_mil);
    } else if (obd.screen == SCR_DTC || obd.screen == SCR_CONFIRM) {
        if (obd.screen == SCR_DTC) {
            put(0, 0, "STORED DTC:");
            num(0, 13, obd.n_dtc);
            for (unsigned i = 0; i < obd.n_dtc && i < 8u; i++) {
                char s[6];
                obd_dtc_str(obd.dtc[i], s);
                put(1u + i / 2u, (i & 1u) ? 7u : 0u, s);
            }
            if (obd.refused)
                put(5, 0, "ENGINE ON: NOT CLEAR");
            else if (obd.cleared_ok)
                put(5, 0, "CLEARED");
            put(6, 0, "HOLD 2S: CLEAR");
            put(7, 0, "PRESS: NEXT");
        } else {
            uint32_t left = (OBD_CONFIRM_MS - (now - btn.t_confirm) + 999u) / 1000u;
            put(0, 0, "CLEAR ALL DTC?");
            put(1, 0, "ALSO FREEZE FRAME");
            put(2, 0, "AND READINESS DATA");
            put(3, 0, "IGNITION ON,");
            put(4, 0, "ENGINE OFF ONLY");
            put(6, 0, "HOLD 2S: YES");
            put(7, 0, "PRESS: NO");
            num(7, 20, (int32_t)left);
        }
    } else if (obd.screen == SCR_VIN) {
        put(0, 0, "VIN");
        put(2, 0, obd.vin[0] ? obd.vin : "---");
    } else {
        put(0, 0, "ECU");
        for (unsigned i = 0; i < obd.n_ecu && i < 4u; i++)
            hexn(0, 4u + 4u * i, obd.ecu_id[i], 3);
        for (unsigned r = 0; r < 3u; r++) {
            put(1u + r, 0, r == 0u ? "PID 01-20" : r == 1u ? "PID 21-40" : "PID 41-60");
            hexn(1u + r, 11, obd.pids[r], 8);
        }
        put(4, 0, "TX");
        num(4, 9, (int32_t)obd.tx);
        put(4, 11, "RX");
        num(4, 20, (int32_t)obd.rx);
        put(5, 0, "TIMEOUT");
        num(5, 9, (int32_t)obd.timeouts);
        put(5, 11, "NRC");
        num(5, 20, (int32_t)obd.nrc);
    }
    if (obd.lost && obd.screen != SCR_CONFIRM)
        put(7, 13, "NO LINK");
}

F103: сроки, очередь и прерывание. Проверки делителей CAN и I2C при сборке; прерывание приёма складывает кадры в очередь на 16 и считает потерянные:

/* Раздел 198р «Мини-проект: OBD-II сканер»: BluePill (STM32F103C8), регистры через CMSIS, без HAL.
 *   PB8/PB9 — CAN_RX/CAN_TX после ремапа (CAN_REMAP = 10), трансивер SN65HVD230 на 3,3 В;
 *   PB6/PB7 — I2C1 SCL/SDA к дисплею SSD1306 128 × 64 (адрес 0x3C), 400 кГц;
 *   PA0     — кнопка на землю, внутренняя подтяжка вверх.
 * Такт: кварц 8 МГц × 9 = 72 МГц, APB1 = 36 МГц — от него тактуются bxCAN и I2C1. */
#include "stm32f10x.h"
#include "obd_core.h"
#include "font5x7.h"

#define FCPU 72000000u
#define FCAN 36000000u                          /* APB1 */
#define CAN_BRP 4u                              /* 36 МГц / 4 = 9 МГц: квант 111 нс */
#define CAN_TS1 15u
#define CAN_TS2 2u
#define CAN_SJW 2u
_Static_assert(FCAN / (CAN_BRP * (1u + CAN_TS1 + CAN_TS2)) == 500000u
               && FCAN % (CAN_BRP * (1u + CAN_TS1 + CAN_TS2)) == 0u, "500 кбит/с ровно");
#define I2C_CCR (FCAN / (3u * 400000u))         /* быстрый режим, DUTY = 0: Tlow = 2·Thigh */
_Static_assert(I2C_CCR == 30u, "36 МГц / (3 · 30) = 400 кГц");
#define OLED 0x3Cu

static volatile uint32_t ms;
static volatile obd_frame rxq[16];              /* кадры из прерывания — в главный цикл */
static volatile uint32_t rxh, rxt, rx_lost;

void SysTick_Handler(void)
{
    ms++;
}

void USB_LP_CAN1_RX0_IRQHandler(void)
{
    while (CAN1->RF0R & CAN_RF0R_FMP0) {
        uint32_t lo = CAN1->sFIFOMailBox[0].RDLR, hi = CAN1->sFIFOMailBox[0].RDHR;
        if (rxh - rxt < 16u) {
            volatile obd_frame *f = &rxq[rxh % 16u];
            f->id = CAN1->sFIFOMailBox[0].RIR >> 21;    /* фильтр пропускает только 11-битные 0x7E8…0x7EF */
            f->dlc = (uint8_t)(CAN1->sFIFOMailBox[0].RDTR & 0xFu);
            for (unsigned i = 0; i < 4u; i++) {
                f->d[i] = (uint8_t)(lo >> (8u * i));
                f->d[4u + i] = (uint8_t)(hi >> (8u * i));
            }
            rxh++;
        } else {
            rx_lost++;
        }
        CAN1->RF0R = CAN_RF0R_RFOM0;            /* отпустить ящик очереди */
    }
}

F103: отправка кадра. Свободный ящик из трёх; журнал на плате пустой:

int plat_can_send(const obd_frame *f)
{
    uint32_t tsr = CAN1->TSR;
    unsigned m = (tsr & CAN_TSR_TME0) ? 0u : (tsr & CAN_TSR_TME1) ? 1u : (tsr & CAN_TSR_TME2) ? 2u : 3u;
    if (m == 3u)
        return 1;                               /* все три ящика заняты */
    CAN1->sTxMailBox[m].TDTR = f->dlc;
    CAN1->sTxMailBox[m].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[m].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[m].TIR = (f->id << 21) | CAN_TI0R_TXRQ;
    return 0;
}

void obd_log_req(uint32_t now, uint16_t id, uint8_t svc, uint8_t pid)
{
    (void)now; (void)id; (void)svc; (void)pid;
}

void obd_log_val(uint32_t now, uint8_t pid, int32_t v)
{
    (void)now; (void)pid; (void)v;
}

F103: bxCAN. Режим настройки с ограничением ожидания, делители, фильтр-маска из ядра, прерывание и выход на шину:

static int can_init(void)
{
    uint32_t t;
    CAN1->MCR = CAN_MCR_INRQ | CAN_MCR_ABOM;    /* из сна — в инициализацию; после bus-off — вернуться самому */
    for (t = 100000u; !(CAN1->MSR & CAN_MSR_INAK); t--)
        if (t == 0u)
            return 1;
    CAN1->BTR = ((CAN_SJW - 1u) << 24) | ((CAN_TS2 - 1u) << 20) | ((CAN_TS1 - 1u) << 16) | (CAN_BRP - 1u);

    CAN1->FMR |= CAN_FMR_FINIT;                 /* фильтр 0: маска, 32 бита, 0x7E8…0x7EF → очередь 0 */
    CAN1->FA1R &= ~1u;
    CAN1->FS1R |= 1u;
    CAN1->FM1R &= ~1u;
    CAN1->sFilterRegister[0].FR1 = OBD_FR1;
    CAN1->sFilterRegister[0].FR2 = OBD_FR2;
    CAN1->FFA1R &= ~1u;
    CAN1->FA1R |= 1u;
    CAN1->FMR &= ~CAN_FMR_FINIT;

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

F103: дисплей. I2C опросом и SSD1306: команды включения и один символ (6 столбцов) за вызов, строка за строкой:

/* ── I2C1 опросом: одна короткая посылка за вызов ─────────────────────── */
static int i2c_wait(uint32_t flag)
{
    for (uint32_t t = 20000u; !(I2C1->SR1 & flag); t--)
        if (t == 0u || (I2C1->SR1 & I2C_SR1_AF))
            return 1;
    return 0;
}

static int i2c_write(uint8_t ctrl, const uint8_t *p, unsigned n)
{
    int err = 0;
    I2C1->CR1 |= I2C_CR1_START;
    err |= i2c_wait(I2C_SR1_SB);
    I2C1->DR = OLED << 1;
    err |= i2c_wait(I2C_SR1_ADDR);
    (void)I2C1->SR2;                            /* чтение SR1, затем SR2 снимает ADDR */
    I2C1->DR = ctrl;                            /* 0x00 — дальше команды, 0x40 — данные */
    for (unsigned i = 0; i < n && !err; i++) {
        err |= i2c_wait(I2C_SR1_TXE);
        I2C1->DR = p[i];
    }
    err |= i2c_wait(I2C_SR1_BTF);
    I2C1->CR1 |= I2C_CR1_STOP;
    if (err)
        I2C1->SR1 = 0;                          /* снять AF */
    return err;
}

static const uint8_t OLED_INIT[] = {
    0xAE,       /* выключить */
    0xD5, 0x80, /* делитель частоты */
    0xA8, 0x3F, /* мультиплекс: 64 строки − 1 */
    0xD3, 0x00, /* смещение 0 */
    0x40,       /* начальная строка 0 */
    0x8D, 0x14, /* накачка заряда: включить — панель питается от 3,3 В */
    0x20, 0x02, /* адресация по страницам */
    0xA1, 0xC8, /* развёртка: зеркала по X и Y — «вверх ногами» не будет */
    0xDA, 0x12, /* раскладка COM для 128 × 64 */
    0x81, 0x8F, /* яркость */
    0xD9, 0xF1, /* предзаряд */
    0xDB, 0x40, /* уровень VCOMH */
    0xA4, 0xA6, /* из памяти, без инверсии */
    0xAF,       /* включить */
};

static unsigned orow, ocol;                     /* какой символ экрана выводим следующим */

static void oled_step(void)
{
    uint8_t g[6];
    if (ocol == 0u) {
        if (orow == 0u)
            obd_render(ms);                     /* новый кадр — снимок экрана из ядра */
        uint8_t pos[3] = {(uint8_t)(0xB0u | orow), 0x02, 0x10};   /* страница, столбец 2 */
        i2c_write(0x00, pos, 3);
    }
    uint8_t c = (uint8_t)obd_lines[orow][ocol];
    const uint8_t *gl = (c >= 0x20u && c <= 0x5Au) ? FONT5X7[c - 0x20u] : FONT5X7[0];
    for (unsigned i = 0; i < 5u; i++)
        g[i] = gl[i];
    g[5] = 0;
    i2c_write(0x40, g, 6);                      /* 8 байт на шине: ≈ 0,18 мс на 400 кГц */
    if (++ocol == 21u) {
        ocol = 0;
        orow = (orow + 1u) & 7u;
    }
}

F103: такт и главный цикл. 72 МГц от кварца; выводы, I2C 400 кГц, SysTick; в цикле — кадры в ядро, раз в миллисекунду — кнопка, расписание и символ на дисплей:

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

int main(void)
{
    uint32_t last = 0;
    clock_72mhz();
    RCC->APB2ENR |= RCC_APB2ENR_AFIOEN | RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN;
    RCC->APB1ENR |= RCC_APB1ENR_CAN1EN | RCC_APB1ENR_I2C1EN;
    AFIO->MAPR = AFIO_MAPR_CAN_REMAP_REMAP2;    /* PB8 RX, PB9 TX; SWJ_CFG = 000 — отладчик жив */
    GPIOB->CRH = (GPIOB->CRH & ~0xFFu) | 0x4u | (0xBu << 4);            /* PB8 вход, PB9 — AF двухтактный */
    GPIOB->CRL = (GPIOB->CRL & ~0xFF000000u) | (0xFu << 24) | (0xFu << 28);   /* PB6, PB7 — AF открытый сток */
    GPIOA->CRL = (GPIOA->CRL & ~0xFu) | 0x8u;   /* PA0 — вход с подтяжкой… */
    GPIOA->ODR |= 1u;                           /* …вверх */

    I2C1->CR2 = FCAN / 1000000u;                /* FREQ = 36 */
    I2C1->CCR = I2C_CCR_FS | I2C_CCR;
    I2C1->TRISE = FCAN / 1000000u * 300u / 1000u + 1u;   /* 300 нс быстрого режима: 11 */
    I2C1->CR1 = I2C_CR1_PE;

    SysTick->LOAD = FCPU / 1000u - 1u;
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;
    i2c_write(0x00, OLED_INIT, sizeof OLED_INIT);
    obd_init();
    can_init();

    for (;;) {
        while (rxt != rxh) {                    /* кадры из очереди прерывания — в ядро */
            obd_frame f;
            f.id = rxq[rxt % 16u].id;
            f.dlc = rxq[rxt % 16u].dlc;
            for (unsigned i = 0; i < 8u; i++)
                f.d[i] = rxq[rxt % 16u].d[i];
            rxt++;
            obd_rx(&f, ms);
        }
        if (ms != last) {                       /* раз в миллисекунду: кнопка, таймауты, расписание… */
            last = ms;
            obd_poll(last, !(GPIOA->IDR & 1u));
            oled_step();                        /* …и один символ на дисплей */
        }
    }
}

Старт. Копия .data, обнуление .bss, таблица векторов с SysTick и прерыванием CAN (номер 20, общий с USB):

/* Мини-проект «OBD-II сканер» (раздел 198р): стартовый код BluePill.
 * Два обработчика: SysTick (миллисекунды ядра) и приём CAN (IRQ 20, общий с USB — RM0008, таблица векторов). */
#include <stdint.h>

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

void Reset_Handler(void)
{
    uint32_t *src = &_sidata, *dst = &_sdata;
    while (dst < &_edata)
        *dst++ = *src++;
    for (dst = &_sbss; dst < &_ebss;)
        *dst++ = 0;
    main();
    for (;;) {
    }
}

void Default_Handler(void)
{
    for (;;) {
    }
}

typedef void (*vector_t)(void);

__attribute__((section(".isr_vector"), used))
const vector_t vectors[16 + 21] = {
    [0] = (vector_t)&_estack, [1] = Reset_Handler, [2] = Default_Handler, [3] = Default_Handler,
    [4] = Default_Handler, [5] = Default_Handler, [15] = SysTick_Handler,
    [16 + 20] = USB_LP_CAN1_RX0_IRQHandler,
};

Шрифт. 59 знаков 5 × 7, от пробела до Z, по 5 байт-столбцов:

/* font5x7.h — раздел 198р: шрифт 5 × 7 для SSD1306, символы 0x20…0x5A (пробел … Z).
 * Сгенерирован obd_model.py --font; столбец — байт, младший бит — верхняя точка (страница SSD1306). */
#ifndef FONT5X7_H
#define FONT5X7_H
#include <stdint.h>
static const uint8_t FONT5X7[0x5B - 0x20][5] = {
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* ' ' */
    {0x00, 0x00, 0x5F, 0x00, 0x00}, /* '!' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '"' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '#' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '$' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '%' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '&' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* ''' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '(' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* ')' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '*' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '+' */
    {0x00, 0x50, 0x30, 0x00, 0x00}, /* ',' */
    {0x08, 0x08, 0x08, 0x08, 0x08}, /* '-' */
    {0x00, 0x60, 0x60, 0x00, 0x00}, /* '.' */
    {0x20, 0x10, 0x08, 0x04, 0x02}, /* '/' */
    {0x3E, 0x51, 0x49, 0x45, 0x3E}, /* '0' */
    {0x00, 0x42, 0x7F, 0x40, 0x00}, /* '1' */
    {0x42, 0x61, 0x51, 0x49, 0x46}, /* '2' */
    {0x21, 0x41, 0x45, 0x4B, 0x31}, /* '3' */
    {0x18, 0x14, 0x12, 0x7F, 0x10}, /* '4' */
    {0x27, 0x45, 0x45, 0x45, 0x39}, /* '5' */
    {0x3C, 0x4A, 0x49, 0x49, 0x30}, /* '6' */
    {0x01, 0x71, 0x09, 0x05, 0x03}, /* '7' */
    {0x36, 0x49, 0x49, 0x49, 0x36}, /* '8' */
    {0x06, 0x49, 0x49, 0x29, 0x1E}, /* '9' */
    {0x00, 0x36, 0x36, 0x00, 0x00}, /* ':' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* ';' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '<' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '=' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '>' */
    {0x02, 0x01, 0x51, 0x09, 0x06}, /* '?' */
    {0x00, 0x00, 0x00, 0x00, 0x00}, /* '@' */
    {0x7E, 0x09, 0x09, 0x09, 0x7E}, /* 'A' */
    {0x7F, 0x49, 0x49, 0x49, 0x36}, /* 'B' */
    {0x3E, 0x41, 0x41, 0x41, 0x22}, /* 'C' */
    {0x7F, 0x41, 0x41, 0x22, 0x1C}, /* 'D' */
    {0x7F, 0x49, 0x49, 0x49, 0x41}, /* 'E' */
    {0x7F, 0x09, 0x09, 0x09, 0x01}, /* 'F' */
    {0x3E, 0x41, 0x49, 0x49, 0x7A}, /* 'G' */
    {0x7F, 0x08, 0x08, 0x08, 0x7F}, /* 'H' */
    {0x00, 0x41, 0x7F, 0x41, 0x00}, /* 'I' */
    {0x20, 0x40, 0x41, 0x3F, 0x01}, /* 'J' */
    {0x7F, 0x08, 0x14, 0x22, 0x41}, /* 'K' */
    {0x7F, 0x40, 0x40, 0x40, 0x40}, /* 'L' */
    {0x7F, 0x02, 0x0C, 0x02, 0x7F}, /* 'M' */
    {0x7F, 0x04, 0x08, 0x10, 0x7F}, /* 'N' */
    {0x3E, 0x41, 0x41, 0x41, 0x3E}, /* 'O' */
    {0x7F, 0x09, 0x09, 0x09, 0x06}, /* 'P' */
    {0x3E, 0x41, 0x51, 0x21, 0x5E}, /* 'Q' */
    {0x7F, 0x09, 0x19, 0x29, 0x46}, /* 'R' */
    {0x46, 0x49, 0x49, 0x49, 0x31}, /* 'S' */
    {0x01, 0x01, 0x7F, 0x01, 0x01}, /* 'T' */
    {0x3F, 0x40, 0x40, 0x40, 0x3F}, /* 'U' */
    {0x1F, 0x20, 0x40, 0x20, 0x1F}, /* 'V' */
    {0x3F, 0x40, 0x38, 0x40, 0x3F}, /* 'W' */
    {0x63, 0x14, 0x08, 0x14, 0x63}, /* 'X' */
    {0x03, 0x04, 0x78, 0x04, 0x03}, /* 'Y' */
    {0x61, 0x51, 0x49, 0x45, 0x43}, /* 'Z' */
};
#endif

Скрипт компоновщика. 63 КБ флеш-памяти, 20 КБ ОЗУ и 2 КБ под стек:

/* Мини-проект «OBD-II сканер» (раздел 198р): BluePill, 64 КБ флеш-памяти, 20 КБ ОЗУ.
 * Каждая строка отвечает на один вопрос: где лежит, где работает, откуда берутся начальные значения. */
ENTRY(Reset_Handler)                     /* с чего начинается программа — для отладчика и ELF */

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 63K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

_estack     = ORIGIN(RAM) + LENGTH(RAM); /* вершина стека: стек растёт вниз от конца ОЗУ */
_stack_size = 0x800;                     /* сколько ОЗУ оставить под стек — проверяется в ASSERT */

SECTIONS
{
  .isr_vector :
  {
    KEEP(*(.isr_vector))                 /* таблица векторов — первой, с адреса 0x08000000 */
  } > FLASH

  .text :
  {
    *(.text .text.*)                     /* код */
    *(.rodata .rodata.*)                 /* константы и строки */
    . = ALIGN(4);
    _etext = .;
  } > FLASH

  .data :
  {
    _sdata = .;                          /* где .data работает — адрес в ОЗУ (VMA) */
    *(.data .data.*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH                     /* а хранится во флеш-памяти сразу за кодом (LMA) */
  _sidata = LOADADDR(.data);             /* откуда стартовый код копирует начальные значения */

  .bss (NOLOAD) :
  {
    _sbss = .;                           /* нули: во флеш-памяти не хранятся вовсе */
    *(.bss .bss.*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM

  ASSERT(_ebss + _stack_size <= _estack, "RAM too small for .data + .bss + stack")
}

198р.8 Грабли

На шине ничего нет. Без фильтра bxCAN не принимает ничего (19.5), а с фильтром на 0x7E8 без маски — только двигатель. Маска 0x7F8 пропускает все восемь адресов ответа.

Составной ответ обрывается после первого кадра. Блок ждёт FC. Его шлют на физический адрес блока (ответ минус 8), а не на 0x7DF.

Ответили два блока, в памяти каша. У ядра один буфер сборки: если два блока шлют составные ответы одновременно, второй первый кадр перезапишет первый. В опыте двигатель отвечает одиночным кадром, коробка составным, и всё сходится; для надёжности нужен буфер на каждый адрес.

Напряжение не показывается. PID 0x42 — в диапазоне 41…60, его поддержку сообщает ответ 0140, а о нём — последний бит 0120. Одной таблицы 0100 мало.

Обороты в четыре раза больше. 256·A + B — это обороты × 4 (UAS 0x07 — 0,25 об/мин на единицу).

Коды стёрлись на ходу. Служба 04 стирает и стоп-кадр, и готовность систем. Долгое нажатие с подтверждением и запрет при оборотах больше нуля.

Дисплей тормозит опрос. Весь буфер по I2C — 23,1 мс в главном цикле; по символу — 0,18 мс.

Сканер разряжает аккумулятор. Вывод 16 — прямо от аккумулятора; на многих машинах питание на нём есть и при выключенном зажигании (общеизвестно, не проверено). Сканер на ночь вынимают.


198р.9 Итоги

Дальше: раздел 198с «Мини-проект: приборная панель по CAN — обороты, скорость и температура из шины машины» — тот же разъём и та же шина, но без вопросов: машина сама рассылает обороты и скорость, их остаётся услышать.

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