
Пролог
В этом тексте я написал про то как наблюдать за расходованием стековой памяти прямо во время исполнения прошивки на микроконтроллере.
Терминология
RAM память — оперативная память в которой хранятся глобальные переменные.
Стек — Часть RAM памяти. В стеке хранятся локальные переменные, адрес возврата, значения регистров процессора. Обычно стек растет от большего адреса к меньшему адресу. При вызове прерываний в стек сохраняется регистровый файл процессора.
Стековый кадр — часть RAM памяти на стеке, которая образуется при вызове Си‑функции. В стековом кадре хранятся аргументы функции, локальные переменные данной функции и адрес возврата.
компоновщик (Linker) — консольная утилита, которая из множества объектных файлов склеивает один монолитный *.elf файл с финальной программой.
Реализация технологии раскраски стека
В микроконтроллерных прошивках вся RAM память делится на область глобальных переменных, стек и кучу. Надо отметить, что стек это динамическая часть памяти. При этом по умолчанию не существует никаких ограничений на бесконтрольное разрастание стека. В связи с этим, задача слежения за переполнением стека целиком и полностью ложится на плечи программиста. Следить за переполнением стека можно по-разному.
№ |
Способ наблюдения за стеком |
сложность |
1 |
Ограничить стек снизу интервалом MPU |
** |
2 |
Раскрасить стек и периодически вычислять степень максимального заполнения |
* |
3 |
Активировать prolog функции компилятора и делать проверку переполнения стека в прологе |
*** |
4 |
Смотреть за адресами локальных переменных в прерываниях по аппаратному таймеру |
** |
В этом тексте поговорим о раскраске стека.
Как можно заметить, в процедуре Reset_Handler сразу после инициализации регистра указателя на верхушку стека происходит обнуление стековой RAM памяти. Это делает процедура 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
/* Clear Stack */
ldr r2, =__Core0_StackLimit
ldr r4, =__Core0_StackTop
movs r3, #0
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 */
Переменные __Core0_StackTop и __Core0_StackLimit определяются компоновщиком в скрипте компоновщика. Они содержат адрес начала и условного конца стековой памяти.
.stack_dummy :
{
. = ALIGN(8);
__Core0_StackLimit = .;
. += STACK_SIZE;
. = ALIGN(8);
__Core0_StackTop = .;
} > DTCM_STACK
Как же понять в каком направлении у нас растет стековая память? Самое простое это написать функцию которая сама и ответит на этот вопрос.
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;
}
У меня на микроконтроллере эта функция сообщает, что при увеличении стека вызова функций адреса локальных переменных уменьшаются. Значит стек растет вниз.

В процессоре ARM Cortex-M стек растет от большего адреса к меньшему. Вот так.

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

