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

198э. Мини-проект: датчик температуры по Bluetooth LE на P-NUCLEO-WB55 — два ядра, IPCC и свой сервис GATT

Bluetooth LE в справочнике уже был трижды. В разделе 158 сервер GATT поднимался на ESP32 со стеком NimBLE: там стек и программа живут на одном процессоре. В разделе 189 BLE разобран со стороны телефона — MTU, одна запись за раз, разрешения. В разделе 94б STM32WB55 встретился внутри Flipper Zero, но только как «процессор с радио»: что внутри стека и как до него достучаться, там не разбиралось.

Здесь — законченный прибор на одной плате P-NUCLEO-WB55: датчик температуры, который телефон находит по имени, читает и на который подписывается. Главное отличие от ESP32 — у STM32WB55 два ядра. Программа живёт на Cortex-M4, стек Bluetooth — на отдельном Cortex-M0+, и разговаривают они через общую память и блок IPCC. Раздел о том, как устроен этот разговор, как выглядит свой сервис GATT и что именно летит в эфир, когда телефон обходит таблицу прибора.

Все прогоны раздела сделаны на компьютере. Код ядра M4 — сервис, команды радиоядру, драйвер IPCC — настоящий, тот же, что собирается под STM32WB55. Под ним вместо кристалла три модели: блок IPCC по справочному руководству, радиоядро со своей таблицей GATT и «телефон» — клиент GATT, который ведёт журнал всех пакетов ATT. Обмен между ядрами в модели — свой простой формат, а не формат библиотеки ST: что на его месте в настоящем приборе, показано в 198э.4.

Опоры: справочное руководство RM0434 (rev. 12) — IPCC, загрузка второго ядра, флеш и ОЗУ, АЦП и датчик температуры; даташит DS11929 (rev. 16) — частоты, память, калибровки; описание платы DB3659 и файлы платы stm32wbxx_nucleo.h из пака Keil STM32WBxx_DFP 1.4.0; заголовок CMSIS stm32wb55xx.h, шаблон компоновки stm32wb55xx_flash_cm4.ld того же пака. Как устроен разговор со стеком ST в живом приборе — по исходникам Flipper Zero, github.com/flipperdevices/flipperzero-firmware (коммит 7f0b6e1, GPL-3.0), каталог targets/f7/ble_glue. Коды ATT, номера типов и полей рекламы сверены с заголовками NimBLE (github.com/espressif/esp-nimble, коммит a9a4c70, Apache-2.0) и ESP-IDF v5.4.1 (коммит 4c2820d, Apache-2.0). Сборка на компьютере — gcc 12.2.0 в stm-sys:1, прошивка — arm-none-eabi-gcc 12.2.1 в stm-arm:1. Регистры STM32WB — приложение A17.

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

Оригинал Перевод
CPU1, CPU2 ядро M4 с программой и ядро M0+ с радиостеком
wireless stack, FUS (Firmware Upgrade Service) прошивка радиоядра от ST и её установщик
IPCC, channel, occupied / free блок связи процессоров, канал, «занят» / «свободен»
Simplex, Half-duplex канал в одну сторону; команда и ответ в одном буфере
mailbox, shared memory (SRAM2a) общая память, в которой лежат сообщения
ACI, HCI команды стеку ST и стандартный интерфейс контроллера Bluetooth
GATT, ATT, attribute handle таблица сервисов, протокол атрибутов, номер атрибута
CCCD (0x2902) описатель подписки: клиент пишет 01 00, чтобы получать уведомления
notification / indication уведомление без подтверждения / индикация с подтверждением
Environmental Sensing (0x181A), Temperature (0x2A6E) стандартные сервис датчиков среды и характеристика температуры
advertising, AD type реклама — посылки до подключения; поле рекламы

198э.1 Плата и замысел

STM32WB55 и путь значения температуры: справа ядро Cortex-M4 с приложением — АЦП на каналах 17 и 0, сервис tsvc198e, команды bleif198e и драйвер ipcc198e с прерываниями 44 и 45; посередине IPCC и SRAM2a с двумя каналами — команда от M4 с ответом в том же буфере и событие от M0+; слева ядро Cortex-M0+ на 32 МГц со стеком BLE от ST и таблицей GATT; ещё левее телефон, который обходит таблицу, подписывается и получает уведомления; снизу таблица атрибутов прибора с номерами 0x0001–0x0010 из прогона модели и пример уведомления 1b 0c 00 fc 08

P-NUCLEO-WB55 — набор из двух плат (описание DB3659): Nucleo-68 со STM32WB55RG в корпусе VFQFPN68 и маленький USB-донгл со STM32WB55CG. Здесь нужна только Nucleo. На ней:

Что Где Откуда
три светодиода LED1 синий — PB5, LED2 зелёный — PB0, LED3 красный — PB1 stm32wbxx_nucleo.h
три кнопки и сброс SW1 — PC4, SW2 — PD0, SW3 — PD1 stm32wbxx_nucleo.h
консоль USART1: TX — PB6, RX — PB7, AF7 (COM1 платы) stm32wbxx_nucleo.h
программатор ST-LINK/V2-1: флеш-накопитель, виртуальный COM-порт, отладка DB3659
антенна на плате или разъём SMA под неё DB3659
питание от USB ST-LINK или внешнее, гнездо под CR2032 DB3659

Схемы платы в документах нет — только описание и файлы платы, поэтому что к какому выводу идёт, взято из stm32wbxx_nucleo.h и с самой платой не сверялось.

Чем WB55 отличается от опорных плат справочника — BluePill на F103 и Discovery на F407:

F103 F407 STM32WB55RG
ядро программы Cortex-M3, 72 МГц Cortex-M4F, 168 МГц Cortex-M4F, до 64 МГц
второе ядро нет нет Cortex-M0+, 32 МГц, только для радио
флеш / ОЗУ 64 КБ / 20 КБ 1 МБ / 192 КБ 1 МБ / 256 КБ, часть занимает радиоядро
радио нет нет 2,4 ГГц: Bluetooth LE 5.4 и 802.15.4

Частоты ядер и память WB55 — по DS11929. Радио: чувствительность −96 дБм для BLE на 1 Мбит/с, мощность до +6 дБм с шагом 1 дБ (DS11929, первая страница) — в разделе это не измерялось.

Что делает прибор:

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


198э.2 Два ядра: кто за что отвечает

Стек — чужая прошивка. На ядре M0+ работает двоичная прошивка ST, исходников у неё нет. У Flipper Zero в fbt_options.py выбран облегчённый вариант:

COPRO_STACK_BIN = "stm32wb5x_BLE_Stack_light_fw.bin"

Кроме стека у радиоядра есть установщик FUS — он ставит, удаляет и обновляет стек. Ядро M4 узнаёт, кто сейчас работает, из первого события после запуска: в ble_glue.c Flipper сравнивает поле sysevt_ready_rsp с WIRELESS_FW_RUNNING (работает стек) и FUS_FW_RUNNING (работает установщик). Команды установщику — SHCI_C2_FUS_FwUpgrade, SHCI_C2_FUS_FwDelete, SHCI_C2_FUS_StartWs — там же.

Защищённая память. Стек лежит в верхней части флеша, и ядро M4 его не видит. Где начинается защищённая часть, говорит байт настройки SFSA — номер первой 4-килобайтной страницы (RM0434, 3.4.1, адрес 0x1FFF 8070). Так же устроено ОЗУ: поля SBRSA и SNBRSA (адрес 0x1FFF 8078) задают начало защищённых частей SRAM2a и SRAM2b с шагом 1 КБ. Как это выглядит со стороны прошивки Flipper, разобрано в 94б.2.

Память на двоих. По DS11929 у WB55 три блока ОЗУ: SRAM1 до 192 КБ с адреса 0x2000 0000, SRAM2a — 32 КБ с 0x2003 0000 (сохраняется в Standby) и SRAM2b — 32 КБ с 0x2003 8000. Сообщения между ядрами лежат в SRAM2a. Шаблон компоновки ST для ядра M4 (stm32wb55xx_flash_cm4.ld) делит память так:

Область Начало Размер Что там
FLASH 0x0800 0000 512K программа M4; выше — место под стек радиоядра
RAM1 0x2000 0008 0x2FFF8 данные и стек M4
RAM_SHARED 0x2003 0000 10K MAPPING_TABLE, MB_MEM1, MB_MEM2 — общая с M0+ память

Как радиоядро находит эту таблицу? В байтах настройки есть поле IPCCDBA (адрес 0x1FFF 8068): смещение начала буфера в SRAM2 в двойных словах. Его заводское значение — 0 (RM0434, 3.4.1), то есть начало SRAM2a — ровно туда шаблон кладёт MAPPING_TABLE. Поэтому в своих сценариях компоновки эту секцию держат первой.

Запуск второго ядра. После сброса M0+ стоит. Он стартует, только когда ядро M4 поставит бит C2BOOT в PWR_CR4 (RM0434, 2.4). Flipper перед этим посылает событие — на случай, если бит уже поставил загрузчик и повторная запись ничего не даст (комментарий в hw_ipcc.c). Своя функция раздела делает то же:

void cpu2_boot(void)
{
    __SEV();
    __WFE();
    PWR->CR4 |= PWR_CR4_C2BOOT;
}

Порядок запуска в живом приборе — по ble_glue.c и ble_app.c Flipper: общая таблица и очереди (TL_Init, shci_init, TL_MM_Init, TL_Enable), запуск ядра, ожидание события «готов», настройка стека SHCI_C2_BLE_Init и только потом GAP, GATT, свои сервисы и реклама. Параметры стека — одна структура; Flipper проверяет её размер _Static_assert(sizeof(SHCI_C2_Ble_Init_Cmd_Packet_t) == 58, …) с припиской, что поля сверены со стеком v1.17.3. Сколько чего заказано у Flipper (app_conf.h):

