STM32 без магії: переривання. Частина 3

Минулого разу у Частині 2 ми написали свій HAL — gpio_init, uart_printf, delay_ms. Все працювало, Blue Pill моргав, UART виводив текст, і намить мені навіть здалось, що життя налогоджується. Та стан розуму постійно змінюється і ось перебуваючи в одному з таких станів я захотів зробити кнопку.

Не polling кнопку де у вічному циклі while(1) постійно питаєш «ну що там, ти натиснута?». А нормальну таку щоб процесор сам дізнавався коли натиснута. Це називається переривання (interrupt). І ось тут почалось справжнє страждання.

Що таке переривання?

Уяви що ти процесор і читаєш книгу (while(1)). Тут кур’єр дзвонить у двері. Ти ставиш закладку на сторінку, і йдеш відчиняти двері (виконує ISR — Interrupt Service Routine), і повертаєшся читати з того ж місця.

main(): ████████████████░░░░████████████████

↑↑↑↑

ISR: ████ ← кнопка!

Головна перевага в тому, що main не витрачає час на постійну перевірку. Він займається своїми справами, а переривання само прилетить коли треба.

На STM32F103 зовнішні переривання від GPIO обробляє модуль EXTI (External Interrupt Controller). Але щоб кнопка запрацювала тобі треба налаштувати цілий ланцюжок.

Ланцюжок: від кнопки до функції

Кнопка натиснута → PA1 впав до 0

AFIO — маршрутизатор: який порт слухає лінію EXTI1?

EXTI — детектор: побачив falling edge, виставив прапорець

NVIC — менеджер переривань: отримав IRQ7, зупинив main

EXTI1_IRQHandler() — твій код

Чотири блоки, чотири налаштування. Пропустив один і все пропало.

Налаштування крок за кроком

1. RCC — тактування

Перш за все ми вмикаємо тактування. Без цього регістри периферії просто не реагують на запис. Це як клеми накинути на акум перед тим, як крутнеш стартер.

RCC_APB2ENR |= (1 << 2); // GPIOA
RCC_APB2ENR |= (1 << 4); // GPIOC
RCC_APB2ENR |= (1 << 0); // AFIO ← без цього EXTI не бачить GPIO!

AFIO — це окремий модуль і окремий біт в RCC. Якщо забути то EXTI буде просто не підключений до пінів.

2. GPIO — пін як вхід з підтяжкою

GPIOA_CRL &= ~(0xF << 4); // очищаємо PA1
GPIOA_CRL |= (0x8 << 4); // input pull-up
GPIOA_ODR |= (1 << 1); // підтяжка вгору через ODR

Кнопка підключена між PA1 і GND. В спокої — HIGH (підтяжка тримає). Натиснута — LOW.

3. AFIO — маршрутизатор

Ось тут увага, трохи на подумати. У STM32F103 є 16 ліній EXTI (EXTI0-EXTI15), але на кожну лінію можуть претендувати піни з різних портів:
PA1, PB1, PC1 — всі йдуть на лінію EXTI1

Процесор не може слухати їх одночасно. AFIO — це мультиплексор який вирішує хто саме прохидить кастинг.

// AFIO_EXTICR1 біти [7:4] — вибір порту для EXTI1
// 0000 = Port A, 0001 = Port B, 0010 = Port C
AFIO_EXTICR1 &= ~(0xF << 4); // обнуляємо → Port A

Птут просто: номер піна це номер лінії EXTI. PA1 → EXTI1, PB5 → EXTI5, PC13 → EXTI13, а порт вибираємо в AFIO.

4. EXTI — детектор події

EXTI_FTSR |= (1 << 1); // falling edge — натискання (1→0)
EXTI_IMR |= (1 << 1); // unmask — дозволяємо лінію 1

FTSR — Falling Trigger Selection Register. Реагуємо на спад напруги (кнопка натиснута).
IMR — Interrupt Mask Register. Без цього переривання замасковане і не пройде далі.

5. NVIC — головний менеджер

NVIC (Nested Vectored Interrupt Controller) — це вбудований в Cortex-M3 менеджер всіх переривань. Він вирішує хто важливіший, ставить в чергу, і каже процесору куди стрибати.

// EXTI1 = IRQ7, тому біт 7 в ISER0
NVIC_ISER0 |= (1 << 7);

ISER0 — Interrupt Set Enable Register 0. Керує IRQ0-31. Записуємо 1 у відповідний біт і тепер переривання дозволено.

Обробник переривання (ISR)

void EXTI1_IRQHandler(void) {
    if (EXTI_PR & (1 << 1)) { // перевіряємо що це лінія 1
        led_state ^= 1;
        if (led_state) {
            GPIOC_BSRR = (1 << (13 + 16)); // LOW = LED on
        } else {
            GPIOC_BSRR = (1 << 13); // HIGH = LED off
        }
        EXTI_PR = (1 << 1); // ОБОВ'ЯЗКОВО скидаємо прапорець
    }
}

Два важливих моменти:

Чому `EXTI_PR = (1 << 1)` а не `|=`?

Цей регістр скидається записом 1, а не 0. Ну так собі історія, але як є. Якщо забути скинути тоді переривання буде викликатись нескінченно, main ніколи не продовжиться, і ти довго дивитимешся на завислу програму.

Ім’я функції — не випадкове.

EXTI1_IRQHandler це точне ім’я яке має збігатись з таблицею векторів. Помилився в одній літері і функція не запуститься.

Таблиця векторів — найбільший сюрприз

Ось де я застряг найдовше.

Написав код, прошив, натиснув кнопку і нічого. LED мовчить. Код компілюється. Схема правильна. Що ж не так?

Справа в тому, що таблиця векторів у startup.c закінчувалась на SysTick і не мала жодного периферійного IRQ.

// ТАК БУЛО — таблиця закінчується на позиції 15
__attribute__((section(".vectors")))
uint32_t vectors[] = {
    (uint32_t)&_estack,
    (uint32_t)Reset_Handler,
    // ...
    (uint32_t)SysTick_Handler, // позиція 15
    // далі нічого!
};

Коли стається EXTI1 тоді процесор йде на позицію 23 в таблиці векторів. А там... нуль. Або сміття. Або Default_Handler. Як результат відстутність результату, хоча це теж результат, ну або HardFault.

Що таке таблиця векторів насправді

Для AVRщиків і ардуїнщиків тут важливо зрозуміти, що це не регістри. Це масив адрес у Flash-пам’яті за адресою 0×08000000.
Регістри (RCC, GPIO, NVIC) тут ти керуєш ними вручну, читаєш і пишеш коли треба.
Таблицю векторів же процесор читає сам, автоматично, коли стається переривання.

Подія: натиснута кнопка (EXTI1)
Процесор: іду до позиції 23 в таблиці
Таблиця: тут адреса EXTI1_IRQHandler
Процесор: стрибаю туди

Метафора для засвоєння: таблиця векторів це список номерів екстрених служб на стіні. Якщо пожежа то Вася не шукає кнопку «гасити», він дивиться на список і дзвонить пожежникам.

Де взяти правильні позиції в RM0008?

RM0008, розділ 10.1.2, Table 63 — «Vector table for other STM32F10xxx devices».

Важливо: є ще Table 61 — для connectivity line (STM32F105/107). Не плутати!

Позиція 0 → початок стеку (_estack)
Позиція 1 → Reset_Handler
Позиція 2 → NMI_Handler
...
Позиція 15 → SysTick_Handler
Позиція 16 → IRQ0 (WWDG)
Позиція 17 → IRQ1 (PVD)
...
Позиція 22 → IRQ6 (EXTI0)
Позиція 23 → IRQ7 (EXTI1) ← наша функція
...
Позиція 37+16 → IRQ37 (USART1)

Критичне правило: таблиця тут це фіксований масив з точними позиціями. Між SysTick (позиція 15) і EXTI1 (позиція 23) є 7 записів які не можна пропустити. Навіть якщо IRQ не використовується то його місце треба заповнити (хоча б Default_Handler).

__attribute__((section(".vectors")))

