STM32 з нуля без HAL: GPIO, регістри і переривання

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

Рівень: є досвід з Arduino або ESP32, хочеться зрозуміти що під капотом


Навіщо HAL?

Якщо читає ці рядки, то точно вже мигав/ла LED через Arduino або CubeMX. В пам’яті вже спливає, твоє перше і можливо поки що останнє (знаю зараз модно казати крайній, але ті кримінальні часи коли за “последній” можна було і відповісти минули, ну а крайня у моєму розумінні або північ або плоть ;)) знайомство де ти натискаєш кнопку в GUI, генерується код, якось воно працює. Але ти не розумієш що відбувається всередині. Окрім банальної цікавості, не розумієш, що відбувається, наприклад: HAL_GPIO_WritePin не спрацьовує, гугл каже «перевір ClockEnable», але чому хз. Або I2C зависає, і годинами крутиш параметри CubeMX не розуміючи що саме впливає, зливаючи тисячі токенів чатбота. Хоча, якщо чесно ще з часів користування Notepad ++, Eclipse, Code::Blocks, коли вчиш у CooCox CoIDE («Кук»), а потім тре Keil uVision, а ще тре STM32CubeIDE. І коли тобі хочеться погратись у інженера і ти береш платку, а потім починаєш розбиратись з IDE і через якісь час розумієш, що вже награвся і кладеш платку в шухлядку, і так нічого і не спрацювало було інтерфейс змінився, що там куди тикати не зрозуміло. Та цього разу усе буде по іншому, обіцяю бо цей опус задуманий не випадково, а в рамках шести місячного шляху в embedded по дорожній карті яку мені люб’язно надав Claude. Колись щось подібне проходив з AVR по книгам і форумам, тоді не було чатботів 🙂 Кому цікаво, доєднуйтесь у весь шлях стараюсь конспектувати та занотувати, спочатку у звичайному блокноті, а після в статті яку сам же і буду перечитувати, отже надаю структуру курсу.

Загальна структура курсу

Курс розрахований на 6 місяців самостійного навчання з фокусом на практичні задачі. Кожен місяць це окремий блок з чіткою темою, конкретними задачами і результатом у вигляді працюючого проекту.

Ключовий принцип: STM32 присутній в кожному місяці в новій ролі.

Місяць Блок Основна тема Платформа
1 C та STM32 периферія GPIO/UART/I2C/Timer bare-metal + свій HAL STM32 Blue Pill
2 Buildroot + STM32 міст BR2_PACKAGE, U-Boot, STM32 шле дані в Linux Luckfox + Blue Pill
3 Device Tree + GStreamer DTS модифікація, GStreamer, Linux керує STM32 Luckfox + Blue Pill
4 Kernel modules character device driver, HC-SR04, STM32 через SPI Luckfox / Pi
5 FreeRTOS + Python FreeRTOS на STM32, Python автотести, OpenCV STM32 + Pi
6 Фінальний проект OpenWrt або TDOA мікрофони TP-Link / Luckfox

Місяць 1 — C та STM32 периферія

Мета: Впевнено писати bare-metal C для STM32, покрити всю базову периферію, написати свій мінімальний HAL як в Arduino. Підготуватись до місяця 2 де STM32 стає периферією для Linux.

Статус: частково зроблено (SOS morse, GPIO bare-metal, перша стаття).

Тиждень Тема Практична задача Результат
Тиж 1 GPIO + переривання Стаття 1: GPIO bare-metal — регістри, BSRR, EXTI Коспект, шпаргалки, розуміння, базовий код
Тиж 2 Свій HAL: GPIO Пишемо pinMode/digitalWrite/digitalRead як в Arduino HAL gpio.c/.h
Тиж 3 SysTick + UART + HAL delay_ms через SysTick. UART TX/RX. Додаємо в HAL HAL uart.c/.h
Тиж 4 I2C + Timer/PWM + HAL BMP180 або MPU6050. ШІМ через TIM2. HAL повний HAL i2c + timer

Результат місяця:

  • Власний HAL: GPIO + UART + I2C + Timer — можна використовувати в проектах
  • STM32 відповідає на UART команди — готовий до місяця 2

Погнали, пишемо код для STM32F103 (поки так, бо маю їх цілу шухляду) з нуля — без HAL, без CubeMX, тільки три файли і даташит. По дорозі розберемо: чому треба вмикати тактування перш ніж чіпати GPIO, що таке CRH і чому там << 20, навіщо BSRR якщо є ODR, і як правильно обробляти кнопку через переривання.

Додай в обрано бо в кінці статті є шпаргалка з усіма адресами і таблицями яку зручно тримати відкритою поруч з кодом.


Блок 1 — Три файли і перше блимання

Для тих хто сильно звик до Arduino, тут просто китайська грамота немає setup() і loop() лише оргія оголеної C з компілятором і процесором які взаємодіють за чіткими правилами Камасутри, тьху срамота оце понесло 😉

Тож нам потрібно рівно чотири файли:

Файл 1: Лінкер скрипт — карта пам’яті чіпа

Ось цей файл це не код, а карта пам’яті, маю надію його не тре постійно правити а можна копіпастити, то ж, тут вказуємо компілятору де у нашого чіпа живе Flash і де SRAM:

 

Розберемо кожну частину окремо.

MEMORY — фізична карта STM32F103:

Адреси 0x08000000 і 0x20000000 я не вигадував. Вони прописані в ARM специфікації і в RM0008. Flash завжди починається з 0x08000000, SRAM — з 0x20000000. Це стандарт для всіх STM32.

Що означають секції:

  • .text — код програми і константи, лягають у Flash
  • .data — глобальні змінні з початковими значеннями. Зберігаються у Flash, але при старті копіюються в SRAM
  • .bss — неініціалізовані змінні, тільки в SRAM, обнуляються при старті
  • _estack — вершина стеку, це SRAM_start + SRAM_size = 0x20005000

Секція .text — код і константи:

 

KEEP(*(.vectors)) гарантує що таблиця векторів не буде викинута оптимізацією і ляже на адресу 0x08000000. Це критично без неї МК не стартує.

Секція .data — змінні з ініціалізацією:

Тут важливий момент який багато хто не розуміє. Якщо написати:

Значення 10 зберігається у Flash (там воно не зітреться при вимкненні). Але під час роботи змінна x живе в SRAM. Тому > SRAM AT > FLASH означає:

  • > SRAM — де змінна буде під час виконання
  • AT > FLASH — звідки її копіювати при старті

_sidata = LOADADDR(.data) — це адреса у Flash звідки треба копіювати. І саме це робить наш startup.c:

Секція .bss — неініціалізовані змінні:

 

Змінні типу int x; або static int y; не мають значення у Flash — вони просто займають місце в SRAM і обнуляються при старті. Startup це робить:

Стек:

Це вершина SRAM і як ти певно знаєш саме звідси стек починає рости вниз. Перше слово в таблиці векторів саме ця адреса. Cortex-M3 при старті завантажує її в регістр SP (Stack Pointer). Стек росте вниз. Heap росте вгору і десь посередині SRAM вони дивляться одне на одного як сусіди в  комуналці і якщо не порахувати пам’ять правильно, зустрінуться і тоді stack overflow не той де питання усілякі розбирають, а справжній коли після нього МК під LSD і поводиться трохи стрьомно.


Та не переживай ти так, «МК під LSD» то жарт 😄 який тобі точно зайшов інакше ти б це не читав, отже лікбез, ну не позіхай, тут швидко.

Heap це динамічна пам’ять, це коли malloc() і free() Коли пишеш в C:

пам’ять береться з heap. Він живе в SRAM і росте вгору, назустріч стеку.

Але в bare-metal STM32 без ОС malloc() використовують рідко і з обережністю:

  • SRAM всього 20 КБ — легко вичерпати
  • Фрагментація: багато malloc/free → дірки в пам’яті → в якийсь момент malloc повертає NULL хоча пам’ять «є»
  • Embedded люблять передбачуваність, а динамічна пам’ять — ні

Тому зазвичай або статичні масиви, або свій пул пам’яті з фіксованими блоками. В нашому коді heap = 0, стек і heap так і не зустрілись і це супер.


А тепер про ті цифри які видає make. Ось що в мене:

Це arm-none-eabi-size — утиліта яка показує розмір секцій у байтах:

Колонка Що це Де живе
text код + константи Flash
data глобальні змінні з початковим значенням (int x = 5) Flash (копія) + SRAM (робоча)
bss глобальні змінні без значення (int x) тільки SRAM, обнуляються при старті
dec text + data + bss в десятковій
hex те саме в шістнадцятковій

У мене 376 байт у text — це весь наш код у Flash. data = 0 і bss = 0 бо глобальних змінних немає взагалі.

Тепер про 0x8000000 у рядку flash:

134217728 — це 0x08000000 в десятковій. Та сама адреса з лінкер скрипту. st-flash записує бінарник саме туди де ARM очікує знайти перше слово таблиці векторів. Звідси Cortex-M3 і стартує після reset.

І нарешті найприємніший рядок у embedded розробці:

«Jolly good»  означає дуже добре. та рухаємось далі. Знаю нудятина. Але ти точно не знаєш чому програміст засунув палець в дупу і посміхається. Якщо дочитаєш далі зрозумієш, але не розказуй то рузьким бо в них то тема заборонена, ну я про палець якщо що 😉


Файл 2: startup.c — що відбувається до main()

В Arduino є прихований код який стартує до setup(). В нас він явний Він робить три речі: містить таблицю векторів, копіює .data з Flash в SRAM і обнуляє .bss.

 

Що таке таблиця векторів? Це масив адрес функцій на початку Flash. Після reset Cortex-M3 читає перші два слова з 0x08000000:

  • слово 0 → початкове значення стеку
  • слово 1 → адреса Reset_Handler

Далі стрибає на Reset_Handler і починає виконувати наш код.

Чому WEAK_ALIAS? Це означає: «якщо ніхто не визначив цю функцію — використовуй Default_Handler». Можна спокійно не визначати SysTick_Handler поки він не потрібен компілятор жалітись не буде.

Що відбувається при старті покроково:

Файл 3: main.c — перше мигання (йди випий кави і трохи відпочинь, бо тут саме воно)

Якщо ти читаєш далі ти явно крепкій орєшек, але запам’ятай це важливе слово  volatile бо про нього навіть в книзі Джозеф.Ю згадує. Без нього компілятор може вирішити «я вже читав цю адресу, навіщо читати знову» і закешувати значення. Але регістр мікроконтролера може змінитись апаратно  без участі нашого коду, але volatile каже компілятору: «завжди читай і пиши реально, не оптимізуй». РОзбираємо крок, отже кожен макрос типу GPIOC_CRH — це просто вказівник на адресу:

 

Makefile і збірка

 

Відступи в Makefile — це Tab, не пробіли. Це критично важливо.

Результат:

272 байти. Порожній HAL-проект з CubeMX на тому ж F103 — ~3-4 КБ тільки на ініціалізацію. Ми зробили те саме за 272 байти тому що знаємо що відбувається на кожному кроці.

LED на PC13 мигає. Але виникає питання. А якщо тре більше портів?


Блок 2 — А якщо треба більше портів?

Окей, PC13 мигає. Але в реальному проекті треба кілька пінів на різних портах. Що тоді?

Відповідь: кожен порт має свою базову адресу, і кожен треба окремо увімкнути через RCC. Ось код який мигає пінами на портах A і C одночасно:

Код працює. Але тут є кілька речей які варто розжувати.

Карта пам’яті: де живуть порти

STM32 використовує Memory-Mapped I/O — кожна периферія відображена на певну ділянку адресного простору. Уяви це як місто де кожна периферія має свою земельну ділянку з кадастровим кодом:

Блок Адреса Функція
Flash 0x08000000 програма (код)
SRAM 0x20000000 змінні під час виконання
GPIOA 0x40010800 порт А
GPIOB 0x40010C00 порт В
GPIOC 0x40011000 порт C — наш LED
RCC 0x40021000 тактування всього

Саме тому наш макрос:

Це вказівник на адресу 0x40011004. Пишемо туди — пишемо в регістр конфігурації порту C.

А тепер давай пожартуємо і напишемо неправильну адресу наприклад переплутаємо GPIOC з GPIOA:

Налаштували GPIOA порт A замість C. PC13 навіть не підозрює що щось відбувалось. Ось чому адреси треба брати з RM0008 а не з пам’яті і компілятор тебе не врятує, він чесно запише куди сказали.

Вже почали то згадаймо і про другий класичний випадок з правильною адресою, але забули тактування:

 

Результат той самий не блимає. Запам’ятай бо це дійсно важливо RCC це не просто формальність, а рубильник без якого периферія не працює.

Чому треба вмикати тактування?

За замовчуванням вся периферія STM32 «спить» — тактування на неї не подається. Це зроблено для економії енергії. RCC (Reset and Clock Control) — це «головна електростанція» яка вмикає тактування для кожного блоку окремо.

Якщо не увімкнути тактування і одразу писати в регістри GPIO — нічого не відбудеться. Запис просто ігнорується. Це одна з найпоширеніших помилок новачків.

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

GPIO має два регістри конфігурації:

  • CRL (Control Register Low) — піни 0-7, зміщення 0x00
  • CRH (Control Register High) — піни 8-15, зміщення 0x04

Кожен пін займає 4 біти у своєму регістрі. Ці 4 біти — це два поля MODE і CNF:

Формула зміщення:

  • Для CRL: пін × 4
  • Для CRH: (пін - 8) × 4

Приклади:

 

Звідси і береться << 20 в нашому коді для PC13.

Бітові операції: маски і чому саме так

Ми ніколи не пишемо REG = значення бо це затре налаштування всіх інших пінів. Використовуємо Read-Modify-Write:

Якщо треба налаштувати кілька пінів одночасно — об’єднуємо через оператор труба  |:

Можна записати ще наочніше через бінарні літерали просто «малюємо» біти:

Або зробити макрос LED(n) для зручності:

Чому BSRR краще ніж ODR ^=?

До речі — «програміст засунув палець в дупу і посміхається бо до цього там у нього був костиль» і це точно про BSRR де 13 + 16 = 29? Ну типу «костиль» це ODR toggle а «палець» це (1 << (13 + 16)) який виглядає стрьомно але працює ідеально? Ну смішно ж правда? 😄

 

BSRR (Bit Set/Reset Register) — один 32-бітний регістр де:

  • біти 0-15: Set (встановити пін в HIGH)
  • біти 16-31: Reset (скинути пін в LOW)

ODR ^= — це три операції: прочитати ODR, змінити біт, записати назад. Якщо між читанням і записом прийде переривання яке зачіпить ODR буде непередбачуваний результат.

BSRR — один запис і все. Ніякого читання, ніякої гонки. Саме тому в реальних проектах використовують BSRR.

Важливо: якщо ти пишеш GPIOC_ODR = ... — ти перезаписуєш всі 16 бітів порту одразу. Тому краще або BSRR для окремих пінів, або |= і &=~ для ODR.

Таблиця регістрів GPIO

Зміщення Регістр Що робить
0x00 CRL конфігурація пінів 0-7 (4 біти на пін)
0x04 CRH конфігурація пінів 8-15 (4 біти на пін)
0x08 IDR читання стану пінів (тільки читання)
0x0C ODR запис стану пінів + вибір pull-up/down для входів
0x10 BSRR атомарний set (біти 0-15) і reset (біти 16-31)
0x14 BRR тільки reset (тільки запис)

Блок 3 — А якщо треба кнопку?

Мигати пінами — це добре. Але що як треба читати стан кнопки?

Спосіб 1: Polling через IDR

IDR (Input Data Register) — 32-бітний регістр де перші 16 бітів у реальному часі відображають фізичну напругу на пінах:

Стан на піні Значення біта в IDR
3.3V (HIGH) 1
0V / GND (LOW) 0

Підключаємо кнопку до PA1. Найкращий режим для кнопки — Input Pull-up: на піні завжди HIGH через внутрішній резистор, при натисканні кнопка замикає пін на GND і там стає LOW. Зовнішніх резисторів не потрібно.

Налаштування PA1 як Input Pull-up:

 

Читаємо кнопку в циклі:

 

Перевірка !(IDR & bit) — тому що з pull-up логіка інверсна: натиснуто = 0, відпущено = 1.

Це polling — ми постійно крутимося і перевіряємо. Простий спосіб, але є проблема: якщо в while(1) є важкий код — кнопка може не реагувати одразу. І процесор постійно зайнятий перевіркою навіть коли нічого не відбувається.

Спосіб 2: Правильно — через переривання EXTI

Кращий підхід: налаштувати переривання і забути про кнопку. Процесор займається своїми справами, а коли кнопка натиснута — залізо саме «гукне» і викличе нашу функцію.

Для GPIO переривань в STM32F1 є ланцюжок з трьох блоків:

  • AFIO (Alternate Function I/O) — вибирає який порт (A, B або C) підключений до лінії переривання. Наприклад, лінії EXTI1 можуть відповідати PA1, PB1 або PC1 — AFIO вибирає хто саме
  • EXTI (External Interrupt) — стежить за фронтом сигналу: falling edge (1→0, натискання) або rising edge (0→1, відпускання)
  • NVIC (Nested Vectored Interrupt Controller) — менеджер переривань в ядрі Cortex-M3, зупиняє main() і запускає наш handler

Регістри EXTI:

Зміщення Назва Функція
0x00 IMR Interrupt Mask — дозволити переривання на лінії
0x08 RTSR Rising Trigger — реагувати на 0→1 (відпускання)
0x0C FTSR Falling Trigger — реагувати на 1→0 (натискання)
0x14 PR Pending Register — прапорець події, скидати вручну!

Налаштування EXTI для PA1 — п’ять кроків:

 

Додаємо вектор в таблицю в startup.c — після SysTick_Handler:

Функція-обробник:

 

Найчастіша помилка: забути скинути EXTI_PR. Якщо не скинути — процесор одразу знову зайде в переривання, і так нескінченно. Програма зависне в ISR назавжди.

Проблема брязкоту контактів

Запускаємо, натискаємо кнопку — і LED перемикається… але іноді двічі або тричі від одного натискання. Це дребезг (debouncing) — механічний контакт при натисканні кілька разів замикається і розмикається за мілісекунди:

Одне натискання генерує 5-10 переривань поспіль.

Апаратне рішення — найпростіше і найнадійніше: конденсатор 100нФ між піном кнопки і GND. RC-фільтр згладжує дребезг на рівні схеми, і до мікроконтролера приходить чистий сигнал. Жодного коду не потрібно.

Програмне рішення — потрібен таймер: затримка 20-50 мс після першого спрацювання щоб ігнорувати брязкіт. SysTick дає нам саме такий таймер з функцією delay_ms(). Детально розберемо в наступній статті де будемо писати свій HAL.


Поради щодо інструментарію (2026 рік)

  • GDB Text User Interface (TUI): Замість “сліпого” дебагу в терміналі можна натиснути Ctrl+X, A у GDB. Це відкриє вихідний код прямо в консолі, як на мене виглядає дуже по-хакерськи і неймовірно зручно.

  • SVD-файли: для кожного чіпа є .svd файл. Це XML-опис усіх регістрів. Сучасні дебагери (як probe-rs або плагіни до VSCode) використовують їх, щоб показувати вам назви регістрів, а не просто числа під час відладки.

Та, що тут казати, давай пробувати консоль


Налаштування відладки (GDB + ST-Link)

Щоб побачити свій код у дії та “пощупати” регістри в реальному часі, нам знадобиться два термінали та кілька команд.

1. Додаємо Debug-можливість у Makefile

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

Makefile

 

Важливо: У системі має бути встановлено: sudo apt install gdb-multiarch stlink-tools.

2. Крок за кроком: Запуск сесії

  1. Підключи залізо: Встроми ST-Link у USB, а Blue Pill — до ST-Link (GND, CLK, DIO, 3.3V).

  2. Запусти Сервер (Термінал 1): Це місток між USB та GDB.

    Має з’явитися напис: Listening at *:4242.

  3. Запусти Дебагер (Термінал 2): Перейди у папку з проєктом, де лежить твій .elf файл, і виконай:

3. Гарячі клавіші GDB (TUI Mode)

Коли відкриється консоль з кодом, використовуй ці комбінації, щоб не “загубитись”:

  • Ctrl + X, потім O — Це швидке перемикання фокуса. Просто клацай цю комбінацію, поки жирна рамка не опиниться на потрібному вікні.

  • Ctrl + X, потім A — Якщо TUI тебе зовсім дістав, ця команда повністю вимикає графіку і повертає тебе в звичайний текстовий режим GDB. Потім цією ж командою можна повернутися назад.

  • Ctrl + L — Це команда “Refresh”. Вона перемалює весь інтерфейс з нуля. Допомагає, якщо ST-Link викинув помилку прямо посеред коду.

4. Робота з “залізом” та пам’яттю

Команда Що робить
monitor reset halt Скинути мікроконтролер і зупинити його на старті
x/1xw 0x4001100C Прочитати 32-бітне слово з регістра GPIOC_ODR
set {int}0x4001100C=0x2000 Силоміць записати значення в регістр (увімкнути LED без коду!)
info registers Показати всі регістри процесора (якщо вікно regs закрите)

5. “Рентген” регістрів (Приклад з адресою)

Якщо хочеш перевірити, чи реально змінився стан заліза (наприклад, чи загорівся біт світлодіода), використай пряме читання пам’яті:

  • x — Examine (дослідити).

  • 1xw — Показати 1 значення в hex форматі, розміром у word (32 біти).

  • 0x4001100C — Адреса регістра GPIOC_ODR.

Якщо ти побачиш там 0x00002000 (для PC13) або 0x00004000 (для PC14) — вітаю, ти щойно прочитав стан фізичного транзистора всередині чіпа! Це взагалі велика тема, яка потребує окремого вивчення, але для знайомства тре потикати обов’язково.


 

Шпаргалка

Базові адреси і тактування

Периферія Адреса Тактування RCC_APB2ENR
GPIOA 0x40010800 біт 2
GPIOB 0x40010C00 біт 3
GPIOC 0x40011000 біт 4
AFIO 0x40010000 біт 0
USART1 0x40013800 біт 14
RCC 0x40021000
EXTI 0x40010400
NVIC_ISER0 0xE000E100

Таблиця режимів GPIO (MODE + CNF = 4 біти)

Що хочемо MODE CNF Hex
Output push-pull 2MHz (LED) 10 00 0x2
Output push-pull 50MHz 11 00 0x3
Alt. push-pull 50MHz (UART TX, SPI) 11 10 0xB
Alt. open-drain 50MHz (I2C SDA/SCL) 11 11 0xF
Input floating 00 01 0x4
Input pull-up/down (кнопка) 00 10 0x8
Input analog (ADC) 00 00 0x0

Формула зміщення для CRL/CRH

Шаблон налаштування GPIO

 

Чеклист налаштування EXTI

 

IRQ номери для EXTI

Лінія EXTI IRQ номер Вектор #
EXTI0 6 22
EXTI1 7 23
EXTI2 8 24
EXTI3 9 25
EXTI4 10 26
EXTI9_5 23 39
EXTI15_10 40 56 (NVIC_ISER1)

Що далі?

В наступній статті напишемо свій HAL — pinMode(), digitalWrite(), digitalRead() як в Arduino, ну чи майже так, як вже піде, ну і delay_ms() через SysTick тре робити і правильний програмний debouncing для кнопкиґ, ой ще вичти і вчити…

Весь код з цієї статті: github.com/pipicosim800-maker

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

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

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