Embedded HandbookПриложения
Все разделы › IV. FreeRTOS
STM32F103, STM32F407 · уровень средний · 21 мин чтения
Открыть в интерактивном справочнике

23а. Прибор без ОС: автоматы, очередь событий, неблокирующие драйверы

Раздел 23 закончился правилом «суперцикл, пока он читается» — и честным списком его пределов. Прежде чем брать ядро, стоит увидеть, как далеко эти пределы отодвигаются без него. Приём известен десятилетия: события складываются в очередь, логика записывается автоматами, ожидание превращается в программный таймер, а между событиями процессор спит. Получается та же архитектура, что у операционной системы, — но из трёх страниц собственного кода, устройство которого видно насквозь.

Всё в этом разделе собрано двумя компиляторами и прогнано в QEMU; очередь дополнительно проверена на компьютере двумя потоками под ThreadSanitizer (тем же способом, что в разделе 139).

Как это называется в документации

Оригинал Перевод
finite state machine (FSM) конечный автомат
state / event / transition состояние, событие, переход
transition table таблица переходов
action действие, привязанное к переходу
event queue очередь событий
ring buffer / circular buffer кольцевой буфер
single producer, single consumer один писатель — один читатель
lock-free без блокировок и запрета прерываний
non-blocking неблокирующий: вызов возвращается сразу
busy-wait / polling занятое ожидание, опрос
event loop / dispatcher цикл событий, диспетчер
software timer программный таймер
run to completion шаг автомата выполняется до конца
data race гонка данных

23а.1 Автомат: сначала таблица переходов, потом код

Пример на весь раздел — кнопка. Требования звучат безобидно: подавить дребезг, отличить короткое нажатие от длинного, о длинном сообщить сразу по истечении времени, не дожидаясь отпускания. Написанное на if и флагах, это превращается в кашу уже к третьему требованию — потому что флаги перечисляют не состояния, а обрывки истории.

Автомат наводит порядок одним ходом: сначала выписываются все состояния и все события, потом — таблица «состояние × событие → новое состояние и действие». Код пишется в последнюю очередь, механически по таблице.

Состояний у кнопки четыре, событий три. События здесь — не уровни на выводе, а фронты (нажали, отпустили) и срабатывание таймера: время в событийной программе — такое же событие, как всё остальное.

нажатие отпускание таймер
ОТПУЩЕНА → ДРЕБЕЗГ · взвести 20 мс — —
ДРЕБЕЗГ — → ОТПУЩЕНА · стоп таймера → НАЖАТА · взвести 780 мс
НАЖАТА — → ОТПУЩЕНА · стоп, «короткое» → ДЛИННОЕ · «длинное»
ДЛИННОЕ — → ОТПУЩЕНА —

Прочерк означает «остаться на месте и ничего не делать» — и это полезная часть таблицы: она явно говорит, что повторное «нажатие» в состоянии НАЖАТА не значит ничего. В коде на флагах такие случаи обычно просто не рассмотрены.

Автомат кнопки: четыре состояния, шесть переходов, действия на стрелках

Обратите внимание: ни в одном состоянии автомат не ждёт. «Ждём 20 мс» — это не цикл с задержкой, а взведённый при входе таймер; его срабатывание придёт событием. Действие принадлежит переходу, а не состоянию: «короткое нажатие» объявляется на стрелке НАЖАТА → ОТПУЩЕНА, и только на ней.

На C заголовок автомата — три перечисления и две константы:

enum state { ST_UP, ST_DEBOUNCE, ST_DOWN, ST_LONG, N_STATES };
enum event { EV_PRESS, EV_RELEASE, EV_TIMEOUT, N_EVENTS };
enum out   { OUT_SHORT = 1, OUT_LONG };

#define T_DEBOUNCE 20u                  /* мс: дребезг закончился */
#define T_LONG    780u                  /* мс: дальше — длинное нажатие */

Дальше таблицу можно записать кодом тремя способами — и они заметно различаются по размеру, скорости и удобству сопровождения.


23а.2 Три записи на C: switch, таблица, указатель на функцию

Запись первая — вложенный switch. Самая привычная: снаружи — по состоянию, внутри — по событию.

static uint8_t st = ST_UP;

