Перейти к содержимому

Системное время и планировщик задач … ↩

Системное время и планировщик задач mod. Четверг, 15 октября 2026 22:55 . См. 8

BeeFC · Урок S6 из 33 · Уровень «Старт»

Как отсчитывать время в микросекундах без ошибок при переполнении счётчика, как устроен кооперативный планировщик задач с приоритетами и что происходит, когда задача выполняется дольше, чем ей отведено.

Планировщик — основа BeeFC. Все следующие модули подключаются к нему как задачи, а его поведение проверяется тестами на модельном времени, без платы.

Содержание

1. Цели урока

Продолжительность урока — три часа: один час теории и два часа практики. После урока слушатель:

  • получает время в микросекундах от аппаратного таймера и правильно вычисляет интервалы при переполнении;
  • объясняет разницу между кооперативной и вытесняющей многозадачностью;
  • реализует планировщик задач с приоритетами, статистикой опозданий и замером загрузки процессора;
  • проверяет планировщик тестами на модельном времени;
  • объясняет, почему длительная задача с низким приоритетом нарушает частоту задачи с высоким приоритетом.
↑ К оглавлению

2. Термины и сокращения

ТерминОпределение
системное времячисло микросекунд от запуска прошивки
кооперативная многозадачностьзадачи выполняются по очереди, и каждая сама возвращает управление планировщику
вытесняющая многозадачностьоперационная система может прервать задачу в любой момент и передать управление другой
операционная система реального времени (ОСРВ)операционная система, гарантирующая время реакции на события
период задачизаданный интервал между запусками задачи
джиттерразброс фактических интервалов между запусками относительно периода
загрузка процессорадоля времени, которую процессор тратит на выполнение задач
модельное времявремя, которое в тесте задаёт сам тест, а не аппаратный таймер
↑ К оглавлению

3. Системное время

В STM32F405 есть три источника для отсчёта времени. Для BeeFC выбран таймер TIM5: он 32-битный и отсчитывает микросекунды без участия процессора.

ИсточникРазрядностьПереполнение при отсчёте 1 мксОсобенность
системный таймер SysTick24 бита16,8 стребует обработчика прерывания для продолжения счёта
счётчик тактов DWT32 бита25,6 с при отсчёте в тактахсчитает такты ядра, а не микросекунды
таймер TIM532 битаоколо 71,6 минутыработает без прерываний

Файл hal/stm32f4/time_stm32.c

void time_init(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_TIM5EN;
    (void)RCC->APB1ENR;
    /* Шина APB1 работает на 42 МГц с делителем 4, поэтому таймеры
     * этой шины тактируются удвоенной частотой — 84 МГц. */
    TIM5->PSC = 84u - 1u;            /* 84 МГц / 84 = 1 МГц: один отсчёт — 1 мкс */
    TIM5->ARR = 0xFFFFFFFFu;         /* полный 32-битный диапазон: около 71,6 минуты */
    TIM5->EGR = TIM_EGR_UG;          /* загрузить PSC немедленно */
    TIM5->CR1 = TIM_CR1_CEN;
}

uint32_t micros(void)
{
    return TIM5->CNT;
}
Plain text

Интервалы при переполнении

Через 71,6 минуты счётчик переходит через ноль. Интервал между двумя показаниями вычисляется вычитанием беззнаковых 32-битных чисел, и результат остаётся верным при переходе через ноль, потому что вычитание выполняется по модулю \( 2^{32} \).

\( (5 - 4294967291) \bmod 2^{32} = 10 \)

Поэтому в BeeFC время всегда сравнивается через разность: now - start >= period. Сравнение моментов времени напрямую, например now >= deadline, при переходе через ноль даёт ошибку.

Переход через ноль в тестах: Ошибку перехода через ноль трудно поймать на плате: она проявляется один раз за 71,6 минуты. Поэтому в тесте планировщика начальное значение модельного времени выбрано примерно за секунду до переполнения, и переход через ноль проверяется при каждом запуске тестов.
↑ К оглавлению

