Embedded HandbookApps
All sections › III. Peripherals
STM32F103 · level intermediate · 36 min read
Open in the interactive handbook

16в. Bluetooth module HC-05/HC-06: transparent UART, AT mode, pairing and a protocol over the link

In 16, 16а and 16б the UART ran over a wire: the frame, the baud rate, RS-485, reception through DMA into a ring. Running a wire is not always convenient: the device sits in a cabinet, and you want to set it up from a phone. The cheapest way to drop the wire is an HC-05 or HC-06 module. For the microcontroller it is still the same UART, for a phone or a computer it is a serial port over Bluetooth. Simple to look at, but radio is not a wire: bytes arrive in bursts and late, the link breaks, and when it breaks everything that was on the way is lost.

What is inside: how the HC-05 differs from the HC-06, how to connect the module to an STM32F103 and why the STATE and KEY pins matter; what the transparent mode is and how bytes behave in the air; the AT mode and setting the module up without waiting in a loop; pairing with a phone and with a second HC-05; a frame with a number, a length and CRC-16, acknowledgement and retransmission over the link; the link indicator on STATE, reconnection and power draw — why the HC-05 is not for a battery and why BLE is better there.

Sources: DocID13587 Rev 17 (STM32F103x8/xB): PA9, PA10 are USART1_TX, USART1_RX with 5 V tolerance, p. 31; the input high threshold is 0.65·VDD, table 35, p. 62. The COBS frame with CRC-16/XMODEM comes from 194.4, the leading zero from 189а.2, non-blocking parsing of AT replies from 106г.3. The project has no documents on the HC-05 and HC-06 and no Bluetooth specification: the AT command set, the form of replies, the defaults (name, PIN, baud rate), the AT-mode baud rate and the meaning of the STATE and KEY pins are recalled from the manufacturer's documentation and marked "not verified". Everything the section calls the run is the code _verify/bt16d/hc05.c and _verify/bt16d/btl16d.c on a computer (gcc 12.2.0) with a model of the module, the air and the phone in Python 3.11; the firmware is built by armclang 6.24 for the Cortex-M3.

What it is called in the documentation

Original Meaning
Bluetooth Classic (BR/EDR) classic Bluetooth
Serial Port Profile (SPP), RFCOMM the "serial port" profile and its protocol
Bluetooth Low Energy (BLE) low-power Bluetooth
pairing, PIN (passkey) pairing, pairing code
master / slave, role master / slave, the role
bind address the address to connect to
transparent (data) mode transparent mode: bytes as they are
AT command mode AT command mode
link supervision timeout time after which the link is considered lost
COBS (Consistent Overhead Byte Stuffing) replacing zeros in a frame with a fixed overhead
ARQ, stop-and-wait retransmission on timeout; "send, then wait for the acknowledgement"

16в.1 The module: HC-05 and HC-06, supply, levels, STATE and KEY

Two modules. Both are classic Bluetooth with the SPP profile: a phone or a computer sees them as a serial port. The HC-05 can be a slave (others connect to it) or a master (it connects to another module itself) — the role is set by AT+ROLE. The HC-06 is a slave only, and its AT commands are simpler and look different. This is recalled from the manufacturer's documentation; the project does not have it, not verified.

