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

198у. Мини-проект: свой блок на шине CAN — датчик, DBC, диагностика UDS и прошивка по шине

Сканер из 198р спрашивал чужие блоки, панель из 198с и регистратор из 198т слушали чужую шину. Здесь последний шаг: свой блок, который живёт на шине наравне с заводскими. Это датчик температуры и давления масла на STM32F103: он рассылает свои значения раз в 100 мс, отвечает на диагностику UDS, ведёт свои коды неисправностей, переживает отключение от шины и принимает новую прошивку прямо по CAN.

Каждая часть по отдельности уже была: ISO-TP — в 19в, UDS и загрузчик на F407 — в 19д, DBC, счётчик и CRC — в 19е, свой загрузчик — в 48. Здесь они собраны в одно изделие, и видно, где они друг другу мешают: стирание флеш-памяти останавливает процессор посреди диагностического ответа, библиотека UDS сбрасывает передачу на повторе блока, канал ISO-TP тестера залипает после потерянного кадра.

Проверено без платы и без шины. Код узла (node.c, ee.c, упаковка из DBC) собран gcc на компьютере вместе с сервером и клиентом UDS и прогнан на модели шины 500 кбит/с: побитно, со вставкой битов, с чужим блоком на шине и с флеш-памятью F103, которая стоит на стирании. Загрузчик и приложение для F103 собраны arm-none-eabi-gcc по регистрам CMSIS; на плате они не запускались.

Опоры: RM0008 (bxCAN: 24.7.6 «Error management», регистр CAN_ESR, бит ABOM в CAN_MCR; флеш-память 3.3.3 — страницы по 1 КБ у средней плотности), DS5319 (DocID13587, STM32F103x8/xB: таблица 28 — стирание страницы 20…40 мс, запись полуслова 40…70 мкс; таблица 29 — 10 000 циклов). Библиотека UDS — iso14229 0.11.0 (github.com/driftregion/iso14229, коммит a165a7d, со встроенным isotp-c), разборщик DBC — dbcmini.py из 19е, названия кодов неисправностей — таблица codes.py из python-OBD (github.com/brendan-w/python-OBD, коммит a378bdd). Лицензия: iso14229 — MIT, python-OBD — GPL-2.0; копии нужных файлов лежат в проверке с лицензиями. Тексты ISO 11898 и ISO 14229 здесь не сверялись: правило «TEC + 8 за ошибку передачи» взято как допущение модели (RM0008 здесь отсылает к стандарту).

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

Оригинал Перевод
ECU, node блок управления, узел шины
cyclic message, cycle time циклическое сообщение, его период
alive counter / rolling counter счётчик сообщения, 0…15 по кругу
E2E protection, Data ID защита кадра CRC и счётчиком, «соль» по номеру счётчика
Transmit Error Counter (TEC), Receive Error Counter (REC) счётчики ошибок передачи и приёма
error warning, error passive, bus-off предупреждение, пассивное состояние, отключение от шины
ABOM (automatic bus-off management) автоматический выход из отключения
128 occurrences of 11 consecutive recessive bits 128 раз по 11 рецессивных бит подряд
debounce, confirmed DTC подтверждение неисправности, подтверждённый код
EEPROM emulation эмуляция EEPROM на страницах флеш-памяти
page erase, half-word programming стирание страницы, запись полуслова

198у.1 Что за блок и из чего он

Блок — STM32F103C8 с трансивером CAN (19.2) и двумя датчиками на входах АЦП. Кривые датчиков здесь — допущения, а не данные паспортов:

Свой блок на шине CAN: датчики NTC и давления на входах АЦП F103, код узла пересчитывает их и раз в 100 мс шлёт сообщение OilSens_01 0x3A1 по DBC со счётчиком и CRC-8 через bxCAN с ABOM равным 0 на шину 500 кбит/с; тестер 0x741 и 0x749 говорит с сервером UDS iso14229, который по 10 02 передаёт управление загрузчику; при отключении от шины узел восстанавливается три раза сразу, потом через 200 мс; внизу раскладка флеш-памяти 64 КБ: загрузчик 16 КБ, приложение 45 КБ, страница записи о годном образе и две страницы эмуляции EEPROM; итог прогона

Что блок делает:

  1. Раз в 10 мс читает оба входа АЦП, пересчитывает коды в температуру и давление и проверяет их на обрыв и замыкание (198у.3).
  2. Раз в 100 мс шлёт сообщение OilSens_01 на 0x3A0 + номер узла, то есть 0x3A1 у узла 1. Его раскладка — в файле DBC (198у.2).
  3. Отвечает на UDS по ISO-TP: запрос на 0x740 + номер, ответ с 0x748 + номер, функциональный — на 0x7DF. Номера и период меняются через 0x2E и хранятся в эмуляции EEPROM (198у.4).
  4. Переживает отключение от шины и считает такие случаи (198у.5).
  5. По 0x10 02 уходит в загрузчик, а тот принимает образ по UDS (198у.6).

198у.2 Сообщение в DBC: раскладка, счётчик и CRC-8

Сначала файл DBC, потом код — не наоборот. Файл описывает сообщение для всех, кто будет его слушать: панель из 198с или регистратор из 198т расшифруют его без нашего исходника.

BO_ 929 OilSens_01: 8 OILNODE
 SG_ OilSens_CRC : 0|8@1+ (1,0) [0|255] "" TESTER
 SG_ OilSens_BZ : 8|4@1+ (1,0) [0|15] "" TESTER
 SG_ OilSens_Status : 12|4@1+ (1,0) [0|15] "" TESTER
 SG_ OilSens_Temp : 16|10@1+ (0.25,-40) [-40|215.5] "degC" TESTER
 SG_ OilSens_Press : 26|12@1+ (0.005,0) [0|20.47] "bar" TESTER
 SG_ OilSens_BusOff : 38|8@1+ (1,0) [0|255] "" TESTER

Температура — 10 бит с шагом 0,25 °C от −40, давление — 12 бит с шагом 0,005 бар. Сырые 1023 и 4095 — «значение недостоверно»: так блок сообщает об оборванном датчике, не выдумывая числа. OilSens_Status — биты неисправностей, OilSens_BusOff — сколько раз блок отключался от шины с включения.

Код упаковки не пишется руками: gen.py берёт генератор из dbcmini.py (19е.5) и делает oil_dbc.h и oil_dbc.c. Байт 0 и младшая тетрада байта 1 — CRC и счётчик по схеме VW MQB из 19е.4: CRC-8 с многочленом 0x2F по байтам 1…7 и по «соли» — байту из таблицы на 16 значений, выбранному счётчиком. Таблица у этого сообщения своя. Отправка целиком:

static uint8_t crc8_2f(uint8_t crc, uint8_t b) {
    crc ^= b;
    for (int k = 0; k < 8; k++)
        crc = (crc & 0x80u) ? (uint8_t)((crc << 1) ^ 0x2Fu) : (uint8_t)(crc << 1);
    return crc;
}
uint8_t e2e_crc(const uint8_t *d) {
    uint8_t crc = 0xFF;
    for (int i = 1; i < 8; i++) crc = crc8_2f(crc, d[i]);
    crc = crc8_2f(crc, DATA_ID[d[1] & 0x0Fu]);
    return crc ^ 0xFFu;
}
static void send_cyclic(void) {
    oil_oilsens_01_t m;
    uint8_t d[8], st = 0;
    for (int k = 0; k < DTC_N; k++) if (node.dtc_status[k] & 0x08) st |= 0x04;
    if (node.dtc_status[DTC_T_LOW] & 0x01 || node.dtc_status[DTC_T_HIGH] & 0x01) st |= 0x01;
    if (node.dtc_status[DTC_P_LOW] & 0x01 || node.dtc_status[DTC_P_HIGH] & 0x01) st |= 0x02;
    if (node.busoff_cnt) st |= 0x08;
    memset(&m, 0, sizeof m);
    m.oilsens_bz = node.bz;
    m.oilsens_status = st;
    m.oilsens_temp = node.t_raw;
    m.oilsens_press = node.p_raw;
    m.oilsens_busoff = node.busoff_cnt;
    oil_oilsens_01_pack(d, &m);
    d[0] = e2e_crc(d);
    if (plat_can_send(ID_CYCLIC_BASE + node.node_id, d, 8) == 0) {
        node.bz = (uint8_t)((node.bz + 1u) & 0x0Fu);   /* счётчик растёт только у ушедшего кадра */
        node.sent++;
    } else {
        node.skipped++;
    }
}

Счётчик растёт только у кадра, который действительно лёг в ящик. Если все три ящика bxCAN заняты, кадр пропускается, и приёмник увидит это как пропуск, а не как повтор со старым счётчиком.

Проверка по DBC. Модель записала все 134 циклических кадра прогона вместе с «правдой» — температурой и давлением, которые подавались на вход. Разбор на Python — отдельным путём: dbcmini.decode по oilnode.dbc и свой CRC-8:

кадров OilSens_01: 134
CRC-8 не сошёлся: 0
разрывов счётчика: 0
распаковка и упаковка по DBC вернули те же байты: 134 из 134
годных кадров 130, с недостоверным значением (1023 или 4095) 4
наибольшее расхождение с правдой: температура 0.200 °C, давление 0.0050 бар
первый кадр: 100.472 мс 3A1 3D 00 F4 3C 03 00 00 00 -> OilSens_CRC=61 OilSens_BZ=0 OilSens_Status=0 OilSens_Temp=244 OilSens_Press=207 OilSens_BusOff=0

Распаковка и обратная упаковка на Python дали те же байты, что упаковал код C, — во всех 134 кадрах. Расхождение с правдой — не больше 0,2 °C и 0,005 бар: это таблица NTC с шагом 5 °C, шаг АЦП и шаг сигнала.


198у.3 Датчики и свои коды неисправностей

АЦП. Оба входа читаются программно раз в 10 мс, с самой долгой выборкой 239,5 такта — у NTC с подтяжкой 10 кОм высокое сопротивление источника. Калибровка — один раз после включения:

static void adc_init(void) {
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_ADC1EN;
    GPIOA->CRL &= ~0xFFu;                                 /* PA0, PA1 — аналоговые входы */
    ADC1->SMPR2 = (7u << 0) | (7u << 3);                  /* 239,5 такта: высокоомный NTC */
    ADC1->CR2 = ADC_CR2_ADON;
    for (volatile int i = 0; i < 1000; i++) {
    }
    ADC1->CR2 |= ADC_CR2_RSTCAL;
    while (ADC1->CR2 & ADC_CR2_RSTCAL) {
    }
    ADC1->CR2 |= ADC_CR2_CAL;                             /* калибровка после включения */
    while (ADC1->CR2 & ADC_CR2_CAL) {
    }
}
uint16_t plat_adc(int ch) {
    ADC1->SQR3 = (uint32_t)ch;
    ADC1->CR2 |= ADC_CR2_ADON;                            /* повторная запись ADON — старт */
    while (!(ADC1->SR & ADC_SR_EOC)) {
    }
    return (uint16_t)ADC1->DR;
}

