24. Устройство FreeRTOS
Ядро — это два десятка файлов, которые компилируются вместе с вашей программой. Раздел о том, какие из них нужны, почему функции названы так странно и что именно требуется сделать, чтобы всё это запустилось на плате.
Материал раздела опирается на версию 10.5.1. Расхождения между
близкими версиями невелики, но имена и наборы возможностей от версии
к версии всё же меняются, поэтому свою стоит знать: она лежит
в task.h под именем tskKERNEL_VERSION_NUMBER.
Как это называется в документации
Оригинал Перевод kernel source исходники ядра port layer слой адаптации под процессор heap implementation схема выделения памяти handle описатель, ссылка на объект ядра hook function функция-ловушка, вызываемая ядром idle hook / tick hook ловушка простоя / ловушка тика stack depth глубина стека (в словах) Hungarian notation венгерская запись имён wrapper обёртка над интерфейсом ядра
24.1 Из чего состоит ядро
Файлы делятся на три группы, и путаница между ними — источник большинства неприятностей при первой сборке.
Переносимая часть — общая для всех процессоров, от восьмиразрядных до серверных. Это и есть собственно ядро:
| Файл | Что внутри | Размер |
|---|---|---|
tasks.c |
планировщик, задачи, состояния, тик | 218 КБ |
queue.c |
очереди, семафоры, мьютексы | 123 КБ |
list.c |
связные списки, на которых держится всё остальное | 10 КБ |
timers.c |
программные таймеры | 49 КБ |
event_groups.c |
группы событий | 31 КБ |
stream_buffer.c |
потоковые буферы и буферы сообщений | 61 КБ |
croutine.c |
сопрограммы — устаревший механизм, не используется | 16 КБ |
Обязательны первые три. Остальные подключают по надобности: не нужны
программные таймеры — timers.c можно не компилировать, и полтора
килобайта прошивки останутся свободны.
Обратите внимание на скромность list.c. Десять килобайт исходника —
это двусвязные списки с сортировкой по значению, и на них построены
все очереди готовности, все списки ожидания и вся работа со временем.
Планировщик не «ищет» задачу — он берёт первую из списка нужного
приоритета, и потому время выбора не зависит от числа задач.
Слой адаптации — привязка к конкретному процессору. Всего два файла:
| Файл | Что делает |
|---|---|
port.c |
запуск планировщика, переключение контекста, обработчик тика |
portmacro.h |
типы, критические секции, разрядность |
Именно здесь живут девятнадцать инструкций PendSV из раздела 23. Для каждого
семейства процессоров адаптация своя, и лежат они в каталоге portable.
⚠️ Cortex‑M3 и Cortex‑M4 — разные адаптации, и подменять их нельзя. Каталог
ARM_CM4Fучитывает блок плавающей запятой: при переключении контекста сохраняются ещё и его регистры. Адаптация от Cortex‑M3 на F407 с включённым FPU собирается без единого замечания, но о расширенном кадре исключения не знает: в новую задачу она возвращается с кодомEXC_RETURNот прежней, ядро снимает со стека 26 слов вместо 8 или наоборот, и стек задачи съезжает на 18 слов. В прогоне в QEMU (Cortex‑M4F, FreeRTOS 10.5.1, две задачи на float) прибор упал в HardFault вскоре после первого вытеснения такой задачи, а еслиmainпользовалась FPU ещё до запуска планировщика, — сразу на старте. Если автоматическое сохранение FPU выключено (FPCCR.ASPEN = 0), падения нет, зато числа портятся без всякой закономерности: в том же прогоне неверным вышел примерно каждый двухсотый расчёт. Ни то ни другое не похоже на ошибку сборки.
Отдельная тонкость для компилятора Arm Compiler 6 (armclang, Keil MDK):
брать нужно адаптацию из каталога GCC, а не RVDS. Последняя написана
под старый armcc версии 5 и на ключевом слове __forceinline просто
не компилируется.
Схема выделения памяти — ровно один файл из пяти на выбор,
heap_1.c … heap_5.c. Им целиком посвящён раздел 25.
И ваш FreeRTOSConfig.h — единственный файл ядра, который пишете вы.
Это набор из нескольких десятков макросов: частота тика, число приоритетов,
размер кучи, какие возможности включены. Он есть в каждом проекте,
и он же — первое место, куда смотрят, когда что-то не работает.
24.2 Соглашения об именах
Имена в FreeRTOS выглядят пугающе, пока не узнаёшь правило. А правило простое: первые буквы говорят о типе.
Тип возвращаемого значения — приставка у функции
| Приставка | Что возвращает | Пример |
|---|---|---|
v |
ничего (void) |
vTaskDelay |
x |
BaseType_t или описатель |
xTaskCreate, xQueueSend |
ux |
UBaseType_t — беззнаковое |
uxTaskPriorityGet |
pv |
указатель на void |
pvPortMalloc |
pc |
указатель на символ | pcTaskGetName |
e |
значение перечисления | eTaskGetState |
Область видимости — вторая приставка
| Приставка | Где определено |
|---|---|
prv |
внутренняя функция файла, наружу не видна |
x, v без приставки |
часть открытого интерфейса |
Отсюда prvIdleTask — внутренняя функция, vTaskDelay — открытая.
Приставки у имён переменных
ul — беззнаковое длинное, us — короткое, uc — байт, p — указатель,
x — BaseType_t или структура ядра. Отсюда ulValue, pxTask, pcName.
Приставки у макросов
| Приставка | Откуда макрос |
|---|---|
config |
из вашего FreeRTOSConfig.h |
INCLUDE_ |
оттуда же, включает отдельную функцию |
port |
из слоя адаптации |
task, queue |
из соответствующего модуля |
pd, err |
значения возврата |
Правило INCLUDE_ заслуживает отдельного слова: эти макросы не настраивают
поведение, а добавляют или убирают код. Забыли INCLUDE_vTaskDelay —
и получите ошибку компоновки на функции, которая точно есть в исходниках.
Типы
Ядро не пользуется int и long напрямую: их разрядность разная
на разных процессорах, а переносимость — главное требование к ядру.
Вместо них свои имена:
| Тип | Что это | На Cortex‑M |
|---|---|---|
BaseType_t |
естественное слово процессора, знаковое | 4 байта |
UBaseType_t |
оно же без знака | 4 байта |
TickType_t |
счётчик тиков | 4 байта (или 2, см. ниже) |
StackType_t |
элемент стека | 4 байта |
TaskHandle_t |
описатель задачи | указатель |
TickType_t управляется макросом configUSE_16_BIT_TICKS. На восьми-
и шестнадцатиразрядных процессорах его ставят в единицу ради скорости,
на Cortex‑M — никогда: сэкономите два байта и получите переполнение
счётчика через 65 секунд вместо 49 суток.
С ядра 11 макрос называется configTICK_TYPE_WIDTH_IN_BITS и принимает
TICK_TYPE_WIDTH_16_BITS, TICK_TYPE_WIDTH_32_BITS или
TICK_TYPE_WIDTH_64_BITS. Старое имя ядро понимает ради совместимости:
configUSE_16_BIT_TICKS 0 даёт там те же четыре байта. А вот задать оба
сразу нельзя — сборка останавливается с «Only one of
configUSE_16_BIT_TICKS and configTICK_TYPE_WIDTH_IN_BITS must be defined».
Не задать ни одного тоже нельзя: «Missing definition».
Коды возврата
pdPASS — успех, pdFAIL — неудача, и они же pdTRUE и pdFALSE.
⚠️ Мина:
errQUEUE_EMPTYиerrQUEUE_FULL— одно и то же значение, и оно равноpdFAIL. Оба нули. Различить по коду возврата, что именно случилось — очередь была пуста или переполнена, — невозможно. Смысл определяется тем, какую функцию вызывали, а не тем, что она вернула.
24.3 Пересчёт времени
Все функции ядра принимают время в тиках, а не в миллисекундах. Переводит макрос:
vTaskDelay(pdMS_TO_TICKS(250)); /* правильно */
vTaskDelay(250); /* тоже работает — но только при 1 кГц */
Второй вариант работает случайно: при configTICK_RATE_HZ = 1000 тик
равен миллисекунде, и числа совпадают. Стоит поменять частоту тика — и все
задержки в программе разъедутся молча, без единого предупреждения.
⚠️ Макрос делит нацело и округляет вниз — вплоть до нуля. При тике 1 кГц
pdMS_TO_TICKS(5)даёт 5, но при тике 100 Гц те же пять миллисекунд превращаются в ноль тиков. АvTaskDelay(0)не ждёт вовсе: задача просто уступает процессор и тут же возвращается. Вместо паузы получается цикл, съедающий всё свободное время. Симптом — «прибор стал тормозить после того, как я понизил частоту тика».Второй край — переполнение: в 10.5.1 макрос сначала умножает на частоту тика в 32 битах, и при 1 кГц всё, что дольше 4 294 967 мс (71,6 минуты), молча обрезается (с версии 11 умножение идёт в 64 битах). Часовые паузы считают сами —
(TickType_t)(ms / 1000u * configTICK_RATE_HZ)— или спят в цикле по минуте.
Отсюда практическое правило: частота тика 1000 Гц и pdMS_TO_TICKS
везде. Первое избавляет от округлений, второе — от привязки к первому.
24.4 Что нужно, чтобы ядро запустилось
Пять шагов. Пропуск любого даёт свой характерный отказ.
Шаг 1. Отдать ядру три исключения
Ядро забирает себе SVC, PendSV и SysTick. Внутри оно называет
их обработчики по-своему, поэтому в конфигурации имена подменяются
на имена из таблицы векторов:
#define vPortSVCHandler SVC_Handler
#define xPortPendSVHandler PendSV_Handler
#define xPortSysTickHandler SysTick_Handler
⚠️ Самая частая ошибка первой сборки. Заготовка обработчиков, которую кладёт в проект конфигуратор, уже содержит пустые
SVC_Handler,PendSV_HandlerиSysTick_Handler. Вместе с ядром получается два определения одного имени, и компоновщик говорит прямо:
Error: L6200E: Symbol SVC_Handler multiply defined (by port.o and it.o). Error: L6200E: Symbol PendSV_Handler multiply defined (by port.o and it.o). Error: L6200E: Symbol SysTick_Handler multiply defined (by port.o and it.o).Лечение — удалить три заготовки из файла обработчиков, а не убирать подмену имён из конфигурации. Ошибка честная и понятная; хуже, когда подмену забыли сделать вовсе: тогда собирается всё, но задачи не переключаются, потому что вызываются пустые заготовки.
Если тик системы нужен и вашему коду (например, задержкам библиотеки HAL), его берут с другого таймера — свободных на плате достаточно.
Шаг 2. Настроить приоритеты прерываний
Требование, невыполнение которого даёт самые загадочные сбои.
Все значащие биты приоритета должны быть битами вытеснения.
У Cortex‑M приоритет делится на две части — вытеснение и подприоритет,
а место деления задаёт поле PRIGROUP. Ядру нужно, чтобы подприоритета
не осталось вовсе.
У STM32 значащих битов четыре — старшие из восьми. Значит подходит любое
PRIGROUP от 0 до 3: при них граница деления проходит по неиспользуемым
младшим битам, и все четыре значащих оказываются битами вытеснения.
Ядро именно это и проверяет — что PRIGROUP не грубее трёх.
NVIC_SetPriorityGrouping(0); /* годится на любом Cortex-M */
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); /* то же самое: PRIGROUP = 3 */
Обе строки решают задачу. Первая универсальна, вторая привычнее в проектах от конфигуратора; путаницу создаёт только то, что «четвёрка» в имени означает число битов вытеснения, а не значение поля.
Прерывания, которые обращаются к ядру, должны быть не приоритетнее порога. Порог задаётся в конфигурации:
#define configPRIO_BITS 4 /* у F1 и F4 */
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
#define configMAX_SYSCALL_INTERRUPT_PRIORITY (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))
Ядро читает только третью строку — тот же порог, сдвинутый в старшие биты
(0x50), для регистра BASEPRI; «библиотечное» 5 — для NVIC_SetPriority,
которая сдвигает сама. Одна «библиотечная» строка без сдвинутой ядру ничего
не даёт: сборка остановится на неизвестном имени. Полный набор, с приоритетом
самого ядра, — в разделе 33.1.
Смысл: прерывания приоритетом 5 и ниже (то есть численно больше) ядро
умеет на время маскировать, и вызывать из них функции с окончанием
FromISR можно. Прерывания приоритетом 0…4 не маскируются никогда —
они быстрые, но обращаться к ядру из них нельзя. Подробно об этом
в разделе 28.
⚠️ Приоритет по умолчанию — ноль, и он всегда неверный. Прерывание, которому не назначили приоритет явно, получает нулевой, то есть самый высокий. Вызов любой функции ядра из такого обработчика — ошибка, которая при включённом
configASSERTостанавливает программу сразу, а при выключенном приводит к порче внутренних структур ядра и к сбою через час работы.
Шаг 3. Включить configASSERT
Одна строка, экономящая дни:
#define configASSERT(x) if ((x) == 0) { taskDISABLE_INTERRUPTS(); for (;;); }
Ядро само проверяет себя в нескольких десятках мест: правильно ли выбран
приоритет прерывания, не вызвана ли обычная функция из обработчика,
не испорчен ли список. Без configASSERT эти проверки не делают ничего,
и ошибка проявляется позже и в другом месте.
Останов в бесконечном цикле выглядит грубо, но он идеален для отладки: подключаетесь отладчиком, смотрите стек вызовов и сразу видите, какая проверка не прошла.
Шаг 4. Задать ловушки
Две функции, которые ядро вызывает при бедах, если в конфигурации стоят
configCHECK_FOR_STACK_OVERFLOW 2 (раздел 26.7) и configUSE_MALLOC_FAILED_HOOK 1.
Включили макрос, а функцию не написали — ошибка компоновки; написали, а макрос
не включили — ловушка молчит:
void vApplicationStackOverflowHook(TaskHandle_t task, char *name)
{
(void)task; (void)name;
for (;;) { } /* имя задачи, у которой кончился стек, — в name */
}
void vApplicationMallocFailedHook(void)
{
for (;;) { } /* кончилась куча ядра */
}
Обе — про ошибки, которые иначе проявляются как случайные зависания.
Аргумент name во второй строке дороже всего: он прямо называет
виновную задачу.
Шаг 5. Создать задачи и запустить планировщик
int main(void)
{
clock_init();
gpio_init(); /* периферия — до планировщика */
if (xTaskCreate(worker, "worker", 128, NULL, 2, NULL) != pdPASS) {
for (;;) { } /* не хватило кучи */
}
vTaskStartScheduler();
for (;;) { } /* сюда попадают, если не создалась
задача простоя */
}
Разбор аргументов xTaskCreate:
| Аргумент | Смысл |
|---|---|
| функция | тело задачи, из неё нельзя выходить |
| имя | только для отладчика, на работу не влияет |
| глубина стека | в словах, а не в байтах |
| аргумент | передаётся задаче, часто NULL |
| приоритет | больше — важнее, ноль занят задачей простоя |
| описатель | сюда положат ссылку; NULL, если не нужна |
⚠️ Глубина стека задаётся в словах.
128— это 512 байт, а не 128. Ошибка в четыре раза в обе стороны одинаково неприятна: в меньшую даёт переполнение стека, в большую — молчаливый расход памяти вчетверо больше запланированного.
Проверять результат xTaskCreate обязательно. Функция возвращает
pdPASS или ошибку нехватки памяти, и второй случай на камне с 20 КБ
совершенно реален. Задача, которая «не создалась», ничем себя не проявит:
прибор просто не будет делать часть работы.
24.5 Два способа создать объект
У каждого объекта ядра — задачи, очереди, семафора — есть две формы создания.
Динамическая берёт память из кучи ядра:
xTaskCreate(worker, "worker", 128, NULL, 2, NULL);
Статическая требует, чтобы память дала программа:
static StaticTask_t worker_tcb;
static StackType_t worker_stack[128];
xTaskCreateStatic(worker, "worker", 128, NULL, 2, worker_stack, &worker_tcb);
Разница глубже, чем кажется. Статический вариант не может не сработать: память выделена компоновщиком, её наличие проверено на этапе сборки. Динамический может вернуть отказ в самый неудобный момент — и обязан проверяться.
| Динамически | Статически | |
|---|---|---|
| память | из кучи ядра | из вашей памяти |
| может не создаться | да | нет |
| виден расход | только на исполнении | в карте памяти после сборки |
| требует | heap_*.c |
ничего |
| удобство | выше | ниже |
Практика такая: всё, что живёт от запуска до выключения, создавать
статически. Динамическое создание оставляют объектам, которые
появляются и исчезают по ходу работы, — а таких в приборе обычно нет
вовсе. Тогда динамическое создание выключают — configSUPPORT_DYNAMIC_ALLOCATION 0, —
а файл кучи в проект не добавляют (25.6), и целый класс ошибок исчезает.
Если библиотека сама берёт память у ядра, куча всё же нужна.
⚠️ Разрешив статическое размещение, вы обязаны отдать ядру память под задачу простоя. Ядро запросит её функцией
vApplicationGetIdleTaskMemory, а при включённых программных таймерах — ещё иvApplicationGetTimerTaskMemory. Без них программа не скомпонуется. Ошибка выглядит как неразрешённая ссылка на функцию, которую вы никогда не вызывали, и сбивает с толку в первый раз.
24.6 Обёртка CMSIS-RTOS
Проект, созданный конфигуратором, обычно содержит не xTaskCreate,
а osThreadNew. Это CMSIS-RTOS — стандартный интерфейс, придуманный,
чтобы прикладной код не зависел от конкретного ядра.
osThreadNew(worker, NULL, &worker_attributes); /* обёртка */
xTaskCreate(worker, "worker", 128, NULL, 2, NULL); /* напрямую */
Обе строки в итоге делают одно и то же: обёртка вызывает функцию ядра.
| CMSIS-RTOS | Напрямую | |
|---|---|---|
| переносимость на другое ядро | есть | нет |
| доступ ко всем возможностям | не ко всем | ко всем |
| понятность сообщений об ошибках | хуже | лучше |
| работа конфигуратора | сам генерирует | правится вручную |
Разумный подход — знать оба и не смешивать в одном файле. Изучать стоит прямой интерфейс: обёртка описана в терминах ядра, и без понимания ядра её документация читается плохо. А в готовом проекте от конфигуратора проще оставить то, что он сгенерировал.
Заметная разница в приоритетах: у CMSIS-RTOS своя шкала со своими именами
(osPriorityNormal и подобные), и она не совпадает с числами
FreeRTOS. Смешав два способа в одной программе, вы получите задачи,
приоритеты которых не соответствуют вашим намерениям.
24.7 Грабли
Не собирается: SVC_Handler определён дважды. Заготовки обработчиков
остались в файле прерываний. Удалить три штуки.
Собирается, но задачи не переключаются. Подмена имён обработчиков не сделана: ядро вызывает свои функции, а вектора ведут на пустые заготовки.
Неразрешённая ссылка на vApplicationGetIdleTaskMemory. Включено
статическое размещение — напишите обе функции выдачи памяти.
Неразрешённая ссылка на функцию, которая есть в исходниках. Не включён
соответствующий INCLUDE_.
Программа останавливается внутри port.c. Сработал configASSERT.
Смотреть стек вызовов: чаще всего это неверный приоритет прерывания,
из которого вызвали функцию ядра.
Работает на F103, а на F407 падает в HardFault или портит числа. Взята
адаптация ARM_CM3 вместо ARM_CM4F: она не знает о расширенном кадре
исключения и не сохраняет регистры плавающей запятой при переключении.
__forceinline не компилируется. Взята адаптация RVDS вместо GCC
при сборке компилятором Arm Compiler 6.
Задержки стали втрое короче. Поменяли configTICK_RATE_HZ, а задержки
в коде записаны числами вместо pdMS_TO_TICKS.
Прибор внезапно потяжелел на 4 КБ. Включены программные таймеры, группы событий и потоковые буферы, из которых используется одно.
24.8 Итоги
- Ядро — это три обязательных файла (
tasks.c,queue.c,list.c), два файла адаптации под процессор, один файл схемы памяти и вашFreeRTOSConfig.h. - Адаптации для Cortex‑M3 и Cortex‑M4 разные; подмена не даёт ошибки
сборки, но на F407 с FPU кончается HardFault, а при выключенном
ASPEN— порчей вычислений с плавающей запятой. - Для Arm Compiler 6 берётся адаптация из каталога
GCC, а неRVDS. - Приставки в именах говорят о типе:
v— ничего не возвращает,x—BaseType_t,ux— беззнаковое,prv— внутренняя функция. errQUEUE_EMPTY,errQUEUE_FULLиpdFAIL— одно и то же значение, различить причину по коду возврата нельзя.- Время везде в тиках;
pdMS_TO_TICKSокругляет вниз и при низкой частоте тика способен превратить задержку в ноль. - Запуск требует пяти вещей: отдать ядру три исключения, настроить
группировку и приоритеты прерываний, включить
configASSERT, задать ловушки, создать задачи. - Глубина стека задаётся в словах: 128 — это 512 байт.
- Результат
xTaskCreateпроверять обязательно. - Статическое создание не может не сработать и показывает расход памяти сразу после сборки — для постоянных объектов оно предпочтительнее.
- CMSIS-RTOS — обёртка над тем же ядром со своей шкалой приоритетов; смешивать её с прямыми вызовами в одной программе не стоит.
Дальше: раздел 25 «Управление памятью» — пять схем работы с кучей, чем они отличаются и почему выбор между ними важнее, чем кажется.