uint32_t vectors[] = {
    (uint32_t)&_estack,
    (uint32_t)Reset_Handler,
    (uint32_t)NMI_Handler,
    (uint32_t)HardFault_Handler,
    0, 0, 0, 0, 0, 0, 0,
    (uint32_t)SVC_Handler,
    0, 0,
    (uint32_t)PendSV_Handler,
    (uint32_t)SysTick_Handler, // позиція 15
    (uint32_t)Default_Handler, // IRQ0 WWDG
    (uint32_t)Default_Handler, // IRQ1 PVD
    (uint32_t)Default_Handler, // IRQ2 TAMPER
    (uint32_t)Default_Handler, // IRQ3 RTC
    (uint32_t)Default_Handler, // IRQ4 FLASH
    (uint32_t)Default_Handler, // IRQ5 RCC
    (uint32_t)Default_Handler, // IRQ6 EXTI0
    (uint32_t)EXTI1_IRQHandler, // IRQ7 EXTI1 ← ось тут магія
};

EXTI має 16 ліній але векторів менше — чому?

Це питання виникло коли я порівняв Figure 21 з RM0008 (AFIO схема — 16 ліній) і Table 63 (таблиця векторів).

Figure 21 показує скільки ліній є фізично.

Table 63 показує скільки векторів під них виділено.

ST зекономив: EXTI5-9 і EXTI10-15 йдуть в групові handler’и:
EXTI0 → окремий IRQ6 → EXTI0_IRQHandler
EXTI1 → окремий IRQ7 → EXTI1_IRQHandler
EXTI2 → окремий IRQ8 → EXTI2_IRQHandler
EXTI3 → окремий IRQ9 → EXTI3_IRQHandler
EXTI4 → окремий IRQ10 → EXTI4_IRQHandler
EXTI5-9 → спільний IRQ23 → EXTI9_5_IRQHandler
EXTI10-15 → спільний IRQ40 → EXTI15_10_IRQHandler

Якщо кнопка на PA7 тоді пишеш EXTI9_5_IRQHandler, а всередині перевіряєш який саме біт в EXTI_PR.

NVIC_ISER0 vs NVIC_ISER1

Ще одна знайдена палка в дупі це UART RX через переривання.

USART1 = IRQ37. А NVIC_ISER0 покриває тільки IRQ0-31. Тому:
// ❌ неправильно для USART1
NVIC_ISER0 |= (1 << 37); // 37 > 31, біт просто загубиться
// ✅ правильно
#define NVIC_ISER1 (*(volatile uint32_t *)0xE000E104)
NVIC_ISER1 |= (1 << (37 - 32)); // біт 5 в ISER1
ISER0 — IRQ0-31, ISER1 — IRQ32-63. Просто ділимо номер IRQ на 32.

weak і alias чому це працює

В startup.c є магічний рядок:

#define WEAK_ALIAS __attribute__((weak, alias("Default_Handler")))
void EXTI1_IRQHandler(void) WEAK_ALIAS;

weak — ця функція «слабенька». Якщо де-інде є сильніша версія з тим самим іменем — лінкер візьме її.

alias — якщо «сильної» немає тоді використай Default_Handler.

Результат: якщо ти написав void EXTI1_IRQHandler(void) у main.c — лінкер підставить твою функцію в таблицю векторів автоматично. Не написав тоді летить у Default_Handler (нескінченний цикл).

Це саме те що ST робить у своєму startup_stm32f103xb.s, що правда на асемблері.

Шпаргалка: пін → IRQ → NVIC

ПінЛінія EXTIIRQNVIC регістрНазва функції
Px0EXTI06ISER0 біт 6EXTI0_IRQHandler
Px1EXTI17ISER0 біт 7EXTI1_IRQHandler
Px2EXTI28ISER0 біт 8EXTI2_IRQHandler
Px3EXTI39ISER0 біт 9EXTI3_IRQHandler
Px4EXTI410ISER0 біт 10EXTI4_IRQHandler
Px5-9EXTI9_523ISER0 біт 23EXTI9_5_IRQHandler
Px10-15EXTI15_1040ISER1 біт 8EXTI15_10_IRQHandler
USART137ISER1 біт 5USART1_IRQHandler
TIM228ISER0 біт 28TIM2_IRQHandler

Повний код BUTTON_EXTI

// main.c — EXTI bare-metal, кнопка PA1 → LED PC13
#include <stdint.h>
#define RCC_BASE 0x40021000
#define RCC_APB2ENR (*(volatile uint32_t *)(RCC_BASE + 0x18))
#define GPIOA_BASE 0x40010800
#define GPIOA_CRL (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
#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 AFIO_BASE 0x40010000
#define AFIO_EXTICR1 (*(volatile uint32_t *)(AFIO_BASE + 0x08))
#define EXTI_BASE 0x40010400
#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))
#define NVIC_ISER0 (*(volatile uint32_t *)0xE000E100)
static uint8_t led_state = 0;

void EXTI1_IRQHandler(void) {
    if (EXTI_PR & (1 << 1)) {
         led_state ^= 1;
         if (led_state) {
             GPIOC_BSRR = (1 << (13 + 16)); // LOW — LED on
         } else {
            GPIOC_BSRR = (1 << 13); // HIGH — LED off
         }
         EXTI_PR = (1 << 1); // скидаємо pending flag
     }
}

int main(void) {
    RCC_APB2ENR |= (1 << 2) | (1 << 4) | (1 << 0); // GPIOA, GPIOC, AFIO
   // PC13 output
   GPIOC_CRH &= ~(0xF << 20);
   GPIOC_CRH |= (0x2 << 20);
   GPIOC_ODR |= (1 << 13); // LED off
   // PA1 input pull-up
   GPIOA_CRL &= ~(0xF << 4);
   GPIOA_CRL |= (0x8 << 4);
   GPIOA_ODR |= (1 << 1);
   // AFIO: EXTI1 → Port A
   AFIO_EXTICR1 &= ~(0xF << 4);
   // EXTI: falling edge, unmask
   EXTI_FTSR |= (1 << 1);
   EXTI_IMR |= (1 << 1);
   // NVIC: IRQ7
   NVIC_ISER0 |= (1 << 7);
   while (1) {} // нічого — вся логіка в ISR
}

Частина 2 — Таймери: SysTick, TIM2 і PWM

Чому polling — це погано

Наш старий delay_ms виглядав так:

void delay_ms(uint32_t ms) {
   while (ms--) {
      STK_LOAD = 7999;
      STK_VAL = 0;
      STK_CTRL = ENABLE | CLKSOURCE;
      while (!(STK_CTRL & COUNTFLAG)); // ← тут висимо
      STK_CTRL = 0;
   }
}

Процесор буквально нічого не робить крутиться собі в циклі і чекає. Хочеш мигати LED і одночасно читати кнопку? Не вийде. Поки висиш в delay_ms кнопка ігнорується.

Тре використати SysTick через переривання.

SysTick IRQ це системний таймер Cortex-M3

SysTick це вбудований таймер ядра Cortex-M3. Він є на будь-якому Cortex-M незалежно від виробника. Три регістри:

STK_LOAD — до якого числа рахуємо вниз
STK_VAL — поточне значення
STK_CTRL — керування

Раніше ми використовували STK_CTRL_COUNTFLAG — polling. Тепер вмикаємо STK_CTRL_TICKINT — один біт який перетворює таймер на джерело переривань:

#define STK_CTRL_ENABLE (1 << 0)
#define STK_CTRL_TICKINT (1 << 1) // ← вмикаємо переривання
#define STK_CTRL_CLKSOURCE (1 << 2)
static volatile uint32_t _ticks = 0;

void SysTick_Handler(void) {
    _ticks++; // викликається кожну 1мс автоматично
}

void systick_init(uint32_t cpu_hz) {
    STK_LOAD = (cpu_hz / 1000) - 1; // 1мс
    STK_VAL = 0;
    STK_CTRL = STK_CTRL_ENABLE | STK_CTRL_TICKINT | STK_CTRL_CLKSOURCE;
}