Supply and levels. The module itself lives on 3.3 V, and its UART is 3.3 V too. Most often it is sold on a carrier board with a regulator and six pins: STATE, RXD, TXD, GND, VCC, EN (also called KEY); the board is powered from 5 V (from memory, the range is in the board's document, not verified). With an STM32F103 the lines connect directly: the 3.3 V output PA9 goes to the module's RXD input, the module's TXD output goes to PA10. PA10 is 5 V tolerant (DocID13587, p. 31) and is guaranteed to see a "one" from 0.65·VDD (table 35, p. 62) — at 3.3 V that is 2.145 V; compare the module's output level against this threshold using its document. The dangerous side is the other one — a 5 V board: its TX output into the module's RXD without a divider (16.7).

STATE and KEY. On common boards STATE is high while there is a link to the phone — this is how the firmware learns about a break (16в.6). KEY high at the moment of power-up puts the HC-05 into AT mode at 38 400 baud; otherwise the module starts in transparent mode at its working baud rate. Both statements are from memory, not verified. Hence the circuit: the module's supply goes through a switch on PB0, so the firmware itself restarts the module in the needed mode; KEY goes to PB1; STATE goes to the PB5 input with a pull-down, so a broken wire reads as "no link".

Left — an STM32F103 and an HC-05 board: PA9 TX to RXD, PA10 RX from TXD, PB0 drives the switch of the module's VCC, PB1 to KEY (EN), PB5 is the input from STATE, common ground; 3.3 V levels connect directly, a 5 V board needs a divider into the module's RXD; STATE high means a link, per the documentation, not verified. Right — two modes: KEY = 1 at power-up is AT mode at 38 400 baud, KEY = 0 is transparent mode at 115 200 baud; between them the supply is removed for 200 ms; the path of a byte: UART, the module's buffer, a burst through the air to the phone with the SPP profile; on a break everything on the way is lost, and STATE drops later, in the model after 1 s. Bottom — the frame over the link: leading 00, type, number, length, data 0…64 bytes, CRC-16, trailing 00; everything between the zeros is in COBS, the whole frame up to 72 bytes. Bottom right — the exchange: DATA k, ACK k; the frame is lost — after 250 ms a retransmission; STATE = 0 for longer than 20 ms — retransmissions pause, STATE = 1 again — the frame goes out at once; a repeat of an accepted frame is acknowledged and dropped


16в.2 Transparent mode: UART through the air

In transparent mode the module knows nothing about messages: whatever the firmware puts out on TX appears in the port on the phone, and back. There are no boundaries: where one message ended and another began, the module does not pass on. The module's UART baud rate is the one written with AT+UART; on an F103 at 72 MHz BRR for 115 200 baud is 625, for 38 400 it is 1875, for 9600 it is 7500, all three divide without a remainder. A byte at 115 200 baud takes 86.8 µs.

Inside, the module collects bytes from the UART and sends them into the air in bursts, and the radio link adds its own delay. How exactly a real module collects them is not verified; the bench model sends a burst 5 ms after the first byte or at 128 bytes, and a burst travels through the air for 8…30 ms. The numbers below follow from these parameters and are not a measurement of a module; what matters is their shape:

Experiment in the model Result
one byte to the phone, 50 times from 13.2 to 34.4 ms, median 22.8 ms
a 200-byte block in one write 4 bursts at the phone: 58, 58, 58 and 26 bytes
first and last burst 30.8 and 34.9 ms after the write
echo of the same block back at the device after 72 ms, in 18 pieces by polls

Hence the rule, the same as in 189.2 for BLE: a message must survive being cut and glued at any point. A block written at once arrives in pieces; two short messages in a row may arrive as one burst. Reading "one message per read" does not work.

Within an established link the radio channel itself usually does not deliver corrupted bytes: Bluetooth has its own checks and retransmissions in the air (recalled from the specification, not verified). Bytes are lost elsewhere: on a break — everything that lay in the module's buffer and was flying through the air, and on the UART wires, if they are long and a power stage is noisy nearby.


16в.3 AT mode: entering by KEY and setup without waiting

A command is an AT… line ended by a CR LF pair; the reply is a data line and OK, or an error like ERROR:(0). The set and form of commands are recalled from the manufacturer's documentation for firmware 2.0, not verified; other firmware versions look different (for example, the PIN is in quotes):

Command Model reply What it does
AT OK the module is alive and caught the baud rate
AT+VERSION? +VERSION:2.0-20100601 firmware version
AT+NAME?, AT+NAME=имя +NAME:HC-05 the name the phone sees
AT+PSWD?, AT+PSWD=4321 +PSWD:1234 pairing code
AT+UART?, AT+UART=115200,0,0 +UART:9600,0,0 baud rate, stop bits, parity of transparent mode
AT+ROLE?, AT+ROLE=1 +ROLE:0 0 — slave, 1 — master
AT+CMODE=0, AT+BIND=… OK for a master: connect only to the address from BIND
AT+ADDR? +ADDR:… own address — it goes into the master's BIND

The defaults in the model are name HC-05, PIN 1234, 9600,0,0, role 0; this is how they are usually named, but the document is not verified.

Parsing of replies works as in 106г.3: a ring, line assembly up to LF, one command at a time with its own deadline, — and is not repeated here. It is simpler: there are no unsolicited messages in AT mode, and every command has one final reply — OK or ERROR. The main point is different: ask first, then write. For each setting the driver sends a query, writes on a mismatch and asks again:

    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;
        }

This way the module is not rewritten at every power-up, and a write that did not take is seen at once: the repeated query returns the old value, and the driver stops in the FAIL state instead of working at a foreign baud rate.

