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 присутній в кожному місяці в новій ролі.

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

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

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

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

ТижденьТемаПрактична задачаРезультат
Тиж 1GPIO + перериванняСтаття 1: GPIO bare-metal — регістри, BSRR, EXTIКоспект, шпаргалки, розуміння, базовий код
Тиж 2Свій HAL: GPIOПишемо pinMode/digitalWrite/digitalRead як в ArduinoHAL gpio.c/.h
Тиж 3SysTick + UART + HALdelay_ms через SysTick. UART TX/RX. Додаємо в HALHAL uart.c/.h
Тиж 4I2C + Timer/PWM + HALBMP180 або 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, обнуляються при старті
dectext + 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 &amp;&amp; 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 — кожна периферія відображена на певну ділянку адресного простору. Уяви це як місто де кожна периферія має свою земельну ділянку з кадастровим кодом:

БлокАдресаФункція
Flash0×08000000програма (код)
SRAM0×20000000змінні під час виконання
GPIOA0×40010800порт А
GPIOB0×40010C00порт В
GPIOC0×40011000порт C — наш LED
RCC0×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) — один 32-бітний регістр де:

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

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

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

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

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

ЗміщенняРегістрЩо робить
0×00CRLконфігурація пінів 0-7 (4 біти на пін)
0×04CRHконфігурація пінів 8-15 (4 біти на пін)
0×08IDRчитання стану пінів (тільки читання)
0×0CODRзапис стану пінів + вибір pull-up/down для входів
0×10BSRRатомарний set (біти 0-15) і reset (біти 16-31)
0×14BRRтільки 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:

#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×00IMRInterrupt Mask — дозволити переривання на лінії
0×08RTSRRising Trigger — реагувати на 0→1 (відпускання)
0×0CFTSRFalling Trigger — реагувати на 1→0 (натискання)
0×14PRPending 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) — механічний контакт при натисканні кілька разів замикається і розмикається за мілісекунди:

 Ідеальна кнопка:  ────┐        ┌────
                       └────────┘
 Реальна кнопка:   ────┐┌┐┌┐   ┌┐┌┐┌────
                       └┘└┘└───┘└┘└┘

Одне натискання генерує 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

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. Крок за кроком: Запуск сесії

  1. Підключи залізо: Встроми ST-Link у USB, а Blue Pill — до ST-Link (GND, CLK, DIO, 3.3V).
  2. Запусти Сервер (Термінал 1): Це місток між USB та GDB.Має з’явитися напис: Listening at *:4242.
  3. st-util
  4. Запусти Дебагер (Термінал 2): Перейди у папку з проєктом, де лежить твій .elf файл, і виконай:
  5. 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Прочитати 32-бітне слово з регістра 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
GPIOA0×40010800біт 2
GPIOB0×40010C00біт 3
GPIOC0×40011000біт 4
AFIO0×40010000біт 0
USART10×40013800біт 14
RCC0×40021000
EXTI0×40010400
NVIC_ISER00xE000E100

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

Що хочемоMODECNFHex
Output push-pull 2MHz (LED)10000×2
Output push-pull 50MHz11000×3
Alt. push-pull 50MHz (UART TX, SPI)11100xB
Alt. open-drain 50MHz (I2C SDA/SCL)11110xF
Input floating00010×4
Input pull-up/down (кнопка)00100×8
Input analog (ADC)00000×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

Лінія EXTIIRQ номерВектор #
EXTI0622
EXTI1723
EXTI2824
EXTI3925
EXTI41026
EXTI9_52339
EXTI15_104056 (NVIC_ISER1)

Що далі?

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

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

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось16
До обраногоВ обраному12
LinkedIn
Ctrl + Enter
Ctrl + Enter

Відносно «брязкоту»
В мікроконтролері при конфігурації піна на «вхід» є вбудований тріггер Шмітта
(читайте мануал rm0008-stm32f101xx-stm32f102xx-stm32f103xx-stm32f105xx-and-stm32f107xx-advanced-armbased-32bit-mcus-stmicroelectronics.pdf) — потрібно лише його активувати

брязкіт існує 2..5 мілісекунд —можете перевірити на осцилографі

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

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

Веселі люди оці ваші ембеддери :)

Я ще розумію, що можна забити на hal на avr, де в принципі більшу половину функціоналу залізяки можна в голові тримати. А от на стм, поки законфігуруєш таймер, можна випадково диявола визвати

Тут не про забити, а просто зрозуміти як воно влаштовано і навіщ той hal 😀

навіщ той hal

габстракція над зялізяком (ну щоб макросами не дригати ноги ардуіни)

може трохи прибрати

<<

lt/lt ці? бо таке собі рідабіліті

Спробую, чомусь розмітка зламалась

GDB + ST-Link

кому той gdb debug впав, як тільки не джунам, що влітають поза межі масиву чи за адресою неініціалізованого вірно (або обнуленого) вказівника, або яка звільнення звільненої пам"яті

Дійсно. Я думав це в тебе графоманське хоббі, а виявляється, що це світло серця Данко в темні маси.

Та не хобі, то скорше останній акорд, поклик зробити щось корисне

```c

(uint32_t)&_estack,

```

дивний сінтаксіс

З.І в коменті все нормально, в статті странно

то баг в доу з розміткою

. Але що як треба читати стан кнопки?

А якщо «брязкіт»?

налаштувати переривання і забути про кнопку

«брязкіт» не зник

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

дочитав

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

а де R якщо впаяно тільки С?
яка «тау»?

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

а якщо «теребонькає» контакт довше?

А може який трігєр товаріща Шмідта?
А може який одновібратор на N555 чи щось того?
А якщо зробити перетворення Фур’є і проаналізувати спектр?

Тема не на одну статтю.

p.s.
Я тут придумав «сферичну кнопку у вакуумі», яка дає ідеальне натискання без дебаунсінга і овершутінга з прямим і блискучим, як лом з нержавійки тільки що розпакований з магазину, фронтом.
Просто її припаяти до GPIO.
Можна запилити стартап і вирішити проблему раз і назавжди!

Ох цей брязкіт, колись намаявся з ним, але то тема... інша тема

Чергове занурення в нижній рівень ембеддеда без скафандра:
— Бобер, видихай!

Такі думки приходять, але тре довести до кінця :-)

Підписатись на коментарі