4. Многозадачность без операционной системы

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

ПрошивкаПодход
BeeFC, Betaflight, INAVсобственный кооперативный планировщик без операционной системы
ArduPilotоперационная система ChibiOS с потоками, а основной цикл выполняет кооперативный планировщик AP_Scheduler
PX4операционная система NuttX и очереди работ, каждая очередь работает в своём потоке

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

↑ К оглавлению

5. Устройство планировщика BeeFC

Задача описывается структурой с указателем на функцию, периодом и приоритетом. Статистику заполняет сам планировщик.

Файл core/sched.h

typedef uint32_t (*sched_clock_t)(void);   /* источник времени, мкс */
typedef void     (*task_fn_t)(uint32_t now_us);

typedef struct {
    const char *name;
    task_fn_t   fn;
    uint32_t    period_us;
    uint8_t     priority;        /* больше — важнее */
    /* статистика, заполняется планировщиком */
    uint32_t    last_start_us;
    uint32_t    runs;
    uint32_t    late;            /* запусков с опозданием больше периода */
    uint32_t    max_exec_us;
} task_t;

typedef struct {
    task_t       *tasks;
    uint8_t       count;
    sched_clock_t clock;
    uint32_t      busy_us;       /* время в задачах за окно измерения */
    uint32_t      window_start_us;
    uint8_t       load_percent;  /* загрузка процессора за прошлое окно */
} sched_t;

void sched_init(sched_t *s, task_t *tasks, uint8_t count, sched_clock_t clock);
bool sched_run_once(sched_t *s);   /* true — какая-то задача выполнена */
Plain text

За один вызов sched_run_once планировщик выбирает среди задач, у которых наступил срок, задачу с наибольшим приоритетом и выполняет её. Время следующего запуска отсчитывается от предыдущего срока, а не от фактического момента запуска. Благодаря этому небольшие задержки не накапливаются, и средняя частота задачи остаётся точной.

Файл core/sched.c

bool sched_run_once(sched_t *s)
{
    uint32_t now = s->clock();
    task_t *best = 0;
    uint32_t best_overdue = 0;

    for (uint8_t i = 0; i != s->count; i++) {
        task_t *t = &s->tasks[i];
        uint32_t age = now - t->last_start_us;      /* верно и при переполнении */
        if (age < t->period_us) continue;           /* задаче ещё рано */
        uint32_t overdue = age - t->period_us;
        if (!best || t->priority > best->priority ||
            (t->priority == best->priority && overdue > best_overdue)) {
            best = t;
            best_overdue = overdue;
        }
    }

    if (best) {
        if (best_overdue >= best->period_us) best->late++;
        /* Шаг по сетке периода сохраняет среднюю частоту задачи;
         * при большом опоздании сетка сбрасывается на текущее время. */
        best->last_start_us = (best_overdue >= best->period_us)
                                  ? now : best->last_start_us + best->period_us;
        best->fn(now);
        uint32_t exec = s->clock() - now;
        if (exec > best->max_exec_us) best->max_exec_us = exec;
        best->runs++;
        s->busy_us += exec;
    }

    uint32_t window = s->clock() - s->window_start_us;
    if (window >= SCHED_WINDOW_US) {
        s->load_percent = (uint8_t)((100ull * s->busy_us) / window);
        s->busy_us = 0;
        s->window_start_us += window;
    }
    return best != 0;
}
Plain text

Тест на модельном времени

Тест подставляет вместо аппаратного таймера функцию, которая возвращает значение переменной fake_now. Задачи в тесте увеличивают эту переменную на время своего выполнения. Так за доли секунды на компьютере моделируется секунда работы прошивки, включая переход счётчика через ноль.

Файл tests/unit/test_level1.c

