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
| Пін | Лінія 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
// 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
| SysTick | TIM2 | |
| Де живе | Ядро 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 —
— 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 — коли що використовувати
| SPI | I2C | |
| Дроти | 4 (+ CS для кожного slave) | 2 (для будь-якої кількості) |
| Швидкість | до 50+ MHz | 100кГц / 400кГц / 1MHz |
| Адресація | CS пін | |
| Підтяжки | не потрібні | обов’язкові (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 —
Налаштування 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 5110 | SSD1306 OLED | ST7735 TFT |
| Контролер | PCD8544 | SSD1306 | ST7735 |
| Роздільність | 84×48 | 128×64 | 128×160 |
| Кольори | монохром | монохром | 65536 |
| Команда ініц. | 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 | будь-який GPIO | температура | -55°C до +125°C, до 127 на одному дроті | |
| DHT11 / DHT22 | будь-який 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 | відстань | ультразвук |
| VL53L0X | I2C | PB6/PB7 | відстань (до 2м) | лазер ToF, точніший |
| PIR HC-SR501 | GPIO | будь-який GPIO | рух | датчик присутності |
| IR модуль | GPIO | будь-який GPIO | відстань | відбите ІЧ |
| 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) | кут | 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 → одне вимірювання.
// 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
14 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівНу чому ж. Ще 49 днів і щастя прийде :)
А взагалі — грандіозна робота, мої аплодисменти!
трохи побуду нубом,
а хіба в STM нема двохрівневої системи пріорітетів переривань?
щось я пропустив пріоритизацію і її менеджемент
Що планується далі
Далі другий Місяць досліджень та пригод — STM32 підключається до Linux. Blue Pill збирає дані з MPU6050 і можливо пульс сенсора, відправляє через UART в Luckfox Pico.
===
не маштабуємо в сенсі UART монопольно для двох, нє ну можна яку систему тіпа на RS485, але то трохи теж не то, але хозяін барін
Так тут не про маштаб, а про наглядність. Так колись починав на avr пам’ятаю складно було. RS485 потім не складно задіяти.
многабукаффнєасіліл, але може коли пригодиться якщо візьмуть таки в мілфтех на ловлевел ембедєд прогєра вбудованих сістєм на бає СТМ32.
зі: а де тут HAL а де RTOS?
упдейт,
поняв, вопрос снімаєццо
HAL самописний іде в усій серії в порівнянні
я про «заводьский», тіпа як mbed HAL чи де там він ще десь в Cube генериться Cube HAL чи як його, але ж ти хочеш «закат солнца вручну»
Ну, це мій метод. Свій ровер. Препарований контролер хіба не цвкавий?
нє, тому що мікроконтролерів, думаю більше тис. сімейств, STM32 одни із найбільш ширнармасний для кухарок
Мені цікаво було давно, ще один гештальт майже закрито 😀
А це типова манера. Наприклад, якщо читати описи чипсетів Intel, то там много полів, помічених RW1C или RW/1C, які як раз це і означають.
Клод мені відповів, чому воно так:
Ну, мені здається, дуже схоже на правду.
тут ще хтось даташіти досі читає ?
Угу, в перерві між серіями детективу...
всі 500 сторінок плю ерати?