Run: a module "from the factory". The driver removes the supply for 200 ms, sets KEY = 1, powers the module up and sends AT every 0.3 s up to 10 times. The model boots for 500 ms, so the first two AT went unanswered, and OK came 612 ms after power-up. Then — 14 commands, of them 3 writes (name BENCH-16, PIN 4321, baud 115 200); the role is already 0, it was only queried. Then the supply is removed for another 200 ms, KEY = 0, the module is powered up in transparent mode at 115 200 baud — at 1192 ms from the start. A repeated start with the same settings: 8 commands, not a single write, 1117 ms.


16в.4 Pairing: a phone and a second HC-05

Everything here is qualitative — in the model pairing is reduced to one event "the phone connected", and a real module was not tested.

An Android phone. In the Bluetooth settings the module is visible under its name (BENCH-16); pairing uses the code from AT+PSWD. Pairing is not yet a link: the channel is opened by an app that speaks SPP, and only then does STATE go high. In the model, after the connection the driver saw the link after 21 ms — 20 ms of STATE filtering and one poll step.

An iPhone. As far as is known, iOS apps cannot use classic SPP with arbitrary devices, and the HC-05 does not work with an iPhone — BLE is needed (16в.6). Not verified.

A computer. After pairing, Windows creates virtual ports "Standard Serial over Bluetooth link" — on the machine where 115б.2 was written, 7 of them were found and not a single cable. Find the port by the device name; do not take the first one.

Two HC-05 with each other. One stays a slave as it is. The second one in AT mode gets: AT+ROLE=1, AT+CMODE=0, the same PIN and the same baud rate, and AT+BIND= with the address of the first one from its AT+ADDR?, with colons replaced by commas: 1234:56:abcdef → AT+BIND=1234,56,abcdef (the address is a placeholder). After power-up the master looks for its slave and connects by itself, and reconnects by itself after a break. All from memory, not verified.


16в.5 A protocol over the link: frame, acknowledgement, retransmission

The transparent link gives a byte stream without boundaries, and on a break it loses the tail. A protocol of its own is needed on top, and almost all of it is already in the book: the frame from 194.4 — the data, CRC-16/XMODEM high byte first, everything in COBS, a zero at the end; the leading zero before the frame comes from 189а.2: a fragment of an earlier frame stuck in the receiver after a break ends on it and is dropped. Inside the frame there are three bytes here: type (data or acknowledgement), number and length — the length is checked once more against what came out of 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;
}

The run checks the C code against an independent implementation in Python (binascii.crc_hqx and its own COBS): the CRC of the string 123456789 is 0x31C3, as in 194.4; the frames matched byte for byte:

Data Frame, bytes Overhead At 115 200 baud
MSG 0001, number 1 16: 00 0E 01 01 08 4D 53 47 20 30 30 30 31 6C 1C 00 8 1389 µs
8 zero bytes 16: 00 04 01 01 08 01 01 01 01 01 01 01 03 9E F3 00 8 1389 µs
64 bytes 72 8 6250 µs

The overhead is 8 bytes per frame for any data up to 64 bytes: two zeros, the COBS byte, type, number, length and CRC.

Acknowledgement and retransmission. The sender holds one frame until an acknowledgement with its number arrives, and repeats it every 250 ms. The receiver acknowledges every intact frame — repeats too: a repeat means the earlier acknowledgement got lost. A repeat is recognised by its number — it equals the number of the last accepted frame — and is dropped, so the data reaches the application exactly once:

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));
}

The first frame after start is accepted with any number (rx_sync = 0), and so is any number other than that of the last frame: restarting the firmware or the app on the phone does not break the exchange.

The experiment. The device sends 1000 messages MSG 0001…MSG 1000, the phone echoes them back. The link breaks every 5…9 s for 2…4 s; the air dies at once, STATE drops after 1 s; on the UART wires a byte is corrupted with probability 0.0003 and lost with probability 0.0001 — all of these are model parameters. Without a protocol the device sends a line every 50 ms, and the phone returns the bytes as they came; with the protocol the device sends the next message after the previous one is acknowledged, and the phone echoes in a frame of its own.

no protocol with protocol
messages 1000 1000
link breaks during the run 5 6
came back intact, once each, in order 706 1000
lost 294 0
corrupted lines 3 —
frame retransmissions: device / phone — 46 / 33
repeats dropped: device / phone — 4 / 4
frames with a bad CRC: device / phone — 6 / 9
non-frame fragments: device / phone — 2 / 8
run time 50 500 ms 71 211 ms

