Меню

Асинхронність у мікроконтролерах

Асинхронність у мікроконтролерах

Як спеціаліста з силової електроніки та електроприводу, асоціації з «асинхронний» ось такі:

Асинхронний двигун фірми AEG 1890-х

І несправедливість, що возить, поїть, гріє творіння М. О. Доліво-Добровольського, а поминають Н. Теслу. Причому АД другого [Тесли] неймовірно поганий. Двофазний (привіт асиметрії ліній електропередачі), концентрована обмотка на полюсах (прощай КПД), ну і низький пусковий момент. А ось творіння Михаїла Осиповича з першого включення показало КПД у районі 90 % і подвійний пусковий момент, який став де-факто стандартом з 1891 р. і по сьогодні.

Коли керуєш інвертором двигуна, час реального часу вимірюється 10–30 мкс. Це швидко, навіть для сучасних мікросхем. Архітектуру програми треба планувати без зайвих пожирачів часу ядра. Однак є вхідні сигнали, які треба обробляти повільно. Ще є інтерфейси, де виникають колізії та тайм-аути.

Наведу класичний повільний приклад — робота з GSM-модемом. Цей чорний ящик вимагає заклинань у вигляді AT-команд, потрібно почекати відповіді AT OK/ERROR, а відповідь може й не прийти (тайм-аут 100 мс), а іноді треба «підштовхнути» його ще раз, а буває, він взагалі зависає і його треба перезавантажити через живлення.

Зазвичай там, де повільні процеси або пристрої, розробники схиляються до використання RTOS. Тут усе зрозуміло. Є окремі завдання, виконувані паралельно з переключенням контексту та налаштованими пріоритетами. Недоліки: складність використання, треба пам’ятати про переключуваний контекст. Наприклад, виклик printf у різних завданнях може призвести до непередбачуваних наслідків. Не забуваємо й про збільшення розміру коду.

Існує ще один улюблений підхід програмістів мікроконтролерів. А давайте ми зробимо приблизно так:

send_at("AT");
delay(100);
switch(get_at_result()) …

Суть криється в функції delay, яка є просто циклом із командами nop або через таймер, гальмує програму в цьому місці на потрібний час. Підхід як би нормальний, за винятком того, що використовуваної периферії завжди багато в реальних проєктах, і опитування 1-Wire може тривати до кількох секунд, а на запити майстра Modbus треба відповідати з мінімальною затримкою. У цьому випадку розробники починають використовувати переривання й розподіляти код по них. Після певної кількості переривань можна отримати ситуацію, що одне буде заважати іншому в непередбачуваних комбінаціях, які відлагодити буває дуже важко. За законом Мерфі це починається в продукті. З власного досвіду: сміттєвозка, дощ, сморід, олія від гідравліки, бруд, з дебагером і ноутбуком на колінах шукати голку в «сміттєвому кузові» вантажівки. А в людей нерви й плани, куди приїхати треба. У замовників — скільки поставок зробити. Від керівництва питання: «А чому ваша машина не надсилає координати GPS?». Також з’являються петлі в умовній printf, у якої є внутрішні змінні, і її викликом в основному циклі програми та в якомусь перериванні. Може хаотично впасти й на столі лабораторії, буває комбінацію не повторити.

Еволюційно я прийшов до асинхронності. Загальний принцип такий. Контекст не переключається: коли потрібен delay або подія, перевіряємо лічильник або наявність події й стрибаємо на іншу ділянку коду. Основний недолік даного підходу — це контроль часу життя змінних. Мова C не забезпечує це автоматично, як, наприклад, Rust, тож теж можна вистрелити собі в ногу. Переваги: не треба думати про розподіл пам’яті, як в RTOS, і відносно легко контролювати наявність вільних ресурсів на відміну від підходу «давайте тут загальмуємо за допомогою циклу з NOP». Архітектура програми в цьому випадку будується на мінімальному використанні переривань, усю периферію максимально на DMA, і далі три шляхи.

Шлях перший: трохи ассемблера. З часів (десь 2007–2009 рр.), коли використовував AVR8-AVR32:

#define la_set(lab) {asm volatile(#lab ": "::); }
#if (AVR == AVR32)
#define la_save(var, lab) { asm volatile("lda.w %[VAL], "#lab " \n\t" \
" ":[VAL]"=r" (var): "[VAL]" (var)); }

