Пролог

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

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

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. N1X
        17.09.2025 17:06

        проще всего в стартап скрипте сделать заполнение диапазона стека перет отдачей управления в main...


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

          Нужен пример ассемблерного кода.


          1. LennyB
            17.09.2025 17:06

            Вместо "movs r3, #0" напишите "mov r3, #0xbeef", потом "movt r3, #0xdead".


  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 не нужна. 


  1. CitizenOfDreams
    17.09.2025 17:06

    Э... глупый вопрос, а кто мешает просто посмотреть на значение регистра Stack Pointer, чтобы узнать, сколько места осталось в стеке?


    1. ruomserg
      17.09.2025 17:06

      Если вы это делаете в определенном месте (например, в главном цикле в main), то можете получать оттуда успокаивающие данные: стек почти не используется. А между этими замерами процедуры обработки прерываний, например, его переполняют. Самое неприятное - что никакой ошибки нет! Процедура прерывания загнала стек вверх, попортила чьи-то значения своими, благополучно отработала - и вернулась. А ошибка возникает позже, и в совершенно другом месте, не связанном логически с тем где реально переполнилось. А поскольку прерывания зависят от внешних источников - то оно может месяц нормально работать, а потом пошел звон по линии подключенного датчика - и вот процессор получает кучу прерываний по изменению уровня сигнала на ноге. А отваливается при этом индикация, или еще хуже - заменяются на случайные значения константы в pid-контроллере... А покраска стека хотя бы позволяет понять откуда ноги растут...


      1. CitizenOfDreams
        17.09.2025 17:06

        оно может месяц нормально работать, а потом пошел звон по линии подключенного датчика - и вот процессор получает кучу прерываний по изменению уровня сигнала на ноге

        В этой ситуации и раскраска стека будет месяц показывать, что все нормально. Но да, согласен, она позволит надежно измерить пиковое заполнение стека. Хотя если в микроконтроллере есть штатные средства слежения за стеком (прерывание по определенному значению Stack Pointer), то лучше все-таки использовать их.


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

          Хотя если в микроконтроллере есть штатные средства слежения за стеком (прерывание по определенному значению Stack Pointer), то лучше все-таки использовать их.

          Это не штатные средства, а MPU, который еще надо сконфигурировать под стек.
          MPU вообще всё равно есть стек или нет.


          1. Amomum
            17.09.2025 17:06

            Полагаю, что речь не про MPU, а про МК на которых есть именно прерывание по определенному значению стек поинтера (вроде на PIC такое было) - это именно средство борьбы с переполнением стека. Удивительно, что в кортексах такого нет (хотя мб в более новых моделях появились, я уже не очень слежу).


  1. Amomum
    17.09.2025 17:06

    Ну, раз уж вы сослались на мою статью - отмечусь :)

    В целом, способ хороший, но тоже не всеобъемлющий (наверное, ни один из возможных сам по себе таковым не является); так как по сути проверка происходит периодически - то переполнение можно и пропустить, если оно вклинивается в этот период, в смысле, если это уже на финальном изделии выполняется. В этом плане MPU или "мой" хак с HardFault'ом полезнее, т.к. позволит само переполнение перехватить как только мы за границы стека вылезаем. И на этапе отладки это тоже может быть удобнее, когда отладчик просто останавливается ровно на той строке, которая слишком много стека отъедает.

    С другой стороны, ваш подход позволяет и статистику собрать и максимум за какое-то время оценить (хоть и не совсем бесплатно). Так что эти подходы друг другу не противоречат, а скорее дополняют друг друга.

    Еще из поста не совсем понятно, как вы оценивали сложность разных подходов; на мой взгляд они все достаточно простые - особенно если не делать их с нуля, а брать готовые шаблоны.