Embedded HandbookПриложения
Все разделы › III. Периферия
STM32F103 · уровень средний · 36 мин чтения
Открыть в интерактивном справочнике

16в. Bluetooth-модуль HC-05/HC-06: прозрачный UART, AT-режим, сопряжение и протокол поверх канала

В 16, 16а и 16б UART шёл по проводу: кадр, скорость, RS-485, приём через DMA в кольцо. Провод тянуть не всегда удобно: прибор стоит в шкафу, а настраивать его хочется с телефона. Самый дешёвый способ убрать провод — модуль HC-05 или HC-06. Для микроконтроллера это всё тот же UART, для телефона и компьютера — последовательный порт по Bluetooth. Просто на вид, но радио — не провод: байты приходят пачками и с задержкой, связь рвётся, и при обрыве пропадает всё, что было в пути.

Что внутри: чем HC-05 отличается от HC-06, как подключить модуль к STM32F103 и зачем выводы STATE и KEY; что такое прозрачный режим и как ведут себя байты по воздуху; AT-режим и настройка модуля без ожиданий в цикле; сопряжение с телефоном и со вторым HC-05; кадр с номером, длиной и CRC-16, подтверждение и повтор поверх канала; признак связи по STATE, переподключение и потребление — почему HC-05 не для батарейки и чем здесь лучше BLE.

Опоры: DocID13587 Rev 17 (STM32F103x8/xB): PA9, PA10 — USART1_TX, USART1_RX с допуском 5 В, с. 31; порог высокого уровня входа 0,65·VDD, таблица 35, с. 62. Кадр COBS с CRC-16/XMODEM — из 194.4, ведущий ноль — из 189а.2, разбор AT-ответов без блокировки — из 106г.3. Документов на HC-05 и HC-06 и спецификации Bluetooth в проекте нет: набор AT-команд, вид ответов, значения по умолчанию (имя, PIN, скорость), скорость AT-режима и смысл выводов STATE и KEY взяты по памяти о документации производителя и помечены «не проверено». Всё, что в разделе названо прогоном, — код _verify/bt16d/hc05.c и _verify/bt16d/btl16d.c на компьютере (gcc 12.2.0) с моделью модуля, эфира и телефона на Python 3.11; прошивка собрана armclang 6.24 под Cortex-M3.

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

Оригинал Перевод
Bluetooth Classic (BR/EDR) классический Bluetooth
Serial Port Profile (SPP), RFCOMM профиль «последовательный порт» и его протокол
Bluetooth Low Energy (BLE) Bluetooth с низким потреблением
pairing, PIN (passkey) сопряжение, код сопряжения
master / slave, role ведущий / ведомый, роль
bind address адрес, к которому подключаться
transparent (data) mode прозрачный режим: байты как есть
AT command mode режим AT-команд
link supervision timeout срок, после которого связь считается потерянной
COBS (Consistent Overhead Byte Stuffing) замена нулей в кадре с постоянной добавкой
ARQ, stop-and-wait повтор по сроку; «отправил — жди подтверждения»

16в.1 Модуль: HC-05 и HC-06, питание, уровни, STATE и KEY

Два модуля. Оба — классический Bluetooth с профилем SPP: телефон или компьютер видят их как последовательный порт. HC-05 бывает и ведомым (к нему подключаются), и ведущим (подключается сам к другому модулю) — роль задаёт AT+ROLE. HC-06 — только ведомый, и AT-команды у него проще и другого вида. Это по памяти о документации производителя; документа в проекте нет, не проверено.

Питание и уровни. Сам модуль живёт на 3,3 В, и его UART — тоже 3,3 В. Чаще всего его продают на плате-переходнике со стабилизатором и шестью выводами: STATE, RXD, TXD, GND, VCC, EN (он же KEY); плату питают от 5 В (по памяти, диапазон — по документу на плату, не проверено). С STM32F103 линии соединяются напрямую: выход PA9 на 3,3 В — во вход RXD модуля, выход TXD модуля — в PA10. У PA10 допуск 5 В (DocID13587, с. 31), а «единицу» он гарантированно видит от 0,65·VDD (таблица 35, с. 62) — при 3,3 В это 2,145 В; уровень выхода модуля надо сравнить с этим порогом по его документу. Опасна обратная сторона — плата на 5 В: её выход TX в RXD модуля без делителя (16.7).

STATE и KEY. На распространённых платах вывод STATE в единице, пока есть связь с телефоном, — это признак, по которому прошивка узнаёт об обрыве (16в.6). KEY в единице в момент включения переводит HC-05 в AT-режим на 38 400 бод, иначе модуль включается в прозрачном режиме на своей рабочей скорости. Оба утверждения — по памяти, не проверено. Отсюда схема: питание модуля — через ключ на PB0, чтобы прошивка сама перезапускала модуль в нужном режиме; KEY — на PB1; STATE — на вход PB5 с подтяжкой к земле, чтобы оторванный провод читался как «связи нет».

Слева — STM32F103 и плата HC-05: PA9 TX к RXD, PA10 RX от TXD, PB0 управляет ключом питания VCC модуля, PB1 на KEY (EN), PB5 — вход от STATE, общая земля; уровни 3,3 В соединяются напрямую, от платы на 5 В к RXD модуля нужен делитель; STATE в единице — есть связь, по документации, не проверено. Справа — два режима: KEY = 1 при включении — AT-режим на 38 400 бод, KEY = 0 — прозрачный режим на 115 200 бод; между ними питание снимается на 200 мс; путь байта: UART, буфер модуля, пачка по эфиру к телефону с профилем SPP; при обрыве пропадает всё, что было в пути, а STATE падает не сразу, в модели через 1 с. Внизу — кадр поверх канала: ведущий 00, тип, номер, длина, данные 0…64 байта, CRC-16, завершающий 00; всё между нулями — в COBS, кадр целиком до 72 байт. Справа внизу — обмен: ДАННЫЕ k, ПОДТВ k; кадр пропал — через 250 мс повтор; STATE = 0 дольше 20 мс — повторы стоят, STATE = 1 снова — кадр уходит сразу; повтор принятого кадра подтверждается и выбрасывается


16в.2 Прозрачный режим: UART по воздуху