void fsm_step(uint8_t ev)
{
    switch (st) {
    case ST_UP:
        if (ev == EV_PRESS)   { timer_start(T_DEBOUNCE); st = ST_DEBOUNCE; }
        break;
    case ST_DEBOUNCE:
        if (ev == EV_RELEASE) { timer_stop(); st = ST_UP; }   /* дребезг */
        if (ev == EV_TIMEOUT) { timer_start(T_LONG); st = ST_DOWN; }
        break;
    case ST_DOWN:
        if (ev == EV_RELEASE) { timer_stop(); report(OUT_SHORT); st = ST_UP; }
        if (ev == EV_TIMEOUT) { report(OUT_LONG); st = ST_LONG; }
        break;
    case ST_LONG:
        if (ev == EV_RELEASE) { st = ST_UP; }
        break;
    }
}

Запись вторая — таблица переходов в постоянной памяти. Код диспетчера один на любой автомат; растут только данные. Каждая клетка — новое состояние плюс действие:

static void a_none(void)   { }
static void a_debounce(void) { timer_start(T_DEBOUNCE); }
static void a_cancel(void) { timer_stop(); }                  /* дребезг */
static void a_hold(void)   { timer_start(T_LONG); }
static void a_short(void)  { timer_stop(); report(OUT_SHORT); }
static void a_long(void)   { report(OUT_LONG); }

struct cell {
    uint8_t next;                       /* новое состояние */
    void (*action)(void);               /* что сделать на переходе */
};

static const struct cell tab[N_STATES][N_EVENTS] = {
    [ST_UP] = {
        [EV_PRESS]   = { ST_DEBOUNCE, a_debounce },
        [EV_RELEASE] = { ST_UP,       a_none },
        [EV_TIMEOUT] = { ST_UP,       a_none },
    },
    [ST_DEBOUNCE] = {
        [EV_PRESS]   = { ST_DEBOUNCE, a_none },
        [EV_RELEASE] = { ST_UP,       a_cancel },
        [EV_TIMEOUT] = { ST_DOWN,     a_hold },
    },
    [ST_DOWN] = {
        [EV_PRESS]   = { ST_DOWN,     a_none },
        [EV_RELEASE] = { ST_UP,       a_short },
        [EV_TIMEOUT] = { ST_LONG,     a_long },
    },
    [ST_LONG] = {
        [EV_PRESS]   = { ST_LONG,     a_none },
        [EV_RELEASE] = { ST_UP,       a_none },
        [EV_TIMEOUT] = { ST_LONG,     a_none },
    },
};

void fsm_step(uint8_t ev)
{
    const struct cell *c = &tab[st][ev];
    c->action();                        /* действие принадлежит переходу */
    st = c->next;
}

Это буквальный перенос таблицы из 23а.1 — вплоть до того, что заполненность всех клеток проверяется глазами, а прочерки стали a_none.

Запись третья — состояние как указатель на функцию. Диспетчера нет вовсе: текущее состояние — это адрес функции, переход — присваивание.

typedef void (*state_fn)(uint8_t ev);

static state_fn st = s_up;

static void s_down(uint8_t ev)
{
    if (ev == EV_RELEASE) { timer_stop(); report(OUT_SHORT); st = s_up; }
    if (ev == EV_TIMEOUT) { report(OUT_LONG); st = s_long; }
}

void fsm_step(uint8_t ev)
{
    st(ev);
}

Все три собраны для Cortex-M3 с оптимизацией по размеру двумя компиляторами; размеры взяты из map-файлов компоновщиков. Скорость измерена в QEMU пошаговым счётом команд на одном и том же сценарии из девяти событий (дребезг, короткое нажатие, длинное):

switch таблица указатель
код, gcc −Os 100 байт 82 байта 160 байт
код, armclang −Os 102 байта 88 байт 182 байта
константы во флеш-памяти 0 96 байт 0
состояние в ОЗУ 1 байт 1 байт 4 байта
команд на 9 событий 166 156 141
команд на событие 15…25 13…24, из них семь раз по 17 9…21

Выводы из таблицы весомее самой таблицы.

Про размер. У switch и у указателя код растёт с каждым переходом, у таблицы код закончен (82 байта — навсегда), растут данные: по 8 байт на клетку. Восемь, а не пять: байт состояния, три байта выравнивания, четыре байта указателя — про эту арифметику см. Грабли.

Про скорость. Указатель на функцию быстрее всех — исчезла развилка по состоянию. Таблица интереснее: семь событий из девяти обработаны ровно за 17 команд — путь через диспетчер один и тот же, какая бы клетка ни выпала. Для программы, где важна предсказуемость времени отклика, это довод не слабее среднего значения.