#define la_save_set(var, lab) { asm volatile("lda.w %[VAL], "#lab " \n\t" \
" ":[VAL]"=r" (var): "[VAL]" (var)); \
asm volatile(#lab ": "::); }
#define la_jmp(var) { asm volatile("mov pc, %[VAL]":: [VAL]"r"(var)); } // lddpc
typedef struct la_str_jmp {
U32 *timer;
U32 jmp;
} la_str_jmp;

Використання в програмі: la_c_jmp(i_jmp); // перед асинхронною ділянкою. Від даного місця будемо стрибати на мітку.

switch(step) {
case : x
*i_jmp->timer = 0; // скинемо таймер затримки
la_save(i_jmp->jmp, l_td_t3); // запам’ятаємо, куди перестрибнути
la_set(l_td_t3); // ставимо, куди перестрибнути
if (*i_jmp->timer < 500) {
// почекаємо і щось тут робимо
}
break;
}

Таймер можна збільшувати в sys_tick. Усе було цікаво, але зоопарк проєктів і мікроконтролерів ріс, копирсатися в ассемблері кожного, а також забезпечувати сумісність із компілятором змусило шукати шляхи на C.

Шлях другий: вказівники на функції C (приблизно з 2010–2012 рр.).

U8 (*_ij)(void); // вказівник на функцію
U8 (*_ij_next)(void); // вказівник на функцію

U32 _ij_delay = 0;
U8 f_hard_delay(void) {
if (gsm_j_timer < _ij_delay) {
return 0;
}
gsm_j_timer = 0;
return 1;
}
U8 f_turnon(void) {
// ….
gsm_j_timer = 0; _ij_delay = 1500;
_ij_next = f_turnon1;
_ij = f_hard_delay;
return 0x0;
}
U8 f_turnon1(void) {
// ….
_ij_next = f_turnon2;
gsm_j_timer = 0; _ij_delay = 3000;
_ij = f_hard_delay;
return 0x0;
}
while(1) {

// перемикач
if (_ij != NULL) {
if( (_ij)() ) {
_ij = _ij_next;
_ij_next = NULL;
}
}
}

Усе працює, але… Недолік даного підходу — жахлива читабельність коду. Тому довелося шукати далі.

Шлях третій: async.h. Посилання на рішення https://github.com/naasking/async.h. Це красива комбінація вказівників на функцію та переключення на звичайному switch().

Виглядає і працює елегантно:

static struct async pt_me;

// Затримка в мс
async me_delay(struct async *pt, uint32_t ticks)
{
async_begin(pt);
static uint32_t timer_delay;
timer_delay = timer + ticks;
while(!LoopCmp(timer, timer_delay)) {
async_yield;
}
/* And we loop. */
async_end;
}

async read_device(struct async *pt, uint8_t *dev)
{
async_begin(pt);
static uint8_t ret;
async_init(&pt_me);
await(wait_signal(&pt_me, ret)); // почекати якийсь зовнішній сигнал
// щось зробити
async_init(&pt_me);
await(me_delay(&pt_me, 100)); // почекаємо …
*dev = ret;
async_end;
}

static struct async pt_main;
static struct async pt_process;
async app_main( struct async *pt) {
async_begin(pt);
while (1) {
if (!signal1) { // сигнал надійшов
async_yield;
}
async_init(&pt_process);
await( read_device(&pt_process, &dev));
async_init(&pt_process);
uint8_t result;
await( send_at_command(&pt_process, "AT", &result));
if (result) {
// To do …
}
}
async_end;
}

void main(void) {
async_init(&pt_main);
while (1) {
app_main(&pt_main);
}
}

Недоліки:

  1. Час життя змінних компілятор може пропустити.
  2. Змінних як правило вимагається більше.
  3. Не працює всередині switch() {case …}.

Переваги:

  1. Красивий читабельний код.
  2. Повністю сумісне з C.
  3. Не треба занурюватися в роботу компілятора.
  4. Точно контролюємо, куди йде ресурс і скільки ще залишилося часу/тактів на обчислення.

За власним досвідом, async.h — це хороше рішення, коли треба скрестити швидкі процеси типу керування електромагнітними процесами перетворювача і повільні типу інтерфейсів.

Коментарі