void test_1khz_task_runs_1000_times_per_second(void)
{
    task_t tasks[] = {
        { .name = "pid",  .fn = task_fast, .period_us = 1000,  .priority = 10 },
        { .name = "slow", .fn = task_slow, .period_us = 10000, .priority = 1 },
    };
    sched_t s;
    calls_fast = calls_slow = 0;
    slow_cost_us = 200;                     /* медленная задача длится 200 мкс */
    fake_now = 0xFFF00000u;                 /* проверка перехода через 0 */
    sched_init(&s, tasks, 2, fake_clock);

    uint32_t start = fake_now;
    while (fake_now - start <= 1000000u)            /* одна секунда модельного времени */
        if (!sched_run_once(&s)) fake_now += 10;    /* холостой ход 10 мкс */
    TEST_ASSERT_UINT32_WITHIN(1, 1000, calls_fast);
    TEST_ASSERT_UINT32_WITHIN(1, 100, calls_slow);
    TEST_ASSERT_UINT32_WITHIN(1, 7, s.load_percent);    /* 5 % и 2 % */
    TEST_ASSERT_EQUAL_UINT32(0, tasks[0].late);
}
Plain text

Задача ПИД выполняется 1000 раз в секунду по 50 мкс, то есть занимает 5 % времени. Медленная задача выполняется 100 раз в секунду по 200 мкс и занимает 2 %. Общая загрузка процессора составляет 7 %.

↑ К оглавлению

6. Опоздание задач

Что произойдёт, если медленная задача будет выполняться 2,5 мс при периоде задачи ПИД 1 мс? Кооперативный планировщик не может прервать задачу, поэтому задача ПИД будет ждать окончания медленной задачи, какой бы высокий приоритет у неё ни был.

Файл tests/unit/test_level1.c

void test_long_task_makes_fast_task_late(void)
{
    task_t tasks[] = {
        { .name = "pid",  .fn = task_fast, .period_us = 1000,  .priority = 10 },
        { .name = "slow", .fn = task_slow, .period_us = 10000, .priority = 1 },
    };
    sched_t s;
    calls_fast = calls_slow = 0;
    slow_cost_us = 2500;                    /* задача длиннее периода ПИД */
    fake_now = 0;
    sched_init(&s, tasks, 2, fake_clock);
    while (fake_now <= 1000000u)
        if (!sched_run_once(&s)) fake_now += 10;
    TEST_ASSERT_TRUE(tasks[0].late != 0);   /* приоритет не спасает: задачи не вытесняются */
    TEST_ASSERT_TRUE(calls_fast != 1000);
}
Plain text
Приоритет не защищает от длительной задачи: В кооперативном планировщике приоритет определяет только порядок выбора из готовых задач. Если задача с низким приоритетом уже выполняется, задача с высоким приоритетом ждёт её окончания. Для полётного контроллера это означает пропуск циклов управления.
Задача ПИД и медленная задача в кооперативном планировщике: если медленная задача выполняется 2,5 мс, задача ПИД пропускает два срока.
Задача ПИД и медленная задача в кооперативном планировщике: если медленная задача выполняется 2,5 мс, задача ПИД пропускает два срока.

Существует четыре способа устранить опоздания.

  1. Разделить длительную задачу на короткие шаги и выполнять по одному шагу за запуск.
  2. Перенести ожидание на аппаратную часть: прямой доступ к памяти и прерывания (урок S7).
  3. Запускать задачу, только если она успеет завершиться до следующего запуска задачи с высоким приоритетом.
  4. Перенести цикл управления в обработчик прерывания, а планировщику оставить остальные задачи.
↑ К оглавлению

7. Практика

Задание S6.1. Планировщик

Продолжительность — 40 минут, выполняется на компьютере.

Реализовать функции sched_init и sched_run_once. Планировщик должен правильно работать при переходе системного времени через ноль.

Критерий автоматической проверки: тест test_1khz_task_runs_1000_times_per_second пройден: задача ПИД вызвана 1000 ± 1 раз за секунду модельного времени, загрузка равна 7 ± 1 %. Тест test_long_task_makes_fast_task_late пройден.