Про сопровождение. В switch вся логика читается сверху вниз — для трёх-четырёх состояний он лучший. Таблица заставляет перечислить все клетки и потому не даёт забыть случай; на десятках состояний она вне конкуренции. Указатель хорош там, где состояния сильно различаются по поведению, но помните: на вопрос отладчика «в каком я состоянии» он отвечает адресом функции.

⚠️ QEMU считает команды, а не такты. На живом F103 добавятся такты ожидания флеш-памяти (раздел 6), поэтому «17 команд» — это оценка порядка величины: единицы микросекунд на 72 МГц. Для сравнения вариантов между собой счёт команд честен: сценарий один и тот же.


23а.3 Кольцевая очередь: из прерывания в цикл без запрета прерываний

Автомату нужны события, а рождаются они в прерываниях: фронт на выводе, тик таймера, байт по I²C. Обработчик должен быть коротким (раздел 7), поэтому внутри него события не обрабатывают, а складывают — и разбирают потом, в главном цикле. Между прерыванием и циклом нужна очередь.

Классическое решение — кольцевой буфер с двумя индексами, у которого один писатель и один читатель: пишет только прерывание, читает только цикл. При таком разделении очередь корректна вообще без запрета прерываний — каждый индекс меняет ровно одна сторона:

#define Q_SIZE 32u                      /* степень двойки: маска вместо деления */
#define Q_MASK (Q_SIZE - 1u)

struct ev {
    uint8_t  code;                      /* какое событие */
    uint8_t  arg;                       /* от кого */
    uint16_t stamp;                     /* когда, мс */
};

static volatile struct ev q_buf[Q_SIZE];
static volatile uint32_t q_head;        /* пишет ТОЛЬКО прерывание */
static volatile uint32_t q_tail;        /* пишет ТОЛЬКО главный цикл */
static volatile uint32_t q_dropped;     /* сколько событий потеряно на переполнении */

/* Из прерывания. Один слот всегда пуст: head == tail означает «пусто»,
 * поэтому догнать хвост головой нельзя. */
static int q_push(uint8_t code, uint8_t arg, uint16_t stamp)
{
    uint32_t h = q_head;
    uint32_t next = (h + 1u) & Q_MASK;
    if (next == q_tail) {               /* полна: потерю считаем, а не молчим */
        q_dropped++;
        return 0;
    }
    q_buf[h].code = code;
    q_buf[h].arg = arg;
    q_buf[h].stamp = stamp;
    q_head = next;                      /* индекс — последним: слот уже заполнен */
    return 1;
}

/* Из главного цикла. */
static int q_pop(struct ev *e)
{
    uint32_t t = q_tail;
    if (t == q_head)                    /* пусто */
        return 0;
    e->code = q_buf[t].code;
    e->arg = q_buf[t].arg;
    e->stamp = q_buf[t].stamp;
    q_tail = (t + 1u) & Q_MASK;         /* освободить слот — тоже последним */
    return 1;
}

На чём держится корректность — три опоры, и каждая обязательна:

  1. Порядок записей. Слот заполняется до продвижения q_head; слот вычитывается до продвижения q_tail. Пока индекс не сдвинут, вторая сторона слота не видит.
  2. volatile на всём, включая буфер. Он запрещает компилятору и кешировать индексы в регистрах, и переставлять запись слота с записью индекса: обращения к volatile-объектам выполняются в порядке текста программы (раздел 124). Индекс без volatile — см. Грабли: цикл замирает навсегда.
  3. Атомарность индекса. Выровненная запись 32-битного слова на Cortex-M — одна команда, «половину индекса» прочитать нельзя.

Заполнение и опустошение различаются по одному свободному слоту: head == tail — пусто, поэтому полезных мест в очереди на 32 слота — 31. И обязательная деталь взрослого прибора: переполнение считается. Молчаливая потеря событий — худшая из невоспроизводимых ошибок; счётчик q_dropped стоит одной команды, а на отладке первым отвечает на главный вопрос: теряем или нет.

Прогон в QEMU (Cortex-M3, производитель — прерывание SysTick, потребитель — главный цикл). Две тысячи тиков прерывание кладёт по событию, каждый десятый тик — два подряд; затем читатель нарочно останавливается на сто тиков, а прерывание продолжает класть:

A pushed=2200 popped=2200 maxfill=2 order_err=0
B fit=31 dropped=69 maxfill=31
C first=2200 last=2230 order_err=0
TOTAL pushed=2300 popped=2231 dropped=69

Пока читатель жив, 2200 событий пришли строго по порядку, и даже на сдвоенных тиках очередь не заполнялась глубже двух. Когда читатель встал, в очередь поместилось ровно 31 событие, остальные 69 честно сосчитаны в q_dropped, а уцелевшие номера 2200…2230 пришли подряд — арифметика сходится до единицы: 2231 + 69 = 2300.

Прогон на компьютере. На двух настоящих потоках роль volatile играют атомики C11 (раздел 140): запись индекса — с порядком release, чтение чужого — acquire:

static uint32_t buf[Q_SIZE];
static _Atomic uint32_t head, tail;

static int push(uint32_t v)
{
    uint32_t h = atomic_load_explicit(&head, memory_order_relaxed);
    uint32_t next = (h + 1u) & Q_MASK;
    if (next == atomic_load_explicit(&tail, memory_order_acquire))
        return 0;                       /* полна */
    buf[h] = v;
    atomic_store_explicit(&head, next, memory_order_release);
    return 1;
}

Два миллиона номеров прошли через 32 слота без единого нарушения порядка, и ThreadSanitizer промолчал: items=2000000 order_err=0. А в версии с обычными, неатомарными индексами он показал ровно то, что обязан: WARNING: ThreadSanitizer: data race с адресами всех трёх чтений в pop.

⚠️ «Один писатель» — это одно прерывание, а не «все прерывания». Если в очередь пишут два обработчика с разными приоритетами, один может вытеснить другой посреди q_push — и два события лягут в один слот. Либо все источники проходят через одно прерывание (в примерах раздела — тик, который опрашивает кнопку и таймеры), либо q_push выполняется при запрещённых прерываниях, либо у каждого писателя своя очередь.


23а.4 Программные таймеры на одном тике

Автомату из 23а.1 нужны «20 мс» и «780 мс», датчику из 23а.5 — «30 мс», индикации — «раз в секунду». Аппаратный таймер на каждое ожидание — расточительность; хватает одного тика SysTick (раздел 12) и массива сроков:

struct sw_timer {
    uint32_t deadline;                  /* срок в миллисекундах g_ms */
    uint8_t armed;
    uint8_t code;                       /* какое событие положить в очередь */
};

static volatile struct sw_timer timers[N_TIMERS];

void timer_arm(uint8_t i, uint32_t ms, uint8_t code)
{
    timers[i].armed = 0;                /* пока поля меняются, таймер выключен */
    timers[i].deadline = g_ms + ms;
    timers[i].code = code;
    timers[i].armed = 1;                /* включить — последней записью */
}

static void timers_poll(void)           /* из прерывания тика */
{
    for (unsigned i = 0; i < N_TIMERS; i++) {
        if (timers[i].armed && (int32_t)(g_ms - timers[i].deadline) >= 0) {
            timers[i].armed = 0;        /* одноразовый: перевзведёт автомат */
            q_push(timers[i].code, (uint8_t)i, (uint16_t)g_ms);
        }
    }
}

В этих двадцати строках — три решения, и каждое стоит назвать по имени.

Срок, а не остаток. Хранится момент срабатывания, сравнение — через разность со знаком: (int32_t)(g_ms - deadline) >= 0 переживает переполнение счётчика миллисекунд (та же арифметика, что в 23.7, мина — из раздела 3).

Порядок записей при взводе. timer_arm вызывается из главного цикла (это действие автомата), а timers_poll — из прерывания. Пока меняются deadline и code, таймер выключен; флаг armed устанавливается последней записью, и прерывание никогда не увидит таймер с новым флагом и старым сроком.

Таймер не вызывает, а сообщает. Срабатывание — это q_push события EV_TIMEOUT из прерывания тика. Писатель у очереди остаётся один, а автомат получает время тем же путём, что и всё остальное.

Вся конструкция целиком — на схеме:

Событийный каркас прибора без ОС: прерывания кладут события в кольцевую очередь, главный цикл раздаёт их автоматам, таймеры превращают время в события, при пустой очереди процессор спит в WFI

Главный цикл вырождается в диспетчер:

    for (;;) {
        while (q_pop(&e))
            fsm_step(e.code);
        if (g_ms > 1400u)
            break;
        idle();
    }

