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

Привет, Хабр!

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

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

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

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

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

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

  • функции вызывают внутри другие функции, которым тоже нужен стек
  • иногда функции вызывают другие функции не прямым их упоминанием, а по указателю на функцию
  • в принципе возможен — хотя его стоит избегать всеми средствами — рекурсивный вызов функций, когда A зовёт B, B зовёт C, а C внутри себя опять зовёт A
  • в любой момент может случиться прерывание, обработчик которого — та же функция, желающая свой кусок стека
  • если у вас есть иерархия прерываний, внутри прерывания может случиться другое прерывание!
  • Однозначно из этого списка надо вычёркивать рекурсивные вызовы функций, потому что их наличие — повод не считать объём стека, а идти высказывать своё мнение автору кода. Всё остальное, увы, в общем случае вычеркнуть не получается (хотя в частных могут быть нюансы: например, у вам все прерывания могут иметь одинаковый приоритет 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 для вот этой гигантской портянки:

    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, натравив её на один или несколько файлов.

    Выхлоп мы тут получим на порядок более развесистый, приведу лишь его часть для того же 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 может сильно помочь при оптимизации кода на системах с нехваткой ОЗУ.

    Оставить комментарий