Отладка многопоточных программ на базе FreeRTOS

image


Отладка многозадачных программ дело не простое, особенно если ты сталкиваешься с этим впервые. После того, как прошла радость от запуска первой задачи или первой демо программы, от бесконечно волнительного наблюдения за светодиодами, каждый из которых моргает в своей собственной задаче, наступает время, когда ты осознаешь, что довольно мало понимаешь (вообще не врубаешься) о том, что на самом деле происходит. Классика жанра: «Я выделил целых 3КБ операционной системе и запустил всего 3 задачи со стеком по 128Б, а на четвертую уже почему-то не хватает памяти» или «А сколько вообще стека я должен выделить задаче? Столько достаточно? А столько?». Многие решают данные задачи путем проб и ошибок, поэтому в этой статье я решила объединить большинство моментов, которые, в настоящее время, значительно упрощают мне жизнь и позволяют более осознанно отлаживать многопоточные программы на базе FreeRTOS.

Данная статья рассчитана, в первую очередь, на тех, кто только недавно начал осваивать FreeRTOS, однако вполне вероятно, что и хорошо знакомые с этой операционной системой читатели найдут для себя здесь что-то интересное. Кроме того, несмотря на то, что статья ориентирована на разработчиков встраиваемого ПО, прикладным программистам она тоже будет интересна, т.к. много слов будет сказано о FreeRTOS как таковой, безотносительно микроконтроллерной романтики.

В данной статье я расскажу о следующих моментах:


  1. Настройка OpenOCD для работы с FreeRTOS.
  2. Не забываем включать хуки.
  3. Статическое или динамическое выделение памяти?
  4. Сказ, о параметре configMINIMAL_STACK_SIZE.
  5. Мониторинг использования ресурсов.



Настройка OpenOCD для работы с FreeRTOS


Первое, с чем можно столкнуться при использовании FreeRTOS — это отсутствие какой-либо полезной информации в окне Debug:

image

Выглядит это максимально грустно. К счастью, OpenOCD поддерживает отладку FreeRTOS, просто его нужно правильно настроить:

  1. Добавить в проект файл FreeRTOS-openocd.c
  2. Добавить флаги линкеру (Properties > C/C++ Build > Settings > Cross ARM C++ Linker > Miscellaneous > Other linker flags):
    -Wl, --undefined=uxTopUsedPriority
  3. Добавить флаги отладчику (Run > Debugs configurations > Debugger > Config options):
    -c "$_TARGETNAME configure -rtos auto"
  4. Снять галочку Run > Debugs configurations > Startup > Set breakpoint at main.


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

image

Не забываем включать хуки


Если наша программа упала в какой-нибудь hard_fault_handler (), то с настройками из предыдущего параграфа, мы сможем понять из какой задачи мы туда попали. Однако мы ничего не узнаем о причинах этого падения.

image

Например, на картинке выше, мы видим, что ошибка возникла во время выполнения задачи YellowLedTask. Первое, что мы делаем, это в дебаге начинаем шагать строчка за строчкой по бесконечному циклу задачи, чтобы уточнить место падения. Допустим, мы узнали, что программа ломается во время выполнения функции dummy () (кстати, есть способ сразу понимать, в какой функции мы сломались, об этом можно прочитать в этой статье). Мы начинаем проверять тело функции, нет ли там ошибки или опечатки. Проходит час, глаз начинает дергаться, а мы уверены в том, что функция написана корректно так же твердо, как мы уверены в том, что стул на котором мы сидим существует. Так в чем же дело? А дело в том, что возникшая ошибка может не иметь ничего общего с вашей функцией, а проблема заключается именно в работе ОС. И тут нам на помощь приходят хуки.

Во FreeRTOS существуют следующие хуки:

/* Hook function related definitions. */
#define configUSE_IDLE_HOOK                     0
#define configUSE_TICK_HOOK                     0
#define configCHECK_FOR_STACK_OVERFLOW          2
#define configUSE_MALLOC_FAILED_HOOK            1
#define configUSE_DAEMON_TASK_STARTUP_HOOK      0


