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

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

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

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

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

Как широко известно узкому кругу людей, место в стеке выделяется, по сути, под локальные переменные, которые использует выполняющаяся в данный момент функция — за исключением переменных с модификатором 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 для вот этой гигантской портянки:

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

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


  1. Goron_Dekar
    13.03.2019 11:23

    А почему бы не сделать профилирование методом дебаггера, просто выплёвывая в комп или через jtag, или через SWO регистр SP?
    Если добавить ещё и PC то получим привязку к месту, откуда пришли данные по стеку.
    А у тулчейна есть тулза, которая значение PC превращает в file:line
    Подробнее про SWO можно почитать у sigrok
    www.sigrok.org/blog/new-protocol-decoders-arm-tpiu-itm-etmv3


    1. olartamonov Автор
      13.03.2019 11:29

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


      1. Goron_Dekar
        13.03.2019 11:38

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


        1. olartamonov Автор
          13.03.2019 11:42

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


          1. Goron_Dekar
            13.03.2019 11:53

            В случае использования RTOS есть ещё и инструменты работы с ядром через TPI на стороне компа, что упрощает жизнь значительно.
            А если нет RTOS, то и debug-сборка как правило не нужна. Задачи или очень простые, или с критичными к времени исполнения секциями, для которых профилирование debug кода, как и игра с флагами оптимизации компилятора, крайне нежелательна. Разве что ASSERTы.


            1. augorelov
              13.03.2019 16:08

              ядром через TPI

              TPI есть во всех семействах Cortex-M?!


              1. Goron_Dekar
                13.03.2019 21:15

                Почти во всех. У STM32, которые в заголовке, присутствуют во всех моделях, с которыми мне приходилось работать.


                1. augorelov
                  13.03.2019 22:28
                  +1

                  Сейчас я открою Вам информацию, доступную только избранным.
                  Не во все ядра Cortex-M входит TPI.


      1. Sun-ami
        13.03.2019 21:45

        Таки отличается — этот метод позволяет сначала построить с помощью статического анализатора дерево вызовов, а потом, например при помощи обработчика прерываний таймера с (псевдо)случайным интервалом, который определяет цепочку вложенности вызовов для контекста в котором произошло прерывание, и сохраняет SP — диапазон объёма использования стека для большинства из узлов этого дерева. После этого можно заполнить оставшиеся узлы на основе оценки максимального использования стека из компилятора, добавить к максимальным значениям оценку для прерываний — и получить полную потребность в памяти для стека. Преимущество этого метода перед чисто экспериментальным в том, что можно убедиться, что этой оценкой покрыто по крайней мере всё дерево вызовов. Есть нюансы с переменным объёмом стэка внутри функций, и с несколькими вызовами одной и той же функции из разных мест другой функции, но это тоже можно учесть.


  1. emmibox
    13.03.2019 13:09

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


    1. olartamonov Автор
      13.03.2019 13:15

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


      1. emmibox
        13.03.2019 14:31
        +1

        А зачем вам оценка «не сверху»? Вам надо гарантировать непереполнение — значит базироваться на физической возможности процессора выстроить цепь из вложенных на всех уровнях прерываний. Иначе все ваши рассуждения про главный цикл можно вести в этом же ключе — «а что если там в реальности тоже не вызывается цепь событий с максимальной глубиной»…

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


  1. esaulenka
    13.03.2019 15:04

    Кстати, этот метод профилирования есть в IAR (как минимум, в IAR AVR, ARM'овским пользоваться не приходилось). Просто галка где-то в недрах настроек, которая добавляет в map-файл несколько строчек: main использует столько-то, прерывание A — столько-то, прерывание B — столько. Считалась максимальная глубина вызовов. Указатели он не умеет анализировать, складывать стеки прерываний тоже надо вручную. Работает быстро, т.к. анализ бинарников идёт без особых проблем.

    Кстати-2, для gcc есть утилита, которая строит дерево вызовов по объектникам: mcuoneclipse.com/2015/08/21/gnu-static-stack-usage-analysis
    Оно, правда, без костылей не умеет c++.


    1. el_kornholio
      13.03.2019 22:49

      Настройки проекта — Linker — Advanced — Enable stack usage analysis. Плюс надо еще указать явно все обработчики прерываний (всё, что неявно вызывается, вроде как). Очень полезная вещь, да еще и из коробки!


  1. confucij
    13.03.2019 22:49

    Писал для себя похожую утилитку для анализа памяти под потоки ChibiOS на STM32 — github.com/Confucij/sudan
    Она использует другую утилитку egypt и выдает граф выызовов функций и глубину стека на каждом уровне