STM32 з нуля без HAL: UART від bit-bang до HAL. Частина 2

Платформа: Blue Pill (STM32F103C8T6) · ST-Link v2 · Linux · arm-none-eabi-gcc
Вступ — ARM для Ардуїнщиків, які прагнуть чогось більшого
Частина 1 — STM32 з нуля без HAL: GPIO, регістри і переривання

Мигати світлодіодом — це звісно дуже захопливо. В такі моменти згадую одного знайомого, досвідченого інженера-електронщика. В різні періоди життя він перепрошивав телевізори, які контрабандою везли із-за кордону, дуже цікаво та яскраво розповідав, як просвічував екран приладом, бо то був основний компонент, а решту могли замінити. Потім відкрив майстерню, найняв людей. Згодом добував золото з компонентів. Потім вагонами чимось торгував. Потім нерухомість, гарно заробив — і криза 2008, продав майже все через кредити, поїхав до Чехії працювати електриком. Чистий підприємець: вигідно мити золото — миє золото, вигідно будувати дороги — будує дороги.

Та про що це я.

Ми познайомились з ним, в той період життя коли я вивчав електроніку, він постійно казав що грошей тут немає, треба щось інше, в тебе ж освіта і досвід, посада, навіщо тобі це? Одного дня після повчального вступу він розповів історію, одразу зазнчивши, що ця історія стала переломною в його житті, бо він злякався, що може залишитись ні з чим. Отже, в нього був майстер рівня бог, який ремонтував і апґрейдив телевізори. Одного дня він завітав до цього майстра в гості — минули вже певні роки. І той чудак сидів і дивився як блимає синій світлодіод сидячи за столом обладнаним витяжкою, труба йшла у прорізаний в вікні отвір. Виглядав він неймовірно щасливим, а навкруги в кімнаті голо і по кутках явно ховались злидні. Але ж синій світлодіод, в ті роки така рідкість як місячний камінь.

Побачивши це мій знайомий злякався і поклявся що покладе життя, але буде заробляти великі гроші. Ну і заробив, не великі але має стабільний пасивний дохід. Але зараз все одно працює інженером-електронщиком у дуже великій компанії — ремонтує складне обладнання і розробляє своє, будує дачу в лісі і насолоджується життям.

Чому я ним так захоплююсь — бо саме він показав мені, що таке осцилограф і дільник напруги, як хакнути прилад, зчитати Flash пам’ять, зняти копондаун і змінити прошивку вручну. Для мене то була просто магія.

Навіщо, я згадав це все? Бо нещодавно я спіймав себе на думці що сиджу і блаженно дивлюсь як блимає світлодіод у реалізованому мною кастомному HAL. Синього кольору до речі 😄

Ну HAL вже є пора навчити його говорити. Сьогодні розберемо UART: від фізики протоколу і власноруч написаного bit-bang до апаратного USART і зрозуміємо чому перший варіант видає крякозябри а другий працює ідеально.

Що таке UART фізично

Уявімо найпростішу можливу комунікацію між двома пристроями. Один провід. Жодного тактового сигналу. Тільки напруга і час.

 STM32 TX ─────────────────── RX USB-UART
 STM32 RX ─────────────────── TX USB-UART
 GND      ─────────────────── GND

Саме так виглядає UART. Немає clock лінії як в SPI чи I2C. Передавач і приймач просто домовляються заздалегідь: «один біт триває рівно N мікросекунд». Це і називається baud rate.

9600 baud → 1 біт = 1/9600 = 104 мкс
115200 baud → 1 біт = 1/115200 = 8.6 мкс

В спокої лінія завжди HIGH. Коли хочемо передати байт — опускаємо в LOW. Це старт-біт, сигнал приймачу: «починаю передачу».

 Спокій:     ────────────────────
 Старт-біт:  ────────┐
                     └──── (LOW, 104 мкс)

Далі йдуть 8 біт даних — молодший біт першим (LSB first). Потім стоп-біт — повертаємось в HIGH мінімум на 104 мкс.

Наприклад символ 'A' = 0x41 = 0b01000001:

 | START | b0 | b1 | b2 | b3 | b4 | b5 | b6 | b7 | STOP |
 |   0   |  1 |  0 |  0 |  0 |  0 |  0 |  1 |  0 |   1  |

Кожен стан тримається рівно 104 мкс. Приймач читає кожен біт по центру інтервалу — щоб не зловити перехідний момент а стабільний рівень.

Реалізуємо UART руками — bit-bang

Якщо UART це просто «підніми/опусти пін і почекай 104 мкс» — спробуємо зробити це власноруч без жодної периферії. Тільки GPIO і затримка.

PA9 — це пін TX на Blue Pill. Налаштовуємо як звичайний output і смикаємо вручну:

