
Пролог
В этом тексте я написал про то как наблюдать за расходованием стековой памяти прямо во время исполнения прошивки на микроконтроллере. Почему это надо? Дело в том, что существующий стандарт автомобильного программирования ISO-26262 требует выполнять анализ заполнения стековой памяти.

Попробуем разобраться как это можно провернуть в микроконтроллерах.
Терминология
RAM память — оперативная память в которой хранятся глобальные переменные.
Стек — Часть RAM памяти. В стеке хранятся локальные переменные, адрес возврата, значения регистров процессора. Обычно стек растет от большего адреса к меньшему адресу. При вызове прерываний в стек сохраняется регистровый файл процессора.
Стековый кадр — часть RAM памяти на стеке, которая образуется при вызове Си‑функции. В стековом кадре хранятся аргументы функции, локальные переменные данной функции и адрес возврата.
Компоновщик (Linker) — консольная утилита (ld), которая из множества объектных файлов склеивает один монолитный *.elf файл с финальной программой.
Реализация технологии раскраски стека
В микроконтроллерных прошивках вся RAM память делится на область глобальных переменных, стек и кучу. Надо отметить, что стек это динамическая часть памяти. При этом по умолчанию не существует никаких ограничений на бесконтрольное разрастание стека. В связи с этим, задача слежения за переполнением стека целиком и полностью ложится на плечи программиста. Следить за переполнением стека можно по-разному.
№ | Способ наблюдения за стеком | сложность |
1 | Ограничить стек снизу интервалом MPU | ** |
2 | Раскрасить стек и периодически вычислять степень максимального заполнения | * |
3 | Активировать prolog функции компилятора и делать проверку переполнения стека в прологе | *** |
4 | Смотреть за адресами локальных переменных в прерываниях по аппаратному таймеру | ** |
В этом тексте поговорим о раскраске стека.
Фаза 1 Раскраска стека
Как можно заметить, в процедуре Reset_Handler сразу после инициализации регистра указателя на верхушку стека происходит заполнение стековой RAM памяти константой 0xDEADBEEF. Это делает процедура LoopFillZero_STACK
ldr r0, =0xE000ED08 ldr r1, =__vector_table str r1, [r0] /* Initialize the stack pointer */ ldr r0,=__Core0_StackTop mov sp,r0 ; sp=r13 /* Paint Stack */ ldr r2, =__Core0_StackLimit ldr r4, =__Core0_StackTop ldr r3, =0xDEADBEEF; b LoopFillZero_STACK FillZero_STACK: str r3, [r2] adds r2, r2, #4 LoopFillZero_STACK: cmp r2, r4 /* Compare (immediate) subtracts an immediate value from a register value. */ bcc FillZero_STACK /*branch if carry clear */
Фаза 0: Определить указатели начала и конца стека
В скрипте компоновщика надо определить переменные __Core0_StackTop и __Core0_StackLimit. Они содержат адрес начала и условного конца стековой памяти.
.stack_dummy : { . = ALIGN(8); __Core0_StackLimit = .; . += STACK_SIZE; . = ALIGN(8); __Core0_StackTop = .; } > DTCM_STACK
Фаза 2: Определить направление роста стековой памяти
Как же понять в каком направлении у нас растет стековая память? Самое простое это написать функцию, которая сама и ответит на этот вопрос.
static bool stack_dir(int32_t* main_local_addr) { bool res = false; int32_t fun_local = 0; if(((void*)main_local_addr) < ((void*)&fun_local)) { LOG_INFO(SYS, "Stack grows from small addr to big addr -> "); } else { LOG_INFO(SYS, "Stack grows from big addr to small addr <- "); /*hangOn here*/ } return res; } bool explore_stack_dir(void) { bool res = false; int32_t main_local = 0; res = stack_dir(&main_local); return res; }
У меня на микроконтроллере эта функция сообщает, что при увеличении стека вызова функций адреса локальных переменных уменьшаются. Значит стек растет вниз.