В прозрачном режиме модуль ничего не знает о сообщениях: всё, что прошивка выдала в TX, появляется в порту на телефоне, и наоборот. Границ нет: где кончилась одна посылка и началась другая, модуль не передаёт. Скорость UART модуля — та, что записана командой AT+UART; у F103 на 72 МГц BRR для 115 200 бод — 625, для 38 400 — 1875, для 9600 — 7500, все три делятся без остатка. Байт на 115 200 бод идёт 86,8 мкс.

Внутри модуль копит байты из UART и отправляет их в эфир пачками, а радиоканал добавляет свою задержку. Как именно копит настоящий модуль — не проверено; модель стенда отправляет пачку через 5 мс после первого байта или по 128 байтам, и пачка идёт по эфиру 8…30 мс. Числа ниже — следствие этих параметров, а не замер модуля; важна их форма:

Опыт в модели Результат
один байт до телефона, 50 раз от 13,2 до 34,4 мс, в середине 22,8 мс
блок 200 байт одной записью у телефона 4 пачки: 58, 58, 58 и 26 байт
первая и последняя пачка через 30,8 и 34,9 мс после записи
эхо того же блока обратно у прибора через 72 мс, 18 кусками по опросам

Отсюда правило, то же, что в 189.2 для BLE: сообщение должно переживать разрезание и склейку в любом месте. Блок, записанный разом, приходит кусками; два коротких сообщения подряд могут прийти одной пачкой. Читать «по одному сообщению за чтение» нельзя.

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


16в.3 AT-режим: вход по KEY и настройка без ожиданий

Команда — строка AT…, оконченная парой CR LF; ответ — строка с данными и OK или ошибка вида ERROR:(0). Набор и вид команд — по памяти о документации производителя на прошивку 2.0, не проверено; у других прошивок вид отличается (например, PIN — в кавычках):

Команда Ответ модели Что делает
AT OK модуль жив и понял скорость
AT+VERSION? +VERSION:2.0-20100601 версия прошивки
AT+NAME?, AT+NAME=имя +NAME:HC-05 имя, которое видит телефон
AT+PSWD?, AT+PSWD=4321 +PSWD:1234 код сопряжения
AT+UART?, AT+UART=115200,0,0 +UART:9600,0,0 скорость, стоп-биты, чётность прозрачного режима
AT+ROLE?, AT+ROLE=1 +ROLE:0 0 — ведомый, 1 — ведущий
AT+CMODE=0, AT+BIND=… OK ведущему: подключаться только к адресу из BIND
AT+ADDR? +ADDR:… свой адрес — его вписывают в BIND ведущего

Значения по умолчанию в модели — имя HC-05, PIN 1234, 9600,0,0, роль 0; так их обычно называют, но документ не проверен.