Пересчёт. У F103 нет плавающей точки, поэтому температура — по таблице кодов АЦП от −40 до 150 °C с шагом 5 °C и прямой между точками. Таблицу считает gen.py по той же кривой NTC. Давление — в целых: полная шкала АЦП — это 4950 мВ на выходе датчика, до делителя; 2 мВ на шаг сигнала 0,005 бар.

Коды. У каждой неисправности — свой стандартный код из таблицы python-OBD, чтобы любой сканер показал понятное имя:

Код Имя в таблице Когда
P0197 Engine Oil Temperature Sensor Low код NTC ниже 40 — замыкание
P0198 Engine Oil Temperature Sensor High код NTC выше 4050 — обрыв
P0522 Engine Oil Pressure Sensor/Switch Low Voltage ниже 0,25 В
P0523 Engine Oil Pressure Sensor/Switch High Voltage выше 4,75 В
U0073 Control Module Communication Bus Off отключение от шины

Порог 4050 выше самого холодного честного кода 3996, порог 40 — ниже самого горячего 80: исправный датчик в диапазоне −40…150 °C тревоги не даёт. Неисправность признаётся после пяти выборок подряд, то есть 50 мс, и снимается так же — так дребезг контакта не рождает код:

static void dtc_eval(int k, bool bad) {
    uint8_t *st = &node.dtc_status[k];
    if (bad) {
        deb_good[k] = 0;
        if (deb_bad[k] < DEBOUNCE && ++deb_bad[k] == DEBOUNCE)
            *st |= 0x01 | 0x02 | 0x04 | 0x08 | 0x20;   /* сейчас, в этом цикле, ожидающий, подтверждённый, после сброса */
    } else {
        deb_bad[k] = 0;
        if (deb_good[k] < DEBOUNCE && ++deb_good[k] == DEBOUNCE)
            *st &= (uint8_t)~0x01;                     /* ошибки сейчас нет, история остаётся */
    }
}

Биты байта состояния — те же, что в 19д.4. Блок ведёт пять из них, поэтому маска доступности в ответе — 0x2F.

Сеанс на модели. Тестер читает серийный номер, версию и живые значения (DID 0x0200: код АЦП и сырой сигнал обоих датчиков), потом NTC отключают:

  10000.000 мс  -> 22 F1 8C
  10001.622 мс  <- 62 F1 8C 4F 49 4C 2D 30 30 30 30 31 32 33
  10001.622 мс  -> 22 F1 95
  10051.926 мс  <- 62 F1 95 4F 49 4C 2D 41 50 50 20 31 2E 30 2E 30
  10051.926 мс  -> 22 02 00
  10102.704 мс  <- 62 02 00 00 CE 02 58 07 6F 03 84
  живые: АЦП NTC 206 -> 110.00 °C, АЦП давления 1903 -> 4.500 бар (правда 109.991 °C, 4.4996 бар)
  10102.704 мс  -> 19 02 FF
  10153.230 мс  <- 59 02 2F
  обрыв NTC
  10273.230 мс  -> 19 02 08
  10273.704 мс  <- 59 02 2F 01 98 00 2F
  10273.704 мс  -> 22 02 00
  10324.706 мс  <- 62 02 00 0F FF 03 FF 07 6F 03 84
  NTC снова на месте
  10424.706 мс  -> 19 02 FF
  10425.176 мс  <- 59 02 2F 01 98 00 2E
  10425.176 мс  -> 14 FF FF FF
  10475.230 мс  <- 54

Живые значения: код 206 → 110,00 °C при правде 109,991 °C, код 1903 → 4,500 бар при правде 4,4996. После обрыва 19 02 08 (подтверждённые) вернул P0198 с состоянием 0x2F, а живое значение стало 0x3FF — недостоверно. После возврата датчика состояние 0x2E: ошибки сейчас нет, история осталась. 14 FF FF FF стёр всё. Если неисправность держится во время стирания, код возвращается через 50 мс подтверждения:

  обрыв датчика давления держится во время сброса
  10626.230 мс  -> 14 FF FF FF
  10626.702 мс  <- 54
  10626.702 мс  -> 19 02 FF
  10677.230 мс  <- 59 02 2F
  10737.230 мс  -> 19 02 FF
  10737.698 мс  <- 59 02 2F 05 22 00 2F

198у.4 Настройка через 0x2E и эмуляция EEPROM

Два параметра пишутся по UDS в расширенном сеансе (0x10 03): DID 0x0100 — номер узла 1…15, DID 0x0101 — период рассылки 20…1000 мс. Период действует сразу, номер — после перезапуска, потому что от него зависят фильтр bxCAN и идентификаторы ISO-TP:

  10838.170 мс  -> 10 03
  10888.238 мс  <- 50 03 00 32 01 F4
  10888.238 мс  -> 2E 01 01 00 32
  10939.232 мс  <- 6E 01 01
  11139.232 мс  -> 2E 01 01 00 05
  11139.700 мс  <- 7F 2E 31  NRC 0x31
  11139.700 мс  -> 2E 01 00 02
  11190.456 мс  <- 6E 01 00
  11190.456 мс  -> 11 01
  11241.228 мс  <- 51 01
  тестер переходит на 0x742/0x74A
  11441.228 мс  -> 22 01 00
  11441.692 мс  <- 62 01 00 02
  11441.692 мс  -> 22 01 01
  11492.236 мс  <- 62 01 01 00 32

Период 5 мс получил отказ 0x31 (вне диапазона). После перезапуска узел 2 ответил уже на 0x742 и слал OilSens_01 на 0x3A2 — в журнале девять таких кадров, и интервал 0x3A1 сменился на 50 мс.

Где хранить. Отдельной EEPROM у F103 нет, а страница флеш-памяти стирается целиком и выдерживает 10 000 циклов (DS5319, таблица 29). Две страницы по 1 КБ в конце памяти работают по очереди. Запись — 4 байта: сначала значение, потом номер параметра; запись без номера (пропало питание между ними) не читается. Свежее значение — последнее на странице. Когда место кончилось, свежие значения переносятся на другую страницу:

static bool transfer(uint16_t id, uint16_t v) {
    uint32_t dst = other(act);
    uint16_t gen = rd(act + 4u);
    if (!erase_if_dirty(dst) || !fl_prog16(dst, H_RECV) || !fl_prog16(dst + 4u, (uint16_t)(gen + 1u)))
        return false;
    uint32_t s = 0;
    for (uint16_t k = EE_NODE_ID; k <= EE_PERIOD; k++) {
        uint16_t x;
        if (k == id) x = v;
        else if (!lookup(act, k, &x)) continue;
        uint32_t a = dst + SLOT0 + 4u * s++;
        if (!fl_prog16(a, x) || !fl_prog16(a + 2u, k)) return false;
    }
    if (!fl_prog16(dst + 2u, H_VALID)) return false;   /* только теперь новая страница главная */
    if (!fl_erase_page(act)) return false;
    act = dst;
    ee_transfers++;
    find_free();
    return true;
}
bool ee_write(uint16_t id, uint16_t v) {
    uint16_t old;
    if (lookup(act, id, &old) && old == v) return true;   /* то же значение — страницу не тратим */
    if (next_slot >= NSLOT) return transfer(id, v);
    uint32_t a = act + SLOT0 + 4u * next_slot++;
    return fl_prog16(a, v) && fl_prog16(a + 2u, id);
}

Заголовок страницы — три полуслова, каждое пишется один раз по стёртому месту: 0xEEEE «принимаю копию», 0x0000 «действующая», номер поколения. Перезаписывать уже записанное полуслово не нужно ни разу.

На странице 254 записи. 1000 записей периода подряд дали 3 переноса и 3 стирания. Перенос свежих двух значений освобождает 252 места, и каждая страница стирается раз в два переноса, так что 10 000 циклов хватит на 10 000 × 2 × 252 = 5 040 000 записей. Перенос — это 8 операций с флеш-памятью. Модель обрывает питание после каждой из них и включает блок заново:

  переносов 3, стираний 3; на страницу — записей 254
  обрыв после 0 операций из 8: восстановление 0, номер 3, период 200
  обрыв после 1 операций из 8: восстановление 2, номер 3, период 200
  обрыв после 2 операций из 8: восстановление 2, номер 3, период 200
  обрыв после 3 операций из 8: восстановление 2, номер 3, период 200
  обрыв после 4 операций из 8: восстановление 2, номер 3, период 200
  обрыв после 5 операций из 8: восстановление 2, номер 3, период 200
  обрыв после 6 операций из 8: восстановление 2, номер 3, период 200
  обрыв после 7 операций из 8: восстановление 3, номер 3, период 777
  обрыв после 8 операций из 8: восстановление 0, номер 3, период 777

До отметки «действующая» (0…6 операций) остаются старые значения, после неё — новые (7 и 8). Промежуточного состояния, в котором параметр пропал бы, нет: ee_init при двух действующих страницах берёт ту, у которой поколение на единицу больше, а незаконченную копию стирает.


198у.5 Отключение от шины: TEC, ABOM и 128 × 11 бит

Правила bxCAN. По RM0008 (регистр CAN_ESR): предупреждение EWGF — когда TEC или REC ≥ 96, пассивное состояние EPVF — когда > 127, отключение BOFF — когда TEC > 255. В отключённом состоянии bxCAN не передаёт и не принимает. Выход (24.7.6) — только после 128 раз по 11 рецессивных бит подряд на входе CANRX; при ABOM = 1 bxCAN начинает счёт сам, при ABOM = 0 — после того как программа поставит и снимет INRQ. На 500 кбит/с на свободной шине это 1408 бит = 2,816 мс.

Своя политика. Блок, который отключается от шины снова и снова, мешает всем: каждая его попытка — кадр ошибки на шине. Поэтому здесь ABOM = 0 и восстановление в руках программы: три раза сразу, потом с паузой 200 мс, пока не пройдёт хоть один кадр. Настройка bxCAN — по регистрам, как в 19в.6, но без ABOM, с прерываниями ошибок и отключения и с фильтром-списком на два идентификатора — свой физический запрос и 0x7DF:

/* ABOM = 0: из отключения выходим сами (node_on_bus_off); ERRIE + BOFIE — прерывание SCE */
void can_init(uint8_t node_id) {
    RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN;
    RCC->APB1ENR |= RCC_APB1ENR_CAN1EN;
    AFIO->MAPR |= AFIO_MAPR_CAN_REMAP_REMAP2;             /* CAN на PB8/PB9 */
    GPIOB->CRH = (GPIOB->CRH & ~0xFFu) | 0x4u | (0xBu << 4);

    CAN1->MCR = CAN_MCR_INRQ | CAN_MCR_TXFP;              /* без ABOM, ящики по очереди записи */
    while (!(CAN1->MSR & CAN_MSR_INAK)) {
    }
    CAN1->BTR = ((TS2 - 1u) << 20) | ((TS1 - 1u) << 16) | (BRP - 1u);

    CAN1->FMR |= CAN_FMR_FINIT;
    CAN1->FA1R = 0;
    CAN1->FS1R = 1u;                                      /* фильтр 0 — 32 бита, режим списка */
    CAN1->FM1R = 1u;
    CAN1->sFilterRegister[0].FR1 = (ID_DIAG_REQ + node_id) << 21;   /* свой физический запрос */
    CAN1->sFilterRegister[0].FR2 = ID_DIAG_FUNC << 21;              /* и функциональный 0x7DF */
    CAN1->FFA1R = 0;                                      /* оба — в FIFO 0 */
    CAN1->FA1R = 1u;
    CAN1->FMR &= ~CAN_FMR_FINIT;

    CAN1->IER = CAN_IER_FMPIE0 | CAN_IER_ERRIE | CAN_IER_BOFIE | CAN_IER_TMEIE;
    NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
    NVIC_EnableIRQ(CAN1_SCE_IRQn);
    NVIC_EnableIRQ(USB_HP_CAN1_TX_IRQn);
    CAN1->MCR &= ~CAN_MCR_INRQ;
    while (CAN1->MSR & CAN_MSR_INAK) {
    }
}

Каждый случай — в счётчик и в код U0073:

/* bxCAN сообщил BOFF (прерывание ошибок). ABOM = 0: восстанавливаем сами —
 * три раза сразу, потом с паузой 200 мс, чтобы неисправный узел не долбил шину. */
void node_on_bus_off(void) {
    node.bus_off = true;
    if (node.busoff_cnt < 255u) node.busoff_cnt++;
    if (node.busoff_row < 255u) node.busoff_row++;
    node.dtc_status[DTC_BUSOFF] |= 0x01 | 0x02 | 0x04 | 0x08 | 0x20;
    node.recover_at = plat_ms() + (node.busoff_row <= 3u ? 1u : 200u);
}
void node_on_tx_ok(void) {
    node.bus_off = false;
    node.busoff_row = 0;
    node.dtc_status[DTC_BUSOFF] &= (uint8_t)~0x01;
}
void CAN1_SCE_IRQHandler(void) {                          /* ошибки: BOFF — отключены от шины */
    if (CAN1->ESR & CAN_ESR_BOFF) {
        CAN1->IER &= ~CAN_IER_BOFIE;                      /* флаг держится до восстановления */
        node_on_bus_off();
    }
    CAN1->MSR = CAN_MSR_ERRI;
}
void plat_can_recover(void) {                             /* INRQ 1 → 0; дальше 128 × 11 бит сам bxCAN */
    CAN1->MCR |= CAN_MCR_INRQ;
    while (!(CAN1->MSR & CAN_MSR_INAK)) {
    }
    CAN1->MCR &= ~CAN_MCR_INRQ;
    CAN1->IER |= CAN_IER_BOFIE;
}

Опыт. В модели на 30 мс включается помеха: каждый кадр узла ломается на 20-м бите. Каждая ошибка — TEC + 8 (допущение по стандарту CAN) и кадр ошибки на шине; после успешного кадра — TEC − 1:

  помеха с 12098.000 до 12128.000 мс
  12101.236 мс  TEC 96: предупреждение (EWGF, ≥96), ошибка №12
  12101.588 мс  TEC 128: пассивный (EPVF, >127), ошибка №16
  12103.206 мс  TEC 256: ОТКЛЮЧЁН от шины (BOFF), ошибка №32
  12104.000 мс  программа: INRQ 1→0, ждём 128 × 11 рецессивных бит
  12106.816 мс  128 × 11 бит набрано: снова активен, TEC 0
  12108.178 мс  TEC 128: пассивный (EPVF, >127), ошибка №48
  12109.796 мс  TEC 256: ОТКЛЮЧЁН от шины (BOFF), ошибка №64
  12110.000 мс  программа: INRQ 1→0, ждём 128 × 11 рецессивных бит
  12113.020 мс  128 × 11 бит набрано: снова активен, TEC 0
  12114.382 мс  TEC 128: пассивный (EPVF, >127), ошибка №80
  12116.000 мс  TEC 256: ОТКЛЮЧЁН от шины (BOFF), ошибка №96
  12117.000 мс  программа: INRQ 1→0, ждём 128 × 11 рецессивных бит
  12119.816 мс  128 × 11 бит набрано: снова активен, TEC 0
  12121.406 мс  TEC 128: пассивный (EPVF, >127), ошибка №112
  12123.024 мс  TEC 256: ОТКЛЮЧЁН от шины (BOFF), ошибка №128
  12323.000 мс  программа: INRQ 1→0, ждём 128 × 11 рецессивных бит
  12325.816 мс  128 × 11 бит набрано: снова активен, TEC 0
  ошибок 128, предупреждение на 12-й, пассивный на 16-й; отключений 4

До отключения — 32 ошибки: предупреждение на 12-й (TEC 96), пассивное состояние на 16-й (TEC 128), отключение на 32-й (TEC 256). Первая серия заняла 3,206 мс от первого кадра под помехой. Выход на свободной шине — ровно 2,816 мс, как по правилу 128 × 11; во втором случае 3,020 мс — по шине прошёл кадр чужого блока, и его доминантные биты оборвали серию рецессивных. Помеха ещё держалась, поэтому узел отключался четыре раза, а четвёртый выход ждал паузу 200 мс — к тому времени помеха кончилась. Кадры, которые узел не смог отправить, он не копил: в отключённом состоянии рассылка просто молчит, а в ящике остаётся один кадр, и он ушёл первым после восстановления (в журнале — 12 326,056 мс).


198у.6 Прошивка по шине: загрузчик, раскладка и откат

Раскладка. У F103C8 64 КБ страницами по 1 КБ (RM0008, 3.3.3):

Адрес Размер Что
0x0800 0000 16 КБ загрузчик, страницы 0…15
0x0800 4000 45 КБ приложение, страницы 16…60
0x0800 F400 1 КБ запись о годном образе: метка, длина, CRC-32 и её инверсия
0x0800 F800 2 × 1 КБ эмуляция EEPROM
/* Раздел 198у: STM32F103C8, boot. Флеш-память 64 КБ страницами по 1 КБ:
 *   0x08000000 загрузчик 16 КБ | 0x08004000 приложение 45 КБ |
 *   0x0800F400 запись о годном образе 1 КБ | 0x0800F800 эмуляция EEPROM 2 × 1 КБ.
 * ОЗУ 20 КБ; последнее слово 0x20004FFC — просьба «остаться в загрузчике». */
ENTRY(Reset_Handler)

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

Один образ или два. Двум копиям приложения по 22 КБ здесь тесно, и откат на прошлую версию, как в MCUboot (48б), требует места под обе. Выбран один образ, но с гарантией: загрузчик сам умеет UDS и сам ничего не стирает у себя. Первым делом при прошивке стирается запись о годности, последним — пишется новая, только после совпавшей CRC-32 всего образа. Питание пропало на любом шаге — записи нет, загрузчик остаётся в себе и ждёт тестер. Старой версии уже нет, зато блок не превращается в кирпич, а прошивку можно повторить.

Приложение. Главный цикл: кадры из кольца прерывания — в ISO-TP, опрос сервера UDS, по разу на каждую миллисекунду — задачи узла, и перезапуск, если он заказан и ответ уже ушёл:

int main(void) {
    can_rx_t f;
    uint32_t last = 0;
    SCB->VTOR = APP_BASE;                                 /* таблица векторов приложения */
    clock_72mhz();
    adc_init();
    node_init(false);
    can_init(node.node_id);
    UDSServerInit(&srv);
    srv.fn = node_uds;
    srv.tp = &tp.hdl;
    UDSServerTpISOTpCInit(&tp, ID_DIAG_REQ + node.node_id, ID_DIAG_RESP + node.node_id, ID_DIAG_FUNC);
    __enable_irq();
    for (;;) {
        while (can_rx(&f))
            isotp_on_can_message(f.id == ID_DIAG_FUNC ? &tp.func_link : &tp.phys_link, f.d, f.len);
        UDSServerPoll(&srv);
        while (last != ms) {                              /* каждая миллисекунда по разу */
            last++;
            node_tick_1ms();
        }
        reset_if_pending(&srv);
    }
}

Запуск. Слово в последних 4 байтах ОЗУ переживает сброс: приложение пишет туда метку, когда получает 0x10 02, и перезапускается. Загрузчик:

static void jump_to_app(void) {
    uint32_t sp = *(const volatile uint32_t *)APP_BASE;
    uint32_t pc = *(const volatile uint32_t *)(APP_BASE + 4u);
    __disable_irq();
    SysTick->CTRL = 0;                                    /* ничего от загрузчика не тикает */
    NVIC->ICER[0] = 0xFFFFFFFFu;
    NVIC->ICER[1] = 0xFFFFFFFFu;
    NVIC->ICPR[0] = 0xFFFFFFFFu;
    NVIC->ICPR[1] = 0xFFFFFFFFu;
    RCC->APB1RSTR |= RCC_APB1RSTR_CAN1RST;                /* bxCAN — в состояние после сброса */
    RCC->APB1RSTR &= ~RCC_APB1RSTR_CAN1RST;
    SCB->VTOR = APP_BASE;
    __set_MSP(sp);
    __enable_irq();
    ((void (*)(void))pc)();
}