Задание S6.2. Задачи на плате

Продолжительность — 40 минут.

Создать таблицу из трёх задач: светодиод с частотой 2 Гц, заготовка консоли с частотой 100 Гц и заготовка регулятора с частотой 1 кГц, которая переключает отладочный вывод. Логическим анализатором измерить период и джиттер задачи 1 кГц, отладчиком прочитать значение load_percent. Записать результаты в отчёт docs/s6_report.md.

Критерий автоматической проверки: в модели Renode период переключения светодиода равен 250 ± 1 мс.

Задание S6.3. Бюджет времени

Продолжительность — 40 минут, выполняется на компьютере.

Добавить в структуру task_t поле max_delay_us — наибольшую задержку запуска относительно срока. Доработать планировщик третьим способом: задача с низким приоритетом запускается, только если её наибольшее время выполнения max_exec_us меньше времени до ближайшего срока задачи с более высоким приоритетом.

Критерий автоматической проверки: скрытый тест с задачей ПИД 1 кГц длительностью 50 мкс и задачей 100 Гц длительностью 600 мкс; наибольшая задержка задачи ПИД не превышает 100 мкс, медленная задача выполняется не менее 90 раз в секунду.

↑ К оглавлению

8. Как это сделано в больших прошивках

  • В Betaflight таблица задач в файле tasks.c задаётся макросом DEFINE_TASK с именем, функцией, периодом и приоритетом. Задачи GYRO, FILTER и PID имеют приоритет TASK_PRIORITY_REALTIME, задача последовательных портов выполняется с частотой 100 Гц.
  • Планировщик Betaflight в файле scheduler.c подстраивает свой такт под фактическую частоту прерываний гироскопа. Для каждой задачи он оценивает время выполнения в поле anticipatedExecutionTime и запускает задачу, только если она успевает до следующего цикла гироскопа. Это третий способ из списка выше.
  • Если задача всё же опоздала настолько, что пропущен целый цикл гироскопа, Betaflight сдвигает расписание на целое число циклов. Комментарий в коде называет самую частую причину: обмен с конфигуратором по USB, длительность которого заранее неизвестна.
  • В ArduPilot каждая строка таблицы задач содержит, кроме частоты, наибольшее допустимое время выполнения. Метод AP_Scheduler::run запускает задачу, только если оставшегося времени цикла хватает, и считает задачи, пропустившие два и более периода.
  • В PX4 задачи распределены по очередям работ в файле WorkQueueManager.cpp, а время берётся из таймера высокого разрешения, интерфейс которого объявлен в drv_hrt.h.
↑ К оглавлению

9. Проверьте себя

  1. Почему для системного времени выбран таймер TIM5, а не SysTick?
  2. Почему интервал вычисляется как now - start, а не сравнением now >= deadline?
  3. Почему следующий срок задачи отсчитывается от предыдущего срока, а не от момента фактического запуска?
  4. Почему задача с высоким приоритетом опаздывает, если задача с низким приоритетом выполняется дольше её периода?
  5. Как Betaflight избегает запуска задачи, которая не успеет до следующего цикла гироскопа?
↑ К оглавлению

10. Чек-лист самопроверки

Результат: Все пункты выполнены. Можно переходить к уроку S7.
↑ К оглавлению

11. Источники

  1. Справочное руководство RM0090, раздел 18, таймеры общего назначения TIM2–TIM5.
  2. Планировщик Betaflight.
  3. Планировщик ArduPilot.
  4. Руководство разработчика PX4: архитектура.
↑ К оглавлению

12. Что дальше

  • Следующий урок: S7. Последовательный порт, прямой доступ к памяти и консоль.
  • Полный перечень уроков приведён в статье «Курс BeeFC: содержание уровня „Старт“».

Проверено: 13.10.2026

↑ К оглавлению
  • мод. : Четверг, 15 октября 2026