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

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 КБ

Ядро FreeRTOS: переносимая часть с размерами файлов, слой адаптации, одна схема кучи из пяти и FreeRTOSConfig.h

Обязательны первые три. Остальные подключают по надобности: не нужны программные таймеры — 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 везде. Первое избавляет от округлений, второе — от привязки к первому.

Трасса задач: vTaskDelay(pdMS_TO_TICKS(5)) при тике 1000 Гц и 100 Гц


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 Итоги

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

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