Сам стек мы будет измерять при помощи функции core_stack_used. Задача сводится к подсчету количества нулей в массиве.
bool array_max_cont(const uint8_t* const arr,
uint32_t size,
uint8_t patt,
uint32_t* max_cont_patt) {
bool res = false;
if(arr && (0 < size) && max_cont_patt) {
res = true;
uint32_t cur_cont_pat = 0;
uint32_t max_cont_pat = 0;
uint8_t prev_elem = 0xFF;
uint32_t i = 0;
for(i = 0; i < size; i++) {
if(patt == arr[i]) {
cur_cont_pat++;
if(prev_elem != arr[i]) {
cur_cont_pat = 1;
}
} else {
cur_cont_pat = 0;
}
prev_elem = arr[i];
max_cont_pat = MAX(max_cont_pat, cur_cont_pat);
}
*max_cont_patt = max_cont_pat;
}
return res;
}
float core_stack_used(const uint32_t top_stack_val,
const uint32_t exp_size) {
float precent = 0.0f;
LOG_DEBUG(CORE,"StackTop:0x%08X,Size:%u Byte" , top_stack_val, exp_size);
#ifdef HAS_ARRAY_EXT
if(exp_size) {
uint32_t busy = 0;
uint32_t max_cont_patt = 0;
bool res = array_max_cont((uint8_t*)top_stack_val - exp_size,
exp_size, 0, &max_cont_patt);
if(res) {
busy = exp_size - max_cont_patt;
precent = ((float)(100 * busy)) / ((float)exp_size);
}
}
#endif
return precent;
}
extern void __Core0_StackTop;
extern void __Core0_StackLimit;
float core_stack_used_get(void) {
uint32_t exp_size = &__Core0_StackTop - &__Core0_StackLimit;
float stack_used = core_stack_used((uint32_t) &__Core0_StackTop, exp_size);
LOG_DEBUG(CORE, "StackUsed:%s", FloatToStr(stack_used,2));
return stack_used;
}
Далее надо в супер цикле периодически вызывать функцию core_stack_monitor_proc
bool core_stack_monitor_proc(void) {
bool res = false ;
float stack_used=core_stack_used_get();
if(80.0<stack_used){
LOG_ERROR(CORE, "StackUsed80%%+:%s", FloatToStr(stack_used,2));
res = false ;
}else{
if(70.0<stack_used){
LOG_WARNING(CORE, "StackUsed,70%%+:%s", FloatToStr(stack_used,2));
res = false ;
}else{
res = true;
}
}
return res ;
}
Вот пожалуй, и все изменения, которые следует внести в прошивку, чтобы заработала диагностика стека.
Отладка
Что ж, вот мы написали в коде периодическую проверку степени заполнения стековой памяти. Настало время как-н загрузить процессор. Это можно провернуть при помощи вызова специально добавленной рекурсивной функции. Для этого у меня в прошивки есть 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);
#ifdef HAS_LOG
LOG_INFO(CORE, "Depth:%u,StackSize:%u,byte", max_depth, *stack_size);
#endif
return res;
}
В данной сборке уже при вызове с аргументом 50 вложений со стороны главной консоли управления прошивкой появляются первые предупреждения, что стек перевалил за 70%.

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

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