Самыми важными, в рамках отладки программы, являются configCHECK_FOR_STACK_OVERFLOW и configUSE_MALLOC_FAILED_HOOK.

Параметр configCHECK_FOR_STACK_OVERFLOW может быть включен значением 1 или 2 в зависимости от того, какой метод детектирования переполнения стека вы хотите использовать. Подробнее об этом можно почитать здесь. Если вы включили этот хук, то вам нужно будет определить функцию
void vApplicationStackOverflowHook (TaskHandle_t xTask, signed char *pcTaskName), которая будет выполняться каждый раз, когда выделенного для задачи стека будет не хватать для ее работы, а главное вы будете видеть ее в стеке вызовов конкретной задачи. Таким образом, для решения возникшей проблемы нужно будет лишь увеличить размер стека, выделенный для задачи.

vApplicationStackOverflowHook
void vApplicationStackOverflowHook(TaskHandle_t xTask, char* pcTaskName)
{
    rtos::CriticalSection::Enter();
    {
        while (true)
        {
            portNOP();
        }
    }
    rtos::CriticalSection::Exit();
}


Парамерт configUSE_MALLOC_FAILED_HOOK включается 1, как и большинство конфигурируемых параметров FreeRTOS. Если вы включили этот хук, то вам нужно будет определить функцию void vApplicationMallocFailedHook (). Эта функция будет вызвана тогда, когда свободного места в куче, выделенной для FreeRTOS, окажется недостаточно, для размещения очередной сущности. И, опять же, главное, что мы будем видеть все это в стеке вызовов. Следовательно, все что нам нужно будет сделать для решения данной проблемы — это увеличить размер кучи, выделенной для FreeRTOS.

vApplicationMallocFailedHook
void vApplicationMallocFailedHook()
{
    rtos::CriticalSection::Enter();
    {
        while (true)
        {
            portNOP();
        }
    }
    rtos::CriticalSection::Exit();
}


Теперь, если мы запустим нашу программу еще раз, то при ее падении в hard_fault_handler () мы увидем причину этого падения в окне Debug:

image

Кстати, если вы когда-либо находили интересное применение configUSE_IDLE_HOOK, configUSE_TICK_HOOK или configUSE_DAEMON_TASK_STARTUP_HOOK, то было бы очень интересно почитать об этом в комментариях)

Статическое или динамическое выделение памяти?


Итак, мы разобрались с тем, как следить за переполнением стека и кучи во FreeRTOS, а теперь пришло время поговорить о вечном — о памяти.

В этом параграфе мы рассмотрим следующие параметры FreeRTOS:

/* Memory allocation related definitions. */
#define configSUPPORT_STATIC_ALLOCATION         0
#define configSUPPORT_DYNAMIC_ALLOCATION        1
#define configTOTAL_HEAP_SIZE                   100000
#define configAPPLICATION_ALLOCATED_HEAP        0


Во FreeRTOS память для создания задач, семафоров, таймеров и других объектов RTOS может выделяться как статически (configSUPPORT_STATIC_ALLOCATION), так и динамически (configSUPPORT_DYNAMIC_ALLOCATION). Если вы включаете динамическое выделение памяти, то вам необходимо также указать размер кучи, который может использовать RTOS (configTOTAL_HEAP_SIZE). Кроме того, еслы вы хотите, чтобы куча располагалась в каком-то определенном месте, а не автоматически расположена в памяти линкером, то вам необходимо включить параметр configAPPLICATION_ALLOCATED_HEAP и определить массив uint8_t ucHeap[configTOTAL_HEAP_SIZE]. И не забывайте, что для динамического выделения памяти, в папку с файлами FreeRTOS нужно добавить файл heap_1.c, heap_2.c, heap_3.c, heap_4.c или heap_5.c в зависимости от того, какой вариант менеджера памяти вам больше подходит.