Параметр Значение Что это
CFG_BLE_NUM_LINK 2 одновременных подключений
CFG_BLE_NUM_GATT_SERVICES 8 сервисов; по комментарию — «2 плюс свои», GAP и GATT стек добавляет сам
CFG_BLE_NUM_GATT_ATTRIBUTES 68 атрибутов без объявлений сервисов; «9 плюс свои»
CFG_BLE_MAX_ATT_MTU 256 + 128 + 16 + 8 + 4 + 2 = 414 наибольший MTU
CFG_BLE_ATT_VALUE_ARRAY_SIZE 1344 байт под все значения

По тем же правилам нашему прибору хватит 2 + 2 = 4 сервисов и 9 + 5 = 14 атрибутов: у температуры объявление, значение и подписка, у светодиода — объявление и значение.


198э.3 IPCC: шесть флагов в каждую сторону

IPCC не передаёт данные. Он только поднимает флаги: «я положил сообщение» и «я его забрал». Сами сообщения лежат в общей памяти, и где именно, блок не знает (RM0434, 37.1). Каналов шесть в каждую сторону, у каждого канала — флаг CHnF: 1 — занят, данные можно читать получателю, 0 — свободен, можно писать отправителю.

Регистры со стороны ядра M4 (stm32wb55xx.h: IPCC_C1 — с адреса IPCC_BASE, IPCC_C2 — на 0x10 дальше):

Регистр Смещение Биты Что делает
IPCC_C1CR 0x000 16 TXFIE, 0 RXOIE разрешить прерывания «свободен» и «занят»
IPCC_C1MR 0x004 21:16 CHnFM, 5:0 CHnOM маски по каналам; после сброса 0xFFFF FFFF — всё закрыто
IPCC_C1SCR 0x008 21:16 CHnS, 5:0 CHnC занять свой канал; освободить канал от M0+; читается как 0
IPCC_C1TOC2SR 0x00C 5:0 CHnF флаги каналов M4 → M0+
IPCC_C2TOC1SR 0x01C 5:0 CHnF флаги каналов M0+ → M4

Писать в регистры IPCC можно только словами по 32 бита: байт или полуслово не сработают и даже не вызовут ошибку шины (RM0434, 37.4). У ядра M4 два прерывания: IPCC_C1_RX_IRQn = 44 («какой-то канал от M0+ занят и не замаскирован») и IPCC_C1_TX_IRQn = 45 («какой-то мой канал освободился и не замаскирован»).

Режимов у канала два (RM0434, 37.3.2–37.3.3):

Отправка команды и ожидание ответа — ipcc_cmd из ipcc198e.c:

int ipcc_cmd(const uint8_t *msg, uint8_t *rsp, unsigned rsp_max)
{
    unsigned len = 2u + msg[1];
    if (len > MSG_MAX)
        return -2;
    if (ipcc_hw_tx_flags() & CH_BIT(CH_CMD))
        return -1;                                       /* канал ещё занят: буфер трогать нельзя */
    memcpy(mem->cmd, msg, len);
    cmd_wait = 1;
    ipcc_hw_scr(CH_BIT(CH_CMD) << 16);                   /* CHnS: «занят» — у M0+ прерывание RX occupied */
    mr_set(0, CH_BIT(CH_CMD) << 16);                     /* открыть «свободен» (CHnFM = 0): это будет ответ */
    for (unsigned n = 0; cmd_wait; n++) {
        if (n >= IPCC_WAIT_MAX)
            return -3;                                   /* радиоядро молчит */
        ipcc_hw_wait();
    }
    unsigned rl = 2u + mem->cmd[1];
    if (rl > rsp_max || mem->cmd[0] != (msg[0] | RSP_FLAG))
        return -4;
    memcpy(rsp, mem->cmd, rl);
    return (int)rl;
}

Порядок важен: сначала данные в память, потом флаг. Маску «свободен» открываем после постановки флага — иначе прерывание сработает сразу, ведь до CHnS канал был свободен. Обработчики двух прерываний:

void ipcc_tx_isr(void)
{
    uint32_t mr = ipcc_hw_mr();
    uint32_t freed = ~ipcc_hw_tx_flags() & ~(mr >> 16) & 0x3Fu;
    if (freed & CH_BIT(CH_CMD)) {
        ipcc_hw_set_mr(mr | (CH_BIT(CH_CMD) << 16));     /* закрыть «свободен», иначе прерывание не кончится */
        cmd_wait = 0;
    }
}

void ipcc_rx_isr(void)
{
    uint32_t mr = ipcc_hw_mr();
    uint32_t busy = ipcc_hw_rx_flags() & ~mr & 0x3Fu;
    if (!(busy & CH_BIT(CH_EVT)))
        return;
    unsigned depth = evq_head - evq_tail;
    if (depth >= EVQ_LEN) {                              /* очередь полна: событие оставить в канале */
        ipcc_hw_set_mr(mr | CH_BIT(CH_EVT));             /* CHnOM = 1 — M0+ подождёт, ничего не теряется */
        return;
    }
    unsigned len = 2u + mem->evt[1];
    memcpy(evq[evq_head % EVQ_LEN], mem->evt, len > MSG_MAX ? MSG_MAX : len);
    evq_head++;
    if (depth + 1u > ipcc_evq_max)
        ipcc_evq_max = depth + 1u;
    ipcc_hw_scr(CH_BIT(CH_EVT));                         /* CHnC: забрали — канал свободен для M0+ */
}

Событие в обработчике только копируется в очередь на 8 сообщений, а разбирает его главный цикл. Канал при этом сразу освобождается, и M0+ может класть следующее. Если очередь полна, канал остаётся занятым, а прерывание «занят» закрывается маской: радиоядро подождёт, пока главный цикл не разгребёт очередь, и событие не пропадёт.

Драйвер не трогает регистры напрямую: все обращения — через семь функций ipcc_hw_*. На кристалле они пишут в IPCC_C1->SCR и соседей (ipcc_hw_wb198e.c), на компьютере — в модель (ipcc_model198e.c). В модели линия прерывания — уровень, как в кристалле: пока условие держится, обработчик вызывается снова.

Опыты с моделью IPCC:

Что Результат
старт приложения: 5 команд радиоядру 5 ответов, 10 шагов модели, 5 вызовов обработчиков
3 команды подряд через драйвер выполнены 3 из 3
3 команды подряд «в лоб», без проверки флага выполнена 1 из 3 — последняя, значение 0x0912
обработчик «свободен» не закрывает маску команда выполнена, но 1000 вызовов за шаг — «буря»; и так на каждом из 10 следующих шагов
12 событий подряд, главный цикл занят у M4 в очереди 8, у M0+ ждут 12, дошло 9; после разбора — 12 из 12, потеряно 0
радиоядро не запущено ipcc_cmd вернул −3 после 1000 ожиданий

Отправка «в лоб» из опыта — так писать нельзя:

        for (int i = 0; i < 3; i++) {
            uint8_t m[7] = {CMD_UPDATE, 5, (uint8_t)ts.ch_temp, (uint8_t)(ts.ch_temp >> 8), 2, (uint8_t)(0x10 + i), 0x09};
            memcpy(shm.cmd, m, sizeof m);              /* не глядя на CH1F */
            ipcc_hw_scr(CH_BIT(CH_CMD) << 16);
        }

Повторная запись CHnS в уже занятый канал ничего не делает: флаг один. Радиоядро увидело одну команду и прочитало буфер, в котором к тому времени лежала последняя. Две команды пропали без следа и без ошибки.


198э.4 Команды радиоядру: своя модель и что на её месте у ST

Приложению нужно от радиоядра четыре вещи: завести сервис, завести характеристику, обновить значение и включить рекламу. В обратную сторону идут четыре события: подключились, отключились, клиент записал атрибут, договорились о MTU. В модели это свои сообщения (msg198e.h): байт кода, байт длины, параметры младшим байтом вперёд. У ST — команды ACI и события HCI поверх своей транспортной библиотеки. Соответствие — по тому, что вызывает и разбирает Flipper:

Модель (msg198e.h) Стек ST, как у Flipper Где у Flipper
CMD_ADD_SERVICE aci_gatt_add_service furi_ble/gatt.c
CMD_ADD_CHAR aci_gatt_add_char (+ aci_gatt_add_char_desc) furi_ble/gatt.c
CMD_UPDATE aci_gatt_update_char_value furi_ble/gatt.c
CMD_ADVERTISE aci_gap_set_discoverable gap.c
EVT_CONNECTED HCI_LE_CONNECTION_COMPLETE_SUBEVT_CODE gap.c
EVT_DISCONNECTED HCI_DISCONNECTION_COMPLETE_EVT_CODE gap.c
EVT_ATTR_WRITTEN ACI_GATT_ATTRIBUTE_MODIFIED_VSEVT_CODE services/serial_service.c
EVT_MTU ACI_ATT_EXCHANGE_MTU_RESP_VSEVT_CODE gap.c

Приложение (tsvc198e.c) зовёт только bleif_* и не знает, что под ними. Чтобы перенести его на стек ST, переписывается один bleif198e.c — сами функции ACI берутся из пакета STM32CubeWB (у Flipper он подключён как lib/stm32wb_copro, версия 1.20.0 в fbt_options.py). С пакетом ST раздел не собирался.