int main(void) {
    can_rx_t f;
    bool stay = BOOT_REQ == BOOT_REQ_MAGIC;
    BOOT_REQ = 0;
    if (!stay && boot_image_ok())                         /* запись о годности + CRC-32 образа */
        jump_to_app();
    clock_72mhz();
    node_init(true);
    can_init(node.node_id);
    UDSServerInit(&srv);
    srv.fn = node_uds;
    srv.tp = &tp.hdl;
    UDSServerTpISOTpCInit(&tp, ID_DIAG_REQ + node.node_id, ID_DIAG_RESP + node.node_id, ID_DIAG_FUNC);
    if (stay) srv.sessionType = UDS_LEV_DS_PRGS;          /* пришли по 10 02 из приложения */
    __enable_irq();
    for (;;) {
        while (can_rx(&f))
            isotp_on_can_message(f.id == ID_DIAG_FUNC ? &tp.func_link : &tp.phys_link, f.d, f.len);
        UDSServerPoll(&srv);
        reset_if_pending(&srv);
    }
}

Флеш-память по регистрам. Стирание страницы по RM0008: ключи в FLASH_KEYR, бит PER, адрес в FLASH_AR, STRT, ждать BSY. Запись — полусловом при бите PG. Пока идёт стирание, процессор, который читает код из той же флеш-памяти, стоит 20…40 мс (DS5319):

static void fl_unlock(void) {
    if (FLASH->CR & FLASH_CR_LOCK) {
        FLASH->KEYR = 0x45670123u;
        FLASH->KEYR = 0xCDEF89ABu;
    }
}
static bool fl_done(void) {
    while (FLASH->SR & FLASH_SR_BSY) {
    }
    bool ok = (FLASH->SR & (FLASH_SR_PGERR | FLASH_SR_WRPRTERR)) == 0u;
    FLASH->SR = FLASH_SR_EOP | FLASH_SR_PGERR | FLASH_SR_WRPRTERR;
    return ok;
}
bool fl_erase_page(uint32_t addr) {
    if (addr < BOOT_BASE + BOOT_SIZE || addr >= 0x08010000u) return false;   /* загрузчик не трогаем */
    fl_unlock();
    FLASH->CR = FLASH_CR_PER;
    FLASH->AR = addr;
    FLASH->CR = FLASH_CR_PER | FLASH_CR_STRT;             /* процессор стоит 20…40 мс */
    bool ok = fl_done();
    FLASH->CR = FLASH_CR_LOCK;
    return ok;
}
bool fl_prog16(uint32_t addr, uint16_t v) {
    if (addr < BOOT_BASE + BOOT_SIZE || addr >= 0x08010000u) return false;
    fl_unlock();
    FLASH->CR = FLASH_CR_PG;
    *(volatile uint16_t *)addr = v;                       /* 40…70 мкс */
    bool ok = fl_done() && *(volatile uint16_t *)addr == v;
    FLASH->CR = FLASH_CR_LOCK;
    return ok;
}

Стирание и 0x78. Процедура 0xFF00 стирает страницу записи и страницы под образ — 21 страницу на 20 КБ. Модель считает худшие 40 мс на страницу, то есть 840 мс, когда процессор стоит. Ответить за 50 мс нельзя, поэтому обработчик отвечает 0x78 «ответ позже» и стирает по странице за вызов. Но в iso14229 0.11.0 сам 0x78 уходит не сразу, а по таймеру P2, а после него таймер переставляется на 0,3 × P2*. Если начать стирать сразу, процессор встанет раньше, чем 0x78 уйдёт на шину:

  13966.228 мс  -> 31 01 FF 00 44 08 00 40 00 00 00 50 … (13 байт)
  14047.178 мс  <- 7F 31 78 (ответ позже), через 80.950 мс после запроса
  14857.236 мс  <- 71 01 FF 00 00
  16459.228 мс  -> 31 01 FF 00 44 08 00 40 00 00 00 50 … (13 байт)
  16510.454 мс  <- 7F 31 78 (ответ позже), через 51.226 мс после запроса

Без ожидания 0x78 ушёл через 80,950 мс — позже тех 50 мс, что блок сам объявил в ответе 50 02 00 32. Поэтому стирание ждёт, пока таймер библиотеки не уйдёт дальше P2, — то есть пока 0x78 не отправлен:

static UDSErr_t on_routine(UDSServer_t *srv, UDSRoutineCtrlArgs_t *a) {
    if (!node.boot_mode) return UDS_NRC_ServiceNotSupportedInActiveSession;
    if (srv->securityLevel != SEC_LEVEL) return UDS_NRC_SecurityAccessDenied;
    if (a->ctrlType != 0x01) return UDS_NRC_SubFunctionNotSupported;
    if (a->id == RID_ERASE) {            /* 44 адрес длина; стираем запись, потом страницы образа */
        if (a->len != 9 || a->optionRecord[0] != 0x44 || be32(&a->optionRecord[1]) != APP_BASE ||
            be32(&a->optionRecord[5]) == 0 || be32(&a->optionRecord[5]) > APP_SIZE)
            return UDS_NRC_RequestOutOfRange;
        if (er_end == 0) {               /* первый вызов: только запомнить и ответить 0x78 */
            erased = false;
            er_next = REC_BASE;          /* первой стирается запись о годности */
            er_end = APP_BASE + ((be32(&a->optionRecord[5]) + FL_PAGE - 1u) & ~(FL_PAGE - 1u));
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        }
        /* стирание останавливает процессор: сперва пусть уйдёт 0x78. Пока он не ушёл, таймер
         * библиотеки ближе P2; после него iso14229 ставит таймер на 0,3 × P2* */
        if (erase_after_78 && (int32_t)(srv->p2_timer - UDSMillis()) <= (int32_t)srv->p2_ms)
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        if (er_next == REC_BASE) {
            if (!fl_erase_page(REC_BASE)) return UDS_NRC_GeneralProgrammingFailure;
            er_next = APP_BASE;
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        }
        if (er_next < er_end) {          /* по странице за вызов, 20…40 мс каждая (DS5319) */
            if (!fl_erase_page(er_next)) return UDS_NRC_GeneralProgrammingFailure;
            er_next += FL_PAGE;
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        }
        er_end = 0;
        erased = true;
        const uint8_t ok = 0x00;
        return a->copyStatusRecord(srv, &ok, 1);
    }
    if (a->id == RID_CHECK) {            /* CRC-32 образа от тестера; совпал — пишем запись */
        if (a->len != 4 || dl_end == 0) return UDS_NRC_RequestSequenceError;
        uint32_t len = dl_end - APP_BASE, crc = crc32_calc(fl_ptr(APP_BASE), len);
        uint8_t res = 0x01;
        if (crc == be32(a->optionRecord) && rec_write(len, crc)) res = 0x00;
        return a->copyStatusRecord(srv, &res, 1);
    }
    return UDS_NRC_RequestOutOfRange;
}

Загрузка 20 КБ с потерями. Образ — 80 блоков по 256 байт, каждый пишется 128 полусловами. В модели сначала пропадает питание после 40 блоков, потом при новой попытке теряются кадр продолжения блока 0x0B и ответ на блок 0x21:

  17401.236 мс  -> 34 00 44 08 00 40 00 00 00 50 00
  17452.234 мс  <- 74 20 01 02
  19492.228 мс  !! питание пропало после 40 блоков
  program: -2
  19492.228 мс  загрузчик: годного образа нет — остаюсь в загрузчике
  19512.228 мс  -> 22 F1 95
  19543.920 мс  <- 62 F1 95 4F 49 4C 2D 42 4F 4F 54 20 31 2E 30 2E 30
  21588.234 мс  <- 74 20 01 02
  22099.162 мс  !! потерян кадр продолжения 23 блока 0B
  22199.628 мс  блок 0B: UDS_ERR_TPORT, повтор
  23340.228 мс  !! потерян ответ 76 21
  23449.000 мс  блок 21: UDS_ERR_TIMEOUT, повтор
  23459.220 мс  блок 21: 7F 36 24 (RequestSequenceError), повтор
             блок уже был принят; передача сброшена сервером — всё заново
  28582.230 мс  -> 31 01 FF 01 4F 9B 42 C5
  28633.234 мс  <- 71 01 FF 01 00
  загрузка: 80 блоков по 256 байт, повторов 0, 0x24 на повтор 0, CRC-32 образа 0x4F9B42C5, результат 00
  загрузка: 5.174 с от стирания до проверки, кадров на шине 3973, худший цикл 0x36 51.230 мс
  28633.234 мс  -> 11 01
  28684.228 мс  <- 51 01
  28704.000 мс  узел перезапущен
  28704.000 мс  загрузчик: образ 20480 байт, CRC-32 0x4F9B42C5 сошёлся — переход на 0x08004000
  28784.228 мс  -> 22 F1 95
  28785.386 мс  <- 62 F1 95 4F 49 4C 2D 41 50 50 20 31 2E 30 2E 30

Что видно:

Худший цикл 0x36 — 51,23 мс, почти весь — ожидание P2 в библиотеке, как в 19д.6; запись 128 полуслов по 70 мкс — 8,96 мс из них.


198у.7 Грабли

0x78 опоздал, и тестер ушёл по тайм-ауту. Стирание страницы на F103 останавливает процессор на десятки миллисекунд. Начали стирать в обработчике сразу — 0x78 ушёл через 80,950 мс. Сначала 0x78, потом стирание.

Повтор блока получил 0x24, а дальше — ничего. iso14229 0.11.0 считает повтор с тем же счётчиком ошибкой последовательности и на любом отказе сбрасывает передачу. Потерян ответ — начинать со стирания. Итоговая CRC-32 всё равно нужна: она ловит и то, что повтор проглядел.

После потерянного кадра тестер больше ничего не шлёт. В iso14229 0.11.0 канал ISO-TP после тайм-аута ожидания FC остаётся в состоянии ошибки, и каждый следующий запрос сразу возвращает UDS_ERR_TPORT. Тестеру — заново инициализировать канал.

Ответ 50 02 не дошёл, блок уже в загрузчике. Перезапуск в загрузчик делать только после того, как ответ ушёл: запрос обработан и все три ящика bxCAN пусты (reset_if_pending).

27 01 в загрузчике — 7F 27 37. Библиотека не даёт seed 1 с после старта (19д.5): после перезапуска в загрузчик тестер должен подождать.

Блок отключается от шины раз за разом. С ABOM = 1 bxCAN восстанавливается сам через 2,816 мс и снова рвёт шину кадрами ошибок. ABOM = 0 и своя пауза — три быстрых попытки, потом 200 мс.

Номер узла сменили, а блок молчит. Фильтр bxCAN и идентификаторы ISO-TP берутся из EEPROM при старте. Новый номер — только после 11 01, и тестер переходит на новые идентификаторы.

Параметр пропал после отключения питания. Запись «номер, потом значение» после обрыва оставит номер со старым мусором. Порядок — значение, потом номер; страница становится главной только после копии всех значений.


