
Пролог
В этом тексте я написал про то как наблюдать за расходованием стековой памяти прямо во время исполнения прошивки на микроконтроллере. Почему это надо? Дело в том, что существующий стандарт автомобильного программирования 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 память?
--Какое значение в локальной переменной если ничего не присвоено при создании?
--Что произойдет при переполнении стека?
--Как определить на какую максимальную глубину заполнялась стековая память с момента запуска программы?