#include <stdint.h>
#define RCC_BASE        0x40021000
#define RCC_APB2ENR     (*(volatile uint32_t *)(RCC_BASE + 0x18))
#define GPIOA_BASE      0x40010800
#define GPIOA_CRH       (*(volatile uint32_t *)(GPIOA_BASE + 0x04))
#define GPIOA_ODR       (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
#define GPIOC_BASE      0x40011000
#define GPIOC_CRH       (*(volatile uint32_t *)(GPIOC_BASE + 0x04))
#define GPIOC_ODR       (*(volatile uint32_t *)(GPIOC_BASE + 0x0C))
#define TX_PIN  (1 << 9)   // PA9
#define LED_PIN (1 << 13)  // PC13
void delay(volatile uint32_t n) { while (n--); }
void delay_bit(void) {
    delay(150);  // ~104 мкс при 8MHz... або ні?
}
void uart_tx_byte(uint8_t data) {
    // старт-біт
    GPIOA_ODR &= ~TX_PIN;
    delay_bit();
    // 8 біт LSB first
    for (int i = 0; i < 8; i++) {
        if (data & (1 << i))
            GPIOA_ODR |= TX_PIN;
        else
            GPIOA_ODR &= ~TX_PIN;
        delay_bit();
    }
    // стоп-біт
    GPIOA_ODR |= TX_PIN;
    delay_bit();
}
void uart_tx_str(const char *s) {
    while (*s) uart_tx_byte(*s++);
}
int main(void) {
    RCC_APB2ENR |= (1 << 2) | (1 << 4);  // GPIOA + GPIOC
    // PC13 — output
    GPIOC_CRH &= ~(0xF << 20);
    GPIOC_CRH |=  (0x2 << 20);
    // PA9 — output (звичайний, не AF!)
    GPIOA_CRH &= ~(0xF << 4);
    GPIOA_CRH |=  (0x2 << 4);
    // idle = HIGH
    GPIOA_ODR |= TX_PIN;
    while (1) {
        GPIOC_ODR ^= LED_PIN;
        uart_tx_str("HELLO\r\n");
        delay(500000);
    }
}

Прошиваємо. Відкриваємо minicom:

</code>minicom -b 9600 -D /dev/ttyUSB0 І бачимо... щось типу такого: <code>÷▒Å╬╣H² !ʝ╠▒

Мда, ось тобі і GangBang, тобто bit-bang UART 😄

Чому крякозябри — три причини

Причина 1: -O0 і брехливий delay

В нашому Makefile стоїть прапорець -O0 — вимкнена оптимізація. Здається безпечно — код робить чітко те що написано. Але ж як завжди є нюанс.

При -O0 компілятор не оптимізує навіть тривіальний цикл. Ось такwhile(n--) перетворюється в асемблері:

