Чому простий while(1) не завжди працює
Коли ми тільки починаємо писати програми для вбудованих систем, наш код виглядає приблизно так:
void main() {initialization();while(1) {do_something();
do_something_else();
...}}
Спочатку код робить початкові дії - ініціалізацію пристроїв, налаштування системи тощо. Далі запускається основний цикл програми, в якому почергово виконуються необхідні розрахунки або дії.
Особливістю програм для вбудованих систем є постійна взаємодія з зовнішніми та внутрішніми периферійними пристроями: екранами, кнопками, датчиками, таймерами, іншими мікроконтролерами тощо. Для правильної роботи системи програма має контролювати стани пристроїв і реагувати вчасно на зміну цих станів.
Часто виникає проблема синхронізації роботи між цими пристроями, оскільки кожен пристрій працює з різною швидкістю і може видавати сигнали мікроконтролеру в непередбачуваний час.
Уявімо батарею ноутбука, яка має акумулятор і BMS-плату (Система Керування Батареєю). Плата контролює роботу батареї: струм і напругу заряду і розряду, має бути захист від короткого замикання, захист від перегріву, рахувати кількість циклів роботи тощо.
Псевдокод управління такою системою міг би виглядати так:
void main() {ініціалізація();while(1) {if(зафіксовано_коротке_замикання()) {вимкнути_батарею();}
температура_батареї = отримати_температуру_з_датчика();
if(температура_батареї >= критична_температура) {
вимкнути_батарею();
}
else {
if(температура_батареї > дозволена_температура) {
сповістити_комп'ютер("Батарея перегрівається! Необхідно знизити навантаження!");
}
}
команда = отримати_команду_комп'ютера();
switch(команда) {
case "вимкнути":
вимкнути_батарею();
break;
case "запуск":
ввімкнути_батарею();
...
}
...деякі інші операції}
В основному циклі програми код по черзі опитує датчики, порти і робить необхідні розрахунки і дії у відповідь. Такий підхід називається polling (опитування). Все виглядає логічно, але з таким підходом виникає кілька проблем:
- Затримка реакції — кожна ітерація циклу займає певний час. Іноді критично важливо реагувати на подію так швидко, як тільки можливо. Якщо коротке замикання виникне одразу після перевірки, то до наступної перевірки пройде деякий час. Чим більшим буде цей час, тим гірші будуть наслідки.
- Надлишкове звернення до периферії - при кожній ітерації циклу відбувається звернення до інших пристроїв для отримання їх стану, навіть якщо їх стан не змінився. Це так чи інакше займає процесорний час і енергію.
- Ризик пропустити важливу подію. В цьому прикладі якщо коротке замикання з'явиться і зникне до того як цикл програми дійде до перевірки КЗ, факт цієї події не буде зафіксовано. В такому випадку було б правильно видати помилку на ПК і показати користувачу.
- Більш складний випадок - синхронізація передачі даних між пристроями. Коли викликається функція
отримати_команду_комп'ютера(), програма може очікувати завершення передачі певний час. У цей момент обробка інших подій фактично зупиняється. Якщо під час цієї операції виникне аварійна ситуація,
реакція системи буде відкладена до завершення функції.
Таким чином, при зростанні складності системи підхід polling стає малоефективним і потенційно небезпечним. Для вирішення цих проблем нам потрібен інший механізм і такий механізм існує.
Ідея переривань
Замість того щоб постійно опитувати всі пристрої по черзі, можна змінити підхід. Нехай не програма шукає подію, а подія повідомляє програму про себе.
Переривання (interrupts) — це механізм, за допомогою якого периферійний пристрій може привернути увагу процесора спеціальним сигналом у момент виникнення події.
Як це працює загально: основна програма виконується у звичайному режимі, і коли відбувається певна подія (натискання кнопки, спрацювання таймера, прийом байта по UART тощо), периферія формує сигнал переривання. Процесор призупиняє виконання основного коду і переходить до виконання спеціальної функції — обробника переривання (ISR). Після завершення обробника процесор повертається до того місця, де був зупинений.
Цей механізм виконаний у вигляді спеціальної електронної схеми і, відповідно, діє на апаратному рівні. Таким чином реакція системи на подію більше не залежить від швидкості проходження основного циклу. Подія обробляється саме тоді, коли вона виникає, а не тоді, коли програма «дійде» до відповідної перевірки.
Фактично переривання дозволяють перейти від моделі «постійно перевіряємо» до моделі «реагуємо за запитом». Процесор може виконувати основну логіку програми, а периферія — самостійно сигналізувати про важливі зміни свого стану.
Повернемося до прикладу з батареєю. Якщо датчик короткого замикання підключений до лінії переривання, то при виникненні аварійної ситуації:
-
формується апаратний сигнал,
-
процесор миттєво переходить до ISR (функції-обробника переривання),
-
батарея вимикається без очікування завершення циклу,
-
подія фіксується у журналі або передається на ПК.
Навіть якщо основна програма в цей момент виконує обмін даними або обробляє складні розрахунки, реакція буде своєчасною.
Переривання в дії
Для прикладу візьмемо найпростішу схему: маємо кнопку, при натисканні на яку потрібно реагувати. При звичайному підході ми використовуємо полінг для перевірки стану кнопки, але зараз налаштуємо її через механізм переривань. Кнопка підключається через пін GPIO як звичайно:
На зображенні видно, що кнопка підключена з pull-down резистором і коли вона розімкнута, на пін подається низький рівень, а коли натиснута - високий.
Важливо! Для використання переривань через порти GPIO треба використовувати піни, які підтримують переривання. Для цього перевіряйте документацію MCU який використовуєте. Наприклад, ESP32 - всі порти GPIO мають підтримку переривань, в той час як в Arduino Uno, Nano, Mini - тільки 2-й і 3-й піни мають таку функцію.
Коли кнопку підключено, потрібно налаштувати її на переривання в коді програми. В Arduino це робиться функцією attachInterrupt(pin, ISR, mode), де pin - номер піну, ISR - вказівник на фунцію-обробник, mode вказує коли переривання має спрацювати (про це далі).
Приклад:
#include <Arduino.h>
#define BUTTON_PIN 3 //визначаємо 3-й пін як пін кнопки
void interrupt_handler();
void setup() {
Serial.begin(9600); //запускаємо Serial Monitor для виводу тексту в консоль
pinMode(BUTTON_PIN, INPUT); //пін кнопки в режим вводу
//налаштовуємо переривання на кнопці
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), interrupt_handler, RISING);
}
void loop() {
//виводимо текст в основному циклі програми
Serial.write("main loop\n");
//затримки між виводом тексту
delay(1000);
}
//обробник натискання кнопки
void interrupt_handler() {
//виводимо текст про натискання кнопки
Serial.write("Interrupt! Button pressed!!!\n");
}
Зверніть увагу
Ніколи не використовуйте Serial в обробниках переривань! В даному випадку це використано просто щоб показати роботу переривань і простоти коду. В реальних проєктах цього робити не можна (див. нижче)
Як видно з коду, перший параметр (пін) подано як digitalPinToInterrupt(BUTTON_PIN). Це макрос, який обробляє номер піна і просто його повертає якщо цей пін підтримує переривання. Якщо вказати пін, що не підтримує переривання - макрос поверне -1. Це свого роду захист від помилки. Другий параметр - це вказівник на функцію, яку мікроконтролер викличе коли спрацює переривання. Третій параметр вказує умову для спрацювання, в даному випадку RISING - переривання спрацює коли рівень напруги на піну зміниться з низького на високий. Також для Ардуіно можливі варіанти:
- LOW - спрацьовує постійно поки рівень напруги низький,
- CHANGE - спрацьовує при будь-якій зміні рівня,
- FALLING - спрацьовує коли рівень змінюється з високого на низький,
- RISING - з низького на високий,
- HIGH - спрацьовує постійно поки рівень напруги високий (не всі плати підтримують).
На зображеннях вище представлено зібрану на Arduino Nano схему в роботі, і вивід в Serial Monitor. Кожну секунду в консоль виводиться текст "main loop" з основного циклу програми, а при натисканні на кнопку виводиться текст "Interrupt! Button pressed!!!". Важливим є тут те, що вивід тексту при натисканні кнопки є миттєвим - навіть якщо натискання відбулось в момент виконання команди delay(1000) - затримки не буде!
Система, орієнтована на події
Повернемось до прикладу з акумулятором. З використанням переривань можна переписати псевдокод таким чином:
void main() {ініціалізація();додати_переривання(пін_датчика_КЗ, обробник_КЗ);додати_переривання(пін_датчика_температури, обробник_заміру_температури);додати_переривання(пін_шини_даних, обробник_даних_UART);while(1) {if(температура_батареї >= критична_температура) {вимкнути_батарею();}else {if(температура_батареї > дозволена_температура) {сповістити_комп'ютер("Батарея перегрівається! Необхідно знизити навантаження!");}}
if(отримано_команду) {switch(команда) {case "вимкнути":вимкнути_батарею();break;case "запуск":ввімкнути_батарею();...}
отримано_команду = false;
}...деякі інші операції}void обробник_КЗ() {вимкнути_батарею();}void обробник_заміру_температури() {температура_батареї = отримати_температуру_з_датчика();}void обробник_даних_UART() {команда = отримати_команду_комп'ютера();отримано_команду = true;}
Тепер, немає необхідності опитувати периферію на кожному кроці циклу - периферія сама сповіщає MCU про те що було виконано замір, сталося КЗ, прийшов байт на шину передачі даних і важливі події не будуть пропущені.
Але, як можна помітити, код став дещо складнішим. Використання переривань має свої переваги але також має і недоліки.
Використання переривань та практичні рекомендації
Переривання дозволяють системі реагувати на події майже миттєво, але разом із цим переводять програму з простої послідовної моделі у асинхронну. Подія може виникнути у будь-який момент, і виконання основного коду буде призупинено. Саме ця особливість і є джерелом більшості проблем.
Найпоширеніша з них — конкуренція за ресурси. Якщо основний код і обробник переривання працюють з одними й тими самими даними, можливі пошкодження змінних або некоректні стани. Особливо небезпечні неатомарні операції (наприклад, робота з багатобайтовими змінними на 8-бітних мікроконтролерах). У таких випадках необхідно або тимчасово забороняти переривання на критичних ділянках, або використовувати атомарні операції чи спеціальні механізми синхронізації.
Друга проблема — непередбачуваність виконання. Будь-який фрагмент програми може бути перерваний. Це означає, що код більше не є строго послідовним. Якщо обробник виконується занадто довго, зростає затримка інших переривань, порушується детермінізм системи, а часові характеристики стають складнішими для аналізу. Саме тому існує базове правило: обробник переривання має бути максимально коротким і виконувати лише найнеобхідніші дії.
Ще одна небезпека — переповнення стека. Під час входу в переривання процесор зберігає свій стан у стеку. Якщо переривання відбуваються часто або дозволена їх вкладеність, стек може заповнитися, що призведе до критичних збоїв. Тому необхідно контролювати глибину викликів і уникати складної логіки всередині ISR.
Окрему увагу слід приділяти пріоритетам переривань. Неправильне налаштування може призвести до ситуації, коли менш важлива подія блокує обробку більш критичної. У системах реального часу це неприпустимо, тому пріоритети мають визначатися з урахуванням вимог до часу реакції.
Практичні рекомендації можна звести до кількох простих принципів:
-
робити обробники максимально короткими;
-
не виконувати у ISR складних розрахунків або тривалих операцій;
-
не використовувати блокуючі функції (затримки, очікування передачі даних);
-
передавати основну обробку події у головний цикл через прапорці або буфери;
-
позначати спільні змінні як
volatile; -
чітко контролювати критичні секції та доступ до спільних ресурсів.
Правильний підхід виглядає так: переривання лише фіксує факт події та швидко зберігає необхідний мінімум даних, а вся складна логіка виконується вже в основному коді. Така модель дозволяє поєднати швидку реакцію з контрольованою складністю системи.
Отже, переривання — це потужний інструмент, але їх використання вимагає дисципліни. Чим простішим і передбачуванішим буде обробник, тим стабільнішою буде вся система.
