Системное время и планировщик задач … ↩
mod. Суббота, 17 октября 2026 22:14
. См. 92
BeeFC · Урок S6 из 33 · Уровень «Старт»
Как отсчитывать время в микросекундах без ошибок при переполнении счётчика, как устроен кооперативный планировщик задач с приоритетами и что происходит, когда задача выполняется дольше, чем ей отведено.
Планировщик — основа BeeFC. Все следующие модули подключаются к нему как задачи, а его поведение проверяется тестами на модельном времени, без платы.
Содержание
1. Цели урока
Продолжительность урока — три часа: один час теории и два часа практики. После урока слушатель:
- получает время в микросекундах от аппаратного таймера и правильно вычисляет интервалы при переполнении;
- объясняет разницу между кооперативной и вытесняющей многозадачностью;
- реализует планировщик задач с приоритетами, статистикой опозданий и замером загрузки процессора;
- проверяет планировщик тестами на модельном времени;
- объясняет, почему длительная задача с низким приоритетом нарушает частоту задачи с высоким приоритетом.
2. Термины и сокращения
| Термин | Определение |
|---|---|
| системное время | число микросекунд от запуска прошивки |
| кооперативная многозадачность | задачи выполняются по очереди, и каждая сама возвращает управление планировщику |
| вытесняющая многозадачность | операционная система может прервать задачу в любой момент и передать управление другой |
| операционная система реального времени (ОСРВ) | операционная система, гарантирующая время реакции на события |
| период задачи | заданный интервал между запусками задачи |
| джиттер | разброс фактических интервалов между запусками относительно периода |
| загрузка процессора | доля времени, которую процессор тратит на выполнение задач |
| модельное время | время, которое в тесте задаёт сам тест, а не аппаратный таймер |
3. Системное время
В STM32F405 есть три источника для отсчёта времени. Для BeeFC выбран таймер TIM5: он 32-битный и отсчитывает микросекунды без участия процессора.
| Источник | Разрядность | Переполнение при отсчёте 1 мкс | Особенность |
|---|---|---|---|
| системный таймер SysTick | 24 бита | 16,8 с | требует обработчика прерывания для продолжения счёта |
| счётчик тактов DWT | 32 бита | 25,6 с при отсчёте в тактах | считает такты ядра, а не микросекунды |
| таймер TIM5 | 32 бита | около 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;
}
Интервалы при переполнении
Через 71,6 минуты счётчик переходит через ноль. Интервал между двумя показаниями вычисляется вычитанием беззнаковых 32-битных чисел, и результат остаётся верным при переходе через ноль, потому что вычитание выполняется по модулю \( 2^{32} \).
\( (5 - 4294967291) \bmod 2^{32} = 10 \)
Поэтому в BeeFC время всегда сравнивается через разность: now - start >= period. Сравнение моментов времени напрямую, например now >= deadline, при переходе через ноль даёт ошибку.
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 — какая-то задача выполнена */
За один вызов 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;
}
Тест на модельном времени
Тест подставляет вместо аппаратного таймера функцию, которая возвращает значение переменной 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);
}
Задача ПИД выполняется 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);
}

Существует четыре способа устранить опоздания.
- Разделить длительную задачу на короткие шаги и выполнять по одному шагу за запуск.
- Перенести ожидание на аппаратную часть: прямой доступ к памяти и прерывания (урок S7).
- Запускать задачу, только если она успеет завершиться до следующего запуска задачи с высоким приоритетом.
- Перенести цикл управления в обработчик прерывания, а планировщику оставить остальные задачи.
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 | 2026.6.2 |
src/main/scheduler/scheduler.c, src/main/fc/tasks.c, src/test/unit/scheduler_unittest.cc |
| INAV | 9.1.0 |
src/main/scheduler/scheduler.c, src/main/fc/fc_tasks.c |
| ArduPilot | Copter-4.7.2 |
libraries/AP_Scheduler/AP_Scheduler.cpp, ArduCopter/Copter.cpp |
| PX4 | v1.17.0 |
platforms/common/px4_work_queue/WorkQueueManager.cpp, src/drivers/drv_hrt.h |
- В 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. Проверьте себя
- Почему для системного времени выбран таймер TIM5, а не SysTick?
- Почему интервал вычисляется как
now - start, а не сравнениемnow >= deadline? - Почему следующий срок задачи отсчитывается от предыдущего срока, а не от момента фактического запуска?
- Почему задача с высоким приоритетом опаздывает, если задача с низким приоритетом выполняется дольше её периода?
- Как Betaflight избегает запуска задачи, которая не успеет до следующего цикла гироскопа?
10. Чек-лист самопроверки
11. Источники
- Справочное руководство RM0090, раздел 18, таймеры общего назначения TIM2–TIM5.
- Планировщик Betaflight.
- Планировщик ArduPilot.
- Руководство разработчика PX4: архитектура.
12. Что дальше
- Следующий урок: S7. Последовательный порт, прямой доступ к памяти и консоль.
- Полный перечень уроков приведён в статье «Курс BeeFC: содержание уровня „Старт“».
Проверено: 13.10.2026
↑ К оглавлению- мод. : Суббота, 17 октября 2026