void delay_ms(uint32_t ms) {
   uint32_t start = _ticks;
   while (_ticks - start < ms); // не blocking — перевіряємо час
}

uint32_t get_ticks(void) {
   return _ticks; // мс від старту — для вимірювання інтервалів
}

Тепер delay_ms не вимикає таймер і не блокує переривання. Поки він чекає — ISR від кнопки все одно спрацює.

Чому volatile?

_ticks змінюється в SysTick_Handler, а читається в main. Без volatile компілятор може вирішити що значення не змінюється і закешує його в регістр. Тоді while (_ticks — start < ms) буде вічним циклом. volatile каже компілятору: «не оптимізуй, завжди читай з пам’яті».

Чому _ticks — start а не _ticks >= start + ms?

Overflow-safe патерн. _ticks — uint32_t, і через ~49 днів він переповниться і стане 0. Якщо порівнювати через >= — після переповнення умова ніколи не виконається. Різниця _ticks — start працює правильно навіть при переповненні завдяки беззнаковій арифметиці.

SysTick — системний вектор, не периферійний

Важлива деталь: SysTick_Handler вже є в базовій таблиці векторів на позиції 15. Ніяких додаткових IRQ не треба ти просто пишеш функцію і вона автоматично підставляється через WEAK_ALIAS.

// в startup.c вже є:

(uint32_t)SysTick_Handler, // позиція 15 — системний вектор ARM

TIM2 — периферійний таймер ST

SysTick це простий таймер ядра, він тільки рахує. TIM2 це вже повноцінний периферійний таймер STM32 з купою можливостей. Нас цікавлять дві: переривання по переповненню і PWM.

Головна відмінність від SysTick:
SysTick — завжди є, тактується від ядра, один на всіх
TIM2 — периферія ST, треба вмикати RCC, є prescaler і ARR

TIM2 переривання — мигаємо LED без delay

Два ключових регістри:

PSC (Prescaler) — ділить тактову частоту
ARR (Auto Reload) — до якого числа рахуємо

// PSC = 7999 → 8MHz / (7999+1) = 1000 Гц → 1 тік = 1мс
TIM2_PSC = 7999;
// ARR = 499 → 500 тіків = 500мс
TIM2_ARR = 499;

Коли лічильник досягає ARR — виставляється прапорець UIF (Update Interrupt Flag) і якщо дозволено тоді викликається переривання.

// DIER: UIE — Update Interrupt Enable
TIM2_DIER = (1 << 0);

// EGR: UG — примусово завантажуємо PSC і ARR
TIM2_EGR = (1 << 0);
TIM2_SR = 0; // скидаємо прапорець після EGR

// NVIC: TIM2 = IRQ28 → ISER0 біт 28
NVIC_ISER0 |= (1 << 28);
// CR1: CEN — запускаємо таймер
TIM2_CR1 = (1 << 0);

ISR:

void TIM2_IRQHandler(void) {
    if (TIM2_SR & (1 << 0)) { // UIF
        TIM2_SR = 0; // скидаємо — записом 0, не 1!
        gpio_toggle(PIN_PC13);
     }
}

Увага: TIM2_SR скидається записом 0, а EXTI_PR — записом 1. Різна периферія, різна логіка. Це не баг, а так задумано в RM0008.

TIM2 на APB1, не APB2

// ❌ неправильно
RCC_APB2ENR |= (1 << 0); // TIM2 на APB2 — немає

// ✅ правильно
RCC_APB1ENR |= (1 << 0); // TIM2 на APB1 — біт 0

Різні таймери сидять на різних шинах. TIM1 — на APB2. TIM2, TIM3, TIM4 — на APB1. Переплутав шину і таймер мовчить, регістри не реагують.

PWM — плавне дихання LED

PWM (Pulse Width Modulation) це швидке перемикання піна між HIGH і LOW. Якщо робити це достатньо швидко то людське око бачить проміжну яскравість.

Period: |←————————————→|

25% duty: ██░░░░░░░░░░░░░

50% duty: ██████░░░░░░░░░

75% duty: ██████████░░░░░

Два регістри:

ARR → визначає період (частоту PWM)
CCR1 → визначає duty cycle (скважність)

Змінюєш CCR1 від 0 до ARR і яскравість змінюється від 0% до 100%.

Який пін може виводити PWM?

Не будь-який. Тільки той що має Alternate Function прив’язку до каналу таймера.

PC13 — звичайний GPIO, немає AF до жодного таймера → PWM не вийде
PA0 — TIM2_CH1 → PWM тут є!
PA1 — TIM2_CH2
PA2 — TIM2_CH3
PA3 — TIM2_CH4

Це визначено в datasheet STM32F103 — таблиця Alternate Function mapping.
Тому пін треба ініціалізувати як OUTPUT_AF_FAST (0xB) — Alternate Function Push-Pull 50MHz:

// PA0 — AF push-pull 50MHz
GPIOA_CRL &= ~(0xF << 0);
GPIOA_CRL |= (0xB << 0); // 0xB = Alt Function Push-Pull 50MHz

Без AF мод пін залишається під контролем GPIO, а не TIM2. PWM не буде навіть якщо таймер налаштований правильно.

Налаштування TIM2 PWM

// PSC=7 → 8MHz/(7+1) = 1MHz → 1 тік = 1мкс
TIM2_PSC = 7;

// ARR=999 → період 1000мкс = 1кГц PWM
TIM2_ARR = 999;

// CCR1=0 → 0% duty cycle
TIM2_CCR1 = 0;

// CCMR1: PWM mode 1 на CH1
// OC1M[2:0] = 110 (біти [6:4]) → PWM mode 1
// OC1PE = 1 (біт 3) → preload enable
TIM2_CCMR1 = (6 << 4) | (1 << 3);

// CCER: CC1E = 1 → увімкнути вихід CH1
TIM2_CCER = (1 << 0);

// EGR: завантажити PSC і ARR
TIM2_EGR = (1 << 0);

// CR1: ARPE=1 (auto-reload preload), CEN=1
TIM2_CR1 = (1 << 7) | (1 << 0);

Що таке PWM mode 1?

Коли лічильник менший за CCR1 — пін HIGH. Коли більший — LOW. Просто і елегантно:

CNT: 0 ────────────────────► ARR

|←── CCR1 ──►|

OUT: ████████████░░░░░░░░░░░

HIGH LOW

Змінюй TIM2_CCR1 в while(1) і отримаєш плавне дихання:

while (1) {
    for (int i = 0; i <= 999; i++) {
       TIM2_CCR1 = i;
       delay_ms(1);
    }

    for (int i = 999; i >= 0; i--) {
        TIM2_CCR1 = i;
        delay_ms(1);
    }
}

Ключова фраза про PWM

Коли ми пишемо TIM2_CCER = (1 << 0) в цей час апаратно TIM2 починає керувати піном PA0 сам, без участі процесора. Саме тому PWM не навантажує CPU — таймер генерує сигнал автономно, процесор займається іншим.

Порівняння: SysTick vs TIM2

SysTickTIM2
Де живеЯдро Cortex-M3Периферія ST
RCCне потрібенAPB1 біт 0
Таблиця векторівпозиція 15 (системний)позиція 44 (IRQ28)
Prescalerнемаєє (PSC)
PWMне вмієвміє (4 канали)
Використаннясистемний тік, delay_msточні затримки, PWM, захоплення

Повний код TIM2_BLINK

#include <stdint.h>

#define RCC_BASE 0x40021000
#define RCC_APB1ENR (*(volatile uint32_t *)(RCC_BASE + 0x1C))
#define GPIOC_BASE 0x40011000
#define GPIOC_CRH (*(volatile uint32_t *)(GPIOC_BASE + 0x04))
#define GPIOC_ODR (*(volatile uint32_t *)(GPIOC_BASE + 0x0C))
#define TIM2_BASE 0x40000000
#define TIM2_CR1 (*(volatile uint32_t *)(TIM2_BASE + 0x00))
#define TIM2_DIER (*(volatile uint32_t *)(TIM2_BASE + 0x0C))
#define TIM2_SR (*(volatile uint32_t *)(TIM2_BASE + 0x10))
#define TIM2_EGR (*(volatile uint32_t *)(TIM2_BASE + 0x14))
#define TIM2_PSC (*(volatile uint32_t *)(TIM2_BASE + 0x28))
#define TIM2_ARR (*(volatile uint32_t *)(TIM2_BASE + 0x2C))
#define NVIC_ISER0 (*(volatile uint32_t *)0xE000E100)

void TIM2_IRQHandler(void) {
    TIM2_SR = 0;
    GPIOC_ODR ^= (1 << 13);
}

int main(void) {
    RCC_APB2ENR |= (1 << 4); // GPIOC — APB2
    RCC_APB1ENR |= (1 << 0); // TIM2 — APB1!
    GPIOC_CRH &= ~(0xF << 20);
    GPIOC_CRH |= (0x2 << 20);
    TIM2_PSC = 7999; // 1мс на тік
    TIM2_ARR = 499; // 500мс
    TIM2_DIER = (1 << 0);
    TIM2_EGR = (1 << 0);
    TIM2_SR = 0;
    NVIC_ISER0 |= (1 << 28);
    TIM2_CR1 = (1 << 0);
    while (1) {}
}

Частина 3 — Протоколи: SPI і Nokia 5110, I2C і MPU6050

SPI — чотири дроти і повний контроль

SPI (Serial Peripheral Interface) це синхронний протокол. На відміну від UART де кожна сторона сама рахує час, тут є окремий дріт тактування. Master диктує темп, slave слухає.

Чотири дроти:
SCK — тактовий сигнал (master генерує)
MOSI — Master Out Slave In (дані від master до slave)
MISO — Master In Slave Out (відповідь від slave)
NSS — Chip Select, активний LOW

В нашому випадку з Nokia 5110 працює тільки приймач, MISO не використовується. Плюс два додаткових GPIO якими керуємо вручну:
DC — 0 = команда, 1 = дані (пікселі)
RST — скидання дисплея

Як виглядає передача одного байта

NSS ‾‾‾‾\___________________________/‾‾‾‾

SCK _____/‾\_/‾\_/‾\_/‾\_/‾\_/‾\_/‾\_/‾\__

MOSI -----[7][6][5][4][3][2][1][0]----------

NSS тягнеш LOW → надсилаєш 8 біт старшим вперед (MSB first) → NSS назад HIGH. SCK генерує SPI периферія автоматично поки ти пишеш байт в DR.

Налаштування SPI1

// SPI1 на APB2 — біт 12
RCC_APB2ENR |= (1 << 2) | (1 << 12); // GPIOA + SPI1

// PA5 SCK, PA7 MOSI — AF push-pull 50MHz = 0xB
// PA6 MISO — input floating = 0x4
// SPI_CR1 — головний регістр керування
SPI1_CR1 = (1 << 2) | // MSTR — master mode
           (1 << 9) | // SSM — software NSS management
           (1 << 8) | // SSI — NSS high (ми самі керуємо через GPIO)
           (2 << 3) | // BR — fPCLK/8 = 1MHz
           (1 << 6); // SPE — SPI enable

SSM + SSI — програмне керування NSS. Ми самі опускаємо і піднімаємо CE через GPIOA_BSRR. Апаратний NSS (PA4) не використовуємо — він підходить тільки якщо один slave.

Відправка байта:

void spi_send(uint8_t data) {
    while (!(SPI1_SR & SPI_SR_TXE)); // чекаємо поки TX буфер вільний
    SPI1_DR = data;
    while (SPI1_SR & SPI_SR_BSY); // чекаємо завершення
}

TXE — TX Empty, можна писати наступний байт.
BSY — SPI зайнятий, чекаємо до кінця передачі.

Nokia 5110 — ініціалізація і виведення

Nokia 5110 (контролер PCD8544) — 84×48 пікселів, організованих в 6 рядків по 8 пікселів кожен (так звані «pages»). Всього 504 байти відеопам’яті.

DC = LOW → команда. DC = HIGH → дані (пікселі).

void nokia_cmd(uint8_t cmd) {
    DC_LOW; CE_LOW;
    spi_send(cmd);
    CE_HIGH;
}

void nokia_data(uint8_t data) {
    DC_HIGH; CE_LOW;
    spi_send(data);
    CE_HIGH;
}

void nokia_init(void) {
    RST_LOW; delay(10000);
    RST_HIGH; delay(10000);
    nokia_cmd(0x21); // extended commands
    nokia_cmd(0xB8); // Vop — контраст (підбирається)
    nokia_cmd(0x04); // temperature coefficient
    nokia_cmd(0x14); // bias mode 1:48
    nokia_cmd(0x20); // normal commands
    nokia_cmd(0x0C); // display normal mode
}

Залити весь екран:

nokia_cmd(0x40); // Y = page 0
nokia_cmd(0x80); // X = col 0
for (int i = 0; i < 504; i++) nokia_data(0xFF); // 84*6 = 504

Вивести символ зі шрифту 5×8:

void nokia_char(char c) {
    uint8_t idx = (c >= 'A' && c <= 'Z') ? c - 'A' + 1 : 0;
    for (int i = 0; i < 5; i++) nokia_data(font5x8[idx][i]);
    nokia_data(0x00); // пробіл між символами
}

Шрифт — просто масив байт де кожен байт це колонка 8 пікселів:

static const uint8_t font5x8[][5] = {
   {0x00, 0x00, 0x00, 0x00, 0x00}, // пробіл
   {0x7E, 0x11, 0x11, 0x11, 0x7E}, // A
   // ...
};

I2C — два дроти і адреси

I2C (Inter-Integrated Circuit) це ще один протокол зв’язку, але принципово інший.

SPI: master керує CLK, окремий CS для кожного пристрою
→ 4 дроти, але швидко і просто
I2C: тільки 2 дроти, кожен пристрій має унікальну адресу
→ менше проводів, але складніший протокол

Два дроти:
SCL — тактування (clock)
SDA — дані (bidirectional — і master і slave пишуть в один дріт!)

Важливо: I2C потребує зовнішніх підтягуючих резисторів на SCL і SDA до VCC. Зазвичай 4.7кОм. Без них шина просто не працює, а лінії «плавають» і пристрої один одного не чують. Але не переживай майже всі ардуїно модулі вже мають необхідні резистори на платі.

Послідовність транзакції I2C

START → [addr+W] → ACK → [reg] → ACK → REPEATED START → [addr+R] → ACK → [data] → NACK → STOP

— START — master тягне SDA LOW поки SCL HIGH
— addr+W/R — 7-бітна адреса + біт напрямку (0=write, 1=read)
— ACK — slave підтверджує кожен байт (тягне SDA LOW)
— REPEATED START — повторний старт без STOP між записом і читанням
— NACK — master не підтверджує останній байт читання → slave відпускає шину
— STOP — master відпускає SDA

Чому Repeated Start?

Типова операція це записати адресу регістра, потім прочитати дані. Можна зробити STOP між ними, але деякі пристрої це не люблять бо між STOP і START вони «забувають» яку адресу ти вказав. Repeated Start вирішує це елегантно.

Налаштування I2C1

// PB6 SCL, PB7 SDA — AF open-drain 50MHz = 0xF
// Open-drain обов'язково! I2C шина — wired-AND

CC_APB1ENR |= (1 << 21); // I2C1 на APB1 біт 21
I2C1_CR2 = 8; // FREQ = 8MHz (частота APB1)
I2C1_CCR = 40; // 8MHz / (2 * 100kHz) = 40
I2C1_TRISE = 9; // (1000нс / 125нс) + 1 = 9
I2C1_CR1 = (1 << 0); // PE — peripheral enable

CCR — Clock Control Register. Визначає швидкість SCL:
CCR = fPCLK1 / (2 * fSCL)
CCR = 8MHz / (2 * 100kHz) = 40

TRISE — максимальний час наростання фронту. Для 100кГц режиму:
TRISE = (tRISE_max / tPCLK1) + 1 = (1000нс / 125нс) + 1 = 9

Читання регістра MPU6050

uint8_t i2c_read(uint8_t addr, uint8_t reg) {
    // Крок 1: вказуємо адресу регістра (write)
    I2C1_CR1 |= (1 << 8); // START
    while (!(I2C1_SR1 & (1 << 0))); // чекаємо SB (Start Bit)

    I2C1_DR = (addr << 1) | 0; // адреса + write
    while (!(I2C1_SR1 & (1 << 1))); // чекаємо ADDR
    (void)I2C1_SR2; // читаємо SR2 — скидаємо ADDR!
    
    while (!(I2C1_SR1 & (1 << 7))); // TXE
    I2C1_DR = reg;    
    while (!(I2C1_SR1 & (1 << 2))); // BTF — byte transfer finished
    
    // Крок 2: читаємо дані (repeated start)
    I2C1_CR1 |= (1 << 8); // REPEATED START
    while (!(I2C1_SR1 & (1 << 0))); // SB

    I2C1_DR = (addr << 1) | 1; // адреса + read
    while (!(I2C1_SR1 & (1 << 1))); // ADDR
    I2C1_CR1 &= ~(1 << 10); // ACK = 0 (NACK на останній байт)
    (void)I2C1_SR2; // скидаємо ADDR
    
    I2C1_CR1 |= (1 << 9); // STOP
    while (!(I2C1_SR1 & (1 << 6))); // RXNE
    return (uint8_t)I2C1_DR;
}

Чому (void)I2C1_SR2?

Прапорець ADDR скидається тільки послідовним читанням SR1 і SR2. Якщо не прочитати SR2 то прапорець залишається, I2C зависає в очікуванні. (void) необхідно щоб компілятор не лаявся на невикористане значення.

MPU6050 — акселерометр і гіроскоп

MPU6050 — популярний IMU (Inertial Measurement Unit). Адреса 0×68 (якщо AD0 підтягнутий до GND).

Перевірка зв’язку — читаємо WHO_AM_I (регістр 0×75). Має повернути 0×68:
uint8_t who = i2c_read(0×68, 0×75);
// якщо who == 0×68 — MPU6050 знайдений

Виводимо з режиму сну:
i2c_write(0×68, 0×6B, 0×00); // PWR_MGMT_1 = 0

Читаємо акселерометр — 6 байт (X, Y, Z по 2 байти кожен):

uint8_t buf[6];
i2c_read_buf(0x68, 0x3B, buf, 6); // ACCEL_XOUT_H
int16_t ax = (int16_t)((buf[0] << 8) | buf[1]);
int16_t ay = (int16_t)((buf[2] << 8) | buf[3]);
int16_t az = (int16_t)((buf[4] << 8) | buf[5]);

Перевести в одиниці g (при налаштуванні ±2g за замовчуванням):

float ax_g = ax / 16384.0f; // 16384 = 2^14 LSB/g

Результат в терміналі:

AX=-12840 AY=1120 AZ=-9704
AX=-12904 AY=1164 AZ=-9628

Від’ємні значення — нормально. Означає що вісь спрямована проти гравітації.

SPI vs I2C — коли що використовувати

SPII2C
Дроти4 (+ CS для кожного slave)2 (для будь-якої кількості)
Швидкістьдо 50+ MHz100кГц / 400кГц / 1MHz
АдресаціяCS пін7-бітна адреса
Підтяжкине потрібніобов’язкові (4.7кОм)
Підтвердженнянемає (fire and forget)ACK від кожного байта
Складністьпростийскладніший протокол
Колидисплеї, Flash, SD-картисенсори, RTC, EEPROM

Nokia 5110 — SPI. Бо дисплей тільки приймає і потребує швидкості.

MPU6050 — I2C. Бо це сенсор і швидкість не критична, але зручність адресації важлива.

Підключення

Nokia 5110 (SPI)

Blue Pill Nokia 5110

─────────────────────────

PA5 → CLK (SCK)
PA7 → DIN (MOSI)
PA4 → CE (CS)
PA3 → DC
PA2 → RST
3.3V → VCC
GND → GND
3.3V → BL (підсвітка)

MPU6050 (I2C)

Blue Pill MPU6050

─────────────────────

PB6 → SCL
PB7 → SDA
3.3V → VCC
GND → GND
GND → AD0 (адреса 0×68)

Частина 4 — ADC, пульсометр на OLED і власний HAL

ADC — коли цифровому процесору потрібен аналоговий світ

Всі попередні периферії працювали з цифровими сигналами — HIGH або LOW, 0 або 1. Але реальний світ аналоговий. Пульс сенсор видає напругу яка плавно змінюється — від ~1.8V до ~3.3V залежно від кровотоку під шкірою.

ADC (Analog-to-Digital Converter) перетворює напругу в число. На STM32F103 — 12-бітний ADC: від 0 до 4095. 0 = 0V, 4095 = 3.3V.

Налаштування ADC1

// ADC1 на APB2 — біт 9
RCC_APB2ENR |= (1 << 9);

// пін як analog input — режим 0x0 (всі біти в 0)
// для PA1 (канал 1):
GPIOA_CRL &= ~(0xF << 4); // просто обнуляємо — це і є analog input

// вмикаємо ADC
ADC1_CR2 = (1 << 0); // ADON
delay_ms(1); // чекаємо стабілізації

// калібрування — обов'язково після увімкнення
ADC1_CR2 |= (1 << 2); // CAL
while (ADC1_CR2 & (1 << 2)); // чекаємо завершення

// sample time — 239.5 циклів для каналу 1
// довший sample time = точніше вимірювання
ADC1_SMPR2 |= (7 << 3); // канал 1, біти [5:3]

// вибираємо канал
ADC1_SQR3 = 1; // перетворення каналу 1

// EXTTRIG + SWSTART режим — запуск програмно
ADC1_CR2 |= (1 << 20) | (7 << 17); // EXTTRIG + EXTSEL=111 (SWSTART)

Читання:

uint16_t adc_read(void) {
   ADC1_CR2 |= (1 << 22); // SWSTART — запускаємо перетворення
   while (!(ADC1_SR & (1 << 1))); // EOC — End Of Conversion
   return (uint16_t)ADC1_DR;
}

Калібрування — навіщо?

ADC має внутрішні похибки через технологічні відхилення при виробництві. Калібрування вимірює і компенсує їх. Без калібрування — результати можуть бути неточними на кілька одиниць. Для пульсометра це некритично, але для точного вимірювання напруги це важливо.

Pulse Sensor — як він працює

Pulse Sensor — оптичний сенсор. Всередині є LED (зазвичай зелений) і фотодіод. LED підсвічує шкіру, фотодіод вимірює відбите світло. Коли серце б’ється — кровотік під шкірою змінюється, змінюється кількість поглиненого і відбитого світла.

Результат — аналоговий сигнал який «пульсує» в такт серцебиттю.

Спочатку дивимось що видає ADC:
Без пальця: ~2030 (базовий рівень)
З пальцем: пік ~2136, потім падає до ~1854

Різниця між піком і базовим рівнем — всього ~100 одиниць з 4095. Сенсор вимірює дуже малі зміни.

Алгоритм підрахунку BPM

Ловимо піки — моменти коли сигнал перетинає поріг знизу вгору:

#define THRESHOLD 2100 // між базовим ~2030 і піком ~2136

uint32_t last_beat = 0;
uint8_t above = 0; // чи вище порогу зараз
uint8_t warmup = 5; // пропускаємо перші 5 ударів

   while (1) {
         uint16_t val = adc_read();
         uint32_t now = get_ticks(); // мс від старту

         if (val > THRESHOLD && !above) {
              above = 1;
         if (last_beat > 0) {
            if (warmup > 0) {
                 warmup--; // перші виміри ненадійні
            } else {
                uint32_t interval = now - last_beat;
                // фільтруємо нереальні значення (30-200 BPM)
                if (interval > 300 && interval < 2000) {
                    uint32_t bpm = 60000 / interval;
                    uart_printf("BPM: %d\r\n", bpm);
                }
           }
        }
        last_beat = now;
    }

    if (val < THRESHOLD) above = 0;
    delay_ms(10);
}

THRESHOLD — не магічне число

Поріг підбирається під конкретний сенсор і умови освітлення. Алгоритм:
1. Запускаємо без пальця — дивимось базовий рівень
2. Прикладаємо палець — дивимось максимум піку
3. Поріг = десь між ними, ближче до піку

У нас вийшло 2100 (базовий 2030, пік 2136). При затримці дихання — м’язи напружуються, кровотік змінюється, сенсор ловить артефакти. Це відоме обмеження простого алгоритму.

OLED SSD1306 — двоюрідний брат Nokia

SSD1306 — OLED дисплей 128×64 пікселі. Підключення через SPI — ті ж самі піни що і Nokia 5110. Тільки протокол ініціалізації інший.

D0 → PA5 (SCK)
D1 → PA7 (MOSI)
RES → PA2 (Reset)
DC → PA3
CS → PA4

Ініціалізація SSD1306 довша ніж Nokia — більше параметрів:

void oled_init(void) {
    gpio_write(PIN_RST, LOW); delay_loop(10000);
    gpio_write(PIN_RST, HIGH); delay_loop(10000);
    
    oled_cmd(0xAE); // display off
    oled_cmd(0xD5); oled_cmd(0x80); // clock div
    oled_cmd(0xA8); oled_cmd(0x3F); // multiplex 64
    oled_cmd(0xD3); oled_cmd(0x00); // display offset
    oled_cmd(0x40); // start line 0
    oled_cmd(0x8D); oled_cmd(0x14); // charge pump ON
    oled_cmd(0x20); oled_cmd(0x00); // horizontal addressing
    oled_cmd(0xA1); // segment remap
    oled_cmd(0xC8); // COM scan direction
    oled_cmd(0xDA); oled_cmd(0x12); // COM pins
    oled_cmd(0x81); oled_cmd(0xFF); // contrast — максимум
    oled_cmd(0xD9); oled_cmd(0xF1); // precharge
    oled_cmd(0xDB); oled_cmd(0x40); // vcomh
    oled_cmd(0xA4); // display from RAM
    oled_cmd(0xA6); // normal display
    oled_cmd(0xAF); // display on
}

Двоколірний дисплей

Наш модуль фізично двоколірний: перші 2 рядки (16 пікселів) — жовті, решта 6 рядків — сині. Це не програмується — так зроблено фізично. Використовуємо це: жовта зона — індикатор пульсу, синя зона — великі цифри BPM.

Пульсуючий індикатор

При кожному ударі серця — жовта смуга вмикається і вимикається:

static uint8_t pulse_state = 0;

void oled_pulse_indicator(void) {
    oled_set_pos(0, 0); // page 0 — жовта зона
    uint8_t val = pulse_state ? 0xFF : 0x00;
    for (int i = 0; i < 128; i++) oled_data(val);
    pulse_state ^= 1;
}

Виклик в ISR пульсу — і жовта смуга мигає в такт серцебиттю.

Власний HAL — навіщо і як

Після того як всі приклади запрацювали bare-metal — саме час загорнути все в HAL.

Структура HAL:

hal/
 ├── hal.h/.c ← головний include + hal_init()
 ├── hal_gpio.h/.c ← GPIO: init, write, read, toggle
 ├── hal_systick.h/.c← SysTick: delay_ms, get_ticks
 ├── hal_uart.h/.c ← UART: puts, printf, gets
 ├── hal_exti.h/.c ← EXTI переривання
 ├── hal_tim.h/.c ← TIM2 IRQ + PWM
 ├── hal_spi.h/.c ← SPI1
 ├── hal_i2c.h/.c ← I2C1
 └── hal_adc.h/.c ← ADC1

PACK_PIN — найцікавіший трюк

Як передавати пін у функцію? Пін — це порт (адреса) + номер. Два параметри.

Рішення: пакуємо обидва в один uint32_t:

// адреса порту займає старші біти, номер піна — молодші 8 біт
#define PACK_PIN(port, pin) (((uint32_t)(port)) | ((uint32_t)(pin)))
#define UNPACK_PORT(p) ((uint32_t)((p) & 0xFFFFFF00))
#define UNPACK_PIN(p) ((uint8_t)((p) & 0xFF))

// використання
#define PIN_PC13 PACK_PIN(GPIOC_BASE, 13)
#define PIN_PA1 PACK_PIN(GPIOA_BASE, 1)

gpio_init(PIN_PC13, OUTPUT);
gpio_write(PIN_PA1, HIGH);

Це працює бо адреси портів вирівняні по 0×400 — молодші 8 біт завжди нулі. Туди і пакуємо номер піна.

uart_printf без stdlib

Стандартний printf тягне за собою купу коду — malloc, locale, floating point. Нам це не треба. Пишемо свій:

void uart_printf(const char *fmt, ...) {
   va_list args;
   va_start(args, fmt);
   while (*fmt) {
       if (*fmt == '%') {
           fmt++;
           switch (*fmt) {
               case 'd': uart_print_int(va_arg(args, int32_t)); break;
               case 's': uart_puts(va_arg(args, char *)); break;
               case 'x': uart_print_hex(va_arg(args, uint32_t)); break;
               case 'c': uart_putc((char)va_arg(args, int)); break;
               case '%': uart_putc('%'); break;
           }
       } else {
           uart_putc(*fmt);
       }
       fmt++;
   }
   va_end(args);
}

stdarg.h — єдиний системний заголовок який використовуємо. Він не тягне stdlib, просто дає доступ до змінних аргументів через va_list.

Принцип: кожен модуль робить своє

// ❌ погано — tim ініціалізує GPIO

void tim2_init_pwm(void) {
    gpio_init(PIN_PA0, OUTPUT_AF_FAST); // ← не сюди!
    // ...
}

// ✅ добре — користувач явно контролює
int main(void) {
    gpio_init(PIN_PA0, OUTPUT_AF_FAST); // ← тут видно який пін
    tim2_init_pwm(1000, 999);
}

hal_gpio — тільки про піни. hal_tim — тільки про таймер. Не мішаємо.

RCC — один раз визначаємо, скрізь використовуємо

// hal_gpio.h — визначаємо один раз

#define RCC_BASE 0x40021000
#define RCC_APB2ENR (*(volatile uint32_t *)(RCC_BASE + 0x18))

// hal_uart.c, hal_spi.c, hal_adc.c — просто включаємо hal_gpio.h
// і RCC_APB2ENR вже доступний

Якщо визначити RCC_APB2ENR ще раз в іншому файлі — компілятор дасть warning про перевизначення. Не помилку, але варто прибрати.

Повна таблиця векторів — один startup.c для всіх

Найважливіше рішення — повний startup.c з усіма IRQ з Table 63 RM0008. Не мінімальний під кожен приклад, а один повний для всього HAL:

void WWDG_IRQHandler(void) WEAK_ALIAS; // IRQ0
void PVD_IRQHandler(void) WEAK_ALIAS; // IRQ1
// ... всі 43 IRQ ...

void USART1_IRQHandler(void) WEAK_ALIAS; // IRQ37
// ...
void USBWakeUp_IRQHandler(void) WEAK_ALIAS; // IRQ42

Хочеш використати USART1 IRQ — просто пишеш void USART1_IRQHandler(void) у main.c. Лінкер автоматично підставить твою функцію замість дефолтної. Не треба кожного разу лізти в startup.c.

Що вийшло в підсумку

За перший місяць зроблено:

Bare-metal приклади — кожен показує як периферія працює без абстракцій:

BLINK_stm32 ← перше мигання, 272 байти
GPIO_stm32 ← порти, BSRR vs ODR
RUNNING_FIRE_stm32← біжучий вогонь
UART_stm32 ← hardware UART
BUTTON_EXTI_stm32 ← EXTI переривання
UART_RX_IRQ ← UART через ring buffer
SYS_TICK_BLINK ← SysTick IRQ
TIM2_BLINK ← TIM2 переривання
TIM2_PWM_BLINK ← PWM дихання LED
SPI_NOKIA5110 ← Nokia 5110
I2C_MPU6050 ← акселерометр
OLED_PULSE ← пульсометр на OLED

HAL з 8 модулів:

#include "hal/hal.h"

int main(void) {
    hal_init();
    uart_init(9600);
    i2c1_init();
    adc1_init(1);
   
    uint8_t buf[6];
    while (1) {
         i2c1_read_buf(0x68, 0x3B, buf, 6);
         int16_t ax = (int16_t)((buf[0] << 8) | buf[1]);
         uart_printf("AX=%d\r\n", ax);
         uint16_t pulse = adc1_read();
         uart_printf("ADC=%d\r\n", pulse);
         delay_ms(100);
    }
}

Можеш порівняти з bare-metal версією де кожен рядок це робота з регістрами напряму. HAL як писали в коментарі для STM32 просто необхідність бо визначати це в ручну муторно, але якщо ти справжній псих то може зайде й таке. Особисто автор поліз туди, щоб побачити в живу наскільки це все відрізняється від AVR і ардуїно. А тобі це навіщо?

Щоб я виділив

Таблиця векторів — не регістри. Масив адрес у Flash. Процесор читає її сам при кожному перериванні. Пропустив позицію — процесор стрибає не туди.

RCC завжди напочатку (не кукурузному, а в пріоритеті). Забув тактування — периферія мовчить. Без підказок. Просто мовчить.

`EXTI_PR` скидається записом `1`, `TIM2_SR` — записом `0`. Різна периферія, різна логіка. Читай RM0008.

ISER0 vs ISER1. IRQ0-31 → ISER0. IRQ32-63 → ISER1. USART1 = IRQ37 → ISER1, біт 5.

Alternate Function — не кожен пін може виводити PWM або SPI. Тільки ті що мають AF прив’язку до периферії. Дивись datasheet, таблиця Alternate Functions.

`volatile` для змінних в ISR. Без нього компілятор оптимізує і main ніколи не побачить зміну.

Що планується далі

Далі другий Місяць досліджень та пригод — STM32 підключається до Linux. Blue Pill збирає дані з MPU6050 і можливо пульс сенсора, відправляє через UART в Luckfox Pico. Python скрипт на Linux стороні парсить дані і виводить графік в реальному часі.

Bare-metal + Linux будуємо перший справжній embedded міст.

Код на GitHub: github.com/pipicosim800-maker/stm32F103

Додаток Чому не можна просто перемкнути дроти

Ми використовували і Nokia 5110 і SSD1306 OLED — обидва через SPI, обидва мають DC, RST, CS. Здається — поміняй дроти і готово ні не готово. Бо SPI це тільки транспорт. Він каже як передати байти: тактування, фази, порядок бітів. Але що ці байти означають це вирішує кожен контролер дисплея самостійно.

Ти надсилаєш: 0×21

Nokia 5110 (PCD8544): «О, це команда увімкнути extended instruction set»
SSD1306: «О, це команда встановити нижній ніби колонки»
ST7735 (TFT): «О, це NOP»

Один і той самий байт але три різних реакції. Протокол транспортний однаковий, та протокол прикладний у кожного свій.

Що реально відрізняється між дисплеями:

ПараметрNokia 5110SSD1306 OLEDST7735 TFT
КонтролерPCD8544SSD1306ST7735
Роздільність84×48128×64128×160
Кольоримонохроммонохром65536 (16-bit)
Команда ініц.6 байт~20 байт~100 байт
Буфер відео504 байти1024 байти40960 байт
DatasheetPCD8544.pdfSSD1306.pdfST7735.pdf

Те саме з сенсорами на I2C — MPU6050 і BMP280 обидва на I2C, але команди, регістри і формат даних абсолютно різні.

Висновок: міняєш пристрій то переписуєш драйвер. Протокол залишається, логіка ні.

Додаток: популярні модулі для STM32 і Arduino

Шпаргалка по найпопулярніших модулях — протокол, піни на Blue Pill, типове застосування.

📺 Дисплеї

МодульПротоколПіни Blue PillРоздільністьНотатки
Nokia 5110SPIPA5/PA7/PA4/PA3/PA284×48монохром, дешевий, 3.3V
SSD1306 OLEDSPI або I2CSPI: PA5/PA7 або I2C: PB6/PB7128×64чудовий контраст
ST7735 TFTSPIPA5/PA7/PA4/PA3/PA2128×160кольоровий, потребує більше RAM
ILI9341 TFTSPIPA5/PA7/PA4/PA3/PA2240×320популярний 2.8″ дисплей
HD44780 LCDпаралельний або I2C (з PCF8574)PB6/PB7 (I2C варіант)16×2 або 20×4символьний, класика
MAX7219SPIPA5/PA7/PA48×8 LED матрицяможна каскадувати

🌡 Сенсори температури і тиску

МодульПротоколПіни Blue PillЩо вимірюєНотатки
DS18B201-Wireбудь-який GPIOтемпература-55°C до +125°C, до 127 на одному дроті
DHT11 / DHT221-Wireбудь-який GPIOтемпература + вологістьDHT22 точніший
BMP180I2CPB6/PB7тиск + температурависотомір
BMP280I2C або SPIPB6/PB7 або PA5/PA7тиск + температураточніший за BMP180
BME280I2C або SPIPB6/PB7 або PA5/PA7тиск + температура + вологістьвсе в одному
LM35AnalogPA0-PA7температурапростий аналоговий

🏃 IMU — акселерометри і гіроскопи

МодульПротоколПіни Blue PillЩо вимірюєНотатки
MPU6050I2CPB6/PB7акселерометр + гіроскопнайпопулярніший, є DMP
MPU9250I2C або SPIPB6/PB7 або PA5/PA7акцел + гіро + магнітометр9DOF
ADXL345I2C або SPIPB6/PB7 або PA5/PA7акселерометрдо ±16g
HMC5883LI2CPB6/PB7магнітометр (компас)часто в парі з MPU6050
ICM-42688-PSPIPA5/PA7акцел + гіроточніший сучасний варіант

📡 Зв’язок

МодульПротоколПіни Blue PillЩо робитьНотатки
HC-05 / HC-06UARTPA9/PA10Bluetooth 2.0AT команди
ESP8266UARTPA9/PA10WiFiAT команди або прошивка
ESP32UART або SPIPA9/PA10WiFi + BTпотужніший за ESP8266
NRF24L01SPIPA5/PA7/PA4 + GPIO2.4GHz радіодо 1Mbps, до 100м
LoRa Ra-02SPIPA5/PA7/PA4 + GPIOLoRa 433MHzвеликі відстані, малий трафік
SIM800LUARTPA9/PA10GSM/GPRSпотребує 4V, багато струму

📏 Дистанція і рух

МодульПротоколПіни Blue PillЩо вимірюєНотатки
HC-SR04GPIO trigger/echoбудь-які 2 GPIOвідстань (2-400 см)ультразвук
VL53L0XI2CPB6/PB7відстань (до 2м)лазер ToF, точніший
PIR HC-SR501GPIOбудь-який GPIOрухдатчик присутності
IR модульGPIOбудь-який GPIOвідстань 2-30 смвідбите ІЧ
EncoderGPIO interruptEXTI піниоберти мотораквадратурний або однофазний

❤️ Біометрія

МодульПротоколПіни Blue PillЩо вимірюєНотатки
Pulse SensorAnalogPA0-PA7пульсоптичний, потребує фільтрації
MAX30102I2CPB6/PB7пульс + SpO2точніший, вбудований ADC
AD8232AnalogPA0-PA7ЕКГм’язовий сенсор ECG
Muscle Sensor v3AnalogPA0-PA7ЕМГ (м’язи)для жестового управління

💾 Пам’ять і зберігання