; while(n--) при -O0 — 4-5 інструкцій на ітерацію:
LDR  r3, [r7, #4]   ; завантажити n з пам'яті
MOV  r2, r3          ; скопіювати
SUBS r3, #1          ; відняти 1
STR  r3, [r7, #4]   ; записати назад в пам'ять
CMP  r2, #0          ; порівняти
BNE  loop            ; стрибнути якщо не нуль

При -O2 той самий цикл:

; while(n--) при -O2 — 2 інструкції:
 SUBS r0, #1
 BNE  loop

Тобто при -O0 одна ітерація займає вдвічі більше часу. delay(150) при -O0 і -O2 — це різний реальний час. Bit-bang UART ламається.

Причина 2: HSI нестабільний

Blue Pill стартує на внутрішньому RC генераторі — HSI (High Speed Internal). Частота 8MHz але точність ±1-2%. Звучить непогано але подивимось що відбувається з накопиченою похибкою при передачі байта:

Теоретично:  104.16 мкс на біт
HSI +2%:     106.24 мкс на біт
Після 8 біт:
Теоретично:  833 мкс
HSI +2%:     849 мкс → похибка 16 мкс → майже пів біта!

Приймач читає біти по центру інтервалу. Якщо ти запізнився на пів біта — він читає вже наступний біт. Замість 'A' отримуємо '÷'.

Є ще зовнішній кварц HSE на 8MHz — він точніший, ±50ppm проти ±2% у HSI. Але без явного налаштування RCC мікроконтролер стартує на HSI. Тому навіть якщо кварц припаяний — він не використовується поки ти не скажеш йому явно.

Причина 3: накопичена похибка

Так що так. Кожен біт додає маленьку похибку. До 8-го біта вони накопичуються:

Біт 0: +2 мкс похибки
Біт 1: +4 мкс
Біт 2: +6 мкс
...
Біт 7: +16 мкс ← приймач читає не той біт

Саме тому UART такий капризний до таймінгів. На відміну від SPI чи I2C де є clock лінія і приймач завжди знає коли читати, а цей тупо довіряє часу.

Таблиця: що відбувається з бітами

Ідеальний сигнал 'A' = 0x41:
         ┌────┐               ┌────┐
─────────┘    └───────────────┘    └─────
  START   1  0  0  0  0  0  1  0   STOP
Наш bit-bang з похибкою +2%:
         ┌─────┐               ┌─────┐
─────────┘     └───────────────┘     └───
  START    1  0  0  0  0  0   1  0   STOP
                                   ↑
                         приймач читає тут — вже наступний біт!

Що далі

Bit-bang UART, як духовний наставник навчив головному — UART це час і тільки час 😄. Старт-біт, 8 біт даних, стоп-біт, кожен рівно N мікросекунд. Коли час неточний — отримуємо крякозябри. Сумно, що не вийшло реалізувати через смикання, але може ще сюди повернусь, якщо тре буде звісно.

Вирішення очевидне: використати апаратний блок USART всередині STM32 який має власний точний таймер і смикає піном сам без участі CPU. Саме це розберемо в частині 2 — разом з тим чому не всі baud rate однаково добре працюють на 8MHz і коли на мою думку варто переходити на PLL і 72MHz.

Шпаргалка частини 1

ТермінЩо це
Baud rateкількість біт за секунду
Старт-бітHIGH→LOW, сигнал початку передачі
Стоп-бітповернення в HIGH, кінець передачі
LSB firstмолодший біт передається першим
HSIвнутрішній RC генератор 8MHz ±2%
HSEзовнішній кварц 8MHz ±50ppm
Bit-bangемуляція протоколу через GPIO вручну
-O0вимкнена оптимізація компілятора
Baud rateТривалість біта
9600104 мкс
1920052 мкс
5760017 мкс
1152008.6 мкс

Частина 2 — Hardware UART + HAL.

Апаратний USART — що всередині

Добре, вище ми смикали піном вручну і отримували крякозябри. Тепер подивимось що є всередині STM32F103 і що воно таке чи точно воно краще.

Всередині чіпа є окремий блок — USART. По суті це той самий bit-bang але реалізований в кремнії з власним точним таймером. Поки CPU займається своїми справами — USART сам відраховує мікросекунди і смикає піном.

Мій bit-bang:
CPU → ODR → пін PA9
(CPU зайнятий повністю)
Hardware USART:
CPU → DR → USART блок → пін PA9
(CPU кинув байт і вільний)

STM32F103 має три апаратні USART:

  • USART1 — на пінах PA9 (TX) і PA10 (RX), шина APB2
  • USART2 — на пінах PA2 (TX) і PA3 (RX), шина APB1
  • USART3 — на пінах PB10 (TX) і PB11 (RX), шина APB1

Ми використовуємо USART1 — він на APB2 яка працює на повній частоті ядра.

Alternate Function — пін який керує залізо

Вище ми налаштовували PA9 як звичайний output (0x2). Тоді піном керує регістр ODR — що записали туди те й на піні.

Але для апаратного UART треба інший режим — Alternate Function. Це означає: від’єднати пін від ODR і підключити напряму до блоку USART. Тепер USART сам керує піном без участі нашого коду.

Звичайний output:     CPU → ODR → PA9
 Alternate Function:   USART1 → PA9
                      (ODR ігнорується)

В регістрі CRH для PA9 (біти [7:4]):

// Output push-pull 2MHz — звичайний вихід
0x2 = 0b0010  // MODE=10, CNF=00
// Alternate Function push-pull 50MHz — для USART
0xB = 0b1011  // MODE=11, CNF=10

Якщо залишити 0x2 — апаратний USART фізично не зможе керувати піном. Це одна з найпоширеніших помилок при першому знайомстві з USART.

Регістри USART1

 #define USART1_BASE  0x40013800
 #define USART1_SR    (*(volatile uint32_t *)(USART1_BASE + 0x00))  // статус
 #define USART1_DR    (*(volatile uint32_t *)(USART1_BASE + 0x04))  // дані
 #define USART1_BRR   (*(volatile uint32_t *)(USART1_BASE + 0x08))  // baud rate
 #define USART1_CR1   (*(volatile uint32_t *)(USART1_BASE + 0x0C))  // керування

Чотири регістри — це весь мінімум для роботи з UART.

SR — Status Register. Прапорці стану. Нас цікавлять два:

  • біт 7 TXE — TX буфер порожній, можна писати наступний байт
  • біт 5 RXNE — RX буфер не порожній, є новий байт для читання

DR — Data Register. Пишемо байт сюди — USART відправляє. Читаємо звідси — отримуємо прийнятий байт.

BRR — Baud Rate Register. Коефіцієнт ділення тактової частоти. Формула проста:

BRR = CPU_HZ / baud_rate

CR1 — Control Register 1. Вмикаємо потрібні біти:

  • біт 13 UE — увімкнути USART
  • біт 3 TE — увімкнути передавач (TX)
  • біт 2 RE — увімкнути приймач (RX)

Baud rate і похибка — чому не всі значення однакові

Ось де починається цікаве. Формула BRR = CPU_HZ / baud дає ціле число тільки якщо CPU_HZ ділиться рівно. Якщо ні — є залишок, а залишок це похибка.

При 8MHz:

Baud rateBRR точнеBRR реальнеПохибка
9600833.338330.04% ✓
19200416.674170.08% ✓
57600138.891390.08% ✓
11520069.44690.64% ✓
23040034.72350.79% ⚠
46080017.36172.08% ✗
9216008.6893.7% ✗

UART стандарт допускає похибку до ±2%. Тому на 8MHz нормально працює до ~230400 baud. Вище вже ненадійно.

Якщо хочу 921600 або 1M baud без похибок? Потрібна частота 72MHz через PLL. Тоді:

 72MHz / 921600 = 78.125 → похибка 0.16% ✓
 72MHz / 1000000 = 72    → похибка 0% ✓

Налаштування PLL то вже окрема велика тема. Буду розбирати її коли буду йти по дорожній карті другого місяця і підключати Luckfox. Поки що мені вистачає 8MHz і 115200 baud. Але нарешті стало ясно, як то реалізовано в ардуїні.

Hardware UART без HAL — голі регістри

Ось, без будь-яких абстракцій видно що відбувається:

#include &lt;stdint.h&gt;
#define RCC_APB2ENR  (*(volatile uint32_t *)0x40021018)
#define GPIOA_CRH    (*(volatile uint32_t *)0x40010804)
#define GPIOC_CRH    (*(volatile uint32_t *)0x40011004)
#define GPIOC_ODR    (*(volatile uint32_t *)0x4001100C)
#define USART1_SR    (*(volatile uint32_t *)0x40013800)
#define USART1_DR    (*(volatile uint32_t *)0x40013804)
#define USART1_BRR   (*(volatile uint32_t *)0x40013808)
#define USART1_CR1   (*(volatile uint32_t *)0x4001380C)
void uart_init(uint32_t baud) {
    // тактування GPIOA (біт 2) і USART1 (біт 14)
    RCC_APB2ENR |= (1 &lt;&lt; 2) | (1 &lt;&lt; 14);
    // PA9 → Alternate Function push-pull 50MHz
    GPIOA_CRH &amp;= ~(0xF &lt;&lt; 4);
    GPIOA_CRH |=  (0xB &lt;&lt; 4);
    // baud rate
    USART1_BRR = 8000000UL / baud;
    // увімкнути USART + TX
    USART1_CR1 = (1 &lt;&lt; 13) | (1 &lt;&lt; 3);
}
void uart_putc(char c) {
    while (!(USART1_SR &amp; (1 &lt;&lt; 7)));  // чекаємо TXE
    USART1_DR = c;
}
void uart_puts(const char *s) {
    while (*s) uart_putc(*s++);
}
int main(void) {
    // LED PC13
    RCC_APB2ENR |= (1 &lt;&lt; 4);
    GPIOC_CRH &amp;= ~(0xF &lt;&lt; 20);
    GPIOC_CRH |=  (0x2 &lt;&lt; 20);
    uart_init(9600);
    uart_puts("Hardware UART works!\r\n");
    while (1) {
        GPIOC_ODR ^= (1 &lt;&lt; 13);
        uart_puts("blink\r\n");
        for (volatile int i = 0; i &lt; 500000; i++);
    }
}

Прошиваємо і в minicom бачимо чистий текст без жодної крякозябри. Ось вона різниця між bit-bang і апаратною периферією.

Але main.c — вже захаращений адресами і магічними числами. А ще ж тре I2C, SPI і таймери. Мабуть прийшов час робити HAL.

HAL

HAL — Hardware Abstraction Layer ні, робити будемо не монструозний ST HAL який генерує CubeMX, а свій такий манюнькій. Ідея проста, додаю слой абстракції і ховаю регістри і адреси за зрозумілими функціями.

Порівняймо:

// без HAL
GPIOA_CRH &amp;= ~(0xF &lt;&lt; 4);
GPIOA_CRH |=  (0xB &lt;&lt; 4);
RCC_APB2ENR |= (1 &lt;&lt; 14);
USART1_BRR = 8000000UL / 9600;
USART1_CR1 = (1 &lt;&lt; 13) | (1 &lt;&lt; 3);
// з HAL
uart_init(9600);

Результат однаковий. Але другий варіант читається по людські.

Структура файлів

stm32_hal/
├── hal/
│   ├── hal.h           ← головний include
│   ├── hal.c           ← hal_init()
│   ├── hal_gpio.h/.c   ← GPIO
│   ├── hal_systick.h/.c← delay_ms
│   └── hal_uart.h/.c   ← UART
├── src/
│   ├── startup.c
│   └── main.c
├── ld/
│   └── stm32f103.ld
└── Makefile

Концепція PIN_Pxx

В Arduino є D13, A0 зручні імена для пінів. Зроблю те саме але для STM32. Кожен пін пакую в один uint32_t де старші біти це адреса порту, молодший байт це номер піна:

 #define PACK_PIN(port, pin)  ((uint32_t)(port) | (uint32_t)(pin))
 #define UNPACK_PORT(p)       ((p) &amp; 0xFFFFFF00)
 #define UNPACK_PIN(p)        ((p) &amp; 0xFF)
 // готові макроси
 #define PIN_PA9   PACK_PIN(GPIOA_BASE, 9)
 #define PIN_PC13  PACK_PIN(GPIOC_BASE, 13)

І тепер функції самі розпаковують порт і пін — більше не треба думати який регістр CRL чи CRH, яке зміщення:

 gpio_init(PIN_PC13, OUTPUT);
 gpio_toggle(PIN_PC13);

hal_gpio — як gpio_init вмикає тактування сам

Найважливіша деталь нашого HAL тут gpio_init сам вмикає тактування потрібного порту. Як ST HAL, не як в Arduino де все вмикається одразу при старті, колись то було не зрозуміло і цікаво, ножки в повітрі висять, якісь наводки, ех це так мило, були ж часи, коли кока-кола була солодша 😄

static void rcc_enable(uint32_t port) {
    if      (port == GPIOA_BASE) RCC_APB2ENR |= (1 &lt;&lt; 2);
    else if (port == GPIOB_BASE) RCC_APB2ENR |= (1 &lt;&lt; 3);
    else if (port == GPIOC_BASE) RCC_APB2ENR |= (1 &lt;&lt; 4);
}

Тобто якщо ти ніколи не використовуєш GPIOB — його тактування ніколи не вмикається. Менше споживання, більш передбачувана поведінка. До речі якщо подобається ардуїно, спробуй олімпійську задачку зменшити споживання ProMini до максимуму 😄

hal_systick — delay_ms без магічних чисел

SysTick вбудований таймер Cortex-M3. Є в кожному ARM чіпі незалежно від виробника. Рахує вниз від заданого значення до нуля і виставляє прапорець.

void systick_init(uint32_t cpu_hz) {
    ticks_per_ms = cpu_hz / 1000;  // 8MHz → 8000 тактів = 1мс
}
void delay_ms(uint32_t ms) {
    while (ms--) {
        STK_LOAD = ticks_per_ms - 1;
        STK_VAL  = 0;
        STK_CTRL = STK_CTRL_ENABLE | STK_CTRL_CLKSOURCE;
        while (!(STK_CTRL &amp; STK_CTRL_COUNTFLAG));
        STK_CTRL = 0;
    }
}

На відміну від delay(volatile uint32_t n) тут час залежить від CPU_HZ а не від кількості інструкцій. Міняємо частоту значить міняємо одну константу, залишається delay_ms(500).

hal_uart — двосторонній зв’язок

TX ми вже розібрали. Додаємо RX — uart_getc і uart_gets:

// в uart_init додаємо RE:
USART1_CR1 = CR1_UE | CR1_TE | CR1_RE;
char uart_getc(void) {
    while (!(USART1_SR &amp; SR_RXNE));  // чекаємо RXNE
    return (char)USART1_DR;
}
void uart_gets(char *buf, uint8_t len) {
    uint8_t i = 0;
    char c;
    while (i &lt; len - 1) {
        c = uart_getc();
        if (c == '\r' || c == '\n') break;
        buf[i++] = c;
    }
    buf[i] = '\0';
}

Зверни увагу на те, що uart_getc блокуючий. Програма стоїть і чекає поки не прийде байт. Для простих задач це нормально. Для серйозних проектів треба переривання, це розберемо в наступних статтях.

uart_printf — свій без stdlib

Стандартний printf тягне за собою весь newlib — системні виклики, буферизацію, купу коду яка нам не потрібна. Пишемо мінімальний варіант самостійно:

#include &lt;stdarg.h&gt;  // не бібліотека, просто макроси компілятора
static void uart_print_int(int32_t n) {
    if (n &lt; 0) { uart_putc('-'); n = -n; }
    if (n == 0) { uart_putc('0'); return; }
    char buf[12];
    uint8_t i = 0;
    while (n &gt; 0) { buf[i++] = '0' + (n % 10); n /= 10; }
    for (int8_t j = i - 1; j &gt;= 0; j--) uart_putc(buf[j]);
}
void uart_printf(const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);
    while (*fmt) {
        if (*fmt == '%') {
            fmt++;
            switch (*fmt) {
                case 'd': uart_print_int(va_arg(args, int32_t)); break;
                case 's': uart_puts(va_arg(args, char *)); break;
                case 'x': {
                    uint32_t n = va_arg(args, uint32_t);
                    uart_puts("0x");
                    for (int8_t i = 28; i &gt;= 0; i -= 4) {
                        uint8_t nibble = (n &gt;&gt; i) &amp; 0xF;
                        uart_putc(nibble &lt; 10 ? '0'+nibble : 'a'+nibble-10);
                    }
                    break;
                }
                case 'c': uart_putc((char)va_arg(args, int)); break;
                case '%': uart_putc('%'); break;
            }
        } else {
            uart_putc(*fmt);
        }
        fmt++;
    }
    va_end(args);
}

Для самих маленьких поясню </code><code>stdarg.h — це не бібліотека а просто макроси які компілятор розгортає в прямий доступ до стеку. Працює з -nostdlib без жодних проблем.

Підсумок — main.c який читається майже як Ардуїно

Ось що отримали в результаті:

#include "../hal/hal.h"
int main(void) {
    hal_init();
    uart_init(9600);
    gpio_init(PIN_PC13, OUTPUT);
    gpio_init(PIN_PA1,  INPUT_PULLUP);
    uart_puts("STM32 HAL ready\r\n");
    char buf[16];
    while (1) {
        uart_gets(buf, 16);
        if (buf[0]=='o' &amp;&amp; buf[1]=='n') {
            gpio_write(PIN_PC13, LOW);
            uart_puts("LED on\r\n");
        } else if (buf[0]=='o' &amp;&amp; buf[1]=='f' &amp;&amp; buf[2]=='f') {
            gpio_write(PIN_PC13, HIGH);
            uart_puts("LED off\r\n");
        } else {
            uart_printf("unknown command: %s\r\n", buf);
        }
    }
}

Порівняй з тим що було на початку — голі адреси, магічні числа, зсуви бітів. Тепер це читається майже як Arduino. Але ти знаєш що під капотом. Може за це Ардуїно і не люблять, бо багато чого сховано. Думаю, що ті хто хейтять Ардуїну або набивають собі ціну, або ніколи не пробували по справжньому глянути, а що там в середині, спаяти голий Програматор, прошити кристал, а ще краще написати свій буатлоадер типу як в ардуїно. 😄 А навіщо? Та хз...

Шпаргалка USART

Регістри USART1

АдресаРегістрЩо робить
0×40013800SRстатус: TXE біт7, RXNE біт5
0×40013804DRдані: читати/писати байт
0×40013808BRRbaud rate = CPU_HZ / baud
0×4001380CCR1UE біт13, TE біт3, RE біт2

Baud rate при 8MHz

BaudBRRПохибка
96008330.04% ✓
576001390.08% ✓
115200690.64% ✓
230400350.79% ⚠
460800+>2% ✗

AF пін для UART

 // Output push-pull (GPIO)   → 0x2
 // Alt. Function push-pull   → 0xB  ← для UART TX
 // Input pull-up (кнопка)    → 0x8

Команди minicom

 minicom -b 9600 -D /dev/ttyUSB0
 Ctrl+A E   # local echo
 Ctrl+A X   # вихід

HAL під капотом

Ну що ж ми благопулучно розібрали UART, код працює, minicom виводить чистий текст. Але якщо ти уважно дивився на hal_gpio.c і думав «окей, воно працює, але я не до кінця розумію чому» ця частина для тебе.

Тут не буде нового заліза і нових протоколів. Тут будемо розбирати те що вже написали — повільно, по рядку, з поясненням кожного рішення. Чому PACK_PIN а не просто число. Чому rcc_enable всередині gpio_init. Чому delay_ms(500) завжди рівно 500мс а delay(500000) — як пощастить.

Якщо тобі це нецікаво і ти просто хочеш використовувати HAL — окей. Але якщо хочеш розуміти що відбувається в кожному рядку тре читати далі.

Блок 1 — PACK_PIN: як пін і порт живуть в одному числі

В Arduino є D13, A0 — прості числа. pinMode(13, OUTPUT) — зрозуміло і коротко. Але Arduino знає що 13 це конкретний пін на конкретній платі там більше абстракції. Щоб зменшити складність залишу порти A, B, C, що правда тоді просте число 13 нашому HAL нічого не скаже він не буде знати це PA13? PB13? PC13?

Варіанти вирішення:

Варіант 1 — два параметри:

 gpio_init(GPIOC, 13, OUTPUT);
 gpio_write(GPIOC, 13, HIGH);

Працює але незручно — завжди два аргументи замість одного.

Варіант 2 — struct:

typedef struct {
    uint32_t port;
    uint8_t  pin;
} GPIO_Pin;
GPIO_Pin led = {GPIOC_BASE, 13};
gpio_init(led, OUTPUT);

Чисто але struct передається через стек — зайві копіювання. І синтаксис громіздкий.

Варіант 3 — PACK_PIN:

 #define PACK_PIN(port, pin)  ((uint32_t)(port) | (uint32_t)(pin))
 #define PIN_PC13  PACK_PIN(GPIOC_BASE, 13)
 gpio_init(PIN_PC13, OUTPUT);

Один uint32_t містить і порт і пін. Як це працює?

Дивись на адреси портів:

 GPIOA_BASE = 0x40010800
 GPIOB_BASE = 0x40010C00
 GPIOC_BASE = 0x40011000

Молодший байт у всіх — 0x00. Тобто біти [7:0] завжди нулі. Саме туди ми пакуємо номер піна — він від 0 до 15, вміщається в молодший байт:

PIN_PC13:
  GPIOC_BASE = 0x40011000
  pin        = 13 = 0x0D
  OR разом   = 0x4001100D
Розпакування:
  port = 0x4001100D &amp; 0xFFFFFF00 = 0x40011000  ← GPIOC_BASE ✓
  pin  = 0x4001100D &amp; 0x000000FF = 0x0D = 13   ← номер піна ✓

Один uint32_t — і порт і пін. Передається в регістрі процесора без жодних копіювань. Макрос розкривається на етапі компіляції — в рантаймі немає жодних накладних витрат.

Єдине обмеження — номер піна має бути менше 256 і молодший байт адреси порту має бути 0x00. Для всіх портів STM32F103 це виконується.

Блок 2 — gpio_init під капотом

Ось повна функція:

void gpio_init(uint32_t pin, uint8_t mode) {
    uint32_t port = UNPACK_PORT(pin);
    uint8_t  num  = UNPACK_PIN(pin);
    rcc_enable(port);
    uint8_t shift;
    volatile uint32_t *cr;
    if (num &lt; 8) {
        cr    = &amp;GPIO_CRL(port);
        shift = num * 4;
    } else {
        cr    = &amp;GPIO_CRH(port);
        shift = (num - 8) * 4;
    }
    *cr &amp;= ~(0xF &lt;&lt; shift);
    *cr |=  (mode &lt;&lt; shift);
    if (mode == INPUT_PULLUP) {
        GPIO_ODR(port) |= (1 &lt;&lt; num);
    }
}

Розберемо по блоках.

CRL vs CRH і формула зміщення

GPIO має два регістри конфігурації — CRL для пінів 0-7 і CRH для пінів 8-15. Кожен пін займає 4 біти. Тому:

if (num &lt; 8) {
    cr    = &amp;GPIO_CRL(port);   // піни 0-7 → CRL
    shift = num * 4;           // PA0→&lt;&lt;0, PA1→&lt;&lt;4, PA5→&lt;&lt;20
} else {
    cr    = &amp;GPIO_CRH(port);   // піни 8-15 → CRH
    shift = (num - 8) * 4;    // PC13→(13-8)*4=20, PC14→24
}

Наочно для PC13:

num = 13 → CRH
shift = (13 - 8) * 4 = 20
CRH біти: [31:28] [27:24] [23:20] [19:16] [15:12] [11:8] [7:4] [3:0]
пін:        15      14      13      12      11      10     9     8
                           ↑
                     наш PC13 → зміщення 20

Read-Modify-Write — чому не просто присвоєння

 *cr &amp;= ~(0xF &lt;&lt; shift);  // крок 1: очищаємо 4 біти
 *cr |=  (mode &lt;&lt; shift); // крок 2: записуємо нові

Чому не просто *cr = mode << shift? Бо це перезапише конфігурацію всіх інших пінів в регістрі. CRL керує відразу 8 пінами. Якщо записати туди тільки наше значення — решта 7 пінів скинуться в нулі.

Маска ~(0xF << shift) — це всі біти в 1 крім 4 бітів нашого піна. AND з нею очищає тільки наші 4 біти не чіпаючи решту.

rcc_enable всередині gpio_init — архітектурне рішення

static void rcc_enable(uint32_t port) {
    if      (port == GPIOA_BASE) RCC_APB2ENR |= (1 &lt;&lt; 2);
    else if (port == GPIOB_BASE) RCC_APB2ENR |= (1 &lt;&lt; 3);
    else if (port == GPIOC_BASE) RCC_APB2ENR |= (1 &lt;&lt; 4);
}

Чому тактування вмикається всередині gpio_init а не окремо в main?

Порівняй три підходи:

Arduino — вмикає всі порти одразу при старті в прихованому init(). Просто але марно використовується електрика.

ST HAL — генерує явний виклик __HAL_RCC_GPIOC_CLK_ENABLE() в MX_GPIO_Init(). Забув написати — GPIO мовчить, компілятор не скаржиться.

Наш HAL — gpio_init сам вмикає тактування потрібного порту. Забути неможливо бо це відбувається автоматично.

Операція |= ідемпотентна — можна викликати скільки завгодно разів, результат той самий. Тому якщо ти ініціалізуєш десять пінів на GPIOC — rcc_enable спрацює десять разів але тактування просто залишиться увімкненим.

INPUT_PULLUP — чому ODR а не окремий регістр

if (mode == INPUT_PULLUP) {
    GPIO_ODR(port) |= (1 &lt;&lt; num);
}

В STM32F1 вибір між pull-up і pull-down для вхідного піна робиться через ODR — той самий регістр що керує виходом. Коли пін налаштований як вхід:

  • ODR = 1 → підтяжка до HIGH (pull-up)
  • ODR = 0 → підтяжка до LOW (pull-down)

Це неочевидно і часто викликає плутанину. В STM32F4 зробили окремі регістри PUPDR — набагато зрозуміліше. Але F1 є F1.

І звідси інверсна логіка кнопки — з pull-up на піні завжди HIGH. Кнопка замикає на GND → пін стає LOW. Тому перевірка !gpio_read(PIN_PA1) а не gpio_read(PIN_PA1).

Блок 3 — SysTick: чому delay_ms завжди точний

Згадай delay(volatile uint32_t n) з статті 1:

void delay(volatile uint32_t n) { while (n--); }

Скільки часу займає delay(500000) при -O0? Залежить від кількості інструкцій в циклі. При -O2 компілятор може скоротити цикл вдвічі — і затримка зменшиться вдвічі. Міняємо прапорці компіляції — міняється час затримки. Це не затримка а лотерея.

SysTick вирішує це раз і назавжди.

Як працює SysTick

SysTick — вбудований таймер в кожному Cortex-M процесорі. Три регістри:

#define STK_CTRL  (*(volatile uint32_t *)0xE000E010)  // керування
#define STK_LOAD  (*(volatile uint32_t *)0xE000E014)  // початкове значення
#define STK_VAL   (*(volatile uint32_t *)0xE000E018)  // поточне значення

Логіка проста: завантажуємо значення в LOAD, запускаємо, таймер рахує вниз до нуля і виставляє прапорець COUNTFLAG в CTRL. Все.

void delay_ms(uint32_t ms) {
    while (ms--) {
        STK_LOAD = ticks_per_ms - 1;  // 7999 для 8MHz
        STK_VAL  = 0;                  // скидаємо лічильник
        STK_CTRL = STK_CTRL_ENABLE | STK_CTRL_CLKSOURCE;
        while (!(STK_CTRL &amp; STK_CTRL_COUNTFLAG));  // чекаємо
        STK_CTRL = 0;  // зупиняємо
    }
}

ticks_per_ms = cpu_hz / 1000. При 8MHz це 8000 тактів. Рівно стільки тактів процесора в одній мілісекунді. Таймер відраховує їх апаратно — незалежно від того що робить CPU, незалежно від оптимізації компілятора.

Що зміниться при переході на 72MHz

Нічого в коді main.c. Тільки одна константа:

// hal.h
#define CPU_HZ  8000000UL   // зараз
#define CPU_HZ  72000000UL  // після налаштування PLL

ticks_per_ms перерахується автоматично. delay_ms(500) залишиться рівно 500мс. Ось чому важливо не захардкоджувати магічні числа а виводити все з CPU_HZ.

Блок 4 — UART HAL під капотом

Чому AF а не звичайний output

Коли пін налаштований як звичайний output — він підключений до регістру ODR. Що записав в ODR — те й на піні.

Коли 0xB (Alternate Function) — пін від’єднується від ODR і підключається до внутрішньої шини периферії. Тепер USART1 безпосередньо керує напругою на PA9. Твій код більше не може змінити стан цього піна через ODR — USART його ігнорує.

CNF[1:0] для output:
  00 → Push-pull          (ODR керує)
  01 → Open-drain         (ODR керує)
  10 → Alt. Push-pull     (периферія керує) ← наш випадок
  11 → Alt. Open-drain    (периферія керує)
MODE[1:0]:
  11 → 50MHz
Разом: CNF=10, MODE=11 → 0b1011 = 0xB

TXE і RXNE — навіщо чекати прапорець

void uart_putc(char c) {
    while (!(USART1_SR &amp; SR_TXE));  // чекаємо TXE
    USART1_DR = c;
}

USART має внутрішній буфер — один байт. Поки він відправляє поточний байт — новий записати не можна, він перезатре той що відправляється.

TXE (TX Empty) — прапорець що буфер звільнився і готовий прийняти наступний байт. Без цього очікування при відправці рядка букви будуть губитись.

Аналогічно для прийому — RXNE (RX Not Empty) означає що прийшов новий байт і він лежить в DR чекає поки ми його прочитаємо.

Чому uart_getc блокуючий і коли це проблема

char uart_getc(void) {
    while (!(USART1_SR &amp; SR_RXNE));  // стоїмо тут поки не прийде байт
    return (char)USART1_DR;
}

Поки байт не прийшов — програма стоїть. Повністю. LED не мигає, кнопки не читаються, нічого не відбувається.

Для простого парсера команд — нормально. Але уяви що ти чекаєш відповідь від ESP32 яка може прийти через 2 секунди. Весь цей час мікроконтролер стоїть і нічого не робить.

Вирішення — UART через переривання. Байт прийшов → апаратура сама викликає обробник → ти забираєш байт з буфера → програма продовжує працювати. Це тема окремої статті але важливо розуміти обмеження поточного підходу.

stdarg.h і va_list — як це реально працює

void uart_printf(const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);
    // ...
    int n = va_arg(args, int);
    // ...
    va_end(args);
}