(условие останова — стендовое: на приборе цикл бесконечен). Прогон всего каркаса в QEMU — прерывание «нажимает» кнопку по сценарию из 23а.2 — даёт в точности ожидаемое и ни событием больше:

OUT short t=100
OUT long t=1000
loops=1401 ms=1401 maxfill=1 dropped=0 outs=2

(от прогона к прогону бывает и loops=1402: первый виток цикла иногда успевает пройти до первого тика — это не лишнее пробуждение.)

Короткое нажатие объявлено в миллисекунду отпускания, длинное — ровно через 20 + 780 мс после нажатия, дребезг не породил ничего. Очередь за весь прогон не заполнялась глубже одного события.


23а.5 🔧 Неблокирующий драйвер: датчик по I²C вместо ожидания 30 мс

Классическая сцена: датчик по запросу начинает преобразование и лишь через 30 мс готов отдать результат. Драйвер «в лоб» повторяет документацию дословно:

    i2c_write(SENS_ADDR, &cmd, 1);      /* команда «начать» */
    delay_ms(30);                       /* прибор мёртв 30 мс */
    i2c_read(SENS_ADDR, raw, 2);        /* забрать результат */

Тридцать миллисекунд — это первый предел суперцикла из 23.1 в чистом виде: кнопки не опрашиваются, индикация стоит, второй такой датчик доводит мёртвое время до 60 мс. В событийном каркасе то же самое — автомат из трёх состояний, где ожидание превратилось во взведённый таймер, а завершение обмена по шине — в событие от прерывания I²C (раздел 17):

enum s_state { S_IDLE, S_MEASURING, S_READING };

void sensor_step(uint8_t ev)
{
    switch (s_st) {
    case S_IDLE:
        if (ev == SEV_POLL) {           /* пора мерить */
            i2c_start_write(SENS_ADDR, &cmd_measure, 1);
            timer_arm(TIMER_SENSOR, 30, SEV_TIMEOUT);
            s_st = S_MEASURING;
        }
        break;
    case S_MEASURING:
        if (ev == SEV_TIMEOUT) {        /* 30 мс прошли — забрать результат */
            i2c_start_read(SENS_ADDR, raw, 2);
            s_st = S_READING;
        }
        break;
    case S_READING:
        if (ev == SEV_I2C_DONE) {       /* прочитано: обработать и спать */
            sensor_value = (uint16_t)(((uint16_t)raw[0] << 8) | raw[1]);
            s_st = S_IDLE;
        }
        break;
    }
}

Рецепт переделки любого блокирующего драйвера одинаков:

  1. Каждый delay_ms(N) → состояние плюс timer_arm(N).
  2. Каждый опрос флага в цикле → состояние плюс событие из прерывания.
  3. Каждый вызов, который «делает всё сразу», → ..._start(), который только запускает и возвращается.

Процессор между шагами свободен: пока датчик преобразует, автомат кнопки обрабатывает нажатия, а при пустой очереди прибор спит. Ждать осталось негде — и это свойство архитектуры, а не аккуратности конкретного драйвера.


23а.6 Сон между событиями: WFI без потерянного пробуждения

Если события рождаются только в прерываниях, между событиями процессору нечего делать — и цикл может спать. Наивное if (пусто) __WFI(); содержит гонку «потерянного пробуждения», разобранную в 84.6: событие, пришедшее между проверкой и сном, будить уже некого. Лекарство то же, что и там, — проверять и засыпать при запрещённых прерываниях:

static void idle(void)
{
    __asm volatile("cpsid i");
    if (q_tail == q_head)               /* очередь пуста — можно спать */
        __asm volatile("wfi");          /* ожидающее прерывание всё равно разбудит */
    __asm volatile("cpsie i");
}

