Пролог

В этом тексте я написал про то как наблюдать за расходованием стековой памяти прямо во время исполнения прошивки на микроконтроллере.

Терминология

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
Как защититься от переполнения стека (на Cortex M)?

https://habr.com/ru/articles/425071/

Стековый кадр

https://ru.wikipedia.org/wiki/Стековый_кадр

Вопросы

--Как сделать так, чтобы при выходе из стекового кадра происходило обнуление освободившегося интервала RAM памяти?

Only registered users can participate in poll. Log in, please.
Вы делали раскраску стека?
17.65%да3
82.35%нет14
17 users voted. 3 users abstained.
Only registered users can participate in poll. Log in, please.
Как Вы следите за стеком?
15%Покраской стека3
10%Анализом адресов локальных переменных в прерываниях2
15%Через прерывание MPU на запись за пределы стека3
5%Через пролог функции компилятора1
70%Никак не слежу за стеком, путь прошивка падает. Гори всё синим пламенем!14
20 users voted. 4 users abstained.