Фаза 3: Составить конфиг ядер
Скорее всего у вас будет какой-нибудь многоядерный микроконтроллер. Поэтому и стеков тоже будет много. В связи с этим надо как-то передать в Си-код указатели на начало и конец стека для каждого отдельного процессорного ядра. Сделать это можно так. Создать массив конфигурационных структур и проинициализировать элементы указателями на границы стека.
#include "core_config.h" #include "data_utils.h" extern uint8_t __Core0_StackTop; extern uint8_t __Core0_StackLimit; #ifdef HAS_MULTICORE extern uint8_t __Core1_StackTop; extern uint8_t __Core1_StackLimit; extern uint8_t __Core2_StackTop; extern uint8_t __Core2_StackLimit; #endif const CoreConfig_t CoreConfig[] = { { .num = 0, .stack_top = (uint32_t)&__Core0_StackTop, .stack_limit = (uint32_t)&__Core0_StackLimit, .valid = true, .name = "CORE0", }, #ifdef HAS_MULTICORE { .num = 1, .stack_top = (uint32_t)&__Core1_StackTop, .stack_limit = (uint32_t)&__Core1_StackLimit, .valid = true, .name = "CORE1", }, { .num = 2, .stack_top = (uint32_t)&__Core2_StackTop, .stack_limit = (uint32_t)&__Core2_StackLimit, .valid = true, .name = "CORE2", }, #endif }; CoreHandle_t CoreInstance[] = { { .num = 0, .valid = true, }, #ifdef HAS_MULTICORE { .num = 1, .valid = true, }, { .num = 2, .valid = true, }, #endif }; uint32_t core_get_cnt(void) { uint8_t cnt1 = 0; uint8_t cnt2 = 0; cnt1 = ARRAY_SIZE(CoreConfig); cnt2 = ARRAY_SIZE(CoreInstance); if(cnt2 == cnt1) { } return cnt1; }
Как можно заметить, в run-time значения RAM памяти оказались похожими на правду.

Фаза 4: Анализ содержимого стека
В процессоре ARM Cortex-M стек растет от большего адреса к меньшему. То есть просто растет вниз. У меня сейчас для стека заложено 16kByte SRAM памяти. Вот так.

Поэтому начало стека далеко внизу.

Поэтому считать израсходованный стек можно определив соотношение не констант к общему размеру стековой памяти.

Сам стек мы будет измерять при помощи функции core_stack_used. Задача сводится к подсчету количества констант в массиве U32. Стек лучше рассматривать именно как массив U32, так как у нас 32битный процессор. Таким образом вычисления будут производится быстрее.
#define STACK_PATTERN 0xDEADBEEF FloatFixPoint_t core_stack_used(const uint32_t top_stack_val, const uint32_t stack_size) { FloatFixPoint_t Precent = {0}; LOG_DEBUG(CORE, "StackTop:0x%08X,Size:%u Byte", top_stack_val, stack_size); uint32_t stack_size_dword = stack_size/4; if(stack_size_dword) { uint32_t max_cont_patt_dw = 0; uint32_t* start_p = top_stack_val - stack_size; bool res = array_u32_max_cont(start_p, stack_size_dword, STACK_PATTERN, &max_cont_patt_dw); if(res) { uint32_t busy_dw = 0; busy_dw = stack_size_dword - max_cont_patt_dw; res = fraction_to_fixed_point_float(100 * busy_dw, stack_size_dword, 5, &Precent); } } return Precent; } static FloatFixPoint_t core_stack_used_get(CoreHandle_t* const Node) { FloatFixPoint_t stack_used = {0}; if(Node) { if(Node->stack_limit < Node->stack_top) { uint32_t stack_size = Node->stack_top - Node->stack_limit; Node->stack_used = core_stack_used(Node->stack_top, stack_size); LOG_DEBUG(CORE, "StackUsed:%s %%", FloatFixToStr(&Node->stack_used)); stack_used = Node->stack_used; } } return stack_used; } const char* FloatFixToStr(const FloatFixPoint_t* const Node) { static char lText[40] = {0}; if(Node) { snprintf(lText, sizeof(lText), "%d.%u", Node->integer, Node->fractional); } return lText; }
Суть измерения в том, чтобы просто посчитать функцией array_u32_max_cont сколько осталось непрерывных значений константы pattern в интервале стека и поделить на известный размер стековой RAM памяти (например 16kByte).
/* Calculate the maximum number of contiguous elements starting from the beginning of the array. */ bool array_u32_max_cont(const uint32_t* const dword, const uint32_t size_dw, const uint32_t pattern, uint32_t* const max_cont_patt) { bool res = false; if(dword){ if(0 < size_dw) { uint32_t cur_cont_pat_dw = 0; uint32_t max_cont_pat_dw = 0; uint32_t i = 0; for(i = 0; i < size_dw; i++) { if(pattern == dword[i]) { cur_cont_pat_dw++; } else { cur_cont_pat_dw = 0; break; } max_cont_pat_dw = MAX(max_cont_pat_dw, cur_cont_pat_dw); } if(max_cont_patt) { *max_cont_patt = max_cont_pat_dw; res = true; } } } return res; }
При этом процент использования лучше вычислять в целочисленной арифметике. Это позволит мигрировать этот код на микроконтроллеры без PFU. Например ARM Cortex-M0. Так и использовать код в прошивках с отключенным FPU.
typedef struct { int32_t integer; uint32_t fractional; } FloatFixPoint_t; /* Example:xxxx(238500,16384,5,...) -> 14.55688 */ bool fraction_to_fixed_point_float(int32_t numerator, int32_t denominator, uint32_t after_dot_digit, FloatFixPoint_t* const Node) { bool res = false; if(Node) { if(denominator) { int32_t sign = math_sign_s32(numerator / denominator); int32_t numerator_abs = math_abs_s32(numerator); int32_t denominator_abs = math_abs_s32(denominator); uint64_t scale = ipow(10, after_dot_digit); int32_t integer_abs = numerator_abs / denominator_abs; uint64_t val1 = (scale * numerator_abs) / denominator_abs; uint64_t val2 = integer_abs * scale; uint64_t fractional_u64 = val1 - val2; Node->fractional = (uint32_t)fractional_u64; Node->integer = sign * integer_abs; res = true; } } return res; }
Далее надо в супер цикле периодически вызывать функцию core_stack_monitor_proc
bool core_diag_stack_usage(const uint8_t num, const FloatFixPoint_t* stack_used) { bool res = false; if(80 < stack_used->integer) { LOG_ERROR(CORE, "CORE%u,StackUsed80%%+:%s", num, FloatFixToStr(stack_used)); res = false; } else { if(70 < stack_used->integer) { LOG_WARNING(CORE, "CORE%u,StackUsed,70%%+:%s", num, FloatFixToStr(stack_used)); res = false; } else { res = true; } } return res; } static bool core_stack_monitor_proc_one(uint8_t num) { bool res = false; CoreHandle_t* Node = CoreGetNode(num); if(Node) { Node->stack_used = core_stack_used_get(Node); res = core_diag_stack_usage(num, &Node->stack_used); Node->spin++; } return res; }
Вот пожалуй, и все изменения, которые следует внести в прошивку, чтобы заработала диагностика заполнения стека. Я поставил проверку стека раз в 3 секунды.