Without a protocol almost a third of the messages vanished — everything sent in the second before STATE dropped and while there was no link. With the protocol everything arrived; the price is extra time: the median acknowledgement came after 45 ms, and across a break — up to 3779 ms.


16в.6 STATE, reconnection and power draw

The link indicator. The STATE pin is read on every poll; a level counts as new once it has held for 20 ms:

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);
    }
}

While there is no link, retransmissions pause, and the frame on the way is not thrown away. In the experiment of 16в.5 the driver noticed STATE dropping after 20 ms all 6 times, and after the link came back the first acknowledgement arrived after 40…54 ms. But STATE is a late indicator: the module itself learns about a break only when the link loss timeout expires (in the model — 1 s; for a real module — not verified), and everything sent during that time is lost. This gap is closed not by STATE but by acknowledgements. If STATE is not connected at all, the protocol works on retransmissions alone; after 8 retransmissions in a row the driver reports "the link is silent" — in the run STATE never let it get that far.

Reconnection. A slave HC-05 waits for the phone to connect again; a master with AT+CMODE=0 connects to its address by itself (from memory, not verified). The firmware needs to do nothing: frame numbers outlive the link, and the receiver drops a repeat after reconnection.

Power draw. Classic Bluetooth keeps the link up all the time, and the module's radio works almost all the time; the HC-05 has no deep sleep that keeps the link. So it does not live long on a battery — the project has no current figures, not verified. There is one way to save: remove the supply with the switch on PB0 when the link is not needed. BLE is designed for the battery — "for short messages and long sleep" (89.5):

HC-05, classic SPP BLE (158, 189, 198э)
what the app sees a byte stream, a port messages no longer than the MTU: 20 bytes at MTU 23
phones Android, computers Android, iPhone, computers, the web with caveats
microcontroller any with a UART, the module runs the stack stack on the chip (ESP32, STM32WB) or a module
sleep the link keeps the radio on the link lives between short sessions
protocol on top frame, acknowledgement, retransmission the same frame, plus cutting by the MTU

16в.7 Pitfalls

The module heats up or died after being connected to a 5 V board. The TX output of a 5 V board went straight to the module's RXD. The module's input is 3.3 V: a divider or a level translator (16.7). From a 3.3 V STM32 — directly.

In AT mode the module does not answer, though in transparent mode it worked at 9600. AT mode runs at 38 400 baud regardless of the working baud rate (per the documentation, not verified). And the other way round: after AT+UART=115200,0,0 transparent mode runs at 115 200, not at the old 9600. Forgot which baud rate was written — enter AT mode and ask AT+UART?.

The module is silent in reply to AT. The terminal sends only CR, and the module needs the CR LF pair; without it the module waits for the end of the line. The next command with CR LF arrives glued to the previous one and gets ERROR (so in the model).

AT mode does not engage. KEY was applied after power-up, not before it. Remove the supply, set KEY = 1, power up — this is why the module's supply goes through a switch.

Not a single byte in either direction. TX and RX are swapped: the microcontroller's TX goes to the module's RXD, the module's TXD to the microcontroller's RX.

Messages arrive cut or glued. This is a property of the link, not a bug: the module's bursts cut the stream wherever they happen to. A frame with a separator is needed (16в.5), not "one read — one message".

After a break the last few messages are lost. STATE drops later than the link breaks; everything sent in that gap is gone. Acknowledgement of every frame and retransmission — STATE does not replace them.

An iPhone does not see the module. The HC-05 is classic SPP; an iPhone needs BLE (from memory, not verified).

Two HC-05 do not connect. Different PINs or baud rates, the slave was also turned into a master, or the address in AT+BIND was written with colons instead of commas.


16в.8 Summary

Full sources

hc05.h — driver and frames: buffers, states, events and prototypes.

/* 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 — the HC-05 module: AT mode, settings check, transparent mode, 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 — the frame over the link: COBS, CRC-16, acknowledgement and retransmission.

/* 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 firmware: USART1 with interrupts, supply switch, KEY, STATE, the main loop.

/* 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();                                /* до следующего прерывания: байта или тика */
    }
}

Next: section 16г "MP3 module DFPlayer Mini over UART: command frame, checksum, track queue, volume and module replies" — the same UART, but at the other end a module with its own 10-byte frame, checksum, acknowledgements and "track finished" events.

Open in the interactive handbook