Пролог

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

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

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 памяти?

Комментарии (0)


  1. ruomserg
    17.09.2025 17:06

    Отличная идея! Только ведь стек не зануляется, когда мы выходим из подпрограммы ? Получается, что вы фиксируете максимальный уровень стека за все время наблюдений. Это отлично как аварийный критерий (например, можно переставать сбрасывать watchdog и вызывать перезагрузку контроллера - вместо продолжения работы с переполненным стеком и испорченными переменными). А вот если вы хотите соотнести использование стека с какими-то активностями - то вам надо понимать когда уровень воды в колодце опустился...


    1. aabzel Автор
      17.09.2025 17:06

       Это отлично как аварийный критерий (например, можно переставать сбрасывать watchdog и вызывать перезагрузку контроллера - вместо продолжения работы с переполненным стеком и испорченными переменными).

      Благодарю за идею. В самом деле.
      Если стек переполнился, то лучше перезагрузиться.


  1. N1X
    17.09.2025 17:06

    Лучше не нули, а паттерн. Потому как какая-то функция сделает внутри себя

    char myarray[100500] = {0};
    strcpy(myarray, "hi");

    т.е. по факту стек был съеден, но не использован. А был бы каким-то 0xDEADBEEF заполнен - было бы видно...


    1. aabzel Автор
      17.09.2025 17:06

      Лучше не нули, а паттерн. 

      Да. Это хорошее замечание. Мне пока не сосем понятно, как на assembler прописать паттерн.


  1. viordash
    17.09.2025 17:06

    Достаточно теперь прогнать модульные тесты, посмотреть на заполнения стека

    можете пояснить этот момент? Обычно подобные тесты "крутятся" во framework-е, отличном от рабочей программы, и в тестах не будет повторения цепочки вызовов как в рабочем коде.

    Для отладки потенциальных stackoverflow можно еще breakpoint на ячейку памяти поставить. На M0, если я правильно помню это можно только с помощью jtag-а


    1. aabzel Автор
      17.09.2025 17:06

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

      Прошивка тестирует сама себя изнутри.


      1. viordash
        17.09.2025 17:06

        у вас в продакшн коде присутствует код для тестирования? Или отключаете код тестирования в релизе?


        1. aabzel Автор
          17.09.2025 17:06

          у вас в продакшн коде присутствует код для тестирования?

          нет

          Или отключаете код тестирования в релизе?

          да


          1. viordash
            17.09.2025 17:06

            да

            ну тогда есть немаленькая вероятность, что замеры глубины стека будут не валидны


            1. aabzel Автор
              17.09.2025 17:06

              На этот случай в ARM Cortex-M есть MPU, который сгенерирует прерывание, если мы вывалимся за стек.


    1. aabzel Автор
      17.09.2025 17:06

       и в тестах не будет повторения цепочки вызовов как в рабочем коде.

      Глубина заполнения стека зачастую зависит от того какие входные данные поступают в прошивку по мере ее работы.


    1. aabzel Автор
      17.09.2025 17:06

  1. kipar
    17.09.2025 17:06

    Во FreeRTOS эта раскраска уже реализована (сама проверка в функции prvTaskCheckFreeStackSpace, а получать результат можно через uxTaskGetSystemState). При этом если пользоваться FreeRTOS (или любой другой RTOS), то важен как раз стек в задачах, а стек для main который в задается LD файле не особо важен.


    1. aabzel Автор
      17.09.2025 17:06

      Это хорошо. В Zephyr RTOS тоже так.
      Однако не всегда нужно использовать RTOS.
      Вот в тестировочных прошивках RTOS не нужна.