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 але точність
Теоретично: 104.16 мкс на біт HSI +2%: 106.24 мкс на біт Після 8 біт: Теоретично: 833 мкс HSI +2%: 849 мкс → похибка 16 мкс → майже пів біта!
Приймач читає біти по центру інтервалу. Якщо ти запізнився на пів біта — він читає вже наступний біт. Замість 'A' отримуємо '÷'.
Є ще зовнішній кварц HSE на 8MHz — він точніший, ±50ppm проти ±2% у HSI. Але без явного налаштування RCC мікроконтролер стартує на HSI. Тому навіть якщо кварц припаяний — він не використовується поки ти не скажеш йому явно.
Причина 3: накопичена похибка
Так що так. Кожен біт додає маленьку похибку. До
Біт 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 | Тривалість біта |
|---|---|
| 9600 | 104 мкс |
| 19200 | 52 мкс |
| 57600 | 17 мкс |
| 115200 | 8.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 rate | BRR точне | BRR реальне | Похибка |
|---|---|---|---|
| 9600 | 833.33 | 833 | 0.04% ✓ |
| 19200 | 416.67 | 417 | 0.08% ✓ |
| 57600 | 138.89 | 139 | 0.08% ✓ |
| 115200 | 69.44 | 69 | 0.64% ✓ |
| 230400 | 34.72 | 35 | 0.79% ⚠ |
| 460800 | 17.36 | 17 | 2.08% ✗ |
| 921600 | 8.68 | 9 | 3.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 <stdint.h>
#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 << 2) | (1 << 14);
// PA9 → Alternate Function push-pull 50MHz
GPIOA_CRH &= ~(0xF << 4);
GPIOA_CRH |= (0xB << 4);
// baud rate
USART1_BRR = 8000000UL / baud;
// увімкнути USART + TX
USART1_CR1 = (1 << 13) | (1 << 3);
}
void uart_putc(char c) {
while (!(USART1_SR & (1 << 7))); // чекаємо TXE
USART1_DR = c;
}
void uart_puts(const char *s) {
while (*s) uart_putc(*s++);
}
int main(void) {
// LED PC13
RCC_APB2ENR |= (1 << 4);
GPIOC_CRH &= ~(0xF << 20);
GPIOC_CRH |= (0x2 << 20);
uart_init(9600);
uart_puts("Hardware UART works!\r\n");
while (1) {
GPIOC_ODR ^= (1 << 13);
uart_puts("blink\r\n");
for (volatile int i = 0; i < 500000; i++);
}
}
Прошиваємо і в minicom бачимо чистий текст без жодної крякозябри. Ось вона різниця між bit-bang і апаратною периферією.
Але main.c — вже захаращений адресами і магічними числами. А ще ж тре I2C, SPI і таймери. Мабуть прийшов час робити HAL.
HAL
HAL — Hardware Abstraction Layer ні, робити будемо не монструозний ST HAL який генерує CubeMX, а свій такий манюнькій. Ідея проста, додаю слой абстракції і ховаю регістри і адреси за зрозумілими функціями.
Порівняймо:
// без HAL GPIOA_CRH &= ~(0xF << 4); GPIOA_CRH |= (0xB << 4); RCC_APB2ENR |= (1 << 14); USART1_BRR = 8000000UL / 9600; USART1_CR1 = (1 << 13) | (1 << 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) & 0xFFFFFF00) #define UNPACK_PIN(p) ((p) & 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 << 2);
else if (port == GPIOB_BASE) RCC_APB2ENR |= (1 << 3);
else if (port == GPIOC_BASE) RCC_APB2ENR |= (1 << 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 & 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 & SR_RXNE)); // чекаємо RXNE
return (char)USART1_DR;
}
void uart_gets(char *buf, uint8_t len) {
uint8_t i = 0;
char c;
while (i < 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 <stdarg.h> // не бібліотека, просто макроси компілятора
static void uart_print_int(int32_t n) {
if (n < 0) { uart_putc('-'); n = -n; }
if (n == 0) { uart_putc('0'); return; }
char buf[12];
uint8_t i = 0;
while (n > 0) { buf[i++] = '0' + (n % 10); n /= 10; }
for (int8_t j = i - 1; j >= 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 >= 0; i -= 4) {
uint8_t nibble = (n >> i) & 0xF;
uart_putc(nibble < 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' && buf[1]=='n') {
gpio_write(PIN_PC13, LOW);
uart_puts("LED on\r\n");
} else if (buf[0]=='o' && buf[1]=='f' && 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×40013800 | SR | статус: TXE біт7, RXNE біт5 |
| 0×40013804 | DR | дані: читати/писати байт |
| 0×40013808 | BRR | baud rate = CPU_HZ / baud |
| 0×4001380C | CR1 | UE біт13, TE біт3, RE біт2 |
Baud rate при 8MHz
| Baud | BRR | Похибка |
|---|---|---|
| 9600 | 833 | 0.04% ✓ |
| 57600 | 139 | 0.08% ✓ |
| 115200 | 69 | 0.64% ✓ |
| 230400 | 35 | 0.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 & 0xFFFFFF00 = 0x40011000 ← GPIOC_BASE ✓ pin = 0x4001100D & 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 < 8) {
cr = &GPIO_CRL(port);
shift = num * 4;
} else {
cr = &GPIO_CRH(port);
shift = (num - 8) * 4;
}
*cr &= ~(0xF << shift);
*cr |= (mode << shift);
if (mode == INPUT_PULLUP) {
GPIO_ODR(port) |= (1 << num);
}
}
Розберемо по блоках.
CRL vs CRH і формула зміщення
GPIO має два регістри конфігурації — CRL для пінів
if (num < 8) {
cr = &GPIO_CRL(port); // піни 0-7 → CRL
shift = num * 4; // PA0→<<0, PA1→<<4, PA5→<<20
} else {
cr = &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 &= ~(0xF << shift); // крок 1: очищаємо 4 біти *cr |= (mode << 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 << 2);
else if (port == GPIOB_BASE) RCC_APB2ENR |= (1 << 3);
else if (port == GPIOC_BASE) RCC_APB2ENR |= (1 << 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 << 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 & 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 & SR_TXE)); // чекаємо TXE
USART1_DR = c;
}
USART має внутрішній буфер — один байт. Поки він відправляє поточний байт — новий записати не можна, він перезатре той що відправляється.
TXE (TX Empty) — прапорець що буфер звільнився і готовий прийняти наступний байт. Без цього очікування при відправці рядка букви будуть губитись.
Аналогічно для прийому — RXNE (RX Not Empty) означає що прийшов новий байт і він лежить в DR чекає поки ми його прочитаємо.
Чому uart_getc блокуючий і коли це проблема
char uart_getc(void) {
while (!(USART1_SR & 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-Muart_putc,uart_puts,uart_printf— логіка та сама
Саме для цього і потрібен HAL — щоб при зміні чіпа переписувати мінімум коду а не весь проект.
Наступна стаття цієї серії — I2C і SPI. Підключаємо реальні датчики і дисплеї. І так, переривання теж нарешті розберемо 😄
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментаріва хіба не має 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
Тре буде розібратись, гарна ідея