У Flipper есть важная мелочь про номера атрибутов. Обработчик записи в serial_service.c различает, куда писал клиент, так:

            if(attribute_modified->Attr_Handle ==
               serial_svc->chars[SerialSvcGattCharacteristicRx].handle + 2) {

То есть aci_gatt_add_char возвращает номер объявления характеристики, значение лежит на номер дальше, описатель — на два. Модель раздаёт номера так же, и приложение пользуется тем же правилом: подписка на температуру — ts.ch_temp + 2, значение светодиода — ts.ch_led + 1.

Заводит сервисы tsvc_init:

int tsvc_init(uint16_t thr_c100, uint16_t heartbeat_s)
{
    int r = 0;
    memset(&ts, 0, sizeof ts);
    ts.thr = thr_c100;
    ts.heartbeat = heartbeat_s;
    ts.mtu = 23;
    r |= bleif_add_service(U_ESS, 2, 4, &ts.svc_ess);      /* объявление + характеристика из трёх */
    r |= bleif_add_char(ts.svc_ess, U_TEMP, 2, 0x02 | 0x10, 2, 1, 0, &ts.ch_temp);   /* чтение, уведомление */
    r |= bleif_add_service(U_CTL, 16, 3, &ts.svc_ctl);
    r |= bleif_add_char(ts.svc_ctl, U_LED, 16, 0x02 | 0x08, 1, 1, 1, &ts.ch_led);    /* чтение, запись */
    r |= bleif_advertise(TS_ADV_MIN, TS_ADV_MAX, TS_NAME);
    return r;
}

Третий параметр bleif_add_service — сколько номеров занимает сервис, как Max_Attribute_Records у ST. Сервису температуры нужно 4: объявление сервиса, объявление характеристики, значение и подписка. Flipper для батареи с двумя характеристиками заказывает 8 при нужных 7. В модели сервис занимает ровно столько номеров, сколько заказано; как раздаёт номера сам стек ST, здесь не сверялось. Свойства 0x02 (чтение), 0x08 (запись) и 0x10 (уведомление) — биты объявления характеристики, как BLE_GATT_CHR_PROP_* в ble_gatt.h NimBLE.


198э.5 Таблица прибора и реклама

После старта в модели 16 атрибутов. Первые два сервиса заводит само радиоядро, как и стек ST (aci_gap_init и aci_gatt_init у Flipper):

Номер Что UUID
0x0001–0x0005 сервис GAP: имя (чтение), внешний вид (чтение) 0x1800; 0x2A00, 0x2A01
0x0006–0x0009 сервис GATT: Service Changed (индикация) и его подписка 0x1801; 0x2A05, 0x2902
0x000A–0x000D Environmental Sensing: объявление 0x000B, значение 0x000C, подписка 0x000D 0x181A; 0x2A6E, 0x2902
0x000E–0x0010 свой сервис: объявление 0x000F, значение «светодиода» 0x0010 128-битные

Номера 16-битных UUID сверены с заголовками: 0x1800, 0x2A00, 0x2A01 — ble_svc_gap.h, 0x1801 — ble_gatt.h, 0x2A05 — ble_svc_gatt.h, 0x2902 — ble_gatt.h NimBLE; 0x181A — esp_gatt_defs.h, 0x2A6E — mesh/uuid.h ESP-IDF. Значение характеристики температуры — sint16 в сотых градуса младшим байтом вперёд: 23,00 °C — это fc 08. Так это значение описывает реестр характеристик Bluetooth SIG; с самим реестром текст не сверялся, а раздел пользуется этим форматом и на стороне прибора, и на стороне «телефона».

128-битный UUID свой: сервис 7a1e0198-3c55-4b0e-9d1f-2f5ab3c0e101, светодиод — тот же номер с …e102 на конце. В эфире все 16 байт идут задом наперёд, от последнего к первому:

static const uint8_t U_CTL[16] = {0x01, 0xe1, 0xc0, 0xb3, 0x5a, 0x2f, 0x1f, 0x9d,
                                  0x0e, 0x4b, 0x55, 0x3c, 0x98, 0x01, 0x1e, 0x7a};

Так же записаны UUID в serial_service_uuid.inc у Flipper. Это не тот порядок, что у GUID в Windows или uuid.bytes_le в Python: там переставлены только первые три поля, а остальное идёт как в строке.

Реклама — 18 байт из 31 возможного (BLE_HCI_MAX_ADV_DATA_LEN в hci_common.h NimBLE):

02 01 06  0a 09 57 42 35 35 2d 54 31 39 38  03 03 1a 18

Три поля по схеме «длина, тип, данные»: флаги 0x06 (общий поиск, без классического Bluetooth), полное имя WB55-T198 (тип 0x09) и полный список 16-битных сервисов — 0x181A (тип 0x03). Типы — как BLE_HS_ADV_TYPE_* в ble_hs_adv.h. Список сервисов в рекламе нужен, чтобы телефон мог искать приборы по сервису, не подключаясь. Запас небольшой: имя из 22 символов модель ещё принимает, из 23 — отказ ST_BAD_PARAM, реклама не влезает в 31 байт. Длинное имя переносят в ответ на запрос сканера — Flipper кладёт туда свои данные функцией hci_le_set_scan_response_data.

Интервал рекламы — 0x0080…0x00A0 в шагах 0,625 мс, то есть 80…100 мс. Это быстрый режим Flipper (gap.c); после него Flipper переходит на медленный — 0x0640…0x0FA0, 1000…2500 мс. Сколько тока это стоит, в разделе не мерялось.


198э.6 Что видит телефон: пакеты ATT

После подключения телефон ничего не знает о приборе и обходит таблицу. «Телефон» в модели делает это так же, как типичный клиент GATT: один запрос, ответ, следующий запрос. Первые шесть пакетов из журнала прогона (C> — запрос клиента, S< — ответ сервера):

C> 10 01 00 ff ff 00 28
S< 11 06 01 00 05 00 00 18 06 00 09 00 01 18 0a 00 0d 00 1a 18
C> 10 0e 00 ff ff 00 28
S< 11 14 0e 00 10 00 01 e1 c0 b3 5a 2f 1f 9d 0e 4b 55 3c 98 01 1e 7a
C> 10 11 00 ff ff 00 28
S< 01 10 11 00 0a

Разбор:

Дальше то же для характеристик (Read By Type 08 с типом 0x2803 внутри каждого сервиса) и для описателей (Find Information 04) — только у тех характеристик, что умеют уведомлять. Объявление температуры в ответе выглядит как 0b 00 12 0c 00 6e 2a: номер 0x000B, свойства 0x12 (чтение и уведомление), значение в 0x000C, UUID 0x2A6E. Коды операций и ошибок — как BLE_ATT_OP_* и BLE_ATT_ERR_* в ble_att.h NimBLE, форматы ответов Find Information (1 — 16-битные типы, 2 — 128-битные) — как в ble_att_cmd_priv.h.

Обход таблицы Запросов Байт от телефона Байт от прибора
MTU 23, без обмена MTU 13 87 136
сначала обмен MTU 247 14 (13 + обмен) 90 139

Для такой маленькой таблицы MTU ничего не меняет: все ответы и так короче 23 байт, а 16-битные и 128-битные записи в один ответ не складываются. MTU важен для длинных значений — раздел 158.5. Сервер модели готов принять 247 байт, телефон в модели просит столько же.

Чтение, запись и ошибки — из того же журнала:

Запрос Ответ
чтение 0x0003 (имя) 0b и WB55-T198
чтение 0x000C до первого измерения 0b 00 00
чтение 0x0030 — такого номера нет ошибка 0x01, Invalid Handle
запись в значение температуры 0x000C ошибка 0x03, Write Not Permitted
запись 3 байт в подписку 0x000D ошибка 0x0D, Invalid Attribute Value Length
запись 2 байт в «светодиод» (нужен 1) ошибка 0x0D
Read By Group Type с типом 0x2803 ошибка 0x10, Unsupported Group Type
код 0x0E (Read Multiple), модель его не знает ошибка 0x06, Request Not Supported
Write Command 52 с 2 байтами в «светодиод» ответа нет, события у M4 нет
запись 01 в «светодиод» 0x0010 13 — Write Response; у M4 событие, LED1 горит

Предпоследняя строка — ловушка: у команды записи без ответа (52) ответа нет вообще, и ошибочная запись просто теряется. Телефон не узнает, что «светодиод» не включился.


198э.7 Подписка, уведомления и температура кристалла

Подписка. Клиент пишет 01 00 в описатель 0x000D. Пока этого нет, обновления значения в эфир не уходят — сервер модели проверяет подписку сам:

int att_update(uint16_t char_handle, const uint8_t *val, unsigned len)
{
    struct attr *d = at(char_handle), *v = at((uint16_t)(char_handle + 1u));
    if (!d || !is16(d, UUID_CHARACTERISTIC) || !v || len > v->max_len)
        return -2;
    memcpy(v->val, val, len);
    v->len = (uint8_t)len;
    struct attr *c = at((uint16_t)(char_handle + 2u));
    if (c && is16(c, UUID_CCCD) && (get16(c->val) & 1u)) {   /* клиент подписан на уведомления */
        uint8_t p[ATT_MTU_SERVER];
        unsigned n = len < mtu - 3u ? len : mtu - 3u;         /* уведомление не длиннее MTU − 3 */
        p[0] = OP_NOTIFY;
        put16(&p[1], (uint16_t)(char_handle + 1u));
        memcpy(&p[3], val, n);
        tx(p, 3u + n);
        att_notifs++;
    }
    return 0;
}

В прогоне: 5 обновлений до подписки — 0 уведомлений; после записи 01 00 — уведомление 1b 0c 00 fc 08 (код 0x1B, номер 0x000C, 2300 сотых — 23,00 °C); после записи 00 00 — снова тишина. После разрыва связи и нового подключения подписка в модели сброшена и уведомлений нет, пока телефон не подпишется снова. Хранит ли стек ST подписку связанного (bonded) телефона между подключениями — не проверено.

Температура кристалла. Формула — RM0434, 16.4.30: линейная интерполяция между заводскими точками TS_CAL1 (30 °C) и TS_CAL2 (130 °C). Адреса калибровок — DS11929, таблицы 11 и 12: TS_CAL1 — 0x1FFF 75A8, TS_CAL2 — 0x1FFF 75CA, VREFINT — 0x1FFF 75AA. Подвох в том, что датчик калибровали при VREF+ = 3,0 В, VREFINT — при 3,6 В, а питание на плате обычно другое — в опытах 3,3 В (по схеме Nucleo это не сверялось). RM прямо требует пересчитать код датчика к питанию калибровки. Само питание узнаём по VREFINT:

int32_t tsvc_temp_c100(uint32_t ts_data, uint32_t vref_data, uint16_t ts_cal1, uint16_t ts_cal2, uint16_t vref_cal)
{
    int64_t num = ((int64_t)ts_data * vref_cal * 36 - (int64_t)ts_cal1 * vref_data * 30) * (130 - 30) * 100;
    int64_t den = (int64_t)(ts_cal2 - ts_cal1) * vref_data * 30;
    if (den <= 0)
        return INT32_MIN;
    num += num >= 0 ? den / 2 : -den / 2;                  /* округление до ближайшего */
    return (int32_t)(num / den) + 30 * 100;
}

Формула зависит только от отношения кода датчика к коду VREFINT, поэтому в неё можно подавать сразу суммы 8 отсчётов — точность от этого только выше. Модель АЦП в прогоне взята по типовым значениям DS11929, таблица 85 и раздел про VREFINT: 0,76 В при 30 °C, 2,5 мВ/°C, VREFINT 1,212 В. Заводские коды модели вышли 1037, 1379 и 1379. Что получилось:

Опыт Результат
−20…85 °C через 5 °C, питание 3,3 В, без шума ошибка не больше 0,42 °C
один шаг АЦП при 3,3 В 0,322 °C
забыть про питание (считать, что 3,0 В), 25 °C −1,87 °C, ошибка −26,87 °C
питание 3,0 В, 25 °C 25,17 °C

Один шаг АЦП — треть градуса: 3,3 В / 4095 = 0,806 мВ, а датчик даёт 2,5 мВ на градус. Отсюда и среднее из 8 отсчётов, и порог в правиле уведомлений.

Правило уведомлений — tsvc_second, раз в секунду:

void tsvc_second(int32_t t)
{
    ts.sec++;
    if (t > 32767)
        t = 32767;
    if (t < -32768)
        t = -32768;
    int32_t d = t > ts.sent ? t - ts.sent : ts.sent - t;
    if (!ts.subscribed) {
        if (t != ts.sent || ts.updates == 0)
            send(t);
    } else if (ts.force || d >= ts.thr || ts.sec - ts.sent_at >= ts.heartbeat) {
        ts.force = 0;
        send(t);
    }
}

Без подписки значение просто держится свежим для чтения — в эфир оно не уходит. С подпиской уведомление идёт, если температура ушла на порог или с прошлого прошло heartbeat секунд — тогда телефон отличит «ничего не меняется» от «прибор пропал». Сразу после подписки — одно уведомление вне очереди (force).

Опыт — 600 с: 120 с при 22 °C, за 240 с рост до 30 °C, 240 с при 30 °C; шум АЦП в модели — случайный ±2 шага на каждый отсчёт; телефон подписывается на 10-й секунде. Ошибка — расстояние от последнего полученного телефоном значения до настоящей температуры:

Порог Раз в Шум Уведомлений Ошибка: наибольшая / средняя
0 (каждую секунду) 10 с ±2 590 0,79 / 0,213 °C
0,10 °C 10 с ±2 433 0,81 / 0,216 °C
0,50 °C 10 с ±2 88 0,76 / 0,196 °C
0,50 °C 10 с нет 59 0,33 / 0,132 °C
1,00 °C 10 с ±2 59 0,60 / 0,198 °C
0,50 °C 60 с ±2 61 0,65 / 0,233 °C

Порог 0,10 °C ниже шума: 433 уведомления, почти столько же, сколько при отправке каждую секунду, а точнее не стало. Порог 0,50 °C срезает уведомления в 6,7 раза (590 → 88) при той же ошибке. При пороге 1,00 °C уведомлений 59 — это ровно «раз в 10 секунд» за 590 секунд: порог почти не срабатывает, работает только напоминание.


198э.8 Прошивка ядра M4 на регистрах и размеры

fw198e.c — прошивка без HAL на CMSIS: такт MSI 4 МГц, как после сброса (RM0434, 8.2), SysTick на 1 мс, LED1 на PB5, консоль USART1 на PB6, АЦП. Свой начальный код и таблица векторов на 16 + 46 записей — до IPCC_C1_TX_IRQn = 45 включительно. АЦП включается по шагам RM0434, 16.4.6–16.4.9:

static void adc_init(void)
{
    RCC->AHB2ENR |= RCC_AHB2ENR_ADCEN;
    (void)RCC->AHB2ENR;
    ADC1_COMMON->CCR = ADC_CCR_CKMODE_0 | ADC_CCR_TSEN | ADC_CCR_VREFEN;  /* HCLK/1, датчик и VREFINT */
    ADC1->CR &= ~ADC_CR_DEEPPWD;
    ADC1->CR |= ADC_CR_ADVREGEN;
    delay_ms(1);                                    /* tADCVREG_STUP — до 20 мкс (DS11929) */
    ADC1->CR |= ADC_CR_ADCAL;
    while (ADC1->CR & ADC_CR_ADCAL)
        ;
    ADC1->SMPR1 = 4u << ADC_SMPR1_SMP0_Pos;         /* 47,5 такта = 11,9 мкс при 4 МГц */
    ADC1->SMPR2 = 4u << ADC_SMPR2_SMP17_Pos;        /* датчику нужно не меньше 5 мкс */
    ADC1->ISR = ADC_ISR_ADRDY;
    ADC1->CR |= ADC_CR_ADEN;
    while (!(ADC1->ISR & ADC_ISR_ADRDY))
        ;
}

Датчик — канал 17 (RM0434: ADC1_INP17), VREFINT — канал 0. Время выборки для датчика — не меньше 5 мкс (tS_temp, DS11929, таблица 85); 47,5 такта при 4 МГц — 11,9 мкс. Такт АЦП берётся прямо от шины (CKMODE = 01). RM0434 (16.4.3) разрешает это, когда в HPRE записано 0xxx; после сброса RCC_CFGR = 0x0007 0000, то есть HPRE = 0000 (8.4.3).

Сборок две:

Размеры (arm-none-eabi-gcc 12.2.1, -Os, Cortex-M4 без FPU):

Что Код, байт ОЗУ, байт
tsvc198e.o — сервис и формула 630 52
bleif198e.o — команды 398 0
ipcc198e.o — драйвер 496 529 (очередь 8 × 64)
ipcc_hw_wb198e.o — регистры, запуск M0+ 162 0
прошивка FW_UART целиком 2800 + 4 данных 56
прошивка FW_IPCC целиком 4056 716 + 128 в SRAM2a

Из функций: ipcc_cmd — 176 байт, ipcc_rx_isr — 132, ipcc_tx_isr — 40, tsvc_temp_c100 — 148, tsvc_second — 80. Деление 64-битных чисел в формуле тянет из библиотеки __aeabi_ldivmod. Образ FW_UART в .bin — 2804 байта. На фоне 512 КБ, отданных программе, это ничто: место в приборе на WB55 занимает стек радиоядра и то, что ему заказано в ОЗУ.

Как прошить: FW_UART — как любую программу, через ST-LINK (раздел 35б), с адреса 0x0800 0000. Стек радиоядра ставится отдельно, через установщик FUS, и приложение его не трогает; на плате это в разделе не делалось.


198э.9 Грабли

Команды пропадают без ошибки. Запись в общую память, пока канал ещё занят, затирает непрочитанную команду, а повторный CHnS ничего не добавляет. В опыте из трёх команд выполнилась одна — последняя. Перед записью — проверка CHnF, после — ожидание ответа.

Прерывание «свободен» не кончается. Канал свободен почти всё время, и если обработчик не закрыл маску CHnFM, линия держится. В модели — 1000 вызовов за шаг на каждом шаге; в кристалле программа дальше обработчика не уйдёт. Маску открывают только на время ожидания ответа.

Маска — общая для главного цикла и обработчиков. IPCC_C1MR меняют оба. Чтение-изменение-запись из главного цикла без запрета прерываний может затереть то, что успел сделать обработчик; в драйвере это mr_set под __disable_irq. В модели прерывания вызываются только между шагами, поэтому эта гонка в прогоне не воспроизводится.

Радиоядро молчит. Не поставлен C2BOOT, не установлен стек, или работает установщик FUS вместо стека. Ответа не будет никогда, поэтому у ожидания — предел: в модели ipcc_cmd вернул −3 после 1000 ожиданий. Первое событие после запуска говорит, кто работает: стек или установщик.

Общая память не там. Радиоядро ищет таблицу по полю IPCCDBA (заводское значение 0 — начало SRAM2a). Если свой сценарий компоновки положит туда что-то другое, M0+ прочитает мусор.

Подписка «пропала» после переподключения. Без связывания подписка живёт одно подключение: в модели после разрыва уведомлений нет, пока телефон снова не запишет 01 00. Приложение сбрасывает свой флаг по событию «отключились».

Температура −2 °C в комнате. Калибровки сняты при 3,0 В, а питание 3,3 В: без пересчёта по VREFINT ошибка в модели — 26,87 °C.

Порог ниже шума. Шаг АЦП — 0,32 °C, порог 0,10 °C срабатывает от шума: 433 уведомления из 590 возможных. Порог берут больше шума после усреднения.

Байты UUID перевёрнуты не так. В эфире весь 128-битный номер идёт задом наперёд. Python uuid.bytes_le и GUID в Windows переставляют только первые три поля — номер с такими байтами телефон не узнает.

Write Command молча теряется. На запись без ответа (52) с неверной длиной сервер ничего не отвечает, приложение ничего не получает. Для команд, которые важно выполнить, — запись с ответом (12).

Длинное имя не влезает в рекламу. С флагами и номером сервиса в 31 байт входит имя до 22 символов. Длиннее — в ответ на запрос сканера.


198э.10 Итоги


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

Код ядра M4 целиком — тот, что собирается под STM32WB55 и прогоняется на компьютере, — и сценарий прогона. Модели для компьютера (сервер ATT, радиоядро, блок IPCC, «телефон» и сами опыты — att198e.c, cpu2_198e.c, ipcc_model198e.c, phone198e.c, host198e.c) лежат в каталоге проверки; их ключевые функции приведены выше.

msg198e.h — формат сообщений между ядрами в модели и раскладка общей памяти.

/* Раздел 198э: сообщения между ядрами в учебной модели — свой формат, НЕ формат библиотеки ST.
 * Канал 1 (M4 → M0+) — полудуплекс: команда и ответ в одном буфере shm.cmd.
 * Канал 2 (M0+ → M4) — симплекс: событие в буфере shm.evt.
 * Сообщение: код, длина параметров, параметры (многобайтовые поля — младшим байтом вперёд). */
#ifndef MSG198E_H
#define MSG198E_H
#include <stdint.h>

#define CH_CMD 1u /* номер канала IPCC для команд */
#define CH_EVT 2u /* номер канала IPCC для событий */
#define MSG_MAX 64u

/* команды M4 → M0+ */
#define CMD_ADD_SERVICE 0x01 /* uuid_len, uuid[uuid_len], max_records     → status, svc_handle[2] */
#define CMD_ADD_CHAR    0x02 /* svc[2], uuid_len, uuid, props, max_len, events → status, char_handle[2] */
#define CMD_UPDATE      0x03 /* char[2], len, data[len]                    → status */
#define CMD_ADVERTISE   0x04 /* itv_min[2], itv_max[2], name_len, name     → status */
#define RSP_FLAG        0x80 /* в ответе к коду команды добавляется этот бит */

/* события M0+ → M4 */
#define EVT_CONNECTED    0x81 /* conn[2], interval[2] (шаг 1,25 мс) */
#define EVT_DISCONNECTED 0x82 /* conn[2], reason */
#define EVT_ATTR_WRITTEN 0x83 /* attr_handle[2], len, data[len] — клиент записал значение или подписку */
#define EVT_MTU          0x84 /* mtu[2] */

#define ST_OK        0x00
#define ST_NO_SPACE  0x01 /* в таблице атрибутов или в сервисе нет места */
#define ST_BAD_PARAM 0x02
#define ST_BAD_CMD   0x03

/* общая память: в настоящем кристалле — SRAM2a, видимая обоим ядрам */
struct shm198e {
    uint8_t cmd[MSG_MAX]; /* команда, затем ответ на неё */
    uint8_t evt[MSG_MAX]; /* событие */
};

#endif

ipcc198e.h — драйвер IPCC ядра M4: интерфейс и функции доступа к регистрам.

/* Раздел 198э: драйвер IPCC ядра M4 — команды с ответом по каналу CH_CMD и события по CH_EVT.
 * Работа с регистрами — через ipcc_hw_* (на кристалле — ipcc_hw_wb198e.c, на компьютере — модель). */
#ifndef IPCC198E_H
#define IPCC198E_H
#include <stdint.h>
#include "msg198e.h"

#define CH_BIT(n) (1u << ((n) - 1u))     /* канал n = 1…6 → бит n − 1 */
#define IPCC_CR_RXOIE_BIT (1u << 0)      /* IPCC_C1CR.RXOIE */
#define IPCC_CR_TXFIE_BIT (1u << 16)     /* IPCC_C1CR.TXFIE */
#define IPCC_WAIT_MAX 1000u              /* столько ожиданий ответа, потом отказ */
#define EVQ_LEN 8u                       /* очередь событий у M4 */

/* регистры IPCC со стороны процессора 1 (M4) */
uint32_t ipcc_hw_tx_flags(void);         /* IPCC_C1TOC2SR: какие каналы M4 → M0+ заняты */
uint32_t ipcc_hw_rx_flags(void);         /* IPCC_C2TOC1SR: какие каналы M0+ → M4 заняты */
void ipcc_hw_scr(uint32_t v);            /* IPCC_C1SCR: биты 16+ — CHnS (занять), биты 0+ — CHnC (освободить) */
uint32_t ipcc_hw_mr(void);               /* IPCC_C1MR */
void ipcc_hw_set_mr(uint32_t v);
void ipcc_hw_set_cr(uint32_t v);         /* IPCC_C1CR */
void ipcc_hw_wait(void);                 /* ждать прерывания: __WFI на кристалле, шаг модели на компьютере */

void ipcc_init(struct shm198e *shm);
int ipcc_cmd(const uint8_t *msg, uint8_t *rsp, unsigned rsp_max);
int ipcc_next_event(uint8_t *out);       /* 1 — событие скопировано в out, 0 — очередь пуста */
void ipcc_rx_isr(void);                  /* IPCC_C1_RX_IRQn = 44 */
void ipcc_tx_isr(void);                  /* IPCC_C1_TX_IRQn = 45 */

extern volatile unsigned ipcc_evq_max;   /* наибольшая глубина очереди событий */

#endif

ipcc198e.c — драйвер IPCC: команда с ответом, очередь событий, обработчики прерываний.

/* Раздел 198э: драйвер IPCC ядра M4 по RM0434, раздел 37.
 * Канал CH_CMD — полудуплекс: M4 кладёт команду и ставит CHnS; M0+ пишет ответ в тот же буфер и снимает
 * флаг — у M4 срабатывает «канал свободен» (TX free), это и есть «ответ готов».
 * Канал CH_EVT — симплекс: M0+ кладёт событие и ставит флаг; у M4 срабатывает «канал занят» (RX occupied),
 * M4 копирует событие в свою очередь и снимает флаг CHnC — M0+ может класть следующее. */
#include "ipcc198e.h"
#include <string.h>

uint32_t ipcc_hw_irq_off(void);          /* запретить прерывания, вернуть прежнее состояние */
void ipcc_hw_irq_restore(uint32_t s);

static struct shm198e *mem;          /* общая память: SRAM2a на кристалле */
static volatile uint8_t cmd_wait;        /* команда ушла, ответа ещё нет */
static uint8_t evq[EVQ_LEN][MSG_MAX];
static volatile unsigned evq_head, evq_tail;
volatile unsigned ipcc_evq_max;

/* IPCC_C1MR меняют и главный цикл, и обработчики — менять только под запретом прерываний */
static void mr_set(uint32_t set, uint32_t clr)
{
    uint32_t s = ipcc_hw_irq_off();
    ipcc_hw_set_mr((ipcc_hw_mr() | set) & ~clr);
    ipcc_hw_irq_restore(s);
}

void ipcc_init(struct shm198e *s)
{
    mem = s;
    cmd_wait = 0;
    evq_head = evq_tail = 0;
    ipcc_evq_max = 0;
    ipcc_hw_set_mr(0xFFFFFFFFu & ~CH_BIT(CH_EVT));      /* после сброса всё замаскировано; открыть «занят» событий */
    ipcc_hw_set_cr(IPCC_CR_RXOIE_BIT | IPCC_CR_TXFIE_BIT);
}

int ipcc_cmd(const uint8_t *msg, uint8_t *rsp, unsigned rsp_max)
{
    unsigned len = 2u + msg[1];
    if (len > MSG_MAX)
        return -2;
    if (ipcc_hw_tx_flags() & CH_BIT(CH_CMD))
        return -1;                                       /* канал ещё занят: буфер трогать нельзя */
    memcpy(mem->cmd, msg, len);
    cmd_wait = 1;
    ipcc_hw_scr(CH_BIT(CH_CMD) << 16);                   /* CHnS: «занят» — у M0+ прерывание RX occupied */
    mr_set(0, CH_BIT(CH_CMD) << 16);                     /* открыть «свободен» (CHnFM = 0): это будет ответ */
    for (unsigned n = 0; cmd_wait; n++) {
        if (n >= IPCC_WAIT_MAX)
            return -3;                                   /* радиоядро молчит */
        ipcc_hw_wait();
    }
    unsigned rl = 2u + mem->cmd[1];
    if (rl > rsp_max || mem->cmd[0] != (msg[0] | RSP_FLAG))
        return -4;
    memcpy(rsp, mem->cmd, rl);
    return (int)rl;
}

void ipcc_tx_isr(void)
{
    uint32_t mr = ipcc_hw_mr();
    uint32_t freed = ~ipcc_hw_tx_flags() & ~(mr >> 16) & 0x3Fu;
    if (freed & CH_BIT(CH_CMD)) {
        ipcc_hw_set_mr(mr | (CH_BIT(CH_CMD) << 16));     /* закрыть «свободен», иначе прерывание не кончится */
        cmd_wait = 0;
    }
}

void ipcc_rx_isr(void)
{
    uint32_t mr = ipcc_hw_mr();
    uint32_t busy = ipcc_hw_rx_flags() & ~mr & 0x3Fu;
    if (!(busy & CH_BIT(CH_EVT)))
        return;
    unsigned depth = evq_head - evq_tail;
    if (depth >= EVQ_LEN) {                              /* очередь полна: событие оставить в канале */
        ipcc_hw_set_mr(mr | CH_BIT(CH_EVT));             /* CHnOM = 1 — M0+ подождёт, ничего не теряется */
        return;
    }
    unsigned len = 2u + mem->evt[1];
    memcpy(evq[evq_head % EVQ_LEN], mem->evt, len > MSG_MAX ? MSG_MAX : len);
    evq_head++;
    if (depth + 1u > ipcc_evq_max)
        ipcc_evq_max = depth + 1u;
    ipcc_hw_scr(CH_BIT(CH_EVT));                         /* CHnC: забрали — канал свободен для M0+ */
}

int ipcc_next_event(uint8_t *out)
{
    if (evq_head == evq_tail)
        return 0;
    const uint8_t *m = evq[evq_tail % EVQ_LEN];
    memcpy(out, m, 2u + m[1]);
    evq_tail++;
    mr_set(0, CH_BIT(CH_EVT));                           /* место есть — снова принимать события */
    return 1;
}

ipcc_hw_wb198e.c — регистры IPCC на кристалле, включение блока, запуск ядра M0+.

/* Раздел 198э: регистры IPCC на STM32WB55 (CMSIS stm32wb55xx.h). Со стороны M4:
 * IPCC_C1->CR/MR/SCR/SR — это IPCC_C1CR, C1MR, C1SCR, C1TOC2SR; IPCC_C2->SR — C2TOC1SR.
 * Доступ к регистрам IPCC — только словами по 32 бита (RM0434, 37.4). */
#include "stm32wb55xx.h"
#include "ipcc198e.h"

uint32_t ipcc_hw_tx_flags(void) { return IPCC_C1->SR; }
uint32_t ipcc_hw_rx_flags(void) { return IPCC_C2->SR; }
void ipcc_hw_scr(uint32_t v) { IPCC_C1->SCR = v; }
uint32_t ipcc_hw_mr(void) { return IPCC_C1->MR; }
void ipcc_hw_set_mr(uint32_t v) { IPCC_C1->MR = v; }
void ipcc_hw_set_cr(uint32_t v) { IPCC_C1->CR = v; }
void ipcc_hw_wait(void) { __WFI(); }

uint32_t ipcc_hw_irq_off(void)
{
    uint32_t s = __get_PRIMASK();
    __disable_irq();
    return s;
}

void ipcc_hw_irq_restore(uint32_t s) { __set_PRIMASK(s); }

/* включить блок IPCC и его прерывания у M4 */
void ipcc_hw_start(void)
{
    RCC->AHB3ENR |= RCC_AHB3ENR_IPCCEN;
    (void)RCC->AHB3ENR;
    NVIC_SetPriority(IPCC_C1_RX_IRQn, 5);
    NVIC_SetPriority(IPCC_C1_TX_IRQn, 5);
    NVIC_EnableIRQ(IPCC_C1_RX_IRQn);
    NVIC_EnableIRQ(IPCC_C1_TX_IRQn);
}

/* запустить ядро M0+: без C2BOOT после сброса оно не стартует (RM0434, 2.4). Как в hw_ipcc.c
 * Flipper Zero, сначала событие SEV/WFE: если бит уже ставил загрузчик, повторная запись ничего не даст */
void cpu2_boot(void)
{
    __SEV();
    __WFE();
    PWR->CR4 |= PWR_CR4_C2BOOT;
}

void IPCC_C1_RX_IRQHandler(void) { ipcc_rx_isr(); }
void IPCC_C1_TX_IRQHandler(void) { ipcc_tx_isr(); }

bleif198e.h — что приложение просит у радиоядра.

/* Раздел 198э: что приложение на M4 просит у радиоядра. С прошивкой ST этим функциям отвечают
 * aci_gatt_add_service, aci_gatt_add_char, aci_gatt_update_char_value, aci_gap_set_discoverable. */
#ifndef BLEIF198E_H
#define BLEIF198E_H
#include <stdint.h>

int bleif_add_service(const uint8_t *uuid, unsigned uuid_len, unsigned max_records, uint16_t *handle);
int bleif_add_char(uint16_t svc, const uint8_t *uuid, unsigned uuid_len, uint8_t props, unsigned max_len,
                   uint8_t fixed_len, uint8_t events, uint16_t *char_handle);
int bleif_update(uint16_t char_handle, const uint8_t *val, unsigned len);
int bleif_advertise(uint16_t itv_min, uint16_t itv_max, const char *name);

#endif

bleif198e.c — команды радиоядру поверх драйвера IPCC.

/* Раздел 198э: команды радиоядру через IPCC — свой формат msg198e.h поверх драйвера ipcc198e.c. */
#include "bleif198e.h"
#include "ipcc198e.h"
#include <string.h>

static int call(uint8_t *m, uint16_t *handle)
{
    uint8_t r[MSG_MAX];
    int n = ipcc_cmd(m, r, sizeof r);
    if (n < 3)
        return n < 0 ? n : -5;
    if (handle && n >= 5)
        *handle = (uint16_t)(r[3] | (r[4] << 8));
    return r[2];                                    /* код состояния: 0 — выполнено */
}

int bleif_add_service(const uint8_t *uuid, unsigned uuid_len, unsigned max_records, uint16_t *handle)
{
    uint8_t m[MSG_MAX] = {CMD_ADD_SERVICE, (uint8_t)(2u + uuid_len), (uint8_t)uuid_len};
    memcpy(&m[3], uuid, uuid_len);
    m[3 + uuid_len] = (uint8_t)max_records;
    return call(m, handle);
}

int bleif_add_char(uint16_t svc, const uint8_t *uuid, unsigned uuid_len, uint8_t props, unsigned max_len,
                   uint8_t fixed_len, uint8_t events, uint16_t *char_handle)
{
    uint8_t m[MSG_MAX] = {CMD_ADD_CHAR, (uint8_t)(7u + uuid_len), (uint8_t)svc, (uint8_t)(svc >> 8), (uint8_t)uuid_len};
    memcpy(&m[5], uuid, uuid_len);
    m[5 + uuid_len] = props;
    m[6 + uuid_len] = (uint8_t)max_len;
    m[7 + uuid_len] = fixed_len;
    m[8 + uuid_len] = events;
    return call(m, char_handle);
}

int bleif_update(uint16_t char_handle, const uint8_t *val, unsigned len)
{
    uint8_t m[MSG_MAX] = {CMD_UPDATE, (uint8_t)(3u + len), (uint8_t)char_handle, (uint8_t)(char_handle >> 8), (uint8_t)len};
    if (len > MSG_MAX - 5u)
        return -2;
    memcpy(&m[5], val, len);
    return call(m, 0);
}

int bleif_advertise(uint16_t itv_min, uint16_t itv_max, const char *name)
{
    unsigned nl = (unsigned)strlen(name);
    uint8_t m[MSG_MAX] = {CMD_ADVERTISE, (uint8_t)(5u + nl), (uint8_t)itv_min, (uint8_t)(itv_min >> 8),
                          (uint8_t)itv_max, (uint8_t)(itv_max >> 8), (uint8_t)nl};
    if (nl > MSG_MAX - 7u)
        return -2;
    memcpy(&m[7], name, nl);
    return call(m, 0);
}

tsvc198e.h — сервис температуры: состояние и функции.

/* Раздел 198э: приложение на M4 — сервис температуры (Environmental Sensing 0x181A, Temperature 0x2A6E)
 * и свой сервис управления со светодиодом (128-битный UUID). Не знает, кто под ним: модель или стек ST. */
#ifndef TSVC198E_H
#define TSVC198E_H
#include <stdint.h>

#define TS_AVG 8u                 /* отсчётов АЦП на одно значение (раз в секунду) */
#define TS_NAME "WB55-T198"
#define TS_ADV_MIN 0x0080u        /* 128 × 0,625 мс = 80 мс */
#define TS_ADV_MAX 0x00A0u        /* 160 × 0,625 мс = 100 мс */

struct tsvc {
    uint16_t svc_ess, ch_temp, svc_ctl, ch_led;
    uint8_t connected, subscribed, force, led;
    uint16_t mtu, thr, heartbeat;
    int32_t sent;                 /* последнее отправленное значение, сотые °C */
    uint32_t sec, sent_at, updates, errors;
    uint32_t ts_sum, vref_sum;
    unsigned n;
};
extern struct tsvc ts;

int32_t tsvc_temp_c100(uint32_t ts_data, uint32_t vref_data, uint16_t ts_cal1, uint16_t ts_cal2, uint16_t vref_cal);
int tsvc_init(uint16_t thr_c100, uint16_t heartbeat_s);
int tsvc_sample(uint16_t ts_code, uint16_t vref_code);  /* 1 — накопилось TS_AVG отсчётов */
void tsvc_second(int32_t t_c100);
void tsvc_on_event(const uint8_t *msg);
void tsvc_led(int on);            /* светодиод: на кристалле PB5, в модели — запись в журнал */

#endif

tsvc198e.c — сервис температуры: таблица GATT, формула, правило уведомлений, светодиод.

/* Раздел 198э: логика прибора на ядре M4 — измерение, правило уведомлений, светодиод. */
#include "tsvc198e.h"
#include "bleif198e.h"
#include "msg198e.h"
#include <string.h>

struct tsvc ts;

static const uint8_t U_ESS[2] = {0x1A, 0x18};             /* 0x181A — младшим байтом вперёд */
static const uint8_t U_TEMP[2] = {0x6E, 0x2A};            /* 0x2A6E */
/* 7a1e0198-3c55-4b0e-9d1f-2f5ab3c0e101 и …e102 — в эфире все 16 байт задом наперёд */
static const uint8_t U_CTL[16] = {0x01, 0xe1, 0xc0, 0xb3, 0x5a, 0x2f, 0x1f, 0x9d,
                                  0x0e, 0x4b, 0x55, 0x3c, 0x98, 0x01, 0x1e, 0x7a};
static const uint8_t U_LED[16] = {0x02, 0xe1, 0xc0, 0xb3, 0x5a, 0x2f, 0x1f, 0x9d,
                                  0x0e, 0x4b, 0x55, 0x3c, 0x98, 0x01, 0x1e, 0x7a};

/* Температура кристалла в сотых °C (RM0434, 16.4.30; DS11929, таблицы 11 и 12).
 * Заводские TS_CAL1 (30 °C) и TS_CAL2 (130 °C) сняты при VREF+ = 3,0 В, VREFINT_CAL — при 3,6 В.
 * VDDA = 3,6 · VREFINT_CAL / VREFINT_DATA; код датчика приводится к 3,0 В: TS · VDDA / 3,0.
 * Можно подать суммы N отсчётов датчика и VREFINT — важно только их отношение. */
int32_t tsvc_temp_c100(uint32_t ts_data, uint32_t vref_data, uint16_t ts_cal1, uint16_t ts_cal2, uint16_t vref_cal)
{
    int64_t num = ((int64_t)ts_data * vref_cal * 36 - (int64_t)ts_cal1 * vref_data * 30) * (130 - 30) * 100;
    int64_t den = (int64_t)(ts_cal2 - ts_cal1) * vref_data * 30;
    if (den <= 0)
        return INT32_MIN;
    num += num >= 0 ? den / 2 : -den / 2;                  /* округление до ближайшего */
    return (int32_t)(num / den) + 30 * 100;
}

int tsvc_init(uint16_t thr_c100, uint16_t heartbeat_s)
{
    int r = 0;
    memset(&ts, 0, sizeof ts);
    ts.thr = thr_c100;
    ts.heartbeat = heartbeat_s;
    ts.mtu = 23;
    r |= bleif_add_service(U_ESS, 2, 4, &ts.svc_ess);      /* объявление + характеристика из трёх */
    r |= bleif_add_char(ts.svc_ess, U_TEMP, 2, 0x02 | 0x10, 2, 1, 0, &ts.ch_temp);   /* чтение, уведомление */
    r |= bleif_add_service(U_CTL, 16, 3, &ts.svc_ctl);
    r |= bleif_add_char(ts.svc_ctl, U_LED, 16, 0x02 | 0x08, 1, 1, 1, &ts.ch_led);    /* чтение, запись */
    r |= bleif_advertise(TS_ADV_MIN, TS_ADV_MAX, TS_NAME);
    return r;
}

int tsvc_sample(uint16_t ts_code, uint16_t vref_code)
{
    if (ts.n == 0)
        ts.ts_sum = ts.vref_sum = 0;
    ts.ts_sum += ts_code;
    ts.vref_sum += vref_code;
    if (++ts.n < TS_AVG)
        return 0;
    ts.n = 0;
    return 1;
}

static void send(int32_t t)
{
    uint8_t v[2] = {(uint8_t)t, (uint8_t)((uint32_t)t >> 8)};   /* sint16, сотые °C, младшим байтом вперёд */
    if (bleif_update(ts.ch_temp, v, 2) != 0) {
        ts.errors++;
        return;
    }
    ts.sent = t;
    ts.sent_at = ts.sec;
    ts.updates++;
}

/* раз в секунду: без подписки — просто держать значение свежим для чтения;
 * с подпиской — слать, только если ушло на порог или давно не слали */
void tsvc_second(int32_t t)
{
    ts.sec++;
    if (t > 32767)
        t = 32767;
    if (t < -32768)
        t = -32768;
    int32_t d = t > ts.sent ? t - ts.sent : ts.sent - t;
    if (!ts.subscribed) {
        if (t != ts.sent || ts.updates == 0)
            send(t);
    } else if (ts.force || d >= ts.thr || ts.sec - ts.sent_at >= ts.heartbeat) {
        ts.force = 0;
        send(t);
    }
}

void tsvc_on_event(const uint8_t *m)
{
    uint16_t h = (uint16_t)(m[2] | (m[3] << 8));
    switch (m[0]) {
    case EVT_CONNECTED:
        ts.connected = 1;
        break;
    case EVT_DISCONNECTED:
        ts.connected = ts.subscribed = 0;
        ts.mtu = 23;
        break;
    case EVT_MTU:
        ts.mtu = h;
        break;
    case EVT_ATTR_WRITTEN:
        if (h == ts.ch_temp + 2u && m[4] == 2) {           /* CCCD температуры: бит 0 — уведомления */
            ts.subscribed = (m[5] & 1u) != 0;
            ts.force = ts.subscribed;
        } else if (h == ts.ch_led + 1u && m[4] == 1) {     /* значение «светодиода» */
            ts.led = m[5] != 0;
            tsvc_led(ts.led);
        }
        break;
    }
}

fw198e.c — прошивка ядра M4 на регистрах: такт, консоль, светодиод, АЦП, таблица векторов.

/* Раздел 198э: прошивка ядра M4 для P-NUCLEO-WB55 (STM32WB55RG) на регистрах CMSIS.
 * Такт — MSI 4 МГц, как после сброса (RM0434, 8.2). Светодиод LED1 (синий) — PB5, консоль — USART1:
 * PB6 TX, AF7 (так в BSP платы: LED1_PIN, COM1_TX_PIN). Температура кристалла — АЦП, каналы 17 (датчик)
 * и 0 (VREFINT), раз в 125 мс; среднее из 8 — раз в секунду в tsvc_second.
 * Сборка FW_UART: «радио» — печать в консоль. Сборка FW_IPCC: команды по IPCC через bleif198e.c —
 * только для размера: прошивка радиоядра ST таких сообщений не понимает. */
#include "stm32wb55xx.h"
#include "tsvc198e.h"
#include "bleif198e.h"
#include "msg198e.h"
#include <string.h>

#define HCLK 4000000u
#define TS_CAL1 (*(const uint16_t *)0x1FFF75A8u)   /* DS11929, таблица 11: 30 °C, 3,0 В */
#define TS_CAL2 (*(const uint16_t *)0x1FFF75CAu)   /* 130 °C, 3,0 В */
#define VREF_CAL (*(const uint16_t *)0x1FFF75AAu)  /* DS11929, таблица 12: 30 °C, 3,6 В */

static volatile uint32_t ms;
void SysTick_Handler(void) { ms++; }

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

static void uart_init(void)
{
    RCC->AHB2ENR |= RCC_AHB2ENR_GPIOBEN;
    RCC->APB2ENR |= RCC_APB2ENR_USART1EN;           /* USART1SEL = 00 после сброса: такт PCLK2 */
    (void)RCC->APB2ENR;
    GPIOB->MODER = (GPIOB->MODER & ~(3u << GPIO_MODER_MODE6_Pos)) | (2u << GPIO_MODER_MODE6_Pos);
    GPIOB->AFR[0] = (GPIOB->AFR[0] & ~(15u << GPIO_AFRL_AFSEL6_Pos)) | (7u << GPIO_AFRL_AFSEL6_Pos);
    USART1->BRR = (HCLK + 115200u / 2u) / 115200u;  /* 35: 114 286 бит/с, −0,8 % */
    USART1->CR1 = USART_CR1_TE | USART_CR1_UE;
}

static void uart_puts(const char *s)
{
    while (*s) {
        while (!(USART1->ISR & USART_ISR_TXE))
            ;
        USART1->TDR = (uint8_t)*s++;
    }
}

static void led_init(void)
{
    RCC->AHB2ENR |= RCC_AHB2ENR_GPIOBEN;
    (void)RCC->AHB2ENR;
    GPIOB->MODER = (GPIOB->MODER & ~(3u << GPIO_MODER_MODE5_Pos)) | (1u << GPIO_MODER_MODE5_Pos);
}

void tsvc_led(int on) { GPIOB->BSRR = on ? (1u << 5) : (1u << (5 + 16)); }

/* АЦП: выйти из глубокого сна, включить регулятор, откалибровать, включить (RM0434, 16.4.6–16.4.9) */
static void adc_init(void)
{
    RCC->AHB2ENR |= RCC_AHB2ENR_ADCEN;
    (void)RCC->AHB2ENR;
    ADC1_COMMON->CCR = ADC_CCR_CKMODE_0 | ADC_CCR_TSEN | ADC_CCR_VREFEN;  /* HCLK/1, датчик и VREFINT */
    ADC1->CR &= ~ADC_CR_DEEPPWD;
    ADC1->CR |= ADC_CR_ADVREGEN;
    delay_ms(1);                                    /* tADCVREG_STUP — до 20 мкс (DS11929) */
    ADC1->CR |= ADC_CR_ADCAL;
    while (ADC1->CR & ADC_CR_ADCAL)
        ;
    ADC1->SMPR1 = 4u << ADC_SMPR1_SMP0_Pos;         /* 47,5 такта = 11,9 мкс при 4 МГц */
    ADC1->SMPR2 = 4u << ADC_SMPR2_SMP17_Pos;        /* датчику нужно не меньше 5 мкс */
    ADC1->ISR = ADC_ISR_ADRDY;
    ADC1->CR |= ADC_CR_ADEN;
    while (!(ADC1->ISR & ADC_ISR_ADRDY))
        ;
}

static uint16_t adc_read(unsigned ch)
{
    ADC1->SQR1 = ch << ADC_SQR1_SQ1_Pos;            /* одно преобразование, канал ch */
    ADC1->CR |= ADC_CR_ADSTART;
    while (!(ADC1->ISR & ADC_ISR_EOC))
        ;
    return (uint16_t)ADC1->DR;
}

#ifdef FW_UART
static void uart_dec(int32_t v)
{
    char b[12];
    int i = 11;
    uint32_t u = v < 0 ? (uint32_t)-v : (uint32_t)v;
    b[i] = 0;
    do {
        b[--i] = (char)('0' + u % 10u);
        u /= 10u;
    } while (u && i > 1);
    if (v < 0)
        b[--i] = '-';
    uart_puts(&b[i]);
}

/* без радиоядра: «характеристики» — номера по порядку, обновления — в консоль */
static uint16_t next_h = 0x0A;
int bleif_add_service(const uint8_t *uuid, unsigned uuid_len, unsigned max_records, uint16_t *handle)
{
    (void)uuid, (void)uuid_len;
    *handle = next_h;
    next_h = (uint16_t)(next_h + max_records);
    return 0;
}
int bleif_add_char(uint16_t svc, const uint8_t *uuid, unsigned uuid_len, uint8_t props, unsigned max_len,
                   uint8_t fixed_len, uint8_t events, uint16_t *char_handle)
{
    (void)uuid, (void)uuid_len, (void)props, (void)max_len, (void)fixed_len, (void)events;
    *char_handle = (uint16_t)(svc + 1u);
    return 0;
}
int bleif_update(uint16_t char_handle, const uint8_t *val, unsigned len)
{
    (void)char_handle;
    if (len == 2) {
        uart_puts("t=");
        uart_dec((int16_t)(val[0] | (val[1] << 8)));
        uart_puts(" (0,01 C)\r\n");
    }
    return 0;
}
int bleif_advertise(uint16_t itv_min, uint16_t itv_max, const char *name)
{
    (void)itv_min, (void)itv_max;
    uart_puts("adv ");
    uart_puts(name);
    uart_puts("\r\n");
    return 0;
}
#endif

#ifdef FW_IPCC
#include "ipcc198e.h"
void ipcc_hw_start(void);
void cpu2_boot(void);
__attribute__((section(".shm"))) static struct shm198e shm;   /* SRAM2a: её видят оба ядра */
#endif

int main(void)
{
    SysTick->LOAD = HCLK / 1000u - 1u;
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;
    led_init();
    uart_init();
    adc_init();
    uart_puts("boot 198e\r\n");
#ifdef FW_IPCC
    ipcc_hw_start();
    ipcc_init(&shm);
    cpu2_boot();
#endif
    tsvc_init(50, 10);
    uint32_t t = ms;
    for (;;) {
#ifdef FW_IPCC
        uint8_t m[MSG_MAX];
        while (ipcc_next_event(m))
            tsvc_on_event(m);
#endif
        if (ms - t < 125u)
            continue;
        t += 125u;
        if (tsvc_sample(adc_read(17), adc_read(0)))
            tsvc_second(tsvc_temp_c100(ts.ts_sum, ts.vref_sum, TS_CAL1, TS_CAL2, VREF_CAL));
    }
}

/* ── начальный код и таблица векторов: 16 исключений ядра + 46 прерываний (до IPCC_C1_TX = 45) ── */
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack;
void Reset_Handler(void);
static void Default_Handler(void)
{
    for (;;) {
    }
}
void IPCC_C1_RX_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));
void IPCC_C1_TX_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));