stdarg.h — не бібліотека. Це набір макросів які компілятор розгортає в прямий доступ до стеку. Ніяких системних викликів, ніякого libc. Саме тому працює з -nostdlib.

Коли викликаєш uart_printf("val=%d", 42) — аргументи кладуться на стек по черзі. va_start встановлює вказівник за останній іменований параметр (fmt). va_arg читає наступне значення зі стеку і зсуває вказівник.

Стек при виклику uart_printf("val=%d", 42):
┌─────────────┐
│ ptr на fmt  │ ← va_start починає тут
├─────────────┤
│ 42          │ ← va_arg(args, int) читає це
└─────────────┘

Саме тому тип в va_arg має співпадати з реальним типом аргументу. Якщо передав int а читаєш як uint32_t — на ARM з вирівнюванням може пощастити, але це undefined behavior.

Блок 5 — hal_init і масштабування на інші чіпи

// hal.h
#define CPU_HZ  8000000UL
void hal_init(void);
// hal.c
void hal_init(void) {
    systick_init(CPU_HZ);
}

hal_init — одна точка входу яка налаштовує все що потрібно для роботи HAL. Зараз тільки SysTick. В майбутньому сюди можна додати налаштування PLL, watchdog, flash latency.

Портування на STM32F411 Black Pill