Теперь можно прогнать все модульные тесты и потом проверить сколько стековой памяти потребовали эти модульные тесты. В случае появления предупреждений, увеличить стек на 10%.. 20 % и пересобрать проект.
Отладка
Что ж, вот мы написали в коде периодическую проверку степени заполнения стековой памяти. Настало время как-н загрузить процессор. Это можно провернуть при помощи вызова специально добавленной рекурсивной функции. Для этого у меня в прошивки есть UART-CLI команда try stack , вызывающая функцию try_recursion.
static bool call_recursion(uint32_t stack_top_addr, uint32_t cur_depth, uint32_t max_depth, uint32_t* stack_size) { bool res = false; if(cur_depth < max_depth) { res = call_recursion(stack_top_addr, cur_depth + 1, max_depth, stack_size); } else if(cur_depth == max_depth) { uint32_t cur_stack_use = stack_top_addr - ((uint32_t)&res); *stack_size = cur_stack_use; res = true; } else { res = false; } return res; } bool try_recursion(const uint32_t stack_top_addr, const uint32_t max_depth, uint32_t* const stack_size) { bool res = false; res = call_recursion(stack_top_addr, 0, max_depth, stack_size); LOG_INFO(CORE, "Depth:%u,StackSize:%u,byte", max_depth, *stack_size); return res; }
В одной сборке уже при вызове рекурсии с аргументом 50 вложений, со стороны главной консоли управления прошивкой сыплются первые предупреждения, что стек перевалил за 70%.

А при 60 вызовах функциями вложенных функций появляются сообщения, что превышено 80% выделенного компоновщиком стека.

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

Итог
Удалось реализовать в ARM Cortex-M прошивке автоматическую проверку процентного соотношения заполнения стековой RAM памяти. Это даёт ценнейшую метрику при разработке программного обеспечения. Достаточно теперь прогнать модульные тесты, посмотреть на заполнения стека и выделить большее количество в новом релизе прошивки по мере надобности.
Сокращения
Акроним | Расшифровка |
RAM | Random Access Memory |
SRAM | Static RAM |
MPU | Memory protection unit |
FPU | Floating-point unit |
UART | universal asynchronous receiver / transmitter |
CLI | Command Line Interface |
COM | communications port |
Ссылки
Ссылка | URL |
@Amomum | |
Стековый кадр |
Вопросы
--Как сделать так, чтобы при выходе из стекового кадра происходило обнуление освободившегося интервала RAM памяти? Или заполнение константой.
--В стеке какого потока работают прерывания?
--Как отловить факт переполнения стека?
--Что происходит когда мы вызываем функцию?
--Что хранится в стековой памяти?
--Что такое стековый кадр? И что в нем хранится?
--Какой код копирует в стек адрес возврата?
--Можно ли на стеке выделить массив длинна которого задается аргументом функции?
--Какой код копирует из стека адрес возврата из функции для регистра программного счетчика?
--Кто инициализирует локальные переменные если их не проинициализирован явно ?
--В какую сторону растет стек?
--Сколько указателей стека в ARM Cortex-M4?
--Что определяет в каком направлении будет расти стековая RAM память?
--Какое значение в локальной переменной если ничего не присвоено при создании?
--Что произойдет при переполнении стека?
--Как определить на какую максимальную глубину заполнялась стековая память с момента запуска программы?