WFI просыпается от ожидающего прерывания даже при PRIMASK = 1; обработчик выполнится после cpsie i, положит событие — и очередной виток цикла его заберёт. Компилятор собирает это без единой лишней команды — проверка пустоты зажата между cpsid и wfi:

 8000352:   cpsid   i
 8000354:   ldr r2, [r5, #0]
 8000356:   ldr r3, [r7, #0]
 8000358:   cmp r2, r3
 800035a:   bne.n   800035e <main+0xce>
 800035c:   wfi
 800035e:   cpsie   i

Работу пары «очередь + WFI» видно в прогоне из 23а.4: loops=1401 при ms=1401 — главный цикл просыпался ровно один раз на тик, ни одного холостого витка и ни одного потерянного события за 1401 мс. Без idle() тот же цикл крутился бы миллионы раз, сжигая ток на опросе пустой очереди; сколько тока экономит сон и какие режимы глубже — раздел 10.


23а.7 Когда всё же ОС: честное сравнение с 23.6

Стенд из 23а.4 — очередь, четыре таймера, автомат кнопки таблицей и сон — собрался в 1216 байт кода вместе со стендовой печатью; каркас держит в ОЗУ 176 байт (128 — очередь, 32 — таймеры, 12 — индексы и счётчик потерь, 4 — время). Прибор из 23.4 на FreeRTOS занимал 6836 байт кода и ≈ 2,4 КБ ОЗУ плюс куча. Разница — порядок: на F103C8, где ядро с кучей из 23.4 забирали больше половины из 20 КБ, каркас оставляет память практически нетронутой.

При этом главные пределы суперцикла из 23.1 событийный каркас действительно снимает: длинных ожиданий больше нет (23а.5), реакция не зависит от самой медленной ветки, счётчиков времени в цикле нет — время стало событиями. Что он не снимает:

Предел Суперцикл Событийный каркас ОСРВ
блокирующее ожидание съедает всё устранено устранено
длинное вычисление тормозит всё тормозит всё вытесняется
приоритеты нет только порядок разбора есть
код дела последовательность разрезан на состояния последовательность
память минимум +очередь и таймеры +стек каждой задаче

Строка про длинное вычисление — ключевая. Шаг автомата выполняется до конца: если пересчёт фильтра занимает 40 мс, все события ждут эти 40 мс, и никакая очередь не поможет — вытеснять некому. Строка «код дела» — цена читаемости: дело, которое в задаче ОСРВ было бы десятью строками подряд, автомат режет на состояния, и связность теряется (тот самый четвёртый предел из 23.1, он никуда не делся — лишь отодвинулся).

Итого правило раздела 23.6 уточняется так: событийный каркас — пока каждый шаг короткий, а дел меньше десятка. Появились длинные вычисления, которые нельзя порезать, нужны настоящие приоритеты, или автоматы начали обмениваться событиями так густо, что таблицы перестали читаться, — пора в раздел 24. Тем более что переучиваться не придётся: очередь с ожиданием здесь называется xQueueReceive (раздел 27 — кнопка этого раздела повторена там в 27.7 уже на ядре), таймеры — службой таймеров ядра. Механика уже знакома, меняются имена.


23а.8 Грабли

Индекс очереди без volatile — и цикл замирает навсегда. Компилятор выносит чтение неизменяемой (по его мнению) переменной из цикла: на стенде версия без атомиков, собранная с −O1, повисла — читатель вечно видел пустую очередь (прибит по тайм-ауту). Та же программа с −O0 работала, но ThreadSanitizer показал гонку. «Работает в отладочной сборке, виснет в релизной» — это оно (раздел 124).

Таблица переходов наполовину из воздуха. Клетка {uint8_t, указатель} занимает 8 байт: три байта — выравнивание (раздел 126). 96 байт таблицы кнопки — терпимо; на большом автомате замените указатель номером действия из enum — клетка сожмётся до 2 байт.

Два прерывания пишут в одну очередь. Более приоритетное вытесняет q_push посреди работы — два события в одном слоте. Один писатель, либо запрет прерываний вокруг q_push (23а.3).

Слот пишется после индекса. Уберите volatile с буфера — и компилятор вправе сдвинуть заполнение слота за запись q_head; читатель получит мусор. Порядок гарантирован только между volatile-обращениями (раздел 124).

Таймер взводится в два приёма — и стреляет старым сроком. Если armed не сбрасывается на время правки полей, прерывание может застать новый флаг со старым deadline. Флаг — последней записью (23а.4).

«Подождать 30 мс» внутри действия автомата. Действие выполняется в диспетчере: delay_ms в нём останавливает весь прибор — ожидание оформляется состоянием и таймером (23а.5).

Проверка пустоты — до cpsid. Потерянное пробуждение: событие пришло между проверкой и WFI, прибор спит до следующего прерывания (84.6, стенд 23а.6).


23а.9 Итоги

Дальше: раздел 24 «Устройство FreeRTOS» — из каких файлов состоит ядро, как называются его типы и функции и что нужно, чтобы оно запустилось на плате.

Открыть в интерактивном справочнике