Итог
Удалось реализовать в ARM Cortex-M прошивке автоматическую проверку процентного соотношения заполнения стековой RAM памяти. Это даёт ценнейшую метрику при разработке программного обеспечения. Достаточно теперь прогнать модульные тесты, посмотреть на заполнения стека и выделить большее количество в новом релизе прошивки по мере надобности.
Сокращения
Акроним |
Расшифровка |
RAM |
Random Access Memory |
MPU |
Memory protection unit |
UART |
universal asynchronous receiver / transmitter |
CLI |
Command Line Interface |
COM |
communications port |
Ссылки
Ссылка |
URL |
@Amomum |
|
Стековый кадр |
Вопросы
--Как сделать так, чтобы при выходе из стекового кадра происходило обнуление освободившегося интервала RAM памяти?
Комментарии (0)
N1X
17.09.2025 17:06Лучше не нули, а паттерн. Потому как какая-то функция сделает внутри себя
char myarray[100500] = {0}; strcpy(myarray, "hi");
т.е. по факту стек был съеден, но не использован. А был бы каким-то 0xDEADBEEF заполнен - было бы видно...
aabzel Автор
17.09.2025 17:06Лучше не нули, а паттерн.
Да. Это хорошее замечание. Мне пока не сосем понятно, как на assembler прописать паттерн.
viordash
17.09.2025 17:06Достаточно теперь прогнать модульные тесты, посмотреть на заполнения стека
можете пояснить этот момент? Обычно подобные тесты "крутятся" во framework-е, отличном от рабочей программы, и в тестах не будет повторения цепочки вызовов как в рабочем коде.
Для отладки потенциальных stackoverflow можно еще breakpoint на ячейку памяти поставить. На M0, если я правильно помню это можно только с помощью jtag-а
aabzel Автор
17.09.2025 17:06В программировании микроконтроллеров модульные тесты можно исполнять прямо внутри отладочной версии прошивки.
Прошивка тестирует сама себя изнутри.
aabzel Автор
17.09.2025 17:06и в тестах не будет повторения цепочки вызовов как в рабочем коде.
Глубина заполнения стека зачастую зависит от того какие входные данные поступают в прошивку по мере ее работы.
aabzel Автор
17.09.2025 17:06можете пояснить этот момент?
Модульное Тестирование в Embedded
https://habr.com/ru/articles/698092/
kipar
17.09.2025 17:06Во FreeRTOS эта раскраска уже реализована (сама проверка в функции prvTaskCheckFreeStackSpace, а получать результат можно через uxTaskGetSystemState). При этом если пользоваться FreeRTOS (или любой другой RTOS), то важен как раз стек в задачах, а стек для main который в задается LD файле не особо важен.
aabzel Автор
17.09.2025 17:06Это хорошо. В Zephyr RTOS тоже так.
Однако не всегда нужно использовать RTOS.
Вот в тестировочных прошивках RTOS не нужна.
CitizenOfDreams
17.09.2025 17:06Э... глупый вопрос, а кто мешает просто посмотреть на значение регистра Stack Pointer, чтобы узнать, сколько места осталось в стеке?
ruomserg
17.09.2025 17:06Если вы это делаете в определенном месте (например, в главном цикле в main), то можете получать оттуда успокаивающие данные: стек почти не используется. А между этими замерами процедуры обработки прерываний, например, его переполняют. Самое неприятное - что никакой ошибки нет! Процедура прерывания загнала стек вверх, попортила чьи-то значения своими, благополучно отработала - и вернулась. А ошибка возникает позже, и в совершенно другом месте, не связанном логически с тем где реально переполнилось. А поскольку прерывания зависят от внешних источников - то оно может месяц нормально работать, а потом пошел звон по линии подключенного датчика - и вот процессор получает кучу прерываний по изменению уровня сигнала на ноге. А отваливается при этом индикация, или еще хуже - заменяются на случайные значения константы в pid-контроллере... А покраска стека хотя бы позволяет понять откуда ноги растут...
CitizenOfDreams
17.09.2025 17:06оно может месяц нормально работать, а потом пошел звон по линии подключенного датчика - и вот процессор получает кучу прерываний по изменению уровня сигнала на ноге
В этой ситуации и раскраска стека будет месяц показывать, что все нормально. Но да, согласен, она позволит надежно измерить пиковое заполнение стека. Хотя если в микроконтроллере есть штатные средства слежения за стеком (прерывание по определенному значению Stack Pointer), то лучше все-таки использовать их.
aabzel Автор
17.09.2025 17:06Хотя если в микроконтроллере есть штатные средства слежения за стеком (прерывание по определенному значению Stack Pointer), то лучше все-таки использовать их.
Это не штатные средства, а MPU, который еще надо сконфигурировать под стек.
MPU вообще всё равно есть стек или нет.Amomum
17.09.2025 17:06Полагаю, что речь не про MPU, а про МК на которых есть именно прерывание по определенному значению стек поинтера (вроде на PIC такое было) - это именно средство борьбы с переполнением стека. Удивительно, что в кортексах такого нет (хотя мб в более новых моделях появились, я уже не очень слежу).
Amomum
17.09.2025 17:06Ну, раз уж вы сослались на мою статью - отмечусь :)
В целом, способ хороший, но тоже не всеобъемлющий (наверное, ни один из возможных сам по себе таковым не является); так как по сути проверка происходит периодически - то переполнение можно и пропустить, если оно вклинивается в этот период, в смысле, если это уже на финальном изделии выполняется. В этом плане MPU или "мой" хак с HardFault'ом полезнее, т.к. позволит само переполнение перехватить как только мы за границы стека вылезаем. И на этапе отладки это тоже может быть удобнее, когда отладчик просто останавливается ровно на той строке, которая слишком много стека отъедает.
С другой стороны, ваш подход позволяет и статистику собрать и максимум за какое-то время оценить (хоть и не совсем бесплатно). Так что эти подходы друг другу не противоречат, а скорее дополняют друг друга.
Еще из поста не совсем понятно, как вы оценивали сложность разных подходов; на мой взгляд они все достаточно простые - особенно если не делать их с нуля, а брать готовые шаблоны.
ruomserg
Отличная идея! Только ведь стек не зануляется, когда мы выходим из подпрограммы ? Получается, что вы фиксируете максимальный уровень стека за все время наблюдений. Это отлично как аварийный критерий (например, можно переставать сбрасывать watchdog и вызывать перезагрузку контроллера - вместо продолжения работы с переполненным стеком и испорченными переменными). А вот если вы хотите соотнести использование стека с какими-то активностями - то вам надо понимать когда уровень воды в колодце опустился...
aabzel Автор
Благодарю за идею. В самом деле.
Если стек переполнился, то лучше перезагрузиться.