STM32 з нуля без HAL: UART від bit-bang до HAL

Платформа: Blue Pill (STM32F103C8T6) · ST-Link v2 · Linux · arm-none-eabi-gcc


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

Та про що це я.

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

І бачимо… щось типу такого:

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


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

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

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

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

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

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

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

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

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

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

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

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

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


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


Що далі

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 сам відраховує мікросекунди і смикає піном.

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 сам керує піном без участі нашого коду.

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

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


Регістри USART1

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

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

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

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

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

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. Тоді:

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


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

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

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

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


HAL

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

Порівняймо:

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

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

Концепція PIN_Pxx

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

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

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

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

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

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

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

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

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

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

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

uart_printf — свій без stdlib

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

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


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

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

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


Шпаргалка USART

Регістри USART1

Адреса Регістр Що робить
0x40013800 SR статус: TXE біт7, RXNE біт5
0x40013804 DR дані: читати/писати байт
0x40013808 BRR baud rate = CPU_HZ / baud
0x4001380C 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

Команди minicom


 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 — два параметри:

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

Варіант 2 — struct:

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

Варіант 3 — PACK_PIN:

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

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

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

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

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


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

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

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

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

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

Наочно для PC13:

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

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

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

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

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

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

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

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

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

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

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

В 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:

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

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

Як працює SysTick

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

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

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

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

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

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 його ігнорує.

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

Коли купиш F411 — більшість HAL залишиться без змін:

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

  • 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. Підключаємо реальні датчики і дисплеї. І так, переривання теж нарешті розберемо 😄

 

Опубліковано в STM32

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *