STM32 з нуля без HAL: GPIO, регістри і переривання. Частина 1
Платформа: Blue Pill (STM32F103C8T6) · ST-Link v2 · Linux · arm-none-eabi-gcc
Рівень: є досвід з Arduino або ESP32, хочеться зрозуміти що під капотом
Вступ — ARM для Ардуїнщиків, які прагнуть чогось більшого
Навіщо HAL?
Якщо читаєш ці рядки, то точно вже мигав/ла LED через Arduino або CubeMX або вже читав ввідну статтю до цієї серії «ARM для Ардуїнщиків, які прагнуть чогось більшого». В пам’яті вже спливає, твоє перше і можливо поки що останнє (знаю зараз модно казати крайній, але ті кримінальні часи коли за «последній» можна було і відповісти минули, ну а крайня у моєму розумінні або північ або плоть ;)) знайомство де ти натискаєш кнопку в GUI, генерується код, якось воно працює. Але ти не розумієш що відбувається всередині. Окрім банальної цікавості, не розумієш, що відбувається, наприклад: HAL_GPIO_WritePin не спрацьовує, гугл каже «перевір ClockEnable», але чому хз. Або I2C зависає, і годинами крутиш параметри CubeMX не розуміючи що саме впливає, зливаючи тисячі токенів чатбота. Хоча, якщо чесно ще з часів користування Notepad ++, Eclipse, Code::Blocks, коли вчиш у CooCox CoIDE («Кук»), а потім тре Keil uVision, а ще тре STM32CubeIDE. І коли тобі хочеться погратись у інженера і ти береш платку, а потім починаєш розбиратись з IDE і через якісь час розумієш, що вже награвся і кладеш платку в шухлядку, і так нічого і не спрацювало бо інтерфейс змінився, що там куди тикати не зрозуміло. Та цього разу усе буде по іншому, обіцяю бо цей опус задуманий не випадково, а в рамках шести місячного шляху в embedded по дорожній карті. Колись щось подібне проходив з 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 з компілятором і процесором які взаємодіють за чіткими правилами Камасутри, тьху срамота оце понесло ;)
Тож нам потрібно рівно чотири файли:
stm32_blink/ ├── src/ │ ├── startup.c ← таблиця векторів + Reset_Handler │ └── main.c ← наш код ├── ld/ │ └── stm32f103.ld ← карта пам'яті чіпа └── Makefile
Файл 1: Лінкер скрипт — карта пам’яті чіпа
Ось цей файл це не код, а карта пам’яті, маю надію його не тре постійно правити а можна копіпастити, то ж, тут вказуємо компілятору де у нашого чіпа живе Flash і де SRAM:
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:
FLASH (rx) — де лежить прошивка, read + execute SRAM (rwx) — де живуть змінні під час виконання, read + write + execute
Адреси 0x08000000 і 0x20000000 я не вигадував. Вони прописані в ARM специфікації і в RM0008. Flash завжди починається з 0x08000000, SRAM — з 0x20000000. Це стандарт для всіх STM32.
Секція .text — код і константи:
.text :
{
KEEP(*(.vectors)) /* таблиця векторів — перша! */
*(.text*)
*(.rodata*)
} > FLASH
KEEP(*(.vectors)) гарантує що таблиця векторів не буде викинута оптимізацією і ляже на адресу 0x08000000. Це критично без неї МК не стартує.
Секція .data — змінні з ініціалізацією:
.data :
{
_sdata = .;
*(.data*)
_edata = .;
} > SRAM AT > FLASH
Тут важливий момент який багато хто не розуміє. Якщо написати:
int x = 10;
Значення 10 зберігається у Flash (там воно не зітреться при вимкненні). Але під час роботи змінна x живе в SRAM. Тому > SRAM AT > FLASH означає:
> SRAM— де змінна буде під час виконанняAT > FLASH— звідки її копіювати при старті
_sidata = LOADADDR(.data) — це адреса у Flash звідки треба копіювати. І саме це робить наш startup.c:
uint32_t *src = &_sidata; uint32_t *dst = &_sdata; while (dst < &_edata) *dst++ = *src++;
Секція .bss — неініціалізовані змінні:
.bss :
{
_sbss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > SRAM
Змінні типу int x; або static int y; не мають значення у Flash — вони просто займають місце в SRAM і обнуляються при старті. Startup це робить:
dst = &_sbss; while (dst < &_ebss) *dst++ = 0;
Стек:
__estack = ORIGIN(SRAM) + LENGTH(SRAM);
Це вершина SRAM і як ти певно знаєш саме звідси стек починає рости вниз. Перше слово в таблиці векторів саме ця адреса. Cortex-M3 при старті завантажує її в регістр SP (Stack Pointer). Стек росте вниз. Heap росте вгору і десь посередині SRAM вони дивляться одне на одного як сусіди в комуналці і якщо не порахувати пам’ять правильно, зустрінуться і тоді stack overflow не той де питання усілякі розбирають, а справжній коли після нього МК під LSD і поводиться трохи стрьомно.
Та не переживай ти так, «МК під LSD» то жарт 😄 який тобі точно зайшов інакше ти б це не читав, отже лікбез, ну не позіхай, тут швидко.
Heap це динамічна пам’ять, це коли malloc() і free() Коли пишеш в C:
int *buf = malloc(100 * sizeof(int));
пам’ять береться з heap. Він живе в SRAM і росте вгору, назустріч стеку.
Але в bare-metal STM32 без ОС malloc() використовують рідко і з обережністю:
- SRAM всього 20 КБ — легко вичерпати
- Фрагментація: багато malloc/free → дірки в пам’яті → в якийсь момент
mallocповертає NULL хоча пам’ять «є» - Embedded люблять передбачуваність, а динамічна пам’ять — ні
Тому зазвичай або статичні масиви, або свій пул пам’яті з фіксованими блоками. В нашому коді heap = 0, стек і heap так і не зустрілись і це супер.

А тепер про ті цифри які видає make. Ось що в мене:
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:
Attempting to write 376 bytes to stm32 address: 134217728 (0x8000000)
134217728 — це 0x08000000 в десятковій. Та сама адреса з лінкер скрипту. st-flash записує бінарник саме туди де ARM очікує знайти перше слово таблиці векторів. Звідси Cortex-M3 і стартує після reset.
І нарешті найприємніший рядок у embedded розробці:
Flash written and verified! jolly good!
«Jolly good» означає дуже добре. та рухаємось далі. Знаю нудятина. Але ти точно не знаєш чому програміст засунув палець в дупу і посміхається. Якщо дочитаєш далі зрозумієш, але не розказуй то рузьким бо в них то тема заборонена, ну я про палець якщо що ;)
Файл 2: startup.c — що відбувається до main()
В Arduino є прихований код який стартує до setup(). В нас він явний Він робить три речі: містить таблицю векторів, копіює .data з Flash в SRAM і обнуляє .bss.
#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. Cortex-M3 читає 0x08000000 → завантажує в SP (стек) 3. Читає 0x08000004 → стрибає на Reset_Handler 4. Reset_Handler копіює .data Flash→SRAM 5. Reset_Handler обнуляє .bss 6. Викликає main() 7. Твій код починає виконуватись
Файл 3: main.c — перше мигання (йди випий кави і трохи відпочинь, бо тут саме воно)
#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 — це просто вказівник на адресу:
// ось що насправді ховається за макросом: #define GPIOC_CRH (*(volatile uint32_t *)0x40011004) // це звичайний Сі — читаємо або пишемо за адресою 0x40011004
Makefile і збірка
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, не пробіли. Це критично важливо.
make && make flash
Результат:
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 одночасно:
#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 | 0×08000000 | програма (код) |
| SRAM | 0×20000000 | змінні під час виконання |
| GPIOA | 0×40010800 | порт А |
| GPIOB | 0×40010C00 | порт В |
| GPIOC | 0×40011000 | порт C — наш LED |
| RCC | 0×40021000 | тактування всього |
Саме тому наш макрос:
#define GPIOC_CRH (*(volatile uint32_t *)(0x40011000 + 0x04))
Це вказівник на адресу 0x40011004. Пишемо туди — пишемо в регістр конфігурації порту C.
А тепер давай пожартуємо і напишемо неправильну адресу наприклад переплутаємо GPIOC з GPIOA:
// УВАГА: навмисна помилка для демонстрації #define GPIOC_CRH (*(volatile uint32_t *)(0x40010800 + 0x04)) // ^^^^^^^^^^ // це GPIOA, не GPIOC! Компілятор не скаржиться бо адреса валідна, синтаксис правильний. Але результат [LED не мигає], хоча: Flash written and verified! jolly good!
Налаштували GPIOA порт A замість C. PC13 навіть не підозрює що щось відбувалось. Ось чому адреси треба брати з RM0008 а не з пам’яті і компілятор тебе не врятує, він чесно запише куди сказали.
Вже почали то згадаймо і про другий класичний випадок з правильною адресою, але забули тактування:
// тактування не увімкнули випадково видаливши чи закоментувавши ей рядок // RCC_APB2ENR |= RCC_IOPCEN; GPIOC_CRH &= ~(0xF << 20); // пишемо в регістр GPIOC_CRH |= (0x2 << 20); // периферія спить — ігнорує
Результат той самий не блимає. Запам’ятай бо це дійсно важливо RCC це не просто формальність, а рубильник без якого периферія не працює.
Чому треба вмикати тактування?
За замовчуванням вся периферія STM32 «спить» — тактування на неї не подається. Це зроблено для економії енергії. RCC (Reset and Clock Control) — це «головна електростанція» яка вмикає тактування для кожного блоку окремо.
Якщо не увімкнути тактування і одразу писати в регістри GPIO — нічого не відбудеться. Запис просто ігнорується. Це одна з найпоширеніших помилок новачків.
// біти в 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:
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
Приклади:
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: "ластиком" стираємо 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); // встановлюємо
Якщо треба налаштувати кілька пінів одночасно — об’єднуємо через оператор труба |:
// очищаємо PC13, PC14, PC15 одним рядком GPIOC_CRH &= ~((0xF << 20) | (0xF << 24) | (0xF << 28)); // встановлюємо всі три GPIOC_CRH |= ((0x2 << 20) | (0x2 << 24) | (0x2 << 28));
Можна записати ще наочніше через бінарні літерали просто «малюємо» біти:
// 15 14 13 12 11 10 9 8 ← номери пінів // 0 0 1 0 0 0 0 0 ← які біти відповідають PC13 GPIOC_BSRR = 0b0010000000000000; // те саме що (1 << 13)
Або зробити макрос LED(n) для зручності:
#define LED(n) (1 << (n)) GPIOC_ODR ^= (LED(13) | LED(14) | LED(15));

Чому BSRR краще ніж ODR ^=?
До речі — «програміст засунув палець в дупу і посміхається бо до цього там у нього був костиль» і це точно про BSRR де 13 + 16 = 29? Ну типу «костиль» це ODR toggle а «палець» це (1 << (13 + 16)) який виглядає стрьомно але працює ідеально? Ну смішно ж правда? 😄
// 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) — один
- біти
0-15: Set (встановити пін в HIGH) - біти
16-31: Reset (скинути пін в LOW)
ODR ^=— це три операції: прочитати ODR, змінити біт, записати назад. Якщо між читанням і записом прийде переривання яке зачіпить ODR буде непередбачуваний результат.
BSRR— один запис і все. Ніякого читання, ніякої гонки. Саме тому в реальних проектах використовують BSRR.Важливо: якщо ти пишеш
GPIOC_ODR = ...— ти перезаписуєш всі 16 бітів порту одразу. Тому краще або BSRR для окремих пінів, або|=і&=~для ODR.
Таблиця регістрів GPIO
| Зміщення | Регістр | Що робить |
|---|---|---|
| 0×00 | CRL | конфігурація пінів |
| 0×04 | CRH | конфігурація пінів |
| 0×08 | IDR | читання стану пінів (тільки читання) |
| 0×0C | ODR | запис стану пінів + вибір pull-up/down для входів |
| 0×10 | BSRR | атомарний set (біти |
| 0×14 | BRR | тільки reset (тільки запис) |
Блок 3 — А якщо треба кнопку?
Мигати пінами — це добре. Але що як треба читати стан кнопки?
Спосіб 1: Polling через IDR
IDR (Input Data Register) —
| Стан на піні | Значення біта в IDR |
|---|---|
| 3.3V (HIGH) | 1 |
| 0V / GND (LOW) | 0 |
Підключаємо кнопку до PA1. Найкращий режим для кнопки — Input Pull-up: на піні завжди HIGH через внутрішній резистор, при натисканні кнопка замикає пін на GND і там стає LOW. Зовнішніх резисторів не потрібно.
Налаштування PA1 як Input Pull-up:
#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);
Читаємо кнопку в циклі:
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 є ланцюжок з трьох блоків:
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:
| Зміщення | Назва | Функція |
|---|---|---|
| 0×00 | IMR | Interrupt Mask — дозволити переривання на лінії |
| 0×08 | RTSR | Rising Trigger — реагувати на 0→1 (відпускання) |
| 0×0C | FTSR | Falling Trigger — реагувати на 1→0 (натискання) |
| 0×14 | PR | Pending Register — прапорець події, скидати вручну! |
Налаштування EXTI для PA1 — п’ять кроків:
#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:
// в масиві vectors[] після системних векторів: 0, 0, 0, 0, 0, 0, // 16-21: reserved/інші (uint32_t)EXTI1_IRQHandler, // 23: EXTI1
Функція-обробник:
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) — механічний контакт при натисканні кілька разів замикається і розмикається за мілісекунди:
Ідеальна кнопка: ────┐ ┌──── └────────┘ Реальна кнопка: ────┐┌┐┌┐ ┌┐┌┐┌──── └┘└┘└───┘└┘└┘
Одне натискання генерує
Апаратне рішення — найпростіше і найнадійніше: конденсатор 100нФ між піном кнопки і GND. RC-фільтр згладжує дребезг на рівні схеми, і до мікроконтролера приходить чистий сигнал. Жодного коду не потрібно.
Програмне рішення — потрібен таймер: затримка 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 вміє запускати дебагер з правильними прапорцями.
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.Має з’явитися напис:
Listening at *:4242. -
st-util - Запусти Дебагер (Термінал 2): Перейди у папку з проєктом, де лежить твій
.elfфайл, і виконай: -
make debug<br>
3. Гарячі клавіші GDB (TUI Mode)
Коли відкриється консоль з кодом, використовуй ці комбінації, щоб не «загубитись»:
Ctrl + X, потімO— Це швидке перемикання фокуса. Просто клацай цю комбінацію, поки жирна рамка не опиниться на потрібному вікні.Ctrl + X, потімA— Якщо TUI тебе зовсім дістав, ця команда повністю вимикає графіку і повертає тебе в звичайний текстовий режим GDB. Потім цією ж командою можна повернутися назад.Ctrl + L— Це команда «Refresh». Вона перемалює весь інтерфейс з нуля. Допомагає, якщо ST-Link викинув помилку прямо посеред коду.
4. Робота з «залізом» та пам’яттю
| Команда | Що робить |
| monitor reset halt | Скинути мікроконтролер і зупинити його на старті |
| x/1xw 0×4001100C | Прочитати GPIOC_ODR |
| set {int}0×4001100C=0×2000 | Силоміць записати значення в регістр (увімкнути LED без коду!) |
| info registers | Показати всі регістри процесора (якщо вікно regs закрите) |
5. «Рентген» регістрів (Приклад з адресою)
Якщо хочеш перевірити, чи реально змінився стан заліза (наприклад, чи загорівся біт світлодіода), використай пряме читання пам’яті:
(gdb) x/1xw 0x4001100C
x— Examine (дослідити).1xw— Показати 1 значення в hex форматі, розміром у word (32 біти).0x4001100C— Адреса регістраGPIOC_ODR.
Якщо ти побачиш там 0x00002000 (для PC13) або 0x00004000 (для PC14) — вітаю, ти щойно прочитав стан фізичного транзистора всередині чіпа! Це взагалі велика тема, яка потребує окремого вивчення, але для знайомства тре потикати обов’язково.
Шпаргалка
Базові адреси і тактування
| Периферія | Адреса | Тактування RCC_APB2ENR |
|---|---|---|
| GPIOA | 0×40010800 | біт 2 |
| GPIOB | 0×40010C00 | біт 3 |
| GPIOC | 0×40011000 | біт 4 |
| AFIO | 0×40010000 | біт 0 |
| USART1 | 0×40013800 | біт 14 |
| RCC | 0×40021000 | — |
| EXTI | 0×40010400 | — |
| NVIC_ISER0 | 0xE000E100 | — |
Таблиця режимів GPIO (MODE + CNF = 4 біти)
| Що хочемо | MODE | CNF | Hex |
|---|---|---|---|
| Output push-pull 2MHz (LED) | 10 | 00 | 0×2 |
| Output push-pull 50MHz | 11 | 00 | 0×3 |
| Alt. push-pull 50MHz (UART TX, SPI) | 11 | 10 | 0xB |
| Alt. open-drain 50MHz (I2C SDA/SCL) | 11 | 11 | 0xF |
| Input floating | 00 | 01 | 0×4 |
| Input pull-up/down (кнопка) | 00 | 10 | 0×8 |
| Input analog (ADC) | 00 | 00 | 0×0 |
Формула зміщення для CRL/CRH
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
// 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. 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
21 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарівВідносно «брязкоту»
В мікроконтролері при конфігурації піна на «вхід» є вбудований тріггер Шмітта
(читайте мануал rm0008-stm32f101xx-stm32f102xx-stm32f103xx-stm32f105xx-and-stm32f107xx-advanced-armbased-32bit-mcus-stmicroelectronics.pdf) — потрібно лише його активувати
брязкіт існує 2..5 мілісекунд —можете перевірити на осцилографі
Найкращий спосіб не помилитися і не впіймати перешкоду —
Переривання EXTI активує таймер на 2..5 мілісекунд, переривання від таймера
читає стан піна — низький == нажата кнопка
високий == піймали перешкоду
Дякую за змістовновний коментар. Брязкіт це звічна тема, яка заслуговує окремого посту
Веселі люди оці ваші ембеддери :)
Щось у цьому є 😀
Я ще розумію, що можна забити на hal на avr, де в принципі більшу половину функціоналу залізяки можна в голові тримати. А от на стм, поки законфігуруєш таймер, можна випадково диявола визвати
Тут не про забити, а просто зрозуміти як воно влаштовано і навіщ той hal 😀
габстракція над зялізяком (ну щоб макросами не дригати ноги ардуіни)
може трохи прибрати
lt/lt ці? бо таке собі рідабіліті
Спробую, чомусь розмітка зламалась
кому той gdb debug впав, як тільки не джунам, що влітають поза межі масиву чи за адресою неініціалізованого вірно (або обнуленого) вказівника, або яка звільнення звільненої пам"яті
Так а чтіво для кого?
Дійсно. Я думав це в тебе графоманське хоббі, а виявляється, що це світло серця Данко в темні маси.
Та не хобі, то скорше останній акорд, поклик зробити щось корисне
дємбєльний?
Ага 😀
```c
```
дивний сінтаксіс
З.І в коменті все нормально, в статті странно
то баг в доу з розміткою
А якщо «брязкіт»?
«брязкіт» не зник
дочитав
а де R якщо впаяно тільки С?
яка «тау»?
а якщо «теребонькає» контакт довше?
А може який трігєр товаріща Шмідта?
А може який одновібратор на N555 чи щось того?
А якщо зробити перетворення Фур’є і проаналізувати спектр?
Тема не на одну статтю.
p.s.
Я тут придумав «сферичну кнопку у вакуумі», яка дає ідеальне натискання без дебаунсінга і овершутінга з прямим і блискучим, як лом з нержавійки тільки що розпакований з магазину, фронтом.
Просто її припаяти до GPIO.
Можна запилити стартап і вирішити проблему раз і назавжди!
Ох цей брязкіт, колись намаявся з ним, але то тема... інша тема
Чергове занурення в нижній рівень ембеддеда без скафандра:
— Бобер, видихай!
Такі думки приходять, але тре довести до кінця :-)