Минулого разу ми написали свій HAL — gpio_init, uart_printf, delay_ms. Все працювало, Blue Pill моргав, UART виводив текст, і здавалось що life is good.
А потім я захотів зробити кнопку.
Не 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 — тактування
Перш за все — вмикаємо тактування. Без цього регістри периферії просто не реагують на запис. Це як вмикати вимикач перед тим як крутити ручки.
|
1 2 3 |
RCC_APB2ENR |= (1 << 2); // GPIOA RCC_APB2ENR |= (1 << 4); // GPIOC RCC_APB2ENR |= (1 << 0); // AFIO ← без цього EXTI не бачить GPIO! |
AFIO — це окремий модуль і окремий біт в RCC. Якщо забути — EXTI просто не підключений до пінів. Тиша.
2. GPIO — пін як вхід з підтяжкою
|
1 2 3 |
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 — це мультиплексор який вирішує хто саме.
|
1 2 3 |
// 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 — детектор події
|
1 2 |
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 менеджер всіх переривань. Він вирішує хто важливіший, ставить в чергу, і каже процесору куди стрибати.
|
1 2 |
// EXTI1 = IRQ7, тому біт 7 в ISER0 NVIC_ISER0 |= (1 << 7); |
ISER0 — Interrupt Set Enable Register 0. Керує IRQ0-31. Записуємо 1 у відповідний біт — переривання дозволено.
Обробник переривання (ISR)
|
1 2 3 4 5 6 7 8 9 10 11 |
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.
|
1 2 3 4 5 6 7 8 9 |
// ТАК БУЛО — таблиця закінчується на позиції 15 __attribute__((section(".vectors"))) uint32_t vectors[] = { (uint32_t)&_estack, (uint32_t)Reset_Handler, // ... (uint32_t)SysTick_Handler, // позиція 15 // далі нічого! }; |
Коли стається EXTI1 — процесор іде на позицію 23 в таблиці векторів. А там… нуль. Або сміття. Або Default_Handler. Результат — тиша або HardFault.
Що таке таблиця векторів насправді
Це не регістри. Це масив адрес у Flash-пам’яті за адресою 0x08000000.
Регістри (RCC, GPIO, NVIC) — ти керуєш ними вручну, читаєш і пишеш коли треба.
Таблицю векторів — процесор читає сам, автоматично, коли стається переривання.
Подія: натиснута кнопка (EXTI1)
Процесор: іду до позиції 23 в таблиці
Таблиця: тут адреса EXTI1_IRQHandler
Процесор: стрибаю туди
Аналогія: таблиця векторів — це список номерів екстрених служб на стіні. Якщо пожежа — пілот не шукає кнопку «гасити», він дивиться на список і дзвонить пожежникам.
Де взяти правильні позиції?
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).
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
__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 через переривання.
|
1 2 3 4 5 6 7 |
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 є магічний рядок:
|
1 2 |
#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
| Пін | Лінія EXTI | IRQ | NVIC регістр | Назва функції |
| Px0 | EXTI0 | 6 | ISER0 біт 6 | EXTI0_IRQHandler |
| Px1 | EXTI1 | 7 | ISER0 біт 7 | EXTI1_IRQHandler |
| Px2 | EXTI2 | 8 | ISER0 біт 8 | EXTI2_IRQHandler |
| Px3 | EXTI3 | 9 | ISER0 біт 9 | EXTI3_IRQHandler |
| Px4 | EXTI4 | 10 | ISER0 біт 10 | EXTI4_IRQHandler |
| Px5-9 | EXTI9_5 | 23 | ISER0 біт 23 | EXTI9_5_IRQHandler |
| Px10-15 | EXTI15_10 | 40 | ISER1 біт 8 | EXTI15_10_IRQHandler |
| — | USART1 | 37 | ISER1 біт 5 | USART1_IRQHandler |
| — | TIM2 | 28 | ISER0 біт 28 | TIM2_IRQHandler |
Повний код BUTTON_EXTI
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
// 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 виглядав так:
|
1 2 3 4 5 6 7 8 9 |
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 — один біт який перетворює таймер на джерело переривань:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#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 вже є:
|
1 |
(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) — до якого числа рахуємо
|
1 2 3 4 |
// PSC = 7999 → 8MHz / (7999+1) = 1000 Гц → 1 тік = 1мс TIM2_PSC = 7999; // ARR = 499 → 500 тіків = 500мс TIM2_ARR = 499; |
Коли лічильник досягає ARR — виставляється прапорець UIF (Update Interrupt Flag) і якщо дозволено — викликається переривання.
|
1 2 3 4 5 6 7 8 9 10 11 |
// 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:
|
1 2 3 4 5 6 |
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
|
1 2 3 4 5 |
// ❌ неправильно 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:
|
1 2 3 |
// 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
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// 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) — і отримаєш плавне дихання:
|
1 2 3 4 5 6 7 8 9 10 11 |
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
| SysTick | TIM2 | |
| Де живе | Ядро Cortex-M3 | Периферія ST |
| RCC | не потрібен | APB1 біт 0 |
| Таблиця векторів | позиція 15 (системний) | позиція 44 (IRQ28) |
| Prescaler | немає | є (PSC) |
| PWM | не вміє | вміє (4 канали) |
| Використання | системний тік, delay_ms | точні затримки, PWM, захоплення |
Повний код TIM2_BLINK
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
#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
|
1 2 3 4 5 |
// 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 |
|
1 2 3 4 5 6 |
// 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.
Відправка байта:
|
1 2 3 4 5 |
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 → дані (пікселі).
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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 } |
Залити весь екран:
|
1 2 3 |
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:
|
1 2 3 4 5 |
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 пікселів:
|
1 2 3 4 5 |
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кОм. Без них шина просто не працює — лінії «плавають» і пристрої один одного не чують. Багато модулів MPU6050 вже мають резистори на платі.
Послідовність транзакції 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
|
1 2 3 4 5 6 7 8 |
// 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
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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). Адреса 0x68 (якщо AD0 підтягнутий до GND).
Перевірка зв’язку — читаємо WHO_AM_I (регістр 0x75). Має повернути 0x68:
uint8_t who = i2c_read(0x68, 0x75);
// якщо who == 0x68 — MPU6050 знайдений
Виводимо з режиму сну:
i2c_write(0x68, 0x6B, 0x00); // PWR_MGMT_1 = 0
Читаємо акселерометр — 6 байт (X, Y, Z по 2 байти кожен):
|
1 2 3 4 5 |
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 — коли що використовувати
| SPI | I2C | |
| Дроти | 4 (+ CS для кожного slave) | 2 (для будь-якої кількості) |
| Швидкість | до 50+ MHz | 100кГц / 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 (адреса 0x68)
Частина 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
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 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) |
Читання:
|
1 2 3 4 5 |
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
Ловимо піки — моменти коли сигнал перетинає поріг знизу вгору:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#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 — більше параметрів:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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.
Пульсуючий індикатор
При кожному ударі серця — жовта смуга вмикається і вимикається:
|
1 2 3 4 5 6 7 8 |
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:
|
1 2 3 4 5 6 7 8 9 10 |
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:
|
1 2 3 4 5 6 7 8 9 10 11 |
// адреса порту займає старші біти, номер піна — молодші 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); |
Це працює бо адреси портів вирівняні по 0x400 — молодші 8 біт завжди нулі. Туди і пакуємо номер піна.
uart_printf без stdlib
Стандартний printf тягне за собою купу коду — malloc, locale, floating point. Нам це не треба. Пишемо свій:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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.
Принцип: кожен модуль робить своє
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// ❌ погано — 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 — один раз визначаємо, скрізь використовуємо
|
1 2 3 4 5 6 7 |
// 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:
|
1 2 3 4 5 6 7 |
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.
Що вийшло в підсумку
За місяць 1 зроблено:
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 модулів:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#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 не магія — це просто зручна обгортка над тим що ми вже розуміємо.
Головне що зрозуміли
Таблиця векторів — не регістри. Масив адрес у 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 ніколи не побачить зміну.
Що далі
Місяць 2 — 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 — це тільки транспорт. Він каже як передати байти: тактування, фази, порядок бітів. Але що ці байти означають — вирішує кожен контролер дисплея самостійно.
Ти надсилаєш: 0x21
Nokia 5110 (PCD8544): «О, це команда увімкнути extended instruction set»
SSD1306: «О, це команда встановити нижній ніби колонки»
ST7735 (TFT): «О, це NOP»
Один і той самий байт — три різних реакції. Протокол транспортний однаковий, протокол прикладний — у кожного свій.
Що реально відрізняється між дисплеями:
| Параметр | Nokia 5110 | SSD1306 OLED | ST7735 TFT |
| Контролер | PCD8544 | SSD1306 | ST7735 |
| Роздільність | 84×48 | 128×64 | 128×160 |
| Кольори | монохром | монохром | 65536 (16-bit) |
| Команда ініц. | 6 байт | ~20 байт | ~100 байт |
| Буфер відео | 504 байти | 1024 байти | 40960 байт |
| Datasheet | PCD8544.pdf | SSD1306.pdf | ST7735.pdf |
Те саме з сенсорами на I2C — MPU6050 і BMP280 обидва на I2C, але команди, регістри і формат даних абсолютно різні.
Висновок: міняєш пристрій — переписуєш драйвер. Протокол залишається, логіка — ні.
Додаток: популярні модулі для STM32 і Arduino
Шпаргалка по найпопулярніших модулях — протокол, піни на Blue Pill, типове застосування.
📺 Дисплеї
| Модуль | Протокол | Піни Blue Pill | Роздільність | Нотатки |
| Nokia 5110 | SPI | PA5/PA7/PA4/PA3/PA2 | 84×48 | монохром, дешевий, 3.3V |
| SSD1306 OLED | SPI або I2C | SPI: PA5/PA7 або I2C: PB6/PB7 | 128×64 | самосвітний, чудовий контраст |
| ST7735 TFT | SPI | PA5/PA7/PA4/PA3/PA2 | 128×160 | кольоровий, потребує більше RAM |
| ILI9341 TFT | SPI | PA5/PA7/PA4/PA3/PA2 | 240×320 | популярний 2.8″ дисплей |
| HD44780 LCD | паралельний або I2C (з PCF8574) | PB6/PB7 (I2C варіант) | 16×2 або 20×4 | символьний, класика |
| MAX7219 | SPI | PA5/PA7/PA4 | 8×8 LED матриця | можна каскадувати |
🌡 Сенсори температури і тиску
| Модуль | Протокол | Піни Blue Pill | Що вимірює | Нотатки |
| DS18B20 | 1-Wire | будь-який GPIO | температура | -55°C до +125°C, до 127 на одному дроті |
| DHT11 / DHT22 | 1-Wire | будь-який GPIO | температура + вологість | DHT22 точніший |
| BMP180 | I2C | PB6/PB7 | тиск + температура | висотомір |
| BMP280 | I2C або SPI | PB6/PB7 або PA5/PA7 | тиск + температура | точніший за BMP180 |
| BME280 | I2C або SPI | PB6/PB7 або PA5/PA7 | тиск + температура + вологість | все в одному |
| LM35 | Analog | PA0-PA7 | температура | простий аналоговий |
🏃 IMU — акселерометри і гіроскопи
| Модуль | Протокол | Піни Blue Pill | Що вимірює | Нотатки |
| MPU6050 | I2C | PB6/PB7 | акселерометр + гіроскоп | найпопулярніший, є DMP |
| MPU9250 | I2C або SPI | PB6/PB7 або PA5/PA7 | акцел + гіро + магнітометр | 9DOF |
| ADXL345 | I2C або SPI | PB6/PB7 або PA5/PA7 | акселерометр | до ±16g |
| HMC5883L | I2C | PB6/PB7 | магнітометр (компас) | часто в парі з MPU6050 |
| ICM-42688-P | SPI | PA5/PA7 | акцел + гіро | точніший сучасний варіант |
📡 Зв’язок
| Модуль | Протокол | Піни Blue Pill | Що робить | Нотатки |
| HC-05 / HC-06 | UART | PA9/PA10 | Bluetooth 2.0 | AT команди |
| ESP8266 | UART | PA9/PA10 | WiFi | AT команди або прошивка |
| ESP32 | UART або SPI | PA9/PA10 | WiFi + BT | потужніший за ESP8266 |
| NRF24L01 | SPI | PA5/PA7/PA4 + GPIO | 2.4GHz радіо | до 1Mbps, до 100м |
| LoRa Ra-02 | SPI | PA5/PA7/PA4 + GPIO | LoRa 433MHz | великі відстані, малий трафік |
| SIM800L | UART | PA9/PA10 | GSM/GPRS | потребує 4V, багато струму |
📏 Дистанція і рух
| Модуль | Протокол | Піни Blue Pill | Що вимірює | Нотатки |
| HC-SR04 | GPIO trigger/echo | будь-які 2 GPIO | відстань (2-400 см) | ультразвук |
| VL53L0X | I2C | PB6/PB7 | відстань (до 2м) | лазер ToF, точніший |
| PIR HC-SR501 | GPIO | будь-який GPIO | рух | датчик присутності |
| IR модуль | GPIO | будь-який GPIO | відстань 2-30 см | відбите ІЧ |
| Encoder | GPIO interrupt | EXTI піни | оберти мотора | квадратурний або однофазний |
❤️ Біометрія
| Модуль | Протокол | Піни Blue Pill | Що вимірює | Нотатки |
| Pulse Sensor | Analog | PA0-PA7 | пульс | оптичний, потребує фільтрації |
| MAX30102 | I2C | PB6/PB7 | пульс + SpO2 | точніший, вбудований ADC |
| AD8232 | Analog | PA0-PA7 | ЕКГ | м’язовий сенсор ECG |
| Muscle Sensor v3 | Analog | PA0-PA7 | ЕМГ (м’язи) | для жестового управління |
💾 Пам’ять і зберігання
| Модуль | Протокол | Піни Blue Pill | Об’єм | Нотатки |
| AT24C256 EEPROM | I2C | PB6/PB7 | 256 Кбіт | повільна запис, довге зберігання |
| W25Q32 Flash | SPI | PA5/PA7/PA4 | 32 Мбіт | швидка, для прошивок і даних |
| SD карта | SPI | PA5/PA7/PA4 | до 32 ГБ | потрібен FAT32 або FATFS |
⚙️ Актуатори і керування
| Модуль | Протокол | Піни Blue Pill | Що робить | Нотатки |
| Сервопривід | PWM | PA0-PA3 (TIM2) | кут 0-180° | 50Гц, 1-2мс імпульс |
| L298N | GPIO + PWM | будь-які | DC мотор | до 2А, два мотори |
| A4988 | GPIO | STEP + DIR | кроковий мотор | мікрокроки |
| PCA9685 | I2C | PB6/PB7 | 16 каналів PWM | ідеальний для сервоприводів |
| Реле | GPIO | будь-який | комутація 220V | оптоізоляція обов’язкова |
🔊 Звук
| Модуль | Протокол | Піни Blue Pill | Що робить | Нотатки |
| Buzzer | GPIO або PWM | будь-який | звуковий сигнал | активний (просто GPIO) або пасивний (PWM) |
| MAX9814 | Analog | PA0-PA7 | мікрофон з підсилювачем | для запису звуку |
| VS1053 | SPI | PA5/PA7/PA4 | MP3 декодер | програє 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 → одне вимірювання.
|
1 2 3 4 5 6 7 |
// Universal i2c scanner для STM32 // пробуємо всі адреси 0x08-0x77 for (uint8_t addr = 8; addr < 120; addr++) { // спробуємо відправити START + addr // якщо ACK — пристрій знайдено } |
Повний список прикладів і HAL: [github.com/pipicosim800-maker/stm32F103](https://github.com/pipicosim800-maker/stm32F103)
Серія «STM32 з нуля без HAL»