Разбор ответов устроен так же, как в 106г.3: кольцо, сборка строки до LF, одна команда за раз со своим сроком, — и здесь не повторяется. Он проще: незапрошенных сообщений в AT-режиме нет, и итог у каждой команды один — OK или ERROR. Главное другое: сначала спросить, потом писать. Для каждой настройки драйвер задаёт запрос, при расхождении пишет и спрашивает снова:

    while (h->step < 6) {
        uint8_t i = (uint8_t)(h->step - 2u);
        int same = r == HC_RES_OK && !strcmp(h->got, h->want[i]);
        if (h->phase == 0) {                            /* запрос */
            h->phase = 1;
            at_cmd(h, Q[i], 1000);
            return;
        }
        if (h->phase == 1 && !same) {                   /* отличается — записать */
            char c[HC_LINE];
            if (r != HC_RES_OK) {
                fail(h);
                return;
            }
            cat(cat(c, S[i], c + HC_LINE), h->want[i], c + HC_LINE);
            h->phase = 2;
            h->writes++;
            at_cmd(h, c, 1000);
            return;
        }

Так модуль не переписывается при каждом включении, а запись, которая не прошла, видна сразу: повторный запрос вернёт старое значение, и драйвер остановится в состоянии FAIL вместо работы на чужой скорости.

Прогон: модуль «с завода». Драйвер снимает питание на 200 мс, ставит KEY = 1, включает модуль и шлёт AT раз в 0,3 с до 10 раз. Модель загружается 500 мс, поэтому первые два AT ушли без ответа, и OK пришёл через 612 мс после включения. Дальше — 14 команд, из них 3 записи (имя BENCH-16, PIN 4321, скорость 115 200); роль уже 0, её только спросили. Затем питание снято ещё на 200 мс, KEY = 0, модуль включён в прозрачном режиме на 115 200 бод — на 1192 мс от старта. Повторный запуск с теми же настройками: 8 команд, ни одной записи, 1117 мс.


16в.4 Сопряжение: телефон и второй HC-05

Здесь всё качественно — в модели сопряжение сведено к одному событию «телефон подключился», а настоящий модуль не проверялся.

Телефон на Android. В настройках Bluetooth модуль виден под своим именем (BENCH-16); сопряжение — с кодом из AT+PSWD. Сопряжение ещё не связь: канал открывает приложение, которое умеет SPP, и только тогда STATE поднимается. В модели после подключения драйвер увидел связь через 21 мс — 20 мс фильтра STATE и шаг опроса.

Айфон. Насколько известно, приложениям iOS классический SPP с произвольными устройствами недоступен, и HC-05 с айфоном не работает — нужен BLE (16в.6). Не проверено.

Компьютер. После сопряжения Windows заводит виртуальные порты «Standard Serial over Bluetooth link» — на машине, где писался 115б.2, их нашлось 7 и ни одного кабеля. Порт ищут по имени устройства, а не берут первый попавшийся.

Два HC-05 друг с другом. Один остаётся ведомым как есть. Второму в AT-режиме: AT+ROLE=1, AT+CMODE=0, тот же PIN и та же скорость, и AT+BIND= с адресом первого из его AT+ADDR?, где двоеточия заменены запятыми: 1234:56:abcdef → AT+BIND=1234,56,abcdef (адрес условный). После включения ведущий сам ищет и подключается к своему ведомому и сам переподключается после обрыва. Всё по памяти, не проверено.


16в.5 Протокол поверх канала: кадр, подтверждение, повтор

Прозрачный канал даёт поток байтов без границ, а при обрыве теряет хвост. Поверх него нужен свой протокол, и почти весь он уже есть в книге: кадр из 194.4 — данные, CRC-16/XMODEM старшим байтом вперёд, всё в COBS, в конце ноль; ведущий ноль перед кадром — из 189а.2: обрывок прежнего кадра, застрявший в приёмнике после обрыва, закончится на нём и будет выброшен. Внутри кадра здесь три байта: тип (данные или подтверждение), номер и длина — длина ещё раз сверяется с тем, что получилось после COBS.

uint16_t btl_frame(uint8_t type, uint8_t seq, const uint8_t *p, uint8_t n, uint8_t *out)
{
    uint8_t raw[BTL_RAW];
    raw[0] = type;
    raw[1] = seq;
    raw[2] = n;
    if (n)
        memcpy(raw + 3, p, n);
    uint16_t c = btl_crc16(raw, (uint16_t)(n + 3u));
    raw[n + 3] = (uint8_t)(c >> 8);
    raw[n + 4] = (uint8_t)c;
    out[0] = 0;                                         /* ведущий ноль: обрывок прежнего кадра закончится здесь */
    uint16_t m = (uint16_t)(1u + cobs_enc(raw, (uint16_t)(n + 5u), out + 1));
    out[m++] = 0;
    return m;
}

Прогон сверяет Си с независимой реализацией на Python (binascii.crc_hqx и свой COBS): CRC строки 123456789 — 0x31C3, как в 194.4; кадры совпали байт в байт:

Данные Кадр, байт Добавка На 115 200 бод
MSG 0001, номер 1 16: 00 0E 01 01 08 4D 53 47 20 30 30 30 31 6C 1C 00 8 1389 мкс
8 нулевых байтов 16: 00 04 01 01 08 01 01 01 01 01 01 01 03 9E F3 00 8 1389 мкс
64 байта 72 8 6250 мкс

Добавка — 8 байт на кадр при любых данных до 64 байт: два нуля, байт COBS, тип, номер, длина и CRC.

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

static void on_frame(hc_t *h, const uint8_t *f, uint16_t n)
{
    btl_t *l = &h->l;
    uint8_t type = f[0], seq = f[1];
    if (type == BTL_T_ACK) {
        if (l->tx_busy && seq == l->tx_seq) {           /* подтверждён кадр в пути */
            l->tx_busy = 0;
            l->tx_seq++;
            l->n_ack++;
            if (h->on_event)
                h->on_event(h, HC_EV_ACKED, l->tx, l->tx_len);
        }
        return;                                         /* запоздалое подтверждение повтора — не наше */
    }
    if (type != BTL_T_DATA)
        return;
    tx_frame(BTL_T_ACK, seq, 0, 0);                     /* подтвердить и повтор: прежнее подтверждение могло пропасть */
    if (l->rx_sync && seq == (uint8_t)(l->rx_next - 1u)) {
        l->n_dup++;                                     /* тот же кадр ещё раз — данные уже отданы */
        return;
    }
    l->rx_sync = 1;
    l->rx_next = (uint8_t)(seq + 1u);
    l->n_rx++;
    if (h->on_event)
        h->on_event(h, HC_EV_RX, f + 3, (uint16_t)(n - 5u));
}

Первый кадр после запуска принимается с любым номером (rx_sync = 0), и любой номер, кроме номера прошлого кадра, тоже: перезапуск прошивки или приложения на телефоне не ломает обмен.

Опыт. Прибор шлёт 1000 сообщений MSG 0001…MSG 1000, телефон возвращает их эхом. Связь рвётся каждые 5…9 с на 2…4 с; эфир умирает сразу, STATE падает через 1 с; на проводах UART байт искажается с вероятностью 0,0003 и теряется с вероятностью 0,0001 — всё это параметры модели. Без протокола прибор шлёт строку раз в 50 мс, телефон возвращает байты как пришли; с протоколом прибор шлёт следующее сообщение после подтверждения прошлого, телефон — эхо своим кадром.

без протокола с протоколом
сообщений 1000 1000
обрывов связи за опыт 5 6
вернулось целыми, по одному разу, по порядку 706 1000
потеряно 294 0
испорченных строк 3 —
повторов кадра: прибор / телефон — 46 / 33
повторов отброшено: прибор / телефон — 4 / 4
кадров с неверной CRC: прибор / телефон — 6 / 9
обрывков не-кадров: прибор / телефон — 2 / 8
время опыта 50 500 мс 71 211 мс

Без протокола пропала почти треть сообщений — всё, что ушло за секунду до падения STATE и пока связи не было. С протоколом дошло всё, цена — лишнее время: подтверждение приходило в середине через 45 мс, а через обрыв — до 3779 мс.


16в.6 STATE, переподключение и потребление

Признак связи. Вывод STATE читается на каждом опросе; уровень считается новым, если продержался 20 мс:

static void link_watch(hc_t *h, uint8_t pin)
{
    if (pin != h->state_in) {
        h->state_in = pin;
        h->state_since = h->now;
        return;
    }
    if (pin == h->link || !after(h->now, h->state_since + HC_STATE_MS))
        return;
    h->link = pin;
    h->l.al = 0;                                        /* начатый кадр — мусор */
    h->l.over = 0;
    if (pin) {
        h->n_up++;
        h->l.tx_at = h->now;                            /* кадр в пути — повторить сразу */
        emit(h, HC_EV_LINK_UP, 0, 0);
    } else {
        h->n_down++;
        emit(h, HC_EV_LINK_DOWN, 0, 0);
    }
}

Пока связи нет, повторы стоят, а кадр в пути не выбрасывается. В опыте 16в.5 драйвер замечал падение STATE через 20 мс все 6 раз, а после возврата связи первое подтверждение приходило через 40…54 мс. Но STATE — признак запоздалый: модуль сам узнаёт об обрыве только по истечении срока потери связи (в модели — 1 с; у настоящего модуля — не проверено), и всё, что ушло за это время, пропадает. Эту дыру закрывает не STATE, а подтверждения. Если STATE не подключён вовсе, протокол работает на одних повторах; после 8 повторов подряд драйвер сообщает «канал молчит» — в прогоне STATE ни разу не дал до этого дойти.

Переподключение. Ведомый HC-05 ждёт, пока телефон подключится снова; ведущий с AT+CMODE=0 подключается к своему адресу сам (по памяти, не проверено). Прошивке делать ничего не нужно: номера кадров живут дольше связи, повтор после переподключения отбросит получатель.

Потребление. Классический Bluetooth держит связь постоянно, и радио модуля почти всё время работает; глубокого сна с сохранением связи у HC-05 нет. Поэтому от батарейки он живёт недолго — чисел тока в проекте нет, не проверено. Сэкономить можно одним: снимать питание ключом на PB0, когда связь не нужна. Для батарейки задуман BLE — «для коротких посылок и долгого сна» (89.5):

HC-05, классический SPP BLE (158, 189, 198э)
что видит приложение поток байтов, порт посылки не длиннее MTU: 20 байт при MTU 23
телефоны Android, компьютеры Android, айфон, компьютеры, веб с оговорками
микроконтроллер любой с UART, модуль сам держит стек стек на кристалле (ESP32, STM32WB) или модуль
сон связь держит радио включённым связь живёт между короткими сеансами
протокол поверх кадр, подтверждение, повтор тот же кадр, плюс резка по MTU

16в.7 Грабли

Модуль греется или умер после подключения к плате на 5 В. Выход TX платы на 5 В подан на RXD модуля напрямую. Вход модуля — 3,3 В: делитель или преобразователь уровней (16.7). С STM32 на 3,3 В — напрямую.

В AT-режиме модуль не отвечает, хотя в прозрачном работал на 9600. AT-режим — на 38 400 бод, независимо от рабочей скорости (по документации, не проверено). И наоборот: после AT+UART=115200,0,0 прозрачный режим — на 115 200, а не на прежних 9600. Забыли, какая скорость записана, — войти в AT-режим и спросить AT+UART?.

Модуль молчит в ответ на AT. Терминал отправляет только CR, а модулю нужна пара CR LF; без неё он ждёт конца строки. Следующая команда с CR LF приходит склеенной с прошлой и получает ERROR (так в модели).

AT-режим не включается. KEY подан после включения, а не до него. Снять питание, поставить KEY = 1, включить — ради этого питание модуля и идёт через ключ.

Ни одного байта ни в одну сторону. Перепутаны TX и RX: TX микроконтроллера — на RXD модуля, TXD модуля — на RX микроконтроллера.

Сообщения приходят разрезанными или склеенными. Это свойство канала, а не ошибка: пачки модуля режут поток где придётся. Нужен кадр с разделителем (16в.5), а не «одно чтение — одно сообщение».

После обрыва потерялось несколько последних сообщений. STATE падает позже, чем рвётся связь; всё, что ушло в этот промежуток, пропало. Подтверждение каждого кадра и повтор — STATE этого не заменяет.

Айфон не видит модуль. HC-05 — классический SPP; для айфона нужен BLE (по памяти, не проверено).

Два HC-05 не соединяются. Разные PIN или скорость, ведомый тоже переведён в ведущего, или в AT+BIND адрес записан с двоеточиями, а не с запятыми.


16в.8 Итоги

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

hc05.h — драйвер и кадры: буферы, состояния, события и прототипы.

/* hc05.h — раздел 16в: модуль Bluetooth HC-05 по UART и протокол кадров поверх канала.
 * hc05.c — настройка модуля AT-командами (вход по KEY при включении), переход в прозрачный режим,
 *          признак связи по выводу STATE; байты приёма кладёт прерывание USART (hc_rx_isr) в кольцо.
 * btl16d.c — кадр поверх канала: тип, номер, длина, данные, CRC-16/XMODEM, всё в COBS, ведущий и
 *          завершающий ноль; подтверждение каждого кадра и повтор по сроку, отбрасывание повторов.
 * Платформа даёт три функции: hc_hw_tx (байты в USART), hc_hw_uart (скорость USART), hc_hw_pin (питание
 * модуля и KEY). Один и тот же код собирается в прошивку (fw16d.c) и в стенд на компьютере (host16d.c). */
#ifndef HC05_H
#define HC05_H
#include <stdint.h>

#define HC_RXQ      256u        /* кольцо приёма */
#define HC_LINE     64u         /* строка ответа на AT-команду */
#define HC_AT_BAUD  38400u      /* скорость AT-режима при KEY = 1 на включении (по документации, не проверено) */
#define HC_AT_TRIES 10u         /* столько «AT» раз в 0,3 с, пока модуль не ответит */
#define HC_PWR_OFF  200u        /* питание модуля снято на столько мс при смене режима */
#define HC_STATE_MS 20u         /* столько мс STATE держит уровень — и связь считается поднятой или упавшей */

#define BTL_MAX     64u                         /* данные одного кадра */
#define BTL_RAW     (BTL_MAX + 5u)              /* тип, номер, длина, данные, два байта CRC */
#define BTL_WIRE    (BTL_RAW + 3u)              /* COBS (+1 байт до 254), ведущий и завершающий ноль */
#define BTL_RTO     250u        /* срок подтверждения, мс */
#define BTL_STALL   8u          /* столько повторов подряд без подтверждения — событие «канал молчит» */

enum { HC_PIN_PWR, HC_PIN_KEY };
/* OFF — питание снято, KEY = 1 (дальше AT-режим); RESTART — питание снято, KEY = 0 (дальше прозрачный режим) */
enum { HC_ST_OFF, HC_ST_AT, HC_ST_RESTART, HC_ST_DATA, HC_ST_FAIL };
enum { HC_RES_NONE, HC_RES_OK, HC_RES_ERROR, HC_RES_TIMEOUT };
enum { HC_EV_STATE, HC_EV_AT, HC_EV_LINK_UP, HC_EV_LINK_DOWN, HC_EV_RX, HC_EV_ACKED, HC_EV_STALL, HC_EV_RAW };
enum { BTL_T_DATA = 1, BTL_T_ACK = 2 };

typedef struct hc hc_t;
typedef void (*hc_event_fn)(hc_t *h, uint8_t ev, const uint8_t *p, uint16_t n);

typedef struct {
    uint8_t acc[BTL_WIRE];      /* байты до нуля */
    uint16_t al;
    uint8_t over;               /* кадр длиннее допустимого — ждать следующего нуля */
    uint8_t rx_next, rx_sync;   /* какой номер ждём; 0 — ещё ни одного кадра (принять любой) */
    uint8_t tx[BTL_MAX];        /* кадр в пути: до подтверждения не выбрасывается */
    uint8_t tx_len, tx_seq, tx_busy, tries;
    uint32_t tx_at;             /* когда слать (повторять) */
    uint32_t n_tx, n_retx, n_rx, n_dup, n_ack, n_crc, n_junk, n_stall;
} btl_t;

struct hc {
    /* кольцо приёма: head пишет прерывание, tail — hc_poll */
    uint8_t rx[HC_RXQ];
    volatile uint16_t rx_head;
    uint16_t rx_tail, rx_hiwater;
    volatile uint16_t rx_lost;
    /* AT-режим */
    char line[HC_LINE];
    uint8_t len;
    char cmd[HC_LINE];          /* текущая команда — для журнала */
    char got[HC_LINE];          /* строка «+NAME:…» ответа */
    uint8_t busy, result, tries, step, phase, writes;
    uint32_t deadline;
    /* настройки, которые модуль должен иметь */
    const char *name, *pin;
    uint32_t baud;
    char want[4][24];           /* ответы на запросы при верной настройке: имя, PIN, UART, роль */
    /* автомат */
    uint8_t st, raw;            /* raw = 1 — без протокола: байты приложению как есть */
    uint32_t now, wait;
    uint8_t state_in, link;     /* вывод STATE и признак связи после фильтра */
    uint32_t state_since;
    btl_t l;
    hc_event_fn on_event;
    uint32_t n_cmd, n_tmo, n_up, n_down;
};

/* платформа */
void hc_hw_tx(const uint8_t *p, uint16_t n);
void hc_hw_uart(uint32_t baud);
void hc_hw_pin(uint8_t pin, uint8_t level);

void hc_init(hc_t *h, const char *name, const char *pin, uint32_t baud, hc_event_fn ev);
void hc_restart(hc_t *h);                       /* заново: AT-режим, сверка настроек, прозрачный режим */
void hc_rx_isr(hc_t *h, uint8_t b);
void hc_poll(hc_t *h, uint32_t now_ms, uint8_t state_pin);
int hc_send_raw(hc_t *h, const uint8_t *p, uint16_t n);    /* без протокола — как есть */

/* кадры */
int btl_send(hc_t *h, const uint8_t *p, uint8_t n);       /* 0 — принят, −1 — предыдущий ещё в пути */
uint16_t btl_crc16(const uint8_t *p, uint16_t n);
uint16_t btl_frame(uint8_t type, uint8_t seq, const uint8_t *p, uint8_t n, uint8_t *out);
void btl_reset(btl_t *l);
void btl_byte(hc_t *h, uint8_t b);
void btl_poll(hc_t *h);

#endif

hc05.c — модуль HC-05: AT-режим, сверка настроек, прозрачный режим, STATE.

/* hc05.c — раздел 16в: модуль HC-05 по UART. Ни одного ожидания в цикле: hc_poll разбирает то, что уже
 * пришло, проверяет срок текущей AT-команды, делает шаг автомата и следит за выводом STATE.
 * Набор AT-команд и вид ответов — по документации производителя (в проекте её нет), не проверено. */
#include <string.h>
#include "hc05.h"

static const char *const ST_NAME[] = {"OFF", "AT", "RESTART", "DATA", "FAIL"};
/* что сверить: запрос, начало ответа, команда записи; ответ при верной настройке — в h->want */
static const char *const Q[4] = {"AT+NAME?", "AT+PSWD?", "AT+UART?", "AT+ROLE?"};
static const char *const S[4] = {"AT+NAME=", "AT+PSWD=", "AT+UART=", "AT+ROLE="};

static int starts(const char *s, const char *p)
{
    while (*p)
        if (*s++ != *p++)
            return 0;
    return 1;
}

static char *cat(char *d, const char *s, const char *end)
{
    while (*s && d < end - 1)
        *d++ = *s++;
    *d = 0;
    return d;
}

static char *catu(char *d, uint32_t v, const char *end)
{
    char t[10];
    int n = 0;
    do
        t[n++] = (char)('0' + v % 10u);
    while (v /= 10u);
    while (n && d < end - 1)
        *d++ = t[--n];
    *d = 0;
    return d;
}

static int after(uint32_t now, uint32_t t)
{
    return (int32_t)(now - t) >= 0;
}

static void emit(hc_t *h, uint8_t ev, const uint8_t *p, uint16_t n)
{
    if (h->on_event)
        h->on_event(h, ev, p, n);
}

static void set_st(hc_t *h, uint8_t st)
{
    h->st = st;
    emit(h, HC_EV_STATE, (const uint8_t *)ST_NAME[st], st);
}

/* ── AT-режим: одна команда за раз, конец ответа — OK или ERROR ──────── */
static void at_cmd(hc_t *h, const char *c, uint32_t tmo)
{
    cat(h->cmd, c, h->cmd + HC_LINE);
    h->got[0] = 0;
    h->busy = 1;
    h->result = HC_RES_NONE;
    h->deadline = h->now + tmo;
    h->n_cmd++;
    hc_hw_tx((const uint8_t *)h->cmd, (uint16_t)strlen(h->cmd));
    hc_hw_tx((const uint8_t *)"\r\n", 2);              /* без CR LF модуль молчит */
}

static void at_done(hc_t *h, uint8_t res)
{
    h->busy = 0;
    h->result = res;
    if (res == HC_RES_TIMEOUT)
        h->n_tmo++;
    emit(h, HC_EV_AT, (const uint8_t *)h->cmd, res);
}

static void at_line(hc_t *h)
{
    const char *s = h->line;
    if (!h->busy)
        return;                                         /* строка без команды — мусор после включения */
    if (!strcmp(s, "OK"))
        at_done(h, HC_RES_OK);
    else if (starts(s, "ERROR") || starts(s, "FAIL"))
        at_done(h, HC_RES_ERROR);
    else if (s[0] == '+' && strchr(s, ':'))
        cat(h->got, strchr(s, ':') + 1, h->got + HC_LINE);    /* «+NAME:HC-05» → «HC-05» */
}

static void at_byte(hc_t *h, uint8_t b)
{
    if (b == '\r')
        return;
    if (b == '\n') {
        if (h->len) {
            h->line[h->len] = 0;
            at_line(h);
        }
        h->len = 0;
        return;
    }
    if (h->len < HC_LINE - 1u)
        h->line[h->len++] = (char)b;
}

static void to_data(hc_t *h)
{
    hc_hw_pin(HC_PIN_KEY, 0);                           /* KEY = 0 на включении — прозрачный режим */
    hc_hw_pin(HC_PIN_PWR, 0);
    h->wait = h->now + HC_PWR_OFF;
    set_st(h, HC_ST_RESTART);
}

static void fail(hc_t *h)
{
    hc_hw_pin(HC_PIN_KEY, 0);
    set_st(h, HC_ST_FAIL);
}

/* шаг AT-автомата: AT до ответа → версия → для каждой настройки запрос, при расхождении запись и
 * повторный запрос → выключить, KEY = 0, включить в прозрачном режиме */
static void at_step(hc_t *h)
{
    uint8_t r = h->result;
    h->result = HC_RES_NONE;
    if (h->step == 0) {
        if (r != HC_RES_OK) {
            if (h->tries >= HC_AT_TRIES) {
                fail(h);
                return;
            }
            h->tries++;
            at_cmd(h, "AT", 300);
            return;
        }
        h->step = 1;
        h->phase = 0;
        r = HC_RES_NONE;
    }
    if (h->step == 1) {
        if (h->phase == 0) {
            h->phase = 1;
            at_cmd(h, "AT+VERSION?", 1000);
            return;
        }
        h->step = 2;
        h->phase = 0;
        r = HC_RES_NONE;
    }
    while (h->step < 6) {
        uint8_t i = (uint8_t)(h->step - 2u);
        int same = r == HC_RES_OK && !strcmp(h->got, h->want[i]);
        if (h->phase == 0) {                            /* запрос */
            h->phase = 1;
            at_cmd(h, Q[i], 1000);
            return;
        }
        if (h->phase == 1 && !same) {                   /* отличается — записать */
            char c[HC_LINE];
            if (r != HC_RES_OK) {
                fail(h);
                return;
            }
            cat(cat(c, S[i], c + HC_LINE), h->want[i], c + HC_LINE);
            h->phase = 2;
            h->writes++;
            at_cmd(h, c, 1000);
            return;
        }
        if (h->phase == 2) {                            /* записано — проверить запросом */
            if (r != HC_RES_OK) {
                fail(h);
                return;
            }
            h->phase = 3;
            at_cmd(h, Q[i], 1000);
            return;
        }
        if (!same) {                                    /* после записи всё ещё не то */
            fail(h);
            return;
        }
        h->step++;
        h->phase = 0;
        r = HC_RES_NONE;
    }
    to_data(h);
}

/* ── общий вход ──────────────────────────────────────────────────────── */
void hc_restart(hc_t *h)
{
    hc_hw_pin(HC_PIN_PWR, 0);
    hc_hw_pin(HC_PIN_KEY, 1);                           /* KEY = 1 на включении — AT-режим */
    h->wait = h->now + HC_PWR_OFF;
    h->busy = 0;
    h->step = h->phase = h->tries = h->writes = 0;
    h->result = HC_RES_NONE;
    if (h->link)
        emit(h, HC_EV_LINK_DOWN, 0, 0);
    h->link = 0;
    set_st(h, HC_ST_OFF);
}

void hc_init(hc_t *h, const char *name, const char *pin, uint32_t baud, hc_event_fn ev)
{
    memset(h, 0, sizeof *h);
    h->name = name;
    h->pin = pin;
    h->baud = baud;
    h->on_event = ev;
    cat(h->want[0], name, h->want[0] + sizeof h->want[0]);
    cat(h->want[1], pin, h->want[1] + sizeof h->want[1]);
    cat(catu(h->want[2], baud, h->want[2] + sizeof h->want[2]), ",0,0", h->want[2] + sizeof h->want[2]);
    cat(h->want[3], "0", h->want[3] + sizeof h->want[3]);   /* роль 0 — ведомый */
    btl_reset(&h->l);
    hc_restart(h);
}

void hc_rx_isr(hc_t *h, uint8_t b)
{
    uint16_t nx = (uint16_t)((h->rx_head + 1u) % HC_RXQ);
    if (nx == h->rx_tail) {
        h->rx_lost++;
        return;
    }
    h->rx[h->rx_head] = b;
    h->rx_head = nx;
}

int hc_send_raw(hc_t *h, const uint8_t *p, uint16_t n)
{
    if (h->st != HC_ST_DATA)
        return -1;
    hc_hw_tx(p, n);
    return 0;
}

static void link_watch(hc_t *h, uint8_t pin)
{
    if (pin != h->state_in) {
        h->state_in = pin;
        h->state_since = h->now;
        return;
    }
    if (pin == h->link || !after(h->now, h->state_since + HC_STATE_MS))
        return;
    h->link = pin;
    h->l.al = 0;                                        /* начатый кадр — мусор */
    h->l.over = 0;
    if (pin) {
        h->n_up++;
        h->l.tx_at = h->now;                            /* кадр в пути — повторить сразу */
        emit(h, HC_EV_LINK_UP, 0, 0);
    } else {
        h->n_down++;
        emit(h, HC_EV_LINK_DOWN, 0, 0);
    }
}

void hc_poll(hc_t *h, uint32_t now, uint8_t state_pin)
{
    uint8_t raw[64];
    uint16_t nr = 0;
    h->now = now;
    uint16_t used = (uint16_t)((h->rx_head - h->rx_tail + HC_RXQ) % HC_RXQ);
    if (used > h->rx_hiwater)
        h->rx_hiwater = used;
    while (h->rx_tail != h->rx_head) {
        uint8_t b = h->rx[h->rx_tail];
        h->rx_tail = (uint16_t)((h->rx_tail + 1u) % HC_RXQ);
        if (h->st == HC_ST_AT) {
            at_byte(h, b);
        } else if (h->st == HC_ST_DATA) {
            if (!h->raw) {
                btl_byte(h, b);
            } else {
                raw[nr++] = b;
                if (nr == sizeof raw) {
                    emit(h, HC_EV_RAW, raw, nr);
                    nr = 0;
                }
            }
        }                                               /* в OFF и RESTART — мусор включения */
    }
    if (nr)
        emit(h, HC_EV_RAW, raw, nr);
    if (h->busy && after(now, h->deadline))
        at_done(h, HC_RES_TIMEOUT);
    switch (h->st) {
    case HC_ST_OFF:
        if (after(now, h->wait)) {
            h->len = 0;
            hc_hw_uart(HC_AT_BAUD);
            hc_hw_pin(HC_PIN_PWR, 1);
            set_st(h, HC_ST_AT);
        }
        break;
    case HC_ST_AT:
        if (!h->busy)
            at_step(h);
        break;
    case HC_ST_RESTART:
        if (after(now, h->wait)) {
            hc_hw_uart(h->baud);
            hc_hw_pin(HC_PIN_PWR, 1);
            h->state_in = 0;
            h->state_since = now;
            set_st(h, HC_ST_DATA);
        }
        break;
    case HC_ST_DATA:
        link_watch(h, state_pin);
        if (!h->raw)
            btl_poll(h);
        break;
    default:
        break;
    }
}

btl16d.c — кадр поверх канала: COBS, CRC-16, подтверждение и повтор.

/* btl16d.c — раздел 16в: кадры поверх прозрачного канала HC-05.
 * Кадр до COBS: тип, номер, длина, данные, CRC-16/XMODEM старшим байтом вперёд (как в разделе 194).
 * На линии: 00, COBS(кадр), 00. Отправитель держит один кадр, пока не придёт подтверждение с его номером,
 * и повторяет по сроку BTL_RTO; получатель подтверждает каждый целый кадр и выбрасывает повтор. */
#include <string.h>
#include "hc05.h"

uint16_t btl_crc16(const uint8_t *p, uint16_t n)
{
    uint16_t c = 0;                                     /* XMODEM: полином 0x1021, начальное 0 */
    while (n--) {
        c ^= (uint16_t)(*p++ << 8);
        for (int k = 0; k < 8; k++)
            c = (uint16_t)(c & 0x8000u ? (unsigned)c << 1 ^ 0x1021u : (unsigned)c << 1);
    }
    return c;
}

static uint16_t cobs_enc(const uint8_t *in, uint16_t n, uint8_t *out)
{
    uint16_t code_at = 0, o = 1;
    uint8_t code = 1;
    for (uint16_t i = 0; i < n; i++) {
        if (in[i] == 0) {
            out[code_at] = code;
            code_at = o++;
            code = 1;
        } else {
            out[o++] = in[i];
            if (++code == 0xFFu) {
                out[code_at] = code;
                code_at = o++;
                code = 1;
            }
        }
    }
    out[code_at] = code;
    return o;
}

static int cobs_dec(const uint8_t *in, uint16_t n, uint8_t *out, uint16_t cap)
{
    uint16_t i = 0, o = 0;
    while (i < n) {
        uint8_t code = in[i++];
        if (code == 0 || i + code - 1u > n)
            return -1;                                  /* блок длиннее кадра — испорчен */
        for (uint8_t k = 1; k < code; k++) {
            if (o >= cap)
                return -1;
            out[o++] = in[i++];
        }
        if (code < 0xFFu && i < n) {
            if (o >= cap)
                return -1;
            out[o++] = 0;
        }
    }
    return o;
}

uint16_t btl_frame(uint8_t type, uint8_t seq, const uint8_t *p, uint8_t n, uint8_t *out)
{
    uint8_t raw[BTL_RAW];
    raw[0] = type;
    raw[1] = seq;
    raw[2] = n;
    if (n)
        memcpy(raw + 3, p, n);
    uint16_t c = btl_crc16(raw, (uint16_t)(n + 3u));
    raw[n + 3] = (uint8_t)(c >> 8);
    raw[n + 4] = (uint8_t)c;
    out[0] = 0;                                         /* ведущий ноль: обрывок прежнего кадра закончится здесь */
    uint16_t m = (uint16_t)(1u + cobs_enc(raw, (uint16_t)(n + 5u), out + 1));
    out[m++] = 0;
    return m;
}

void btl_reset(btl_t *l)
{
    memset(l, 0, sizeof *l);
}

static void tx_frame(uint8_t type, uint8_t seq, const uint8_t *p, uint8_t n)
{
    uint8_t w[BTL_WIRE];
    hc_hw_tx(w, btl_frame(type, seq, p, n, w));
}

int btl_send(hc_t *h, const uint8_t *p, uint8_t n)
{
    btl_t *l = &h->l;
    if (l->tx_busy || n > BTL_MAX)
        return -1;
    memcpy(l->tx, p, n);
    l->tx_len = n;
    l->tx_busy = 1;
    l->tries = 0;
    l->tx_at = h->now;
    return 0;
}

static void on_frame(hc_t *h, const uint8_t *f, uint16_t n)
{
    btl_t *l = &h->l;
    uint8_t type = f[0], seq = f[1];
    if (type == BTL_T_ACK) {
        if (l->tx_busy && seq == l->tx_seq) {           /* подтверждён кадр в пути */
            l->tx_busy = 0;
            l->tx_seq++;
            l->n_ack++;
            if (h->on_event)
                h->on_event(h, HC_EV_ACKED, l->tx, l->tx_len);
        }
        return;                                         /* запоздалое подтверждение повтора — не наше */
    }
    if (type != BTL_T_DATA)
        return;
    tx_frame(BTL_T_ACK, seq, 0, 0);                     /* подтвердить и повтор: прежнее подтверждение могло пропасть */
    if (l->rx_sync && seq == (uint8_t)(l->rx_next - 1u)) {
        l->n_dup++;                                     /* тот же кадр ещё раз — данные уже отданы */
        return;
    }
    l->rx_sync = 1;
    l->rx_next = (uint8_t)(seq + 1u);
    l->n_rx++;
    if (h->on_event)
        h->on_event(h, HC_EV_RX, f + 3, (uint16_t)(n - 5u));
}

void btl_byte(hc_t *h, uint8_t b)
{
    btl_t *l = &h->l;
    uint8_t f[BTL_RAW];
    if (b) {
        if (l->al < BTL_WIRE)
            l->acc[l->al++] = b;
        else
            l->over = 1;                                /* длиннее любого кадра — мусор до нуля */
        return;
    }
    uint16_t al = l->al;
    uint8_t over = l->over;
    l->al = 0;
    l->over = 0;
    if (!al)
        return;                                         /* ведущий ноль или два нуля подряд */
    int n = over ? -1 : cobs_dec(l->acc, al, f, sizeof f);
    if (n < 5 || f[2] != n - 5) {
        l->n_junk++;                                    /* не кадр: обрывок, склейка, мусор */
        return;
    }
    if (btl_crc16(f, (uint16_t)(n - 2)) != (uint16_t)(f[n - 2] << 8 | f[n - 1])) {
        l->n_crc++;
        return;
    }
    on_frame(h, f, (uint16_t)n);
}

void btl_poll(hc_t *h)
{
    btl_t *l = &h->l;
    if (!h->link || !l->tx_busy || (int32_t)(h->now - l->tx_at) < 0)
        return;
    if (l->tries) {
        l->n_retx++;
        if (l->tries == BTL_STALL) {
            l->n_stall++;
            if (h->on_event)
                h->on_event(h, HC_EV_STALL, l->tx, l->tx_len);
        }
    }
    if (l->tries < 0xFFu)
        l->tries++;
    l->n_tx++;
    tx_frame(BTL_T_DATA, l->tx_seq, l->tx, l->tx_len);
    l->tx_at = h->now + BTL_RTO;
}

fw16d.c — прошивка STM32F103: USART1 с прерываниями, ключ питания, KEY, STATE, главный цикл.

/* fw16d.c — раздел 16в: модуль HC-05 на STM32F103 (BluePill), 72 МГц.
 * USART1: PA9 — TX к RXD модуля, PA10 — RX от TXD модуля; 38 400 бод в AT-режиме, 115 200 — в прозрачном.
 * PB0 — ключ питания модуля (1 — включено), PB1 — KEY (EN) модуля, PB5 — вход STATE с подтяжкой к земле.
 * PC13 — светодиод: горит, пока есть связь. Приём — прерывание RXNE кладёт байт в кольцо драйвера;
 * передача — своё кольцо и прерывание TXE. SysTick раз в 1 мс — только счёт времени; hc_poll — в главном
 * цикле. Тактирование до 72 МГц — как в разделе 6 (здесь не повторяется); USART1 на APB2 = 72 МГц. */
#include <string.h>
#include "stm32f10x.h"
#include "hc05.h"

#define CPU_HZ  72000000u
#define BAUD    115200u
#define TXQ     256u

_Static_assert(CPU_HZ / BAUD == 625u && CPU_HZ % BAUD == 0u, "USART1: BRR = 625 — 115 200 бод");
_Static_assert(CPU_HZ / HC_AT_BAUD == 1875u && CPU_HZ % HC_AT_BAUD == 0u, "USART1: BRR = 1875 — 38 400 бод");

static hc_t H;
static volatile uint32_t ms;
static uint8_t txq[TXQ];
static volatile uint16_t tx_head, tx_tail;
static uint32_t last_tm;
static uint16_t count;

void SysTick_Handler(void)
{
    ms++;
}

void USART1_IRQHandler(void)
{
    uint32_t sr = USART1->SR;
    if (sr & (USART_SR_RXNE | USART_SR_ORE))       /* чтение DR снимает и RXNE, и ORE */
        hc_rx_isr(&H, (uint8_t)USART1->DR);
    if ((USART1->CR1 & USART_CR1_TXEIE) && (sr & USART_SR_TXE)) {
        if (tx_tail != tx_head) {
            USART1->DR = txq[tx_tail];
            tx_tail = (uint16_t)((tx_tail + 1u) % TXQ);
        } else {
            USART1->CR1 &= ~USART_CR1_TXEIE;    /* очередь пуста */
        }
    }
}

/* ── платформа драйвера ───────────────────────────────────────────────── */
void hc_hw_tx(const uint8_t *p, uint16_t n)
{
    for (uint16_t i = 0; i < n; i++) {
        uint16_t nx = (uint16_t)((tx_head + 1u) % TXQ);
        if (nx == tx_tail)
            break;                              /* очередь полна — кадр длиннее 72 байт драйвер не шлёт */
        txq[tx_head] = p[i];
        tx_head = nx;
    }
    USART1->CR1 |= USART_CR1_TXEIE;
}

void hc_hw_uart(uint32_t baud)
{
    /* скорость меняется при снятом питании модуля (200 мс): очередь передачи к этому времени пуста */
    USART1->CR1 &= ~USART_CR1_UE;
    USART1->BRR = CPU_HZ / baud;
    USART1->CR1 |= USART_CR1_UE;
}

void hc_hw_pin(uint8_t pin, uint8_t level)
{
    uint32_t bit = pin == HC_PIN_PWR ? 1u << 0 : 1u << 1;
    GPIOB->BSRR = level ? bit : bit << 16;
}

/* ── приложение ───────────────────────────────────────────────────────── */
static void on_event(hc_t *h, uint8_t ev, const uint8_t *p, uint16_t n)
{
    (void)h;
    if (ev == HC_EV_LINK_UP)
        GPIOC->BSRR = 1u << 29;                 /* PC13 = 0 — светодиод горит */
    else if (ev == HC_EV_LINK_DOWN)
        GPIOC->BSRR = 1u << 13;
    else if (ev == HC_EV_RX && n == 3 && !memcmp(p, "CNT", 3))
        count = 0;                              /* команда с телефона: обнулить счёт */
}

static void board_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN | RCC_APB2ENR_IOPCEN | RCC_APB2ENR_USART1EN;
    GPIOB->BSRR = (1u << 16) | (1u << 17) | (1u << 21);    /* питание выключено, KEY = 0, PB5 — к земле */
    GPIOB->CRL = (GPIOB->CRL & ~0xF000FFu)
               | 0x22u                          /* PB0, PB1 — выходы 2 МГц */
               | 0x8u << 20;                    /* PB5 — вход с подтяжкой (к земле: ODR5 = 0) */
    GPIOA->CRH = (GPIOA->CRH & ~0xFF0u)
               | 0xBu << 4                      /* PA9 — USART1_TX, альтернативная функция */
               | 0x4u << 8;                     /* PA10 — USART1_RX, вход без подтяжки */
    GPIOC->BSRR = 1u << 13;
    GPIOC->CRH = (GPIOC->CRH & ~(0xFu << 20)) | 0x2u << 20;    /* PC13 — светодиод платы */
    USART1->BRR = CPU_HZ / HC_AT_BAUD;
    USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;
    NVIC_EnableIRQ(USART1_IRQn);
    SysTick_Config(CPU_HZ / 1000u);
}

int main(void)
{
    board_init();
    hc_init(&H, "BENCH-16", "4321", BAUD, on_event);
    for (;;) {
        uint32_t now = ms;
        hc_poll(&H, now, (uint8_t)(GPIOB->IDR >> 5 & 1u));
        if (H.link && now - last_tm >= 1000u) {    /* раз в секунду — счёт; не ушёл — попробуем позже */
            uint8_t m[4] = {'C', 'N', (uint8_t)(count >> 8), (uint8_t)count};
            if (btl_send(&H, m, sizeof m) == 0) {
                last_tm = now;
                count++;
            }
        }
        __WFI();                                /* до следующего прерывания: байта или тика */
    }
}

Дальше: раздел 16г «MP3-модуль DFPlayer Mini по UART: кадр команды, контрольная сумма, очередь треков, громкость и ответы модуля» — тот же UART, но на другом конце модуль со своим кадром из 10 байт, суммой, подтверждениями и событиями «трек закончился».

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