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

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

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

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

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

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

    Как широко известно узкому кругу людей, место в стеке выделяется, по сути, под локальные переменные, которые использует выполняющаяся в данный момент функция — за исключением переменных с модификатором 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 может сильно помочь при оптимизации кода на системах с нехваткой ОЗУ.
    Support the author
    Share post

    Similar posts

    Comments 22

      0
      А почему бы не сделать профилирование методом дебаггера, просто выплёвывая в комп или через jtag, или через SWO регистр SP?
      Если добавить ещё и PC то получим привязку к месту, откуда пришли данные по стеку.
      А у тулчейна есть тулза, которая значение PC превращает в file:line
      Подробнее про SWO можно почитать у sigrok
      www.sigrok.org/blog/new-protocol-decoders-arm-tpiu-itm-etmv3
        +1
        Это принципиально не отличается от окраски памяти из первой статьи — мы не делаем профилирование как таковое, мы экспериментально определяем, что вроде бы за разумное время в разумных условиях стек не отжирает больше, чем мы предполагали по методу научного тыка.
          0
          Принципиальное отличие в том, что мы не меняем полезного исходного кода и не добавляем ненужных для функционирования устройства действий в полученную прошивку.
            0
            В случае использования RTOS эти функции обычно уже есть в ней, в голых прошивках — никого же не удивляет, что есть debug-сборка и release-сборка.
              0
              В случае использования RTOS есть ещё и инструменты работы с ядром через TPI на стороне компа, что упрощает жизнь значительно.
              А если нет RTOS, то и debug-сборка как правило не нужна. Задачи или очень простые, или с критичными к времени исполнения секциями, для которых профилирование debug кода, как и игра с флагами оптимизации компилятора, крайне нежелательна. Разве что ASSERTы.
                0
                ядром через TPI

                TPI есть во всех семействах Cortex-M?!
                  0
                  Почти во всех. У STM32, которые в заголовке, присутствуют во всех моделях, с которыми мне приходилось работать.
                    +1
                    Сейчас я открою Вам информацию, доступную только избранным.
                    Не во все ядра Cortex-M входит TPI.
            0
            Таки отличается — этот метод позволяет сначала построить с помощью статического анализатора дерево вызовов, а потом, например при помощи обработчика прерываний таймера с (псевдо)случайным интервалом, который определяет цепочку вложенности вызовов для контекста в котором произошло прерывание, и сохраняет SP — диапазон объёма использования стека для большинства из узлов этого дерева. После этого можно заполнить оставшиеся узлы на основе оценки максимального использования стека из компилятора, добавить к максимальным значениям оценку для прерываний — и получить полную потребность в памяти для стека. Преимущество этого метода перед чисто экспериментальным в том, что можно убедиться, что этой оценкой покрыто по крайней мере всё дерево вызовов. Есть нюансы с переменным объёмом стэка внутри функций, и с несколькими вызовами одной и той же функции из разных мест другой функции, но это тоже можно учесть.
          0
          С прерываниями на самом деле все гораздо проще — на каждом уровне приоритетов находится обработчик с максимальной глубиной стека а затем эти цифры суммируются для всех уровней.
            0
            Это даст оценку сверху. На практике и прерывания могут не всегда накладываться просто в силу особенностей реализации железа и/или маскирования прерываний в рантайме, а в совсем плохом случае — их приоритет меняться в рантайме по ситуации.
              +1
              А зачем вам оценка «не сверху»? Вам надо гарантировать непереполнение — значит базироваться на физической возможности процессора выстроить цепь из вложенных на всех уровнях прерываний. Иначе все ваши рассуждения про главный цикл можно вести в этом же ключе — «а что если там в реальности тоже не вызывается цепь событий с максимальной глубиной»…

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

            Кстати-2, для gcc есть утилита, которая строит дерево вызовов по объектникам: mcuoneclipse.com/2015/08/21/gnu-static-stack-usage-analysis
            Оно, правда, без костылей не умеет c++.
              0
              Настройки проекта — Linker — Advanced — Enable stack usage analysis. Плюс надо еще указать явно все обработчики прерываний (всё, что неявно вызывается, вроде как). Очень полезная вещь, да еще и из коробки!
              0
              Писал для себя похожую утилитку для анализа памяти под потоки ChibiOS на STM32 — github.com/Confucij/sudan
              Она использует другую утилитку egypt и выдает граф выызовов функций и глубину стека на каждом уровне
                0
                Немного оффтоп: можете посоветовать утилиту для анализа не памяти, а команд и тактов на их выполнение? Временами код в прерываниях «толстоват», хотелось бы построить диаграмму таймингов, чтобы быть уверенным, что код в прерывании выполнится до наступления следующего события.
                  0
                  Не знаю такой.

                  Я обычно тайминги конкурирующих процессов смотрю осциллографом или анализатором, вытащив их начало/конец на свободные ножки — очень наглядно получается.
                    0
                    Хотелось бы без отладки по пину, а статически. Поскольку я сварщик не настоящий, осциллограф у меня вельми хреновый, подобранный списанный.
                    Вроде и написать не сложно, но лень возиться с несколькими системами команд.
                    0
                    На Cortex-M можно запрашивать показания Program Counter через ITM и строить flame graph, например. Так можно понять, какой процент времени ядро тратит в каких местах кода.
                      +1
                      спасибо, этого не знал. Просто немного удивительно: есть ассемблерный листинг, есть данные по системе команд (AVR и ARM всё-таки не страшный и непредсказуемый х86), а статического анализатора нет.
                        +1
                        В IAR'е для этого достаточно остановиться по точке останова в начале нужного фрагмента, в окне Register выбрать Data Watchpoint and Trace macrocel, в регистре DWT_CTRL установить CYCCNENA=1, и, шагая по коду, наблюдать изменение DWT_CYCCNT.
                    0
                    Для эклипса есть замечательный плагин freescale.com/lgfiles/updates/Eclipse/KDS
                    выдает по задачам и куче полную инфу только повозится с настройками надо и freeros 10 использовать

                    Only users with full accounts can post comments. Log in, please.