МодульПротоколПіни Blue PillОб’ємНотатки
AT24C256 EEPROMI2CPB6/PB7256 Кбітповільна запис, довге зберігання
W25Q32 FlashSPIPA5/PA7/PA432 Мбітшвидка, для прошивок і даних
SD картаSPIPA5/PA7/PA4до 32 ГБпотрібен FAT32 або FATFS

⚙️ Актуатори і керування

МодульПротоколПіни Blue PillЩо робитьНотатки
СервопривідPWMPA0-PA3 (TIM2)кут 0-180°50Гц, 1-2мс імпульс
L298NGPIO + PWMбудь-якіDC мотордо 2А, два мотори
A4988GPIOSTEP + DIRкроковий мотормікрокроки
PCA9685I2CPB6/PB716 каналів PWMідеальний для сервоприводів
РелеGPIOбудь-якийкомутація 220Vоптоізоляція обов’язкова

🔊 Звук

МодульПротоколПіни Blue PillЩо робитьНотатки
BuzzerGPIO або PWMбудь-якийзвуковий сигналактивний (просто GPIO) або пасивний (PWM)
MAX9814AnalogPA0-PA7мікрофон з підсилювачемдля запису звуку
VS1053SPIPA5/PA7/PA4MP3 декодерпрограє MP3 з SD
I2S DAC (PCM5102)I2Sспеціальні піниякісний аудіо вихіддля STM32 з I2S

Як швидко зрозуміти новий модуль

1. Знайди datasheet — шукай «[назва чіпа] datasheet pdf». Не datasheet модуля, а саме чіпа (PCD8544, SSD1306, MPU6050).

2. Знайди register map — таблиця всіх регістрів. Це і є «словник» пристрою.

3. Знайди initialization sequence — послідовність команд для старту. Зазвичай є в datasheet або application note.

4. Перевір I2C адресу — для I2C пристроїв є утиліта i2c_scan яка знаходить всі пристрої на шині.

5. Починай з мінімуму — для дисплея: ініціалізація → залити екран одним кольором → вивести один піксель. Для сенсора: WHO_AM_I → одне вимірювання.

// Universal i2c scanner для STM32
// пробуємо всі адреси 0x08-0x77

for (uint8_t addr = 8; addr < 120; addr++) {
    // спробуємо відправити START + addr
    // якщо ACK — пристрій знайдено
}

Повний список прикладів і HAL: github.com/pipicosim800-maker/stm32F103

Серія «STM32 з нуля без HAL» на DOU.ua

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

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Overflow-safe патерн. _ticks — uint32_t, і через ~49 днів він переповниться і стане 0. Якщо порівнювати через >= — після переповнення умова ніколи не виконається.

Ну чому ж. Ще 49 днів і щастя прийде :)

А взагалі — грандіозна робота, мої аплодисменти!

трохи побуду нубом,
а хіба в STM нема двохрівневої системи пріорітетів переривань?
щось я пропустив пріоритизацію і її менеджемент

Що планується далі
Далі другий Місяць досліджень та пригод — STM32 підключається до Linux. Blue Pill збирає дані з MPU6050 і можливо пульс сенсора, відправляє через UART в Luckfox Pico.
===
не маштабуємо в сенсі UART монопольно для двох, нє ну можна яку систему тіпа на RS485, але то трохи теж не то, але хозяін барін

Так тут не про маштаб, а про наглядність. Так колись починав на avr пам’ятаю складно було. RS485 потім не складно задіяти.

многабукаффнєасіліл, але може коли пригодиться якщо візьмуть таки в мілфтех на ловлевел ембедєд прогєра вбудованих сістєм на бає СТМ32.
зі: а де тут HAL а де RTOS?

упдейт,

"STM32 з нуля без HAL

поняв, вопрос снімаєццо

HAL самописний іде в усій серії в порівнянні

я про «заводьский», тіпа як mbed HAL чи де там він ще десь в Cube генериться Cube HAL чи як його, але ж ти хочеш «закат солнца вручну»

Ну, це мій метод. Свій ровер. Препарований контролер хіба не цвкавий?

нє, тому що мікроконтролерів, думаю більше тис. сімейств, STM32 одни із найбільш ширнармасний для кухарок

Мені цікаво було давно, ще один гештальт майже закрито 😀

Чому `EXTI_PR = (1 << 1)` а не `|=`?

Цей регістр скидається записом 1, а не 0. Ну так собі історія, але як є.

А це типова манера. Наприклад, якщо читати описи чипсетів Intel, то там много полів, помічених RW1C или RW/1C, які як раз це і означають.

Клод мені відповів, чому воно так:

RW1C (write-1-to-clear) is a solution to a concurrency problem between the hardware that sets the flag and the software that reads and acknowledges it. The naive alternative — where software clears a flag by writing 0 — has a race condition that RW1C eliminates.

## The race condition with write-0-to-clear

Consider a status register with a flag bit that hardware sets when an event occurs (say, a packet arrived, or a timer expired). Software handles the event and wants to clear the flag. With write-0-to-clear semantics, a typical interrupt handler does something like:

1. Read the status register.
2. Handle all the events indicated by the bits that are set.
3. Write back zeros to the bits the handler just processed (typically via read-modify-write, to preserve other bits).

Now suppose that between steps 1 and 3, the hardware sets *another* flag in the same register — a different event that the handler did not see in step 1. The read-modify-write in step 3 reads the register again (or uses the value from step 1), ANDs out the handled bits, and writes the result back. In doing so, it overwrites the newly-set bit with 0. **The event is lost.** There was no software-visible acknowledgement of it, so nothing will ever handle it.

You can try to fix this with a lock, or by masking interrupts, or by re-reading and looping, but all of these are workarounds imposed on the software, and some of them don’t even work if the hardware setting the flag runs independently of the CPU (which it does — that’s the whole point of an interrupt).

## How RW1C fixes it

With RW1C, each bit is cleared independently by writing a 1 to *that specific bit*. Writing a 0 to a bit is a no-op. So the acknowledgement step is:

— Write a mask that has 1s in exactly the positions the handler processed, 0s everywhere else.

This is a *pure write*, not a read-modify-write. If hardware sets a new flag bit concurrently, that bit is at a position where the handler wrote 0, which is a no-op, so the new flag survives. Software only ever acknowledges bits it explicitly saw and handled.

The key insight: the clearing operation is now **idempotent and position-local**. Each bit’s clear is independent. There is no coupling between bits, and no implicit dependency on the register’s prior value.

## The deeper design consideration

RW1C reflects an asymmetric trust model between the two agents touching the register:

— **Hardware is the producer** of status information and must never have its output silently discarded.
— **Software is the consumer** and should only be able to acknowledge what it has actually observed.

Write-0-to-clear conflates “acknowledge what I saw” with “set the register to this value”, and the latter is wrong — software has no business dictating the absolute state of a register whose contents are authored by hardware. RW1C makes the operation semantically precise: software says “I’m done with event X”, not “the register should now look like this”.

There’s a secondary benefit: the same pattern scales to multi-bit fields representing error counters, FIFO events, etc., and it composes cleanly with multi-core systems where two CPUs might service different bits of the same status register without needing a mutex.

## Related patterns

The same reasoning produces related conventions you’ll see in hardware registers:

— **RW1S** (write-1-to-set): symmetric case, used when software needs to signal something to hardware without disturbing other bits.
— **Write-only clear registers**: some designs put status bits in one register and provide a separate “clear” register where writing 1 to bit N clears bit N of the status register. This is just RW1C with the read and write addresses separated, often for pipelining reasons.
— **Read-to-clear (RC)**: an alternative where reading the status register atomically clears it. This also avoids the race but has its own problems — you can’t peek at the state without destroying it, debugging is painful, and two readers (e.g. a debugger and the real handler) interfere with each other. RW1C is generally preferred for this reason.

So RW1C is not a quirk — it’s the natural register semantics once you take seriously that the producer and consumer of the flag are running concurrently and that lost events are unacceptable.

Ну, мені здається, дуже схоже на правду.

тут ще хтось даташіти досі читає ?

Угу, в перерві між серіями детективу...

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