Коли захочеться перейти на F411 — більшість HAL залишиться без змін:

// hal.h — міняємо тільки це
#define CPU_HZ  100000000UL  // F411 до 100MHz

Що треба буде переписати:

  • rcc_enable — у F4 інша структура RCC, інші адреси і біти
  • uart_init — BRR формат трохи інший у F4
  • лінкер скрипт — більший Flash і SRAM

Що залишається без змін:

  • gpio_init, gpio_write, gpio_read, gpio_toggle — GPIO регістри схожі
  • delay_ms — SysTick однаковий на всіх Cortex-M
  • uart_putc, uart_puts, uart_printf — логіка та сама

Саме для цього і потрібен HAL — щоб при зміні чіпа переписувати мінімум коду а не весь проект.

Наступна стаття цієї серії — I2C і SPI. Підключаємо реальні датчики і дисплеї. І так, переривання теж нарешті розберемо 😄

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному4
LinkedIn
Ctrl + Enter
Ctrl + Enter
Вирішення — UART через переривання. Байт прийшов → апаратура сама викликає обробник → ти забираєш байт з буфера → програма продовжує працювати. Це тема окремої статті але важливо розуміти обмеження поточного підходу.

а хіба не має STM готових буферів на UART (окремо на RX, окремо на TX), а ще ніби там DMA можна причепити
В тебе якийсь дивний підхід: тримайте пиво студенти і вчіться, UART працює ось так, але зробим кошерно на наступному курсі.

Ну так, такий підхід, через граблі і велосипеди це мій метод

давай на STM (можна RPI на Лінуксі або EPS32) і CAN bus і хакінг з OBD-II щоб зчитати рівень пального та оберти двигуна

Не зрозумів. А де то взяти? 😀

для RPI
habr.com/ru/articles/1003936
для STM там достатньо підпаяти CAN PHY TJA1050 або аналог

можна ELM327, але нахіба
habr.com/...​ies/unet/articles/408941

Цікавила мене ця тема, навіть OBD-II придбав вже дуже давно. Але для цього мабуть тре авто чи є симулятори?

можна зробити симулятор, тобто тей же RPI з CAN

Тре буде розібратись, гарна ідея

Підписатись на коментарі