- PVSM.RU - https://www.pvsm.ru -

Профилирование памяти на STM32 и других микроконтроллерах: статический анализ размера стека

Привет!

В прошлой статье [1] и я сам упоминал, и в комментариях спрашивали — ок, хорошо, методом научного тыка мы подобрали размер стека, вроде ничего не падает, а можно как-то надёжнее оценить, чему он равен и кто вообще столько сожрал?

Отвечаем коротко: да, но нет.

Нет, методами статического анализа невозможно точно измерить размер потребного программе стека — но, тем не менее, эти методы могут пригодиться.

Ответ немного длиннее — под катом.

Как широко известно узкому кругу людей, место в стеке выделяется, по сути, под локальные переменные, которые использует выполняющаяся в данный момент функция — за исключением переменных с модификатором static, которые хранятся в статически выделенной памяти, в области bss, потому что они должны сохранять свои значения между вызовами функций.

При выполнении функции компилятор добавляет в стеке место под нужные ей переменные, при завершении — освобождает это место обратно. Казалось бы, всё просто, но — и это очень жирное но — у нас есть несколько проблем:

  1. функции вызывают внутри другие функции, которым тоже нужен стек
  2. иногда функции вызывают другие функции не прямым их упоминанием, а по указателю на функцию
  3. в принципе возможен — хотя его стоит избегать всеми средствами — рекурсивный вызов функций, когда A зовёт B, B зовёт C, а C внутри себя опять зовёт A
  4. в любой момент может случиться прерывание, обработчик которого — та же функция, желающая свой кусок стека
  5. если у вас есть иерархия прерываний, внутри прерывания может случиться другое прерывание!

Однозначно из этого списка надо вычёркивать рекурсивные вызовы функций, потому что их наличие — повод не считать объём стека, а идти высказывать своё мнение автору кода. Всё остальное, увы, в общем случае вычеркнуть не получается (хотя в частных могут быть нюансы: например, у вам все прерывания могут иметь одинаковый приоритет by design, например, как в RIOT OS, и вложенных прерываний не будет).

Теперь представьте себе картину маслом:

  • функция A, съевшая 100 байт в стеке, зовёт функцию B, которой нужно 50 байт
  • на момент выполнения B сама A, очевидно, ещё не завершилась, поэтому её 100 байт не освобождены, поэтому у нас уже 150 байт в стеке
  • функция B зовёт функцию C, причём делает это по указателю, который в зависимости от логики программы может указывать на полдесятка разных функций, потребляющих от 5 до 50 байт стека
  • во время выполнения C случается прерывание с тяжёлым обработчиком, работающим относительно долго и потребляющим 20 байт стека
  • во время обработки прерывания случается другое прерывание, с более высоким приоритетом, обработчик которого хочет 10 байт стека

В этой прекрасной конструкции при особенно удачном совпадении всех обстоятельств у вас будет минимум пять одновременно активных функций — A, B, C и два обработчика прерываний. Более того, у одной из них потребление стека не есть константа, ибо в разные проходы это может быть попросту разная функция, да и для понимания возможности или невозможности наложения друг на друга прерываний надо как минимум знать, есть ли у вас вообще прерывания с разными приоритетами, а как максимум — понять, могут ли они накладываться друг на друга.

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

  • просуммировать стеки всех обработчиков прерываний
  • просуммировать стеки функций, выполняющихся в одной ветке кода
  • попытаться найти все указатели на функции и их вызовы, и принять за размер стека максимальный размер стека среди функций, на которые эти указатели указывают

В большинстве случаев получится, с одной стороны, сильно завышенная оценка, а с другой — шанс пропустить какой-нибудь особенно хитрый вызов функции через указатели.

Поэтому в общем случае можно сказать просто: эта задача автоматически не решается. Ручное же решение — человеком, знающим логику данной программы — требует перелопатить довольно много чисел.

Тем не менее, статическая оценка размера стека может быть очень полезна при оптимизации ПО — хотя бы с банальной целью понять, кто сколько жрёт, и не многовато ли.

Для этого в тулчейне GNU/gcc есть два крайне полезных средства:

  • флаг -fstack-usage
  • утилита cflow

Если во флаги gcc (например, в Makefile в строку с CFLAGS) добавить -fstack-usage, то для каждого компилируемого файла %filename%.c компилятор создаст файл %filename%.su, внутри которого будет простой и понятный текст.

Возьмём, например, target.su для вот этой гигантской портянки [2]:

target.c:159:13:save_settings	8	static
target.c:172:13:disable_power	8	static
target.c:291:13:adc_measure_vdda	32	static
target.c:255:13:adc_measure_current	24	static
target.c:76:6:cpu_setup	0	static
target.c:81:6:clock_setup	8	static
target.c:404:6:dma1_channel1_isr	24	static
target.c:434:6:adc_comp_isr	40	static
target.c:767:6:systick_activity	56	static
target.c:1045:6:user_activity	104	static
target.c:1215:6:gpio_setup	24	static
target.c:1323:6:target_console_init	8	static
target.c:1332:6:led_bit	8	static
target.c:1362:6:led_num	8	static

