Платформа: 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 2 3 4 5 6 7 8 |
stm32_blink/ ├── src/ │ ├── startup.c ← таблиця векторів + Reset_Handler │ └── main.c ← наш код ├── ld/ │ └── stm32f103.ld ← карта пам'яті чіпа └── Makefile |
Файл 1: Лінкер скрипт — карта пам’яті чіпа
Ось цей файл це не код, а карта пам’яті, маю надію його не тре постійно правити а можна копіпастити, то ж, тут вказуємо компілятору де у нашого чіпа живе Flash і де SRAM:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K } SECTIONS { .text : { KEEP(*(.vectors)) /* таблиця векторів — перша! */ *(.text*) *(.rodata*) } > FLASH .data : { _sdata = .; *(.data*) _edata = .; } > SRAM AT > FLASH _sidata = LOADADDR(.data); .bss : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } > SRAM _estack = ORIGIN(SRAM) + LENGTH(SRAM); } |
Розберемо кожну частину окремо.

MEMORY — фізична карта STM32F103:
|
1 2 3 |
FLASH (rx) — де лежить прошивка, read + execute SRAM (rwx) — де живуть змінні під час виконання, read + write + execute |
Адреси 0x08000000 і 0x20000000 я не вигадував. Вони прописані в ARM специфікації і в RM0008. Flash завжди починається з 0x08000000, SRAM — з 0x20000000. Це стандарт для всіх STM32.
Що означають секції:
.text— код програми і константи, лягають у Flash.data— глобальні змінні з початковими значеннями. Зберігаються у Flash, але при старті копіюються в SRAM.bss— неініціалізовані змінні, тільки в SRAM, обнуляються при старті_estack— вершина стеку, цеSRAM_start + SRAM_size = 0x20005000
Секція .text — код і константи:
|
1 2 3 4 5 6 |
.text : { KEEP(*(.vectors)) /* таблиця векторів — перша! */ *(.text*) *(.rodata*) } > FLASH |
KEEP(*(.vectors)) гарантує що таблиця векторів не буде викинута оптимізацією і ляже на адресу 0x08000000. Це критично без неї МК не стартує.
Секція .data — змінні з ініціалізацією:
|
1 2 3 4 5 6 |
.data : { _sdata = .; *(.data*) _edata = .; } > SRAM AT > FLASH |
Тут важливий момент який багато хто не розуміє. Якщо написати:
|
1 2 |
int x = 10; |
Значення 10 зберігається у Flash (там воно не зітреться при вимкненні). Але під час роботи змінна x живе в SRAM. Тому > SRAM AT > FLASH означає:
> SRAM— де змінна буде під час виконанняAT > FLASH— звідки її копіювати при старті
_sidata = LOADADDR(.data) — це адреса у Flash звідки треба копіювати. І саме це робить наш startup.c:
Секція .bss — неініціалізовані змінні:
|
1 2 3 |
uint32_t *src = &_sidata; uint32_t *dst = &_sdata; while (dst < &_edata) *dst++ = *src++; |
|
1 2 3 4 5 6 7 |
.bss : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } > SRAM |
Змінні типу int x; або static int y; не мають значення у Flash — вони просто займають місце в SRAM і обнуляються при старті. Startup це робить:
|
1 2 |
dst = &_sbss; while (dst < &_ebss) *dst++ = 0; |
Стек:
|
1 |
_estack = ORIGIN(SRAM) + LENGTH(SRAM); |
Це вершина SRAM і як ти певно знаєш саме звідси стек починає рости вниз. Перше слово в таблиці векторів саме ця адреса. Cortex-M3 при старті завантажує її в регістр SP (Stack Pointer). Стек росте вниз. Heap росте вгору і десь посередині SRAM вони дивляться одне на одного як сусіди в комуналці і якщо не порахувати пам’ять правильно, зустрінуться і тоді stack overflow не той де питання усілякі розбирають, а справжній коли після нього МК під LSD і поводиться трохи стрьомно.
Та не переживай ти так, «МК під LSD» то жарт 😄 який тобі точно зайшов інакше ти б це не читав, отже лікбез, ну не позіхай, тут швидко.
Heap це динамічна пам’ять, це коли malloc() і free() Коли пишеш в C:
|
1 |
int *buf = malloc(100 * sizeof(int)); |
пам’ять береться з heap. Він живе в SRAM і росте вгору, назустріч стеку.
Але в bare-metal STM32 без ОС malloc() використовують рідко і з обережністю:
- SRAM всього 20 КБ — легко вичерпати
- Фрагментація: багато malloc/free → дірки в пам’яті → в якийсь момент
mallocповертає NULL хоча пам’ять «є» - Embedded люблять передбачуваність, а динамічна пам’ять — ні
Тому зазвичай або статичні масиви, або свій пул пам’яті з фіксованими блоками. В нашому коді heap = 0, стек і heap так і не зустрілись і це супер.

А тепер про ті цифри які видає make. Ось що в мене:
|
1 2 3 |
text data bss dec hex filename 376 0 0 376 178 blink.elf |
Це 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:
|
1 2 |
Attempting to write 376 bytes to stm32 address: 134217728 (0x8000000) |
134217728 — це 0x08000000 в десятковій. Та сама адреса з лінкер скрипту. st-flash записує бінарник саме туди де ARM очікує знайти перше слово таблиці векторів. Звідси Cortex-M3 і стартує після reset.
І нарешті найприємніший рядок у embedded розробці:
|
1 2 |
Flash written and verified! jolly good! |
«Jolly good» означає дуже добре. та рухаємось далі. Знаю нудятина. Але ти точно не знаєш чому програміст засунув палець в дупу і посміхається. Якщо дочитаєш далі зрозумієш, але не розказуй то рузьким бо в них то тема заборонена, ну я про палець якщо що 😉
Файл 2: startup.c — що відбувається до main()
В Arduino є прихований код який стартує до setup(). В нас він явний Він робить три речі: містить таблицю векторів, копіює .data з Flash в SRAM і обнуляє .bss.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
#include <stdint.h> extern uint32_t _estack; extern uint32_t _sdata, _edata, _sidata; extern uint32_t _sbss, _ebss; extern int main(void); void Reset_Handler(void) { // копіюємо .data з Flash в SRAM uint32_t *src = &_sidata; uint32_t *dst = &_sdata; while (dst < &_edata) *dst++ = *src++; // обнуляємо .bss dst = &_sbss; while (dst < &_ebss) *dst++ = 0; main(); while (1); // якщо main повернувся — висимо тут } void Default_Handler(void) { while (1); } #define WEAK_ALIAS __attribute__((weak, alias("Default_Handler"))) void NMI_Handler(void) WEAK_ALIAS; void HardFault_Handler(void) WEAK_ALIAS; void SVC_Handler(void) WEAK_ALIAS; void PendSV_Handler(void) WEAK_ALIAS; void SysTick_Handler(void) WEAK_ALIAS; // таблиця векторів — лягає в самий початок Flash __attribute__((section(".vectors"))) uint32_t vectors[] = { (uint32_t)&_estack, // 0: вершина стеку (uint32_t)Reset_Handler, // 1: reset (uint32_t)NMI_Handler, // 2: NMI (uint32_t)HardFault_Handler, // 3: hard fault 0, 0, 0, 0, 0, 0, 0, // 4-10: reserved (uint32_t)SVC_Handler, // 11: SVCall 0, 0, // 12-13: reserved (uint32_t)PendSV_Handler, // 14: PendSV (uint32_t)SysTick_Handler, // 15: SysTick }; |
Що таке таблиця векторів? Це масив адрес функцій на початку Flash. Після reset Cortex-M3 читає перші два слова з 0x08000000:
- слово 0 → початкове значення стеку
- слово 1 → адреса Reset_Handler
Далі стрибає на Reset_Handler і починає виконувати наш код.
Чому WEAK_ALIAS? Це означає: «якщо ніхто не визначив цю функцію — використовуй Default_Handler». Можна спокійно не визначати SysTick_Handler поки він не потрібен компілятор жалітись не буде.
Що відбувається при старті покроково:
|
1 2 3 4 5 6 7 8 |
1. Напруга подана 2. Cortex-M3 читає 0x08000000 → завантажує в SP (стек) 3. Читає 0x08000004 → стрибає на Reset_Handler 4. Reset_Handler копіює .data Flash→SRAM 5. Reset_Handler обнуляє .bss 6. Викликає main() 7. Твій код починає виконуватись |
Файл 3: main.c — перше мигання (йди випий кави і трохи відпочинь, бо тут саме воно)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <stdint.h> // RCC — контролер тактування (RM0008, глава 7) #define RCC_BASE 0x40021000 #define RCC_APB2ENR (*(volatile uint32_t *)(RCC_BASE + 0x18)) #define RCC_APB2ENR_IOPCEN (1 << 4) // GPIOC — порт C (RM0008, глава 9) #define GPIOC_BASE 0x40011000 #define GPIOC_CRH (*(volatile uint32_t *)(GPIOC_BASE + 0x04)) #define GPIOC_ODR (*(volatile uint32_t *)(GPIOC_BASE + 0x0C)) #define PIN13 (1 << 13) void delay(volatile uint32_t n) { while (n--); } int main(void) { // 1. вмикаємо тактування порту C RCC_APB2ENR |= RCC_APB2ENR_IOPCEN; // 2. PC13 — вихід push-pull 2MHz // CRH керує пінами 8-15, PC13 → біти [23:20] GPIOC_CRH &= ~(0xF << 20); // очищаємо 4 біти GPIOC_CRH |= (0x2 << 20); // output push-pull 2MHz while (1) { GPIOC_ODR ^= PIN13; // toggle PC13 delay(500000); } return 0; } |
Якщо ти читаєш далі ти явно крепкій орєшек, але запам’ятай це важливе слово volatile бо про нього навіть в книзі Джозеф.Ю згадує. Без нього компілятор може вирішити «я вже читав цю адресу, навіщо читати знову» і закешувати значення. Але регістр мікроконтролера може змінитись апаратно без участі нашого коду, але volatile каже компілятору: «завжди читай і пиши реально, не оптимізуй». РОзбираємо крок, отже кожен макрос типу GPIOC_CRH — це просто вказівник на адресу:
|
1 2 3 |
// ось що насправді ховається за макросом: #define GPIOC_CRH (*(volatile uint32_t *)0x40011004) // це звичайний Сі — читаємо або пишемо за адресою 0x40011004 |
Makefile і збірка
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
TARGET = blink CC = arm-none-eabi-gcc OBJCOPY = arm-none-eabi-objcopy CFLAGS = -mcpu=cortex-m3 -mthumb -O0 -g \ -Wall -ffunction-sections -fdata-sections LDFLAGS = -T ld/stm32f103.ld \ -mcpu=cortex-m3 -mthumb \ -Wl,--gc-sections \ -nostdlib SRC = src/startup.c src/main.c all: $(CC) $(CFLAGS) $(LDFLAGS) $(SRC) -o $(TARGET).elf $(OBJCOPY) -O binary $(TARGET).elf $(TARGET).bin arm-none-eabi-size $(TARGET).elf flash: st-flash write $(TARGET).bin 0x08000000 clean: rm -f $(TARGET).elf $(TARGET).bin |
Відступи в Makefile — це Tab, не пробіли. Це критично важливо.
|
1 |
make && make flash |
Результат:
|
1 2 3 4 5 |
text data bss dec hex filename 272 0 0 272 110 blink.elf Flash written and verified! jolly good! |
272 байти. Порожній HAL-проект з CubeMX на тому ж F103 — ~3-4 КБ тільки на ініціалізацію. Ми зробили те саме за 272 байти тому що знаємо що відбувається на кожному кроці.
LED на PC13 мигає. Але виникає питання. А якщо тре більше портів?

Блок 2 — А якщо треба більше портів?
Окей, PC13 мигає. Але в реальному проекті треба кілька пінів на різних портах. Що тоді?
Відповідь: кожен порт має свою базову адресу, і кожен треба окремо увімкнути через RCC. Ось код який мигає пінами на портах A і C одночасно:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
#include <stdint.h> // RCC #define RCC_BASE 0x40021000 #define RCC_APB2ENR (*(volatile uint32_t *)(RCC_BASE + 0x18)) /* Кожен порт має свою базову адресу (з RM0008): GPIOA: 0x40010800 GPIOB: 0x40010C00 GPIOC: 0x40011000 */ // GPIOA #define GPIOA_BASE 0x40010800 #define GPIOA_CRL (*(volatile uint32_t *)(GPIOA_BASE + 0x00)) #define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x0C)) // GPIOC #define GPIOC_BASE 0x40011000 #define GPIOC_CRH (*(volatile uint32_t *)(GPIOC_BASE + 0x04)) #define GPIOC_ODR (*(volatile uint32_t *)(GPIOC_BASE + 0x0C)) #define GPIOC_BSRR (*(volatile uint32_t *)(GPIOC_BASE + 0x10)) // біти тактування #define RCC_IOPAEN (1 << 2) // Port A #define RCC_IOPCEN (1 << 4) // Port C // піни #define GPIO_PIN_0 (1 << 0) #define GPIO_PIN_13 (1 << 13) #define GPIO_PIN_14 (1 << 14) #define GPIO_PIN_15 (1 << 15) void delay(volatile uint32_t n) { while (n--); } int main(void) { // вмикаємо тактування Port A і Port C одним рядком RCC_APB2ENR |= (RCC_IOPAEN | RCC_IOPCEN); // налаштовуємо PC13, PC14, PC15 як output push-pull 2MHz // CRH керує пінами 8-15, кожен займає 4 біти // PC13 → [23:20], PC14 → [27:24], PC15 → [31:28] GPIOC_CRH &= ~((0xF << 20) | (0xF << 24) | (0xF << 28)); GPIOC_CRH |= ((0x2 << 20) | (0x2 << 24) | (0x2 << 28)); // налаштовуємо PA0 як output push-pull 2MHz // CRL керує пінами 0-7, PA0 → біти [3:0] GPIOA_CRL &= ~(0xF << 0); GPIOA_CRL |= (0x2 << 0); while (1) { // PC13 — через BSRR (правильний спосіб) GPIOC_BSRR = (1 << 13); // увімкнути delay(300000); GPIOC_BSRR = (1 << (13 + 16)); // вимкнути delay(300000); // PC14, PC15 — через ODR toggle GPIOC_ODR ^= GPIO_PIN_14; delay(300000); GPIOC_ODR ^= GPIO_PIN_15; delay(300000); // PA0 — через ODR toggle GPIOA_ODR ^= GPIO_PIN_0; delay(300000); } return 0; } |
Код працює. Але тут є кілька речей які варто розжувати.
Карта пам’яті: де живуть порти
STM32 використовує Memory-Mapped I/O — кожна периферія відображена на певну ділянку адресного простору. Уяви це як місто де кожна периферія має свою земельну ділянку з кадастровим кодом:
| Блок | Адреса | Функція |
|---|---|---|
| Flash | 0x08000000 | програма (код) |
| SRAM | 0x20000000 | змінні під час виконання |
| GPIOA | 0x40010800 | порт А |
| GPIOB | 0x40010C00 | порт В |
| GPIOC | 0x40011000 | порт C — наш LED |
| RCC | 0x40021000 | тактування всього |
Саме тому наш макрос:
|
1 |
#define GPIOC_CRH (*(volatile uint32_t *)(GPIOC_BASE + 0x04)) |
Це вказівник на адресу 0x40011004. Пишемо туди — пишемо в регістр конфігурації порту C.
А тепер давай пожартуємо і напишемо неправильну адресу наприклад переплутаємо GPIOC з GPIOA:
|
1 2 3 4 5 |
// УВАГА: навмисна помилка для демонстрації #define GPIOC_CRH (*(volatile uint32_t *)(0x40010800 + 0x04)) // ^^^^^^^^^^ // це GPIOA, не GPIOC! Компілятор не скаржиться бо адреса валідна, синтаксис правильний. // Але результат [LED не мигає], хоча: Flash written and verified! jolly good! |
Налаштували GPIOA порт A замість C. PC13 навіть не підозрює що щось відбувалось. Ось чому адреси треба брати з RM0008 а не з пам’яті і компілятор тебе не врятує, він чесно запише куди сказали.
Вже почали то згадаймо і про другий класичний випадок з правильною адресою, але забули тактування:
|
1 2 3 4 5 |
// тактування не увімкнули випадково видаливши чи закоментувавши ей рядок // RCC_APB2ENR |= RCC_IOPCEN; GPIOC_CRH &= ~(0xF << 20); // пишемо в регістр GPIOC_CRH |= (0x2 << 20); // периферія спить — ігнорує |
Результат той самий не блимає. Запам’ятай бо це дійсно важливо RCC це не просто формальність, а рубильник без якого периферія не працює.
Чому треба вмикати тактування?
За замовчуванням вся периферія STM32 «спить» — тактування на неї не подається. Це зроблено для економії енергії. RCC (Reset and Clock Control) — це «головна електростанція» яка вмикає тактування для кожного блоку окремо.
Якщо не увімкнути тактування і одразу писати в регістри GPIO — нічого не відбудеться. Запис просто ігнорується. Це одна з найпоширеніших помилок новачків.
|
1 2 3 4 5 6 7 8 |
// біти в RCC_APB2ENR (зміщення 0x18 від RCC_BASE): // біт 2 → GPIOA // біт 3 → GPIOB // біт 4 → GPIOC ← наш // біт 14 → USART1 // можна вмикати кілька одночасно через OR: RCC_APB2ENR |= (RCC_IOPAEN | RCC_IOPCEN); |
CRL vs CRH і формула зміщення
GPIO має два регістри конфігурації:
- CRL (Control Register Low) — піни 0-7, зміщення
0x00 - CRH (Control Register High) — піни 8-15, зміщення
0x04
Кожен пін займає 4 біти у своєму регістрі. Ці 4 біти — це два поля MODE і CNF:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
MODE[1:0] Напрямок і швидкість 00 → Input (вхід) 01 → Output 10MHz 10 → Output 2MHz ← використовуємо для LED 11 → Output 50MHz CNF[1:0] для OUTPUT: CNF[1:0] для INPUT: 00 → Push-pull ← 00 → Analog 01 → Open-drain 01 → Floating (за замовч.) 10 → Alt. Push-pull 10 → Pull-up/down ← 11 → Alt. Open-drain 11 → Reserved |
Формула зміщення:
- Для CRL:
пін × 4 - Для CRH:
(пін - 8) × 4
Приклади:
|
1 2 3 4 5 |
PA0 → CRL, (0 × 4) = << 0 PA5 → CRL, (5 × 4) = << 20 PC13 → CRH, (13-8) × 4 = << 20 PC14 → CRH, (14-8) × 4 = << 24 PC15 → CRH, (15-8) × 4 = << 28 |
Звідси і береться << 20 в нашому коді для PC13.
Бітові операції: маски і чому саме так
Ми ніколи не пишемо REG = значення бо це затре налаштування всіх інших пінів. Використовуємо Read-Modify-Write:
|
1 2 3 4 5 6 7 8 9 10 |
// Крок 1: "ластиком" стираємо 4 біти для PC13 // 0xF = 0b1111 — маска на 4 біти // << 20 — зсув на позицію PC13 // ~ — інвертуємо: всі біти 1, крім [23:20] де 0 GPIOC_CRH &= ~(0xF << 20); // очищаємо // Крок 2: записуємо нове значення // 0x2 = 0b0010: MODE=10 (Output 2MHz), CNF=00 (Push-pull) GPIOC_CRH |= (0x2 << 20); // встановлюємо |
Якщо треба налаштувати кілька пінів одночасно — об’єднуємо через оператор труба |:
|
1 2 3 4 |
// очищаємо PC13, PC14, PC15 одним рядком GPIOC_CRH &= ~((0xF << 20) | (0xF << 24) | (0xF << 28)); // встановлюємо всі три GPIOC_CRH |= ((0x2 << 20) | (0x2 << 24) | (0x2 << 28)); |
Можна записати ще наочніше через бінарні літерали просто «малюємо» біти:
|
1 2 3 4 |
// 15 14 13 12 11 10 9 8 ← номери пінів // 0 0 1 0 0 0 0 0 ← які біти відповідають PC13 GPIOC_BSRR = 0b0010000000000000; // те саме що (1 << 13) |
Або зробити макрос LED(n) для зручності:
|
1 2 3 |
#define LED(n) (1 << (n)) GPIOC_ODR ^= (LED(13) | LED(14) | LED(15)); |
Чому BSRR краще ніж ODR ^=?
До речі — «програміст засунув палець в дупу і посміхається бо до цього там у нього був костиль» і це точно про BSRR де 13 + 16 = 29? Ну типу «костиль» це ODR toggle а «палець» це (1 << (13 + 16)) який виглядає стрьомно але працює ідеально? Ну смішно ж правда? 😄
|
1 2 3 4 5 |
// ODR toggle — три операції: читай → змінюй → пиши GPIOC_ODR ^= GPIO_PIN_13; // BSRR — один запис, атомарна операція GPIOC_BSRR = (1 << 13); // Set: увімкнути PC13 GPIOC_BSRR = (1 << (13 + 16)); // Reset: вимкнути PC13 |
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:
|
1 2 3 4 5 6 7 |
#define GPIOA_IDR (*(volatile uint32_t *)(GPIOA_BASE + 0x08)) // CNF=10 (Input pull-up/down), MODE=00 (Input) // 0x8 = 0b1000: старший CNF=10, молодший MODE=00 // PA1 → біти [7:4] в CRL (пін 1 × 4 = зміщення 4) GPIOA_CRL &= ~(0xF << 4); GPIOA_CRL |= (0x8 << 4); // запис 1 в ODR для вхідного піна = вибираємо pull-up GPIOA_ODR |= (1 << 1); |
Читаємо кнопку в циклі:
|
1 2 3 4 5 6 7 8 |
while (1) { if (!(GPIOA_IDR & (1 << 1))) { // кнопка натиснута (PA1 = LOW) GPIOC_BSRR = (1 << 13); // LED увімкнути } else { GPIOC_BSRR = (1 << (13 + 16)); // LED вимкнути } } |
Перевірка !(IDR & bit) — тому що з pull-up логіка інверсна: натиснуто = 0, відпущено = 1.
Це polling — ми постійно крутимося і перевіряємо. Простий спосіб, але є проблема: якщо в while(1) є важкий код — кнопка може не реагувати одразу. І процесор постійно зайнятий перевіркою навіть коли нічого не відбувається.
Спосіб 2: Правильно — через переривання EXTI
Кращий підхід: налаштувати переривання і забути про кнопку. Процесор займається своїми справами, а коли кнопка натиснута — залізо саме «гукне» і викличе нашу функцію.
Для GPIO переривань в STM32F1 є ланцюжок з трьох блоків:
|
1 2 |
GPIO пін → AFIO → EXTI → NVIC → наш handler |
- 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 — п’ять кроків:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#define AFIO_BASE 0x40010000 #define EXTI_BASE 0x40010400 #define NVIC_ISER0 (*(volatile uint32_t *)(0xE000E100)) #define RCC_AFIOEN (1 << 0) #define AFIO_EXTICR1 (*(volatile uint32_t *)(AFIO_BASE + 0x08)) #define EXTI_IMR (*(volatile uint32_t *)(EXTI_BASE + 0x00)) #define EXTI_FTSR (*(volatile uint32_t *)(EXTI_BASE + 0x0C)) #define EXTI_PR (*(volatile uint32_t *)(EXTI_BASE + 0x14)) void exti1_init(void) { // Крок 1: тактування AFIO RCC_APB2ENR |= RCC_AFIOEN; // Крок 2: PA1 вже Input Pull-up (з попереднього прикладу) // Крок 3: прив'язуємо PA1 до лінії EXTI1 // EXTICR1 керує лініями EXTI0-EXTI3, кожна займає 4 біти // EXTI1 → біти [7:4], Port A = 0b0000 AFIO_EXTICR1 &= ~(0xF << 4); // Крок 4: налаштовуємо EXTI EXTI_IMR |= (1 << 1); // дозволяємо переривання на лінії 1 EXTI_FTSR |= (1 << 1); // реагуємо на натискання (falling edge) // Крок 5: дозволяємо в NVIC // EXTI1 → IRQ номер 7 NVIC_ISER0 |= (1 << 7); } |
Додаємо вектор в таблицю в startup.c — після SysTick_Handler:
|
1 2 3 4 |
// в масиві vectors[] після системних векторів: 0, 0, 0, 0, 0, 0, // 16-21: reserved/інші (uint32_t)EXTI1_IRQHandler, // 23: EXTI1 |
Функція-обробник:
|
1 2 3 4 5 6 7 8 |
void EXTI1_IRQHandler(void) { if (EXTI_PR & (1 << 1)) { GPIOC_ODR ^= GPIO_PIN_13; // перемикаємо LED // ОБОВ'ЯЗКОВО скидаємо прапорець // Записуємо 1 щоб скинути — не 0! EXTI_PR |= (1 << 1); } } |
Найчастіша помилка: забути скинути EXTI_PR. Якщо не скинути — процесор одразу знову зайде в переривання, і так нескінченно. Програма зависне в ISR назавжди.
Проблема брязкоту контактів
Запускаємо, натискаємо кнопку — і LED перемикається… але іноді двічі або тричі від одного натискання. Це дребезг (debouncing) — механічний контакт при натисканні кілька разів замикається і розмикається за мілісекунди:
|
1 2 3 4 5 6 |
Ідеальна кнопка: ────┐ ┌──── └────────┘ Реальна кнопка: ────┐ ┌┐┌┐ ┌┐┌┐┌──── └┘└┘└───┘└┘└┘ |
Одне натискання генерує 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 вміє запускати дебагер з правильними прапорцями.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
TARGET = gpio_example CC = arm-none-eabi-gcc OBJCOPY = arm-none-eabi-objcopy CFLAGS = -mcpu=cortex-m3 -mthumb -O0 -g \ -Wall -ffunction-sections -fdata-sections LDFLAGS = -T ld/stm32f103.ld \ -mcpu=cortex-m3 -mthumb \ -Wl,--gc-sections \ -nostdlib SRC = src/startup.c src/main.c all: $(CC) $(CFLAGS) $(LDFLAGS) $(SRC) -o $(TARGET).elf $(OBJCOPY) -O binary $(TARGET).elf $(TARGET).bin $(OBJCOPY) -O ihex $(TARGET).elf $(TARGET).hex arm-none-eabi-size $(TARGET).elf flash: st-flash write $(TARGET).bin 0x08000000 clean: rm -f $(TARGET).elf $(TARGET).bin $(TARGET).hex debug: gdb-multiarch -ex "target extended-remote :4242" \ -ex "monitor reset halt" \ -ex "layout src" \ -ex "layout regs" \ $(TARGET).elf |
Важливо: У системі має бути встановлено:
sudo apt install gdb-multiarch stlink-tools.
2. Крок за кроком: Запуск сесії
-
Підключи залізо: Встроми ST-Link у USB, а Blue Pill — до ST-Link (GND, CLK, DIO, 3.3V).
-
Запусти Сервер (Термінал 1): Це місток між USB та GDB.
12st-utilМає з’явитися напис:
Listening at *:4242. -
Запусти Дебагер (Термінал 2): Перейди у папку з проєктом, де лежить твій
.elfфайл, і виконай:12make debug
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. “Рентген” регістрів (Приклад з адресою)
Якщо хочеш перевірити, чи реально змінився стан заліза (наприклад, чи загорівся біт світлодіода), використай пряме читання пам’яті:
|
1 2 |
(gdb) x/1xw 0x4001100C |
-
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
|
1 2 3 4 |
CRL (піни 0-7): зміщення = пін × 4 CRH (піни 8-15): зміщення = (пін - 8) × 4 PA0 → CRL, << 0 PA5 → CRL, << 20 PC13 → CRH, << 20 PC14 → CRH, << 24 PC15 → CRH, << 28 |
Шаблон налаштування GPIO
|
1 2 3 4 5 6 7 8 9 |
// OUTPUT: RCC_APB2ENR |= (1 << RCC_біт); // тактування GPIOx_CRy &= ~(0xF << зміщення); // очистити GPIOx_CRy |= (0x2 << зміщення); // output push-pull 2MHz // INPUT PULL-UP (кнопка): RCC_APB2ENR |= (1 << RCC_біт); GPIOx_CRy &= ~(0xF << зміщення); GPIOx_CRy |= (0x8 << зміщення); // input pull-up/down GPIOx_ODR |= (1 << пін); // вибрати pull-up |
Чеклист налаштування EXTI
|
1 2 3 4 5 6 7 8 |
1. RCC_APB2ENR |= (1 << 0) — тактування AFIO 2. RCC_APB2ENR |= (1 << порт+2) — тактування GPIO 3. Налаштувати пін як Input Pull-up 4. AFIO_EXTICR → вибрати порт (4 біти на лінію, Port A = 0) 5. EXTI_IMR |= (1 << лінія) — дозволити переривання 6. EXTI_FTSR |= (1 << лінія) — falling edge (натискання) 7. NVIC_ISER0 |= (1 << IRQ_номер) — дозволити в NVIC 8. В handler: EXTI_PR |= (1 << лінія) — ОБОВ'ЯЗКОВО скинути! |
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

