Пролог

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

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

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

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

Вопросы

--Как сделать так, чтобы при выходе из стекового кадра происходило обнуление освободившегося интервала RAM памяти? Или заполнение константой.
--В стеке какого потока работают прерывания?
--Как отловить факт переполнения стека?
--Что происходит когда мы вызываем функцию?
--Что хранится в стековой памяти?
--Что такое стековый кадр? И что в нем хранится?
--Какой код копирует в стек адрес возврата?
--Можно ли на стеке выделить массив длинна которого задается аргументом функции?
--Какой код копирует из стека адрес возврата из функции для регистра программного счетчика?
--Кто инициализирует локальные переменные если их не проинициализирован явно ?
--В какую сторону растет стек?
--Сколько указателей стека в ARM Cortex-M4?
--Что определяет в каком направлении будет расти стековая RAM память?
--Какое значение в локальной переменной если ничего не присвоено при создании?
--Что произойдет при переполнении стека?
--Как определить на какую максимальную глубину заполнялась стековая память с момента запуска программы?

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы делали раскраску стека?
18.18%да4
81.82%нет18
Проголосовали 22 пользователя. Воздержались 3 пользователя.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как Вы следите за стеком?
13.04%Покраской стека3
8.7%Анализом адресов локальных переменных в прерываниях2
13.04%Через прерывание MPU на запись за пределы стека3
4.35%Через пролог функции компилятора1
73.91%Никак не слежу за стеком, путь прошивка падает. Гори всё синим пламенем!17
Проголосовали 23 пользователя. Воздержались 5 пользователей.