Здесь мы видим фактическое потребление стека для каждой фигурирующей в ней функции, из которого можем делать какие-то выводы для себя — ну, например, что стоит в первую очередь попробовать оптимизировать, если мы упираемся в нехватку ОЗУ.

При этом, внимание, этот файл не даёт на самом деле точной информации о реальном потреблении стека для функций, из которых вызываются другие функции!

Для понимания полного потребления нам необходимо построить дерево вызовов и суммировать стеки всех входящих в каждую его ветку функций. Сделать это можно, например, утилитой GNU cflow [3], натравив её на один или несколько файлов.

Выхлоп мы тут получим на порядок более развесистый, приведу лишь его часть для того же target.c:

olegart@oleg-npc /mnt/c/Users/oleg/Documents/Git/dap42 (umdk-emb) $ cflow src/stm32f042/umdk-emb/target.c
adc_comp_isr() <void adc_comp_isr (void) at src/stm32f042/umdk-emb/target.c:434>:
    TIM_CR1()
    ADC_DR()
    ADC_ISR()
    DMA_CCR()
    GPIO_BSRR()
    GPIO_BRR()
    ADC_TR1()
    ADC_TR1_HT_VAL()
    ADC_TR1_LT_VAL()
    TIM_CNT()
    DMA_CNDTR()
    DIV_ROUND_CLOSEST()
    NVIC_ICPR()
clock_setup() <void clock_setup (void) at src/stm32f042/umdk-emb/target.c:81>:
    rcc_clock_setup_in_hsi48_out_48mhz()
    crs_autotrim_usb_enable()
    rcc_set_usbclk_source()
dma1_channel1_isr() <void dma1_channel1_isr (void) at src/stm32f042/umdk-emb/target.c:404>:
    DIV_ROUND_CLOSEST()
gpio_setup() <void gpio_setup (void) at src/stm32f042/umdk-emb/target.c:1215>:
    rcc_periph_clock_enable()
    button_setup() <void button_setup (void) at src/stm32f042/umdk-emb/target.c:1208>:
        gpio_mode_setup()
    gpio_set_output_options()
    gpio_mode_setup()
    gpio_set()
    gpio_clear()
    rcc_peripheral_enable_clock()
    tim2_setup() <void tim2_setup (void) at src/stm32f042/umdk-emb/target.c:1194>:
        rcc_periph_clock_enable()
        rcc_periph_reset_pulse()
        timer_set_mode()
        timer_set_period()
        timer_set_prescaler()
        timer_set_clock_division()
        timer_set_master_mode()
    adc_setup_common() <void adc_setup_common (void) at src/stm32f042/umdk-emb/target.c:198>:
        rcc_periph_clock_enable()
        gpio_mode_setup()
        adc_set_clk_source()
        adc_calibrate()
        adc_set_operation_mode()
        adc_disable_discontinuous_mode()
        adc_enable_external_trigger_regular()
        ADC_CFGR1_EXTSEL_VAL()
        adc_set_right_aligned()
        adc_disable_temperature_sensor()
        adc_disable_dma()
        adc_set_resolution()
        adc_disable_eoc_interrupt()
        nvic_set_priority()
        nvic_enable_irq()
        dma_channel_reset()
        dma_set_priority()
        dma_set_memory_size()
        dma_set_peripheral_size()
        dma_enable_memory_increment_mode()
        dma_disable_peripheral_increment_mode()
        dma_enable_transfer_complete_interrupt()
        dma_enable_half_transfer_interrupt()
        dma_set_read_from_peripheral()
        dma_set_peripheral_address()
        dma_set_memory_address()
        dma_enable_circular_mode()
        ADC_CFGR1()
    memcpy()
    console_reconfigure()
    tic33m_init()
    strlen()
    tic33m_display_string()

И это даже не половина дерева.

Чтобы понять реальное потребление стека, нам надо взять потребление для каждой из упомянутых в нём функций и просуммировать эти значения для каждой из веток.

И при этом мы ещё не учтём вызовы функций по указателям и прерывания, в т.ч. вложенные (а конкретно в этом коде они могут быть вложенными).

Как нетрудно догадаться, проделывать это при каждом изменении кода, мягко говоря, затруднительно — поэтому так обычно никто и не делает.

Тем не менее, понимать принципы наполнения стека необходимо — из этого могут рождаться определённые ограничения на код проекта, повышающие его надёжность с точки зрения недопущения переполнения стека (например, запрет вложенных прерываний или вызовов функций по указателям), а конкретно -fstack-usage может сильно помочь при оптимизации кода на системах с нехваткой ОЗУ.

Автор: Олег Артамонов

Источник [4]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/stm32/311468

Ссылки в тексте:

[1] прошлой статье: https://habr.com/ru/post/443030/

[2] вот этой гигантской портянки: https://github.com/unwireddevices/dap42/blob/umdk-emb/src/stm32f042/umdk-emb/target.c

[3] GNU cflow: https://www.gnu.org/software/cflow/

[4] Источник: https://habr.com/ru/post/443544/?utm_source=habrahabr&utm_medium=rss&utm_campaign=443544