198у.8 Итоги

Полные исходники

node.c — логика узла: датчики, коды неисправностей, циклическое сообщение и обработчик UDS.

/* Раздел 198у: логика узла — датчики, коды неисправностей, циклическое сообщение
 * по oilnode.dbc, обработчик UDS для приложения и для загрузчика. */
#include <string.h>
#include "node.h"
#include "oil_dbc.h"
#include "ntc_table.h"

node_t node;
static const char SW_APP[] = "OIL-APP 1.0.0";
static const char SW_BOOT[] = "OIL-BOOT 1.0.0";
static const char SERIAL[] = "OIL-0000123";
static const uint8_t DTC_CODE[DTC_N][3] = {
    {0x01, 0x97, 0x00},   /* P0197 Engine Oil Temperature Sensor Low  — NTC замкнут */
    {0x01, 0x98, 0x00},   /* P0198 Engine Oil Temperature Sensor High — NTC оборван */
    {0x05, 0x22, 0x00},   /* P0522 Oil Pressure Sensor Low Voltage */
    {0x05, 0x23, 0x00},   /* P0523 Oil Pressure Sensor High Voltage */
    {0xC0, 0x73, 0x00},   /* U0073 Control Module Communication Bus Off */
};
static uint8_t deb_bad[DTC_N], deb_good[DTC_N];

/* DataID на каждое значение счётчика: у этого сообщения свой набор, как у VW в 19е */
static const uint8_t DATA_ID[16] = {0x3A, 0x51, 0x7C, 0x0E, 0x93, 0xD4, 0x28, 0xB6,
                                    0x61, 0xEF, 0x05, 0x4B, 0xC2, 0x9D, 0x17, 0x80};

static uint8_t crc8_2f(uint8_t crc, uint8_t b) {
    crc ^= b;
    for (int k = 0; k < 8; k++)
        crc = (crc & 0x80u) ? (uint8_t)((crc << 1) ^ 0x2Fu) : (uint8_t)(crc << 1);
    return crc;
}
uint8_t e2e_crc(const uint8_t *d) {
    uint8_t crc = 0xFF;
    for (int i = 1; i < 8; i++) crc = crc8_2f(crc, d[i]);
    crc = crc8_2f(crc, DATA_ID[d[1] & 0x0Fu]);
    return crc ^ 0xFFu;
}

uint32_t crc32_calc(const uint8_t *p, uint32_t n) {  /* CRC-32, как в zlib */
    uint32_t c = 0xFFFFFFFFu;
    while (n--) {
        c ^= *p++;
        for (int i = 0; i < 8; i++) c = (c >> 1) ^ (0xEDB88320u & (0u - (c & 1u)));
    }
    return ~c;
}

uint32_t toy_key(uint32_t s) {          /* тот же игрушечный ключ, что в 19д: НЕ криптография */
    uint32_t k = s ^ 0x5AA5C33Cu;
    k = (k << 7) | (k >> 25);
    return k + 0x1234ABCDu;
}

/* ── датчики: целочисленно, без плавающей точки (у F103 её нет) ── */
int16_t ntc_temp_q4(uint16_t code) {    /* код падает с ростом температуры */
    if (code >= ntc_code[0]) return (int16_t)(NTC_T0 * 4);
    for (int i = 1; i < NTC_N; i++)
        if (code >= ntc_code[i]) {      /* между точками i-1 и i — прямая */
            int32_t span = ntc_code[i - 1] - ntc_code[i];
            int32_t part = ntc_code[i - 1] - code;
            return (int16_t)((NTC_T0 + (i - 1) * NTC_STEP) * 4 + (part * NTC_STEP * 4 + span / 2) / span);
        }
    return (int16_t)((NTC_T0 + (NTC_N - 1) * NTC_STEP) * 4);
}
static uint16_t press_mv(uint16_t code) { return (uint16_t)(((uint32_t)code * 4950u + 2047u) / 4095u); }
uint16_t press_raw(uint16_t code) {     /* 0,5…4,5 В = 0…10 бар; шаг DBC 5 мбар = 2 мВ */
    uint16_t mv = press_mv(code);
    return mv <= 500u ? 0u : (uint16_t)((mv - 500u) / 2u);
}

static void dtc_eval(int k, bool bad) {
    uint8_t *st = &node.dtc_status[k];
    if (bad) {
        deb_good[k] = 0;
        if (deb_bad[k] < DEBOUNCE && ++deb_bad[k] == DEBOUNCE)
            *st |= 0x01 | 0x02 | 0x04 | 0x08 | 0x20;   /* сейчас, в этом цикле, ожидающий, подтверждённый, после сброса */
    } else {
        deb_bad[k] = 0;
        if (deb_good[k] < DEBOUNCE && ++deb_good[k] == DEBOUNCE)
            *st &= (uint8_t)~0x01;                     /* ошибки сейчас нет, история остаётся */
    }
}

static void sample(void) {
    node.t_adc = plat_adc(0);
    node.p_adc = plat_adc(1);
    uint16_t mv = press_mv(node.p_adc);
    dtc_eval(DTC_T_LOW, node.t_adc < T_SHORT_CODE);
    dtc_eval(DTC_T_HIGH, node.t_adc > T_OPEN_CODE);
    dtc_eval(DTC_P_LOW, mv < P_LOW_MV);
    dtc_eval(DTC_P_HIGH, mv > P_HIGH_MV);
    bool t_bad = node.t_adc < T_SHORT_CODE || node.t_adc > T_OPEN_CODE;
    bool p_bad = mv < P_LOW_MV || mv > P_HIGH_MV;
    node.t_raw = t_bad ? 0x3FFu : (uint16_t)(ntc_temp_q4(node.t_adc) + 160);  /* (T + 40) × 4 */
    node.p_raw = p_bad ? 0xFFFu : press_raw(node.p_adc);
}

static void send_cyclic(void) {
    oil_oilsens_01_t m;
    uint8_t d[8], st = 0;
    for (int k = 0; k < DTC_N; k++) if (node.dtc_status[k] & 0x08) st |= 0x04;
    if (node.dtc_status[DTC_T_LOW] & 0x01 || node.dtc_status[DTC_T_HIGH] & 0x01) st |= 0x01;
    if (node.dtc_status[DTC_P_LOW] & 0x01 || node.dtc_status[DTC_P_HIGH] & 0x01) st |= 0x02;
    if (node.busoff_cnt) st |= 0x08;
    memset(&m, 0, sizeof m);
    m.oilsens_bz = node.bz;
    m.oilsens_status = st;
    m.oilsens_temp = node.t_raw;
    m.oilsens_press = node.p_raw;
    m.oilsens_busoff = node.busoff_cnt;
    oil_oilsens_01_pack(d, &m);
    d[0] = e2e_crc(d);
    if (plat_can_send(ID_CYCLIC_BASE + node.node_id, d, 8) == 0) {
        node.bz = (uint8_t)((node.bz + 1u) & 0x0Fu);   /* счётчик растёт только у ушедшего кадра */
        node.sent++;
    } else {
        node.skipped++;
    }
}

void node_init(bool boot_mode) {
    uint16_t v;
    memset(&node, 0, sizeof node);
    memset(deb_bad, 0, sizeof deb_bad);
    memset(deb_good, 0, sizeof deb_good);
    node.boot_mode = boot_mode;
    ee_init();
    node.node_id = (ee_read(EE_NODE_ID, &v) && v >= 1u && v <= 15u) ? (uint8_t)v : 1u;
    node.period_ms = (ee_read(EE_PERIOD, &v) && v >= 20u && v <= 1000u) ? v : 100u;
}

void node_tick_1ms(void) {
    uint32_t t = plat_ms();
    if (node.boot_mode) return;
    if (t % 10u == 0) sample();
    if (node.bus_off) {
        if (node.recover_at && (int32_t)(t - node.recover_at) >= 0) {
            node.recover_at = 0;
            plat_can_recover();                        /* INRQ 1 → 0; дальше 128 × 11 бит сам bxCAN */
        }
        return;                                        /* на отключённую шину не кладём */
    }
    if (t % node.period_ms == 0) send_cyclic();
}

/* bxCAN сообщил BOFF (прерывание ошибок). ABOM = 0: восстанавливаем сами —
 * три раза сразу, потом с паузой 200 мс, чтобы неисправный узел не долбил шину. */
void node_on_bus_off(void) {
    node.bus_off = true;
    if (node.busoff_cnt < 255u) node.busoff_cnt++;
    if (node.busoff_row < 255u) node.busoff_row++;
    node.dtc_status[DTC_BUSOFF] |= 0x01 | 0x02 | 0x04 | 0x08 | 0x20;
    node.recover_at = plat_ms() + (node.busoff_row <= 3u ? 1u : 200u);
}
void node_on_tx_ok(void) {
    node.bus_off = false;
    node.busoff_row = 0;
    node.dtc_status[DTC_BUSOFF] &= (uint8_t)~0x01;
}

/* ── загрузчик: запись о годном образе в странице REC_BASE ── */
typedef struct { uint32_t magic, len, crc, inv; } rec_t;
bool boot_image_ok(void) {
    rec_t r;
    memcpy(&r, fl_ptr(REC_BASE), sizeof r);
    if (r.magic != REC_MAGIC || r.inv != ~r.crc || r.len == 0 || r.len > APP_SIZE) return false;
    return crc32_calc(fl_ptr(APP_BASE), r.len) == r.crc;
}
static bool rec_write(uint32_t len, uint32_t crc) {
    uint32_t w[4] = {REC_MAGIC, len, crc, ~crc};
    for (int i = 0; i < 8; i++)
        if (!fl_prog16(REC_BASE + 2u * (uint32_t)i, (uint16_t)(w[i / 2] >> (16 * (i % 2))))) return false;
    return true;
}

static uint32_t seed, rng = 0x198C0DEu;
static uint32_t er_next, er_end, dl_addr, dl_end;
bool erase_after_78 = true;
static bool erased;

static uint32_t be32(const uint8_t *p) {
    return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
}