__attribute__((section(".isr_vector"), used)) static void (*const vectors[16 + 46])(void) = {
    [0] = (void (*)(void))&_estack,
    [1] = Reset_Handler,
    [2] = Default_Handler,                          /* NMI */
    [3] = Default_Handler,                          /* HardFault */
    [15] = SysTick_Handler,
    [16 + IPCC_C1_RX_IRQn] = IPCC_C1_RX_IRQHandler,
    [16 + IPCC_C1_TX_IRQn] = IPCC_C1_TX_IRQHandler,
};

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

fw198e.ld — компоновка: 512 КБ флеша, SRAM1, общая память в SRAM2a.

/* Раздел 198э: компоновка ядра M4 STM32WB55RG. Как в шаблоне ST stm32wb55xx_flash_cm4.ld:
 * флеш приложения — первые 512 КБ (вторая половина — под прошивку радиоядра),
 * ОЗУ — SRAM1 с 0x20000008, общая с радиоядром память — первые 10 КБ SRAM2a. */
ENTRY(Reset_Handler)
MEMORY
{
    FLASH (rx)       : ORIGIN = 0x08000000, LENGTH = 512K
    RAM1 (xrw)       : ORIGIN = 0x20000008, LENGTH = 0x2FFF8
    RAM_SHARED (xrw) : ORIGIN = 0x20030000, LENGTH = 10K
}
_estack = 0x20030000;
SECTIONS
{
    .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
    .text : { *(.text*) *(.rodata*) . = ALIGN(4); } > FLASH
    _sidata = LOADADDR(.data);
    .data : { _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM1 AT > FLASH
    .bss (NOLOAD) : { _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM1
    .shm (NOLOAD) : { *(.shm) } > RAM_SHARED
}

run198e.sh — весь прогон: модель в stm-sys:1, прошивка в stm-arm:1.

#!/bin/sh
# Раздел 198э: прогон. /v — этот каталог (только чтение).
#   sh /v/run198e.sh host    в stm-sys:1 — приложение M4 против моделей IPCC, радиоядра и телефона
#   sh /v/run198e.sh build   в stm-arm:1 — прошивка M4 под STM32WB55; /p — CMSIS STM32WBxx (Include), /c — CMSIS Core
set -u
export LC_ALL=C.UTF-8
B=$(mktemp -d)
cd $B
case "$1" in
host)
    echo "== gcc $(gcc -dumpfullversion)"
    gcc -std=c11 -O2 -Wall -Wextra -Werror -o host198e /v/host198e.c /v/tsvc198e.c /v/bleif198e.c /v/ipcc198e.c \
        /v/ipcc_model198e.c /v/cpu2_198e.c /v/att198e.c /v/phone198e.c -lm 2>&1
    echo "build_host=$?"
    ./host198e
    ;;
build)
    echo "== $(arm-none-eabi-gcc --version | head -1)"
    CPU="-mcpu=cortex-m4 -mthumb -mfloat-abi=soft"
    W="$CPU -Os -std=c11 -Wall -Wextra -Werror -ffunction-sections -fdata-sections -DSTM32WB55xx -I/v -I/p -I/c"
    LD="$CPU -nostartfiles --specs=nano.specs --specs=nosys.specs -Wl,--gc-sections -T /v/fw198e.ld"
    for f in tsvc198e bleif198e ipcc198e ipcc_hw_wb198e; do
        arm-none-eabi-gcc $W -c /v/$f.c -o $f.o 2>&1
        echo "build_$f=$?"
    done
    arm-none-eabi-gcc $W -DFW_UART -c /v/fw198e.c -o fw_uart.o 2>&1
    echo "build_fw_uart=$?"
    arm-none-eabi-gcc $W -DFW_IPCC -c /v/fw198e.c -o fw_ipcc.o 2>&1
    echo "build_fw_ipcc=$?"
    arm-none-eabi-gcc $LD -Wl,-Map=fw_uart.map -o fw_uart.elf fw_uart.o tsvc198e.o 2>&1
    echo "link_uart=$?"
    arm-none-eabi-gcc $LD -Wl,-Map=fw_ipcc.map -o fw_ipcc.elf fw_ipcc.o tsvc198e.o bleif198e.o ipcc198e.o ipcc_hw_wb198e.o 2>&1
    echo "link_ipcc=$?"
    arm-none-eabi-size tsvc198e.o bleif198e.o ipcc198e.o ipcc_hw_wb198e.o fw_uart.elf fw_ipcc.elf
    arm-none-eabi-nm -S -t d tsvc198e.o ipcc198e.o | awk '$3 ~ /^[Tt]$/ {print "fn", $4, $2 + 0}' | sort
    arm-none-eabi-nm fw_ipcc.elf | grep -E " (shm|vectors|__aeabi_ldivmod)$" | sed 's/^/sym /'
    arm-none-eabi-objdump -h fw_ipcc.elf | awk '$2 ~ /^\.(isr_vector|text|data|bss|shm)$/ {print "sec", $2, $3, $4}'
    arm-none-eabi-objcopy -O binary fw_uart.elf fw_uart.bin
    echo "bin_uart=$(wc -c < fw_uart.bin)"
    ;;
esac
rm -rf $B

Дальше: часть XXVI, раздел 199 «Сборка платы на заводе: перечень, файл установки и гербер» — плата разведена, пора на завод.

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