Для того, чтобы оценить, сколько памяти вы можете отдать куче FreeRTOS, после сборки проекта нужно посмотреть на размер секции .bss. Она отображает размер RAM необходимый для хранения всех статических переменных. Например, у меня контроллер с оперативной памятью на 128КБ, я отдала FreeRTOS 50КБ и после сборки проекта секция .bss занимает 62304Б. Это значит, что у меня в проекте статических переменных на 12304 байт + 50000 байт статически выделено для кучи ОС. Нужно помнить, что парочку килобайт нужно захабарить для стека main () и в итоге мы получаем, что кучу FreeRTOS можно еще увеличить на (128000 — 62304 — 2000) байта.

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

Что касается моего мнения, то на данном этапе развития я не вижу смысла в использовании статического выделения памяти, поэтому в приведенном выше конфиге статическое выделение памяти выключено. И вот почему:

  1. Зачем самостоятельно выделять буфер для стека и структуру StaticTask_t, если операционная система поддерживает целых 5 различных менеджерей памяти на любой вкус и цвет, которые сами разберутся где, что и как создать, да еще и сообщат, если у них что-то не получилось? В частности, для большинства программ под микроконтроллеры более чем полностью подходит heap_1.c
  2. Вам может понадобиться какая-нибудь сторонняя библиотека, написанная очень оптимально и емко, но использующая внутри себя malloc (), calloc () или new[](). И что же делать? Отказаться от нее в пользу менее оптимальной (это еще если выбор есть)? А можно просто использовать динамическое выделение памяти с heap_2.c или heap_4.c. Единственное, что вам нужно будет сделать — это переопределить соответствующие функции, чтобы выделение памяти происходило средствами FreeRTOS в предоставленной ей куче:
    code snippet
    void* malloc(size_t size) {
        return pvPortMalloc(size);
    }
    
    void* calloc(size_t num, size_t size) {
        return pvPortMalloc(num * size);
    }
    
    void free(void* ptr) {
        return vPortFree(ptr);
    }
    
    void* operator new(size_t sz) {
        return pvPortMalloc(sz);
    }
    
    void* operator new[](size_t sz) {
        return pvPortMalloc(sz);
    }
    
    void operator delete(void* p) {
        vPortFree(p);
    }
    
    void operator delete[](void* p) {
        vPortFree(p);
    }
    


В своих проектах я использую только динамическое выделение памяти с heap_4.c, отдавая под кучу ОС максимально возможный объем памяти, и всегда переопределяю функции malloc (), calloc (), new () и т. д. вне зависимости от того, используются они в настоящий момент или нет.

Ратуя за динамическое выделение памяти, я, разумеется, не отрицаю того, что существуют задачи, для которых идеальным решением является статическое выделение памяти (это, ктати, также можно обсудить в комментариях).

Сказ, о параметре configMINIMAL_STACK_SIZE


Значение параметра configMINIMAL_STACK_SIZE исчисляется НЕ в байтах, а в словах! Причем размер слова меняется от одного порта ОС к другому и он определен в файле portmacro.h дефайном portSTACK_TYPE. Например, в моем случае, размер слова составляет 4 байта. Таким образом, то, что параметр configMINIMAL_STACK_SIZE в моей конфигурации равен 128 означает, что минимальный размер стека для задачи равен 512 байт.

У меня все.

Мониторинг использования ресурсов


Как было бы замечательно иметь ответы на такие вопросы, как:

  • Адекватно ли я выбрал размер стека для задачи? Не слишком ли много? А может слишком мало?
  • А сколько процессорного времени требуется на исполнение моей задачи?
  • А сколько реально кучи, выделенной для ОС, используется? Программа уже на пределе или еще есть, где развернуться?


В данном параграфе я приведу пример того, как можно реализовать простенький мониторинг ресурсов, который поможет получить однозначные ответы на все выше поставленные вопросы.

Во FreeRTOS есть инструментарий, позволяющий на лету собирать статистику использования ресурсов, включающийся следующими параметрами:

#define configGENERATE_RUN_TIME_STATS           0
#define configUSE_TRACE_FACILITY                0
#define configUSE_STATS_FORMATTING_FUNCTIONS    0