static UDSErr_t on_routine(UDSServer_t *srv, UDSRoutineCtrlArgs_t *a) {
    if (!node.boot_mode) return UDS_NRC_ServiceNotSupportedInActiveSession;
    if (srv->securityLevel != SEC_LEVEL) return UDS_NRC_SecurityAccessDenied;
    if (a->ctrlType != 0x01) return UDS_NRC_SubFunctionNotSupported;
    if (a->id == RID_ERASE) {            /* 44 адрес длина; стираем запись, потом страницы образа */
        if (a->len != 9 || a->optionRecord[0] != 0x44 || be32(&a->optionRecord[1]) != APP_BASE ||
            be32(&a->optionRecord[5]) == 0 || be32(&a->optionRecord[5]) > APP_SIZE)
            return UDS_NRC_RequestOutOfRange;
        if (er_end == 0) {               /* первый вызов: только запомнить и ответить 0x78 */
            erased = false;
            er_next = REC_BASE;          /* первой стирается запись о годности */
            er_end = APP_BASE + ((be32(&a->optionRecord[5]) + FL_PAGE - 1u) & ~(FL_PAGE - 1u));
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        }
        /* стирание останавливает процессор: сперва пусть уйдёт 0x78. Пока он не ушёл, таймер
         * библиотеки ближе P2; после него iso14229 ставит таймер на 0,3 × P2* */
        if (erase_after_78 && (int32_t)(srv->p2_timer - UDSMillis()) <= (int32_t)srv->p2_ms)
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        if (er_next == REC_BASE) {
            if (!fl_erase_page(REC_BASE)) return UDS_NRC_GeneralProgrammingFailure;
            er_next = APP_BASE;
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        }
        if (er_next < er_end) {          /* по странице за вызов, 20…40 мс каждая (DS5319) */
            if (!fl_erase_page(er_next)) return UDS_NRC_GeneralProgrammingFailure;
            er_next += FL_PAGE;
            return UDS_NRC_RequestCorrectlyReceived_ResponsePending;
        }
        er_end = 0;
        erased = true;
        const uint8_t ok = 0x00;
        return a->copyStatusRecord(srv, &ok, 1);
    }
    if (a->id == RID_CHECK) {            /* CRC-32 образа от тестера; совпал — пишем запись */
        if (a->len != 4 || dl_end == 0) return UDS_NRC_RequestSequenceError;
        uint32_t len = dl_end - APP_BASE, crc = crc32_calc(fl_ptr(APP_BASE), len);
        uint8_t res = 0x01;
        if (crc == be32(a->optionRecord) && rec_write(len, crc)) res = 0x00;
        return a->copyStatusRecord(srv, &res, 1);
    }
    return UDS_NRC_RequestOutOfRange;
}

static UDSErr_t read_did(UDSServer_t *srv, UDSRDBIArgs_t *a) {
    uint8_t b[8];
    switch (a->dataId) {
    case 0xF18C: return a->copy(srv, SERIAL, sizeof SERIAL - 1);
    case 0xF195: return node.boot_mode ? a->copy(srv, SW_BOOT, sizeof SW_BOOT - 1) : a->copy(srv, SW_APP, sizeof SW_APP - 1);
    case 0xF186: return a->copy(srv, &srv->sessionType, 1);
    case 0x0100: return a->copy(srv, &node.node_id, 1);
    case 0x0101: b[0] = (uint8_t)(node.period_ms >> 8); b[1] = (uint8_t)node.period_ms; return a->copy(srv, b, 2);
    case 0x0200: {                       /* живые значения: коды АЦП и сырые сигналы DBC */
        if (node.boot_mode) return UDS_NRC_RequestOutOfRange;
        b[0] = (uint8_t)(node.t_adc >> 8); b[1] = (uint8_t)node.t_adc;
        b[2] = (uint8_t)(node.t_raw >> 8); b[3] = (uint8_t)node.t_raw;
        b[4] = (uint8_t)(node.p_adc >> 8); b[5] = (uint8_t)node.p_adc;
        b[6] = (uint8_t)(node.p_raw >> 8); b[7] = (uint8_t)node.p_raw;
        return a->copy(srv, b, 8);
    }
    case 0x0201: return a->copy(srv, &node.busoff_cnt, 1);
    default: return UDS_NRC_RequestOutOfRange;
    }
}

static UDSErr_t write_did(UDSServer_t *srv, UDSWDBIArgs_t *a) {
    if (srv->sessionType != UDS_LEV_DS_EXTDS) return UDS_NRC_ServiceNotSupportedInActiveSession;
    if (a->dataId == 0x0100) {           /* номер узла: вступит в силу после перезапуска */
        if (a->len != 1) return UDS_NRC_IncorrectMessageLengthOrInvalidFormat;
        if (a->data[0] < 1u || a->data[0] > 15u) return UDS_NRC_RequestOutOfRange;
        return ee_write(EE_NODE_ID, a->data[0]) ? UDS_PositiveResponse : UDS_NRC_GeneralProgrammingFailure;
    }
    if (a->dataId == 0x0101) {           /* период рассылки, мс: сразу */
        if (a->len != 2) return UDS_NRC_IncorrectMessageLengthOrInvalidFormat;
        uint16_t v = (uint16_t)((a->data[0] << 8) | a->data[1]);
        if (v < 20u || v > 1000u) return UDS_NRC_RequestOutOfRange;
        if (!ee_write(EE_PERIOD, v)) return UDS_NRC_GeneralProgrammingFailure;
        node.period_ms = v;
        return UDS_PositiveResponse;
    }
    return UDS_NRC_RequestOutOfRange;
}

UDSErr_t node_uds(UDSServer_t *srv, UDSEvent_t ev, void *arg) {
    switch (ev) {
    case UDS_EVT_DiagSessCtrl: {
        UDSDiagSessCtrlArgs_t *a = arg;
        a->p2_ms = 50;
        a->p2_star_ms = 5000;
        srv->securityLevel = 0;
        if (a->type == UDS_LEV_DS_PRGS && !node.boot_mode) {
            plat_reset(true);            /* ответ 50 02 уйдёт, потом перезапуск в загрузчик */
            return UDS_PositiveResponse;
        }
        if (a->type == UDS_LEV_DS_PRGS || a->type == UDS_LEV_DS_DS || a->type == UDS_LEV_DS_EXTDS)
            return UDS_PositiveResponse;
        return UDS_NRC_SubFunctionNotSupported;
    }
    case UDS_EVT_EcuReset: {
        UDSECUResetArgs_t *a = arg;
        return a->type == 0x01 ? UDS_PositiveResponse : UDS_NRC_SubFunctionNotSupported;
    }
    case UDS_EVT_DoScheduledReset:
        plat_reset(false);
        return UDS_PositiveResponse;
    case UDS_EVT_ReadDataByIdent:
        return read_did(srv, arg);
    case UDS_EVT_WriteDataByIdent:
        return node.boot_mode ? UDS_NRC_ServiceNotSupported : write_did(srv, arg);
    case UDS_EVT_SecAccessRequestSeed: {
        UDSSecAccessRequestSeedArgs_t *a = arg;
        if (a->level != SEC_LEVEL) return UDS_NRC_SubFunctionNotSupported;
        if (!node.boot_mode) return UDS_NRC_ServiceNotSupportedInActiveSession;
        rng ^= UDSMillis();
        rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
        seed = rng;
        const uint8_t s[4] = {(uint8_t)(seed >> 24), (uint8_t)(seed >> 16), (uint8_t)(seed >> 8), (uint8_t)seed};
        return a->copySeed(srv, s, 4);
    }
    case UDS_EVT_SecAccessValidateKey: {
        UDSSecAccessValidateKeyArgs_t *a = arg;
        if (a->len != 4 || seed == 0 || be32(a->key) != toy_key(seed)) return UDS_NRC_InvalidKey;
        seed = 0;
        return UDS_PositiveResponse;
    }
    case UDS_EVT_ReadDTCInformation: {
        UDSRDTCIArgs_t *a = arg;
        if (node.boot_mode || a->type != 0x02) return UDS_NRC_SubFunctionNotSupported;
        const uint8_t avail = 0x2F;      /* ведём биты 0, 1, 2, 3 и 5 */
        a->copy(srv, &avail, 1);
        for (int k = 0; k < DTC_N; k++)
            if (node.dtc_status[k] & a->subFuncArgs.dtcStatusByMaskArgs.mask) {
                uint8_t r[4] = {DTC_CODE[k][0], DTC_CODE[k][1], DTC_CODE[k][2], node.dtc_status[k]};
                a->copy(srv, r, 4);
            }
        return UDS_PositiveResponse;
    }
    case UDS_EVT_ClearDiagnosticInfo: {
        UDSCDIArgs_t *a = arg;
        if (node.boot_mode) return UDS_NRC_ServiceNotSupported;
        if (a->groupOfDTC != 0xFFFFFFu) return UDS_NRC_RequestOutOfRange;
        memset(node.dtc_status, 0, sizeof node.dtc_status);
        memset(deb_bad, 0, sizeof deb_bad);   /* неисправность, что есть сейчас, вернётся через 50 мс */
        return UDS_PositiveResponse;
    }
    case UDS_EVT_RoutineCtrl:
        return on_routine(srv, arg);
    case UDS_EVT_RequestDownload: {
        UDSRequestDownloadArgs_t *a = arg;
        if (!node.boot_mode || srv->securityLevel != SEC_LEVEL) return UDS_NRC_SecurityAccessDenied;
        if (a->addr != APP_BASE || a->size == 0 || a->size > APP_SIZE) return UDS_NRC_RequestOutOfRange;
        if (!erased) return UDS_NRC_ConditionsNotCorrect;
        dl_addr = APP_BASE;
        dl_end = APP_BASE + (uint32_t)a->size;
        a->maxNumberOfBlockLength = BLOCK_LEN;
        return UDS_PositiveResponse;
    }
    case UDS_EVT_TransferData: {
        UDSTransferDataArgs_t *a = arg;
        if (dl_addr + a->len > dl_end || (a->len & 1u)) return UDS_NRC_TransferDataSuspended;
        for (uint16_t i = 0; i < a->len; i += 2)   /* F103 пишет полусловами */
            if (!fl_prog16(dl_addr + i, (uint16_t)(a->data[i] | (a->data[i + 1] << 8))))
                return UDS_NRC_GeneralProgrammingFailure;
        dl_addr += a->len;
        return UDS_PositiveResponse;
    }
    case UDS_EVT_RequestTransferExit:
        erased = false;
        return dl_addr == dl_end ? UDS_PositiveResponse : UDS_NRC_RequestSequenceError;
    case UDS_EVT_SessionTimeout:
        erased = false;
        return UDS_PositiveResponse;
    default:
        return UDS_NRC_ServiceNotSupported;
    }
}

ee.c — эмуляция EEPROM на двух страницах флеш-памяти.

/* Раздел 198у: эмуляция EEPROM на двух страницах флеш-памяти F103 по 1 КБ.
 * Заголовок страницы — 4 полуслова, каждое пишется один раз по стёртому месту:
 *   h0 = 0xEEEE — страница принимает копию, h1 = 0x0000 — страница действующая,
 *   h2 — поколение (растёт при каждом переносе). Записи с байта 8 по 4 байта:
 *   сначала значение, потом номер — запись без номера (обрыв между ними) не считается. */
#include "node.h"

#define H_RECV   0xEEEEu
#define H_VALID  0x0000u
#define SLOT0    8u
#define NSLOT    ((FL_PAGE - SLOT0) / 4u)       /* 254 записи на страницу */

uint32_t ee_transfers;
static uint32_t act;                           /* адрес действующей страницы */
static uint32_t next_slot;

static uint16_t rd(uint32_t a) { const uint8_t *p = fl_ptr(a); return (uint16_t)(p[0] | (p[1] << 8)); }
static int state(uint32_t pg) {                /* 0 — стёрта, 1 — принимает, 2 — действующая, 3 — мусор */
    uint16_t h0 = rd(pg), h1 = rd(pg + 2);
    if (h1 == H_VALID) return 2;
    if (h1 != 0xFFFFu) return 3;
    if (h0 == H_RECV) return 1;
    return h0 == 0xFFFFu ? 0 : 3;
}
static uint32_t other(uint32_t pg) { return pg == EE_PAGE0 ? EE_PAGE1 : EE_PAGE0; }

static void find_free(void) {
    uint32_t i = NSLOT;
    while (i > 0 && rd(act + SLOT0 + 4u * (i - 1u)) == 0xFFFFu && rd(act + SLOT0 + 4u * (i - 1u) + 2u) == 0xFFFFu)
        i--;
    next_slot = i;                             /* после последней занятой (даже битой) */
}

static bool erase_if_dirty(uint32_t pg) {
    for (uint32_t a = pg; a < pg + FL_PAGE; a += 2)
        if (rd(a) != 0xFFFFu) return fl_erase_page(pg);
    return true;
}

static bool lookup(uint32_t pg, uint16_t id, uint16_t *v) {
    for (uint32_t i = NSLOT; i > 0; i--) {     /* с конца: последняя запись — свежая */
        uint32_t a = pg + SLOT0 + 4u * (i - 1u);
        if (rd(a + 2u) == id) { *v = rd(a); return true; }
    }
    return false;
}

/* перенос на другую страницу: свежие значения всех номеров + новое (id, v) */
static bool transfer(uint16_t id, uint16_t v) {
    uint32_t dst = other(act);
    uint16_t gen = rd(act + 4u);
    if (!erase_if_dirty(dst) || !fl_prog16(dst, H_RECV) || !fl_prog16(dst + 4u, (uint16_t)(gen + 1u)))
        return false;
    uint32_t s = 0;
    for (uint16_t k = EE_NODE_ID; k <= EE_PERIOD; k++) {
        uint16_t x;
        if (k == id) x = v;
        else if (!lookup(act, k, &x)) continue;
        uint32_t a = dst + SLOT0 + 4u * s++;
        if (!fl_prog16(a, x) || !fl_prog16(a + 2u, k)) return false;
    }
    if (!fl_prog16(dst + 2u, H_VALID)) return false;   /* только теперь новая страница главная */
    if (!fl_erase_page(act)) return false;
    act = dst;
    ee_transfers++;
    find_free();
    return true;
}

int ee_init(void) {
    int s0 = state(EE_PAGE0), s1 = state(EE_PAGE1), fix = 0;
    if (s0 == 2 && s1 == 2) {                  /* обрыв после отметки новой, до стирания старой */
        act = (uint16_t)(rd(EE_PAGE1 + 4u) - rd(EE_PAGE0 + 4u)) == 1u ? EE_PAGE1 : EE_PAGE0;
        fl_erase_page(other(act));
        fix = 3;
    } else if (s0 == 2 || s1 == 2) {
        act = s0 == 2 ? EE_PAGE0 : EE_PAGE1;
        if (state(other(act)) != 0) { erase_if_dirty(other(act)); fix = 2; }   /* недоделанная копия */
    } else {                                   /* ни одной действующей: первое включение */
        erase_if_dirty(EE_PAGE0);
        erase_if_dirty(EE_PAGE1);
        fl_prog16(EE_PAGE0, H_RECV);
        fl_prog16(EE_PAGE0 + 4u, 1u);
        fl_prog16(EE_PAGE0 + 2u, H_VALID);
        act = EE_PAGE0;
        fix = 1;
    }
    find_free();
    return fix;
}

bool ee_read(uint16_t id, uint16_t *v) { return lookup(act, id, v); }

bool ee_write(uint16_t id, uint16_t v) {
    uint16_t old;
    if (lookup(act, id, &old) && old == v) return true;   /* то же значение — страницу не тратим */
    if (next_slot >= NSLOT) return transfer(id, v);
    uint32_t a = act + SLOT0 + 4u * next_slot++;
    return fl_prog16(a, v) && fl_prog16(a + 2u, id);
}

f103_hw.c — общее железо STM32F103C8 для загрузчика и приложения.

/* Раздел 198у: общее железо STM32F103C8 для загрузчика и приложения — регистры CMSIS, без HAL.
 * Кварц 8 МГц × 9 = 72 МГц, APB1 = 36 МГц; CAN на PB8/PB9 (ремап), 500 кбит/с:
 * 36 МГц / 4 = 9 МГц, 1 + 15 + 2 = 18 квантов. Флеш-память — страницами по 1 КБ. */
#include "stm32f10x.h"
#include "node.h"
#include "f103_hw.h"

#define BRP 4u
#define TS1 15u
#define TS2 2u
_Static_assert(36000000u / (BRP * (1u + TS1 + TS2)) == 500000u, "500 кбит/с ровно");

volatile uint32_t ms;
static can_rx_t ring[16];
static volatile uint32_t head, tail;

void SysTick_Handler(void) { ms++; }
uint32_t plat_ms(void) { return ms; }
uint32_t UDSMillis(void) { return ms; }
uint32_t isotp_user_get_us(void) {
    uint32_t m, v;
    do { m = ms; v = SysTick->VAL; } while (m != ms);
    return m * 1000u + (71999u - v) / 72u;
}
void isotp_user_debug(const char *message, ...) { (void)message; }

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_CFGR_ADCPRE_DIV6;
    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_Config(72000u);                               /* 1 мс */
}

/* ABOM = 0: из отключения выходим сами (node_on_bus_off); ERRIE + BOFIE — прерывание SCE */
void can_init(uint8_t node_id) {
    RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN;
    RCC->APB1ENR |= RCC_APB1ENR_CAN1EN;
    AFIO->MAPR |= AFIO_MAPR_CAN_REMAP_REMAP2;             /* CAN на PB8/PB9 */
    GPIOB->CRH = (GPIOB->CRH & ~0xFFu) | 0x4u | (0xBu << 4);

    CAN1->MCR = CAN_MCR_INRQ | CAN_MCR_TXFP;              /* без ABOM, ящики по очереди записи */
    while (!(CAN1->MSR & CAN_MSR_INAK)) {
    }
    CAN1->BTR = ((TS2 - 1u) << 20) | ((TS1 - 1u) << 16) | (BRP - 1u);

    CAN1->FMR |= CAN_FMR_FINIT;
    CAN1->FA1R = 0;
    CAN1->FS1R = 1u;                                      /* фильтр 0 — 32 бита, режим списка */
    CAN1->FM1R = 1u;
    CAN1->sFilterRegister[0].FR1 = (ID_DIAG_REQ + node_id) << 21;   /* свой физический запрос */
    CAN1->sFilterRegister[0].FR2 = ID_DIAG_FUNC << 21;              /* и функциональный 0x7DF */
    CAN1->FFA1R = 0;                                      /* оба — в FIFO 0 */
    CAN1->FA1R = 1u;
    CAN1->FMR &= ~CAN_FMR_FINIT;

    CAN1->IER = CAN_IER_FMPIE0 | CAN_IER_ERRIE | CAN_IER_BOFIE | CAN_IER_TMEIE;
    NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
    NVIC_EnableIRQ(CAN1_SCE_IRQn);
    NVIC_EnableIRQ(USB_HP_CAN1_TX_IRQn);
    CAN1->MCR &= ~CAN_MCR_INRQ;
    while (CAN1->MSR & CAN_MSR_INAK) {
    }
}

int plat_can_send(uint32_t id, const uint8_t *d, uint8_t n) {
    uint32_t box = (CAN1->TSR & CAN_TSR_CODE) >> 24, lo = 0, hi = 0;
    if ((CAN1->TSR & (CAN_TSR_TME0 << box)) == 0u) return -1;   /* все три ящика заняты */
    for (uint32_t i = 0; i < n; i++) {
        if (i < 4u) lo |= (uint32_t)d[i] << (8u * i);
        else        hi |= (uint32_t)d[i] << (8u * (i - 4u));
    }
    CAN1->sTxMailBox[box].TDTR = n;
    CAN1->sTxMailBox[box].TDLR = lo;
    CAN1->sTxMailBox[box].TDHR = hi;
    CAN1->sTxMailBox[box].TIR = (id << 21) | CAN_TI0R_TXRQ;
    return 0;
}
int isotp_user_send_can(const uint32_t id, const uint8_t *data, const uint8_t size, void *arg) {
    (void)arg;
    return plat_can_send(id, data, size) == 0 ? ISOTP_RET_OK : ISOTP_RET_NOSPACE;
}

void USB_LP_CAN1_RX0_IRQHandler(void) {                  /* кадр — в кольцо, разбор в цикле */
    while (CAN1->RF0R & CAN_RF0R_FMP0) {
        can_rx_t *f = &ring[head % 16u];
        uint32_t lo = CAN1->sFIFOMailBox[0].RDLR, hi = CAN1->sFIFOMailBox[0].RDHR;
        f->id = CAN1->sFIFOMailBox[0].RIR >> 21;
        f->len = (uint8_t)(CAN1->sFIFOMailBox[0].RDTR & 0xFu);
        for (uint32_t i = 0; i < 4u; i++) {
            f->d[i] = (uint8_t)(lo >> (8u * i));
            f->d[i + 4u] = (uint8_t)(hi >> (8u * i));
        }
        CAN1->RF0R = CAN_RF0R_RFOM0;
        if (head - tail < 16u) head++;
    }
}
bool can_rx(can_rx_t *out) {
    if (tail == head) return false;
    *out = ring[tail % 16u];
    tail++;
    return true;
}

void USB_HP_CAN1_TX_IRQHandler(void) {                   /* кадр ушёл: связь есть */
    uint32_t tsr = CAN1->TSR;
    CAN1->TSR = CAN_TSR_RQCP0 | CAN_TSR_RQCP1 | CAN_TSR_RQCP2;
    if (tsr & (CAN_TSR_TXOK0 | CAN_TSR_TXOK1 | CAN_TSR_TXOK2)) node_on_tx_ok();
}

void CAN1_SCE_IRQHandler(void) {                          /* ошибки: BOFF — отключены от шины */
    if (CAN1->ESR & CAN_ESR_BOFF) {
        CAN1->IER &= ~CAN_IER_BOFIE;                      /* флаг держится до восстановления */
        node_on_bus_off();
    }
    CAN1->MSR = CAN_MSR_ERRI;
}
void plat_can_recover(void) {                             /* INRQ 1 → 0; дальше 128 × 11 бит сам bxCAN */
    CAN1->MCR |= CAN_MCR_INRQ;
    while (!(CAN1->MSR & CAN_MSR_INAK)) {
    }
    CAN1->MCR &= ~CAN_MCR_INRQ;
    CAN1->IER |= CAN_IER_BOFIE;
}

/* ── флеш-память: ключи, стирание страницы, запись полусловами (RM0008 3.3.3) ── */
static void fl_unlock(void) {
    if (FLASH->CR & FLASH_CR_LOCK) {
        FLASH->KEYR = 0x45670123u;
        FLASH->KEYR = 0xCDEF89ABu;
    }
}
static bool fl_done(void) {
    while (FLASH->SR & FLASH_SR_BSY) {
    }
    bool ok = (FLASH->SR & (FLASH_SR_PGERR | FLASH_SR_WRPRTERR)) == 0u;
    FLASH->SR = FLASH_SR_EOP | FLASH_SR_PGERR | FLASH_SR_WRPRTERR;
    return ok;
}
bool fl_erase_page(uint32_t addr) {
    if (addr < BOOT_BASE + BOOT_SIZE || addr >= 0x08010000u) return false;   /* загрузчик не трогаем */
    fl_unlock();
    FLASH->CR = FLASH_CR_PER;
    FLASH->AR = addr;
    FLASH->CR = FLASH_CR_PER | FLASH_CR_STRT;             /* процессор стоит 20…40 мс */
    bool ok = fl_done();
    FLASH->CR = FLASH_CR_LOCK;
    return ok;
}
bool fl_prog16(uint32_t addr, uint16_t v) {
    if (addr < BOOT_BASE + BOOT_SIZE || addr >= 0x08010000u) return false;
    fl_unlock();
    FLASH->CR = FLASH_CR_PG;
    *(volatile uint16_t *)addr = v;                       /* 40…70 мкс */
    bool ok = fl_done() && *(volatile uint16_t *)addr == v;
    FLASH->CR = FLASH_CR_LOCK;
    return ok;
}
const uint8_t *fl_ptr(uint32_t addr) { return (const uint8_t *)addr; }

/* перезапуск: слово в конце ОЗУ переживает сброс (startup его не трогает) */
void plat_reset(bool to_boot) {
    BOOT_REQ = to_boot ? BOOT_REQ_MAGIC : 0u;
    reset_pending = 1u;
}
void reset_if_pending(const UDSServer_t *srv) {           /* только когда ответ ушёл */
    if (reset_pending && !srv->requestInProgress && (CAN1->TSR & CAN_TSR_TME) == CAN_TSR_TME)
        NVIC_SystemReset();
}
volatile uint32_t reset_pending;

f103_app.c — приложение узла с адреса 0x08004000.

/* Раздел 198у: приложение узла на STM32F103C8 — с адреса 0x08004000.
 * PA0 — NTC (подтяжка 10 кОм), PA1 — датчик давления через делитель 2/3.
 * АЦП1 опрашивается программно раз в 10 мс (node_tick_1ms → plat_adc). */
#include "stm32f10x.h"
#include "node.h"
#include "f103_hw.h"

static UDSServer_t srv;
static UDSTpISOTpC_t tp;

static void adc_init(void) {
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_ADC1EN;
    GPIOA->CRL &= ~0xFFu;                                 /* PA0, PA1 — аналоговые входы */
    ADC1->SMPR2 = (7u << 0) | (7u << 3);                  /* 239,5 такта: высокоомный NTC */
    ADC1->CR2 = ADC_CR2_ADON;
    for (volatile int i = 0; i < 1000; i++) {
    }
    ADC1->CR2 |= ADC_CR2_RSTCAL;
    while (ADC1->CR2 & ADC_CR2_RSTCAL) {
    }
    ADC1->CR2 |= ADC_CR2_CAL;                             /* калибровка после включения */
    while (ADC1->CR2 & ADC_CR2_CAL) {
    }
}
uint16_t plat_adc(int ch) {
    ADC1->SQR3 = (uint32_t)ch;
    ADC1->CR2 |= ADC_CR2_ADON;                            /* повторная запись ADON — старт */
    while (!(ADC1->SR & ADC_SR_EOC)) {
    }
    return (uint16_t)ADC1->DR;
}

int main(void) {
    can_rx_t f;
    uint32_t last = 0;
    SCB->VTOR = APP_BASE;                                 /* таблица векторов приложения */
    clock_72mhz();
    adc_init();
    node_init(false);
    can_init(node.node_id);
    UDSServerInit(&srv);
    srv.fn = node_uds;
    srv.tp = &tp.hdl;
    UDSServerTpISOTpCInit(&tp, ID_DIAG_REQ + node.node_id, ID_DIAG_RESP + node.node_id, ID_DIAG_FUNC);
    __enable_irq();
    for (;;) {
        while (can_rx(&f))
            isotp_on_can_message(f.id == ID_DIAG_FUNC ? &tp.func_link : &tp.phys_link, f.d, f.len);
        UDSServerPoll(&srv);
        while (last != ms) {                              /* каждая миллисекунда по разу */
            last++;
            node_tick_1ms();
        }
        reset_if_pending(&srv);
    }
}

f103_boot.c — загрузчик узла.

/* Раздел 198у: загрузчик узла на STM32F103C8 — страницы 0…15 (16 КБ).
 * Годный образ и нет просьбы остаться — сразу переход в приложение.
 * Иначе — сервер UDS на тех же идентификаторах, сеанс программирования. */
#include "stm32f10x.h"
#include "node.h"
#include "f103_hw.h"

static UDSServer_t srv;
static UDSTpISOTpC_t tp;

uint16_t plat_adc(int ch) { (void)ch; return 0; }         /* в загрузчике датчиков нет */

static void jump_to_app(void) {
    uint32_t sp = *(const volatile uint32_t *)APP_BASE;
    uint32_t pc = *(const volatile uint32_t *)(APP_BASE + 4u);
    __disable_irq();
    SysTick->CTRL = 0;                                    /* ничего от загрузчика не тикает */
    NVIC->ICER[0] = 0xFFFFFFFFu;
    NVIC->ICER[1] = 0xFFFFFFFFu;
    NVIC->ICPR[0] = 0xFFFFFFFFu;
    NVIC->ICPR[1] = 0xFFFFFFFFu;
    RCC->APB1RSTR |= RCC_APB1RSTR_CAN1RST;                /* bxCAN — в состояние после сброса */
    RCC->APB1RSTR &= ~RCC_APB1RSTR_CAN1RST;
    SCB->VTOR = APP_BASE;
    __set_MSP(sp);
    __enable_irq();
    ((void (*)(void))pc)();
}

int main(void) {
    can_rx_t f;
    bool stay = BOOT_REQ == BOOT_REQ_MAGIC;
    BOOT_REQ = 0;
    if (!stay && boot_image_ok())                         /* запись о годности + CRC-32 образа */
        jump_to_app();
    clock_72mhz();
    node_init(true);
    can_init(node.node_id);
    UDSServerInit(&srv);
    srv.fn = node_uds;
    srv.tp = &tp.hdl;
    UDSServerTpISOTpCInit(&tp, ID_DIAG_REQ + node.node_id, ID_DIAG_RESP + node.node_id, ID_DIAG_FUNC);
    if (stay) srv.sessionType = UDS_LEV_DS_PRGS;          /* пришли по 10 02 из приложения */
    __enable_irq();
    for (;;) {
        while (can_rx(&f))
            isotp_on_can_message(f.id == ID_DIAG_FUNC ? &tp.func_link : &tp.phys_link, f.d, f.len);
        UDSServerPoll(&srv);
        reset_if_pending(&srv);
    }
}

boot.ld — скрипт компоновщика загрузчика.

/* Раздел 198у: STM32F103C8, boot. Флеш-память 64 КБ страницами по 1 КБ:
 *   0x08000000 загрузчик 16 КБ | 0x08004000 приложение 45 КБ |
 *   0x0800F400 запись о годном образе 1 КБ | 0x0800F800 эмуляция EEPROM 2 × 1 КБ.
 * ОЗУ 20 КБ; последнее слово 0x20004FFC — просьба «остаться в загрузчике». */
ENTRY(Reset_Handler)

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

_estack     = ORIGIN(RAM) + LENGTH(RAM);
_stack_size = 0x600;

SECTIONS
{
  .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
  .text :
  {
    *(.text .text.*)
    *(.rodata .rodata.*)
    . = ALIGN(4);
  } > FLASH
  .data :
  {
    _sdata = .;
    *(.data .data.*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH
  _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")
}

app.ld — скрипт компоновщика приложения.

/* Раздел 198у: STM32F103C8, app. Флеш-память 64 КБ страницами по 1 КБ:
 *   0x08000000 загрузчик 16 КБ | 0x08004000 приложение 45 КБ |
 *   0x0800F400 запись о годном образе 1 КБ | 0x0800F800 эмуляция EEPROM 2 × 1 КБ.
 * ОЗУ 20 КБ; последнее слово 0x20004FFC — просьба «остаться в загрузчике». */
ENTRY(Reset_Handler)

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08004000, LENGTH = 45K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K - 4
}

_estack     = ORIGIN(RAM) + LENGTH(RAM);
_stack_size = 0x600;

SECTIONS
{
  .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
  .text :
  {
    *(.text .text.*)
    *(.rodata .rodata.*)
    . = ALIGN(4);
  } > FLASH
  .data :
  {
    _sdata = .;
    *(.data .data.*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH
  _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ф «Мини-проект: USB-флешка на B-U585I-IOT02A — прибор отдаёт журнал как диск» — прибор без драйверов: подключил кабель, и журнал виден файлом на диске.

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