О значении каждого параметра я расскажу чуть дальше, а для начала нам нужно создать задачу, назовем ее MonitorTask, бесконечный цикл которой будет собирать статистику с интервалом config: MonitorTask: SLEEP_TIME_MS и отправлять ее в терминал.

После того, как задача создана, нам нужно установить параметр configUSE_TRACE_FACILITY в 1, после чего нам станет доступна функция:

UBaseType_t uxTaskGetSystemState(TaskStatus_t* const pxTaskStatusArray, const UBaseType_t uxArraySize, uint32_t* const pulTotalRunTime)


Параметр pxTaskStatusArray должен иметь размер sizeof (TaskStatus_t) * uxTaskGetNumberOfTasks (), т.е. он должен быть достаточно большим, чтобы вместить в себя информацию обо всех существующих задачах.

Кстати, о структуре TaskStatus_t. Какую же информацию относительно каждой задачи мы можем получить? А вот такую:

TaskStatus_t
typedef struct xTASK_STATUS
{
/* The handle of the task to which the rest of the information in the
structure relates. */
TaskHandle_t xHandle;

/* A pointer to the task’s name. This value will be invalid if the task was
deleted since the structure was populated! */
const signed char *pcTaskName;

/* A number unique to the task. */
UBaseType_t xTaskNumber;

/* The state in which the task existed when the structure was populated. */
eTaskState eCurrentState;

/* The priority at which the task was running (may be inherited) when the
structure was populated. */
UBaseType_t uxCurrentPriority;

/* The priority to which the task will return if the task’s current priority
has been inherited to avoid unbounded priority inversion when obtaining a
mutex. Only valid if configUSE_MUTEXES is defined as 1 in
FreeRTOSConfig.h. */
UBaseType_t uxBasePriority;

/* The total run time allocated to the task so far, as defined by the run
time stats clock. Only valid when configGENERATE_RUN_TIME_STATS is
defined as 1 in FreeRTOSConfig.h. */
unsigned long ulRunTimeCounter;

/* Points to the lowest address of the task’s stack area. */
StackType_t *pxStackBase;

/* The minimum amount of stack space that has remained for the task since
the task was created. The closer this value is to zero the closer the task
has come to overflowing its stack. */
unsigned short usStackHighWaterMark;
} TaskStatus_t;


Итак, теперь мы готовы к тому, чтобы описать бесконечный цикл задачи MonitorTask. Он может выглядеть, например, так:

MonitorTask function
TickType_t delay = rtos::Ticks::MsToTicks(config::MonitorTask::SLEEP_TIME_MS);

while(true)
{
  UBaseType_t task_count = uxTaskGetNumberOfTasks();

  if (task_count <= config::MonitorTask::MAX_TASKS_MONITOR)
  {
    unsigned long _total_runtime;
    TaskStatus_t _buffer[config::MonitorTask::MAX_TASKS_MONITOR];

    task_count = uxTaskGetSystemState(_buffer, task_count, &_total_runtime);

    for (int task = 0; task < task_count; task++)
    {
      _logger.add_str(DEBG, "[DEBG] %20s: %c, %u, %6u, %u", 
                            _buffer[task].pcTaskName,
                            _task_state_to_char(_buffer[task].eCurrentState),
                            _buffer[task].uxCurrentPriority,
                            _buffer[task].usStackHighWaterMark,
                            _buffer[task].ulRunTimeCounter);
    }

    _logger.add_str(DEBG, "[DEBG] Current Heap Free Size: %u",
                          xPortGetFreeHeapSize());

    _logger.add_str(DEBG, "[DEBG] Minimal Heap Free Size: %u",
                          xPortGetMinimumEverFreeHeapSize());
						                     
    _logger.add_str(DEBG, "[DEBG] Total RunTime:  %u ms", _total_runtime);

    _logger.add_str(DEBG, "[DEBG] System Uptime:  %u ms\r\n",
				      xTaskGetTickCount() * portTICK_PERIOD_MS);
  }

  rtos::Thread::Delay(delay);
}


Допустим, что в моей программе помимо MonitorTask, есть еще несколько задач с вот такими параметрами, где configMINIMAL_STACK_SIZE = 128:

TasksConfig.h
static constexpr uint32_t MIN_TASK_STACK_SIZE 	= configMINIMAL_STACK_SIZE;
static constexpr uint32_t MIN_TASK_PRIORITY   	= 1;
static constexpr uint32_t MAX_TASK_PRIORITY   	= configMAX_PRIORITIES;

struct LoggerTask {
    static constexpr uint32_t STACK_SIZE        = MIN_TASK_STACK_SIZE * 2;
    static constexpr const char NAME[]          = "Logger Task";
    static constexpr uint32_t PRIORITY          = MIN_TASK_PRIORITY;
    static constexpr uint32_t SLEEP_TIME_MS     = 100;
};

struct MonitorTask {
	static constexpr uint32_t STACK_SIZE 		= MIN_TASK_STACK_SIZE * 3;
	static constexpr const char NAME[]   		= "Monitor Task";
	static constexpr uint32_t PRIORITY   		= MIN_TASK_PRIORITY;
	static constexpr uint32_t SLEEP_TIME_MS 	= 1000;
	static constexpr uint32_t MAX_TASKS_MONITOR     = 10;
};

struct GreenLedTask {
	static constexpr uint32_t STACK_SIZE 		= MIN_TASK_STACK_SIZE * 2;
	static constexpr const char NAME[]   		= "Green Led Task";
	static constexpr uint32_t PRIORITY   		= MIN_TASK_PRIORITY;
	static constexpr uint32_t SLEEP_TIME_MS         = 1000;
};

struct RedLedTask {
	static constexpr uint32_t STACK_SIZE 		= MIN_TASK_STACK_SIZE * 2;
	static constexpr const char NAME[]   		= "Red Led Task";
	static constexpr uint32_t PRIORITY   		= MIN_TASK_PRIORITY;
	static constexpr uint32_t SLEEP_TIME_MS 	= 1000;
};

struct YellowLedTask {
	static constexpr uint32_t STACK_SIZE 		= MIN_TASK_STACK_SIZE * 2;
	static constexpr const char NAME[]   		= "Yellow Led Task";
	static constexpr uint32_t PRIORITY   		= MIN_TASK_PRIORITY;
	static constexpr uint32_t SLEEP_TIME_MS 	= 1000;
};


Тогда, после запуска программы я увижу в терминале следующую информацию:

image

Ого, уже неплохо! Так давайте разберемся, что мы в этом логе видим.

  • Мы видим имена всех существующих задач. Помимо задач, описанных в файле TaskConfig.h мы также видим задачу IDLE, которая создается автоматически, когда стартует планировщик RTOS (о ее предназначении написано здесь).
  • Мы видим состояние каждой задачи, где B = Blocked, R = Ready, S = Suspended, D = Deleted.
  • Мы видим приоритет каждой задачи.
  • Мы видим минимальный размер свободного места на стеке, с момента создания задачи. И тут для нас становится очевидным, что для работы большинства задач, мы выделили слишком много стека. Например, для задачи LoggerTask был выделен стек в 256 слов, а реально она использует только 40. Таким образом, стека в 64 слова вполне достаточно для функционирования задачи. Вот вам и начало оптимизации.
  • Мы видим текущий и минимальный (с момета старта планировщика) размер свободного места в куче. В нашем простом примере, эти значения равны, но в более сложных программах эти две переменные, разумеется, отличаются. Таким образом, мы понимаем, что из 100КБ отданных FreeRTOS, она использует меньше 10КБ, следовательно в наших руках более 90КБ свободной памяти.
  • И, наконец, мы видим количество времени, прошедшее с момента старта планировщика в миллисекундах.


Применив полученные знания к файлу TasksConfig.h и понизив значение параметра configMINIMAL_STACK_SIZE со 128 до 64, мы получаем следующую картину:

image

Супер! Теперь у каждой задачи есть оптимальный запас свободного места на стеке: не слишком большой, и не слишком маленький. Кроме того, мы высвободили почти 3КБ памяти.

А теперь пришло время поговорить о том, чего мы в полученном логе пока не видим. Мы не видим того, сколько процессорного времени использует каждая задача, т.е. сколько времени, задача находилась в состоянии Running. Чтобы это узнать, нам необходимо установить параметр configGENERATE_RUN_TIME_STATS в 1 и дополнить файл FreeRTOSConfig.h следующими определениями:

#if configGENERATE_RUN_TIME_STATS == 1

void vConfigureTimerForRunTimeStats(void);
unsigned long vGetTimerForRunTimeStats(void);

#define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS()    vConfigureTimerForRunTimeStats()
#define portGET_RUN_TIME_COUNTER_VALUE()            vGetTimerForRunTimeStats()

#endif


Теперь нам нужно завести внешний таймер, отсчитающий время (желательно в микросекундах, потому что на выполнение некоторых задач может требоваться времени меньше миллисекунды, а мы хотим все-таки знать обо всем). Дополним файл MonitorTask.h объявлением двух статических функций:

static void config_timer();
static unsigned long get_counter_value();


В файле MonitorTask.cpp напишем их реализацию:

void MonitorTask::config_timer()
{
  _timer->disable_counter();
  _timer->set_counter_direction(cm3cpp::tim::Timer::CounterDirection::UP);
  _timer->set_alignment(cm3cpp::tim::Timer::Alignment::EDGE);
  _timer->set_clock_division(cm3cpp::tim::Timer::ClockDivision::TIMER_CLOCK_MUL_1);
  _timer->set_prescaler_value(hw::config::MONITOR_TIMER_PRESQ);
  _timer->set_autoreload_value(hw::config::MONITOR_AUTORELOAD);
  _timer->enable_counter();
  _timer->set_counter_value(0);
}

unsigned long MonitorTask::get_counter_value()
{
  static unsigned long _counter = 0;
	
  _counter += _timer->get_counter_value();
  _timer->set_counter_value(0);
  return (_counter);
}


А в файле main.cpp напишем реализацию функций vConfigureTimerForRunTimeStats () и vGetTimerForRunTimeStats (), которые мы объявили в FreeRTOSConfig.h:

#if configGENERATE_RUN_TIME_STATS == 1

void vConfigureTimerForRunTimeStats(void)
{
    tasks::MonitorTask::config_timer();
}

unsigned long vGetTimerForRunTimeStats(void)
{
    return (tasks::MonitorTask::get_counter_value());
}

#endif


Теперь, после запуска программы наш лог станет вот таким:

image

Сравнивая значения Total RunTime и System Uptime, мы можем заключить, что лишь треть времени наша программа занята исполнением задач, причем 98% времени тратится на IDLE, а 2% на все остальные задачи. Чем же наша программа занимается оставшиеся две трети времени? Это время тратится на работу планировщика и переключение между всеми задачами. Печально, но факт. Разумеется, есть способы оптимизировать это время, но это уже тема для следующей статьи.

Что касается параметра configUSE_STATS_FORMATTING_FUNCTIONS, то он является очень второстепенным, чаще всего он используется в различных демо программах, предоставляемых разработчиками FreeRTOS. Его суть заключается в том, что он включает две функции:

void vTaskList(char* pcWriteBuffer);
void vTaskGetRunTimeStats(char* pcWriteBuffer);


Обе эти функции НЕ являются частью FreeRTOS. Внутри себя, они вызывают ту же самую функцию uxTaskGetSystemState, которой мы пользовались выше, и складывают в pcWriteBuffer уже отформатированные данные. Сами разработчики не рекомендуют использовать эти функции (но, разумеется, и не запрещают), укзывая на то, что их задача скорее демонстрационная, а вметно них пользоваться функцией uxTaskGetSystemState напрямую, как мы и сделали.

На этом все. Как всегда надеюсь, что эта статья была полезной и информативной)

Для сборки и отладки демо проекта, описанного в статье, использовалась связка Eclipse + GNU MCU Eclipse (formerly GNU ARM Eclipse) + OpenOCD.

Блог компании Третий Пин

© Habrahabr.ru