В статье предпринята попытка разобраться в содержимом startup файла микроконтроллера STM32F4, построенного на базе ядра Arm Cortex M4. Для запуска ядра используется ассемблерный код, который и предстоит изучить. Для лучшего понимания материала необходимо иметь представление об архитектуре ядра Cortex M4. Сразу отмечу, что замечания и уточнения приветствуются, т. к. они позволят дополнить представленную информацию.

Я не стану приводить здесь код startup файла полностью, чтобы избежать загромождения текста. Указанный файл является частью стандартного пакета программного обеспечения от STMicroelectronics, поставляемого с KEIL MDK-Arm. Это означает, что код относится к ассемблеру от Arm и не подходит для ассемблера GNU.

На нумерацию строк кода, представленного далее, не нужно обращать внимания, т. к. она никак не соотносится с последовательностью кода startup файла.

Структура Startup файла

В Startup файле имеется пять основных секций кода:

1.       Декларация области стека (Stack);

2.       Декларация области кучи (Heap);

3.       Таблица векторов прерываний (Vector table);

4.       Код обработки сброса (Reset handler);

5.       Код обработки прочих исключений.

Область стека

Ассемблерный код обычно разделяется на секции при помощи директивы AREA. Давайте посмотрим, как происходит декларация области стека.

Stack_Size     EQU     0x00000400

В данной строке происходит декларация константы Stack_Size с присвоением ей значения 0x00000400. Директива EQU в ассемблере аналогична директиве #define языка С.

AREA     STACK, NOINIT, READWRITE, ALIGN=3

Далее происходит декларация области стека. Для этого используется директива AREA, которая обозначает отдельную секцию в памяти. Слово STACK в данном случае, всего лишь имя данной секции. За именем секции следуют следующие атрибуты.

  • NOINIT обозначает, что данные секции инициализируются нулями;

  • READWRITE, очевидно, позволяет производить чтение и запись секции;

  • ALIGN = 3 выравнивает начало секции по границе 8 байт (2^3 = 8).

Stack_Mem          SPACE          Stack_Size
__initial_sp

В данной строке в области памяти стека выделяется пространство размером Stack_Size (0x0400 байт). Директива SPACE служит для резервирования указанного размера памяти. __initial_sp представляет собой декларацию метки, которая впоследствии будет использована в таблице векторов. Данная метка будет равна адресу, следующему за областью стека. Поскольку стек организован сверху-вниз (уменьшение адресов), данная метка будет служить указателем на его начало.

Таблица векторов

На текущий момент опустим код, относящийся к декларации кучи (Heap) и рассмотрим таблицу векторов. Таблица векторов размещается в секции RESET, которая декларируется строчкой кода:

AREA           RESET,     DATA,     READONLY

RESET – это всего лишь имя секции. Атрибут DATA указывает на то, что в секции будут сохранены данные, а не команды. Действительно, таблица векторов содержит лишь адреса указателей обработчиков прерываний и адрес начала стека. Атрибут READONLY защищает указанную область от случайной записи из кода программы.

Данная секция размещается в начале области CODE флеш памяти по адресу 0x8000000 для выбранного микроконтроллера. Карта памяти приводится в разделе «FLASH memory organization» Reference Manual. Начальный адрес используется при линковке и берется из scatter-файла, либо задается в настройках линкера. Таблица векторов размещается в памяти без смещения, поскольку регистр VTOR по умолчанию имеет нулевое значение. При помощи данного регистра имеется возможность сместить таблицу векторов. В данном случае используются значения, указанные в startup.

Таблица векторов содержит:

  • Указатель начала стека;

  • Адрес обработчика сброса, т.е. код, который будет выполнен при перезагрузке микроконтроллера;

  • Адреса всех прочих исключений и прерываний, включающих NMI (Non-maskable interrupt), прерывание Hard fault и т. п.

DCD          __initial_sp

В данной строке сохраняется метка __initial_sp в области RESET. Директива DCD сохраняет слово (32 бит) в память.

DCD          Reset_Handler

Аналогично, следующей строкой сохраняется адрес обработчика сброса Reset_Handler. Это предварительное объявление, поскольку декларация метки Reset_Handler производится в другом месте кода. Файл ассемблерного кода обрабатывается в два прохода, благодаря чему предварительное объявление становится возможным.

Далее следуют сохранения меток прерываний с различными адресами, таких как NMI_Handler, HardFault_Handler и т. п. До обработчика SysTick_Handler идут исключения процессора Arm. Затем таблицу продолжают внешние прерывания. Речь идет о прерываниях, внешних по отношению к ядру Arm, а не микроконтроллеру STM32. Данные прерывания относятся к различной периферии микроконтроллера, например модулю Watchdog, DMA, RTC и т. д. Список прерываний продолжается до FPU_IRQHandler (Floating-point unit IRQ).

Таблица векторов, и в частности две первых записи, необходимы для того, чтобы ядро запустилось и обработало инструкции PUSH/POP. Дело в том, что когда ядро Cortex M4 стартует, первое 32-битное значение из таблицы прерываний записывается в регистр MSP (Main Stack Pointer). Затем происходит копирование следующей записи в счётчик команд PC (Program counter) и выполняется команда по указанному адресу. Поскольку мы указываем адрес обработчика сброса (Reset Handler), именно он и будет выполнен.

Обработчик сброса

После определения в стартап файле таблицы прерываний начинается непосредственно код. Сохранение кода выполняется в область CODE.

AREA    |.text|, CODE, READONLY

Данной строкой задается область памяти с именем .text, которая содержит код, предназначенный только для чтения. Имя области может быть каким угодно. Символ вертикальной черты необходим для соблюдения правил наименований, поскольку имя не может начинаться с точки.

В указанной области сначала вызывается функция SystemInit, которая настраивает частоту тактирования микроконтроллера. И только затем, управление микроконтроллером передается функцию main().

IMPORT   SystemInit

 Данная строка кода ссылается на функцию SystemInit, которая определена где-то в проекте.

IMPORT __main

Функция __main библиотеки С в конечном счёте вызывает main(), определенную вами.

Если вы пишите код на ассемблере, то потребуется разместить директиву ENTRY в обработчике сброса из-за отсутствия __main. Это позволит линкеру установить точку входа в программу.

LDR     R0, =SystemInit

Эта строка является псевдоинструкцией ассемблера, которая загружает адрес функции SystemInit в регистр R0. Последующая инструкция BLX R0 приводит к выполнению кода программы с данного адреса.

Вызов функции main происходит аналогичным образом, после того как функция SystemInit возвращает управление программой.

Обработчики исключений

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

NMI_Handler  PROC
             EXPORT     NMI_Handler     [WEAK]
             B          .
             ALIGN
             ENDP

Первая строка NMI_Handler служит меткой этой небольшой функции. Ассемблерная директива PROC обозначает старт процедуры или функции.

Строка EXPORT делает метку NMI_Handler доступной другим частям программы. Атрибут [WEAK] добавлен для того, что обработчик можно было переопределить в другом месте. Это позволяет иметь собственный обработчик в проекте или разные обработчики для разных проектов, при этом сохранив одинаковый startup файл. Чем-то это напоминает виртуальные функции языка C++. Разумеется, если вам нужен один и тот же обработчик для всех проектов, разумно модифицировать startup файл для вызова вашей собственной функции или добавить код непосредственно в startup.

По умолчанию обработчики определены как бесконечные циклы инструкцией B. Данная инструкция ведет на один и тот же адрес, тем самым создавая бесконечный цикл. Директива ENDP обозначает конец процедуры. Директива ALIGN выравнивает текущую область памяти к границе следующего слова. Если текущее положение уже соответствует границе, вставляется инструкция NOP (нулевые данные). Директиву можно использовать для выравнивания к различным границам и даже для вставки или дополнения определенных данных, вместо обычного NOP. Аналогичный код используется далее для обработчиков всех исключений.

Для внешних обработчиков прерываний в файле startup используется один бесконечный цикл, который обозначен как Default_Handler. Метки внешних прерываний ссылаются на данный обработчик. Это означает, что для любого исключения, произошедшего в периферии микроконтроллера будет выполнен один и тот же Default_Handler. И снова используется атрибут WEAK, что позволяет переопределить код самостоятельно.

Обратите внимание, что даже Reset_Handler объявлен с данным атрибутом, т.е. при желании можно задать собственный обработчик сброса.

Куча

Определение кучи аналогично коду, определяющему стек. Две метки __heap_base и __heap_limit обозначают соответственно адреса начала и конца кучи. При использовании Arm Microlib начальный указатель стека, указатели начала и конца кучи экспортируются при компоновке.

Дополнительно

Необходимо уделить внимание ещё двум директивам, используемым в startup файле. Директива PRESERVE8 приказывает линкеру сохранять выравнивание стека по длине в 8 байт. Это требование стандартной архитектуры ARM, так называемой Arm Architecture Procedure Call Standard (AAPCS). Директива THUMB указывает на режим процессоров ARM, в котором используется сокращённая система команд. Данный режим относится к ядрам Cortex-M.

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

 

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


  1. mpa4b
    28.05.2024 08:29

    Я как-то писал свой стартап для M3, и удалось обойтись полностью без ассемблера, всё на Си. Естественно, с интринсиками/макросами, скрывающими определённые ассемблерные команды. Ну и не без линкерной магии, само собой.


  1. nikolz
    28.05.2024 08:29
    +1

    до кучи:

    https://programmer.ink/think/stm32-startup-file-startup_stm32f40xx.s-detailed-explanation.html

    STM32 startup file startup_stm32f40xx.s подробное объяснение

    Опубликовано richza в Сб, 22 Янв. 2022 05: 33: 06 +0100

    В этой статье подробно объяснен startup file построчно в соответствии с кодом.


  1. BARSRAB
    28.05.2024 08:29
    +3

    А какой смысл в данной статье? Что она дает нового относительно других статей в инете?


    1. Evgeny_E Автор
      28.05.2024 08:29

      1.       Перевод на русский язык, для тех, у кого английский на уровне технического;

      2.       Разбор материала, по которому иногда возникают вопросы. Например, при работе с RTOS нужно понимать, где и как размещаются стек и куча, как они инициализируются;

      3.       Удобно хранить ссылки на нужную информацию в одном месте, например на Хабре.

      4.       Узкоспециализированные темы зачастую не относятся к категории «интересные». Любопытно то, что по таким темам публикации продолжают набирать число добавлений в закладки, Хабр присылает статистику. А значит, они полезны.  


      1. BARSRAB
        28.05.2024 08:29
        +1

        Отлично, но в чем разница с аналогичными русскоязычными статьями, где написано ровно то же самое? Да и зачем программисту МК русскоязычная статья когда вся документация на английском?

        Например, при работе с RTOS нужно понимать, где и как размещаются стек и куча, как они инициализируются;

        Да, и про это десятки статей уже есть. И вообще странно, что кто-то пытается работать с RTOS, не зная о памяти МК.

        Узкоспециализированные темы зачастую не относятся к категории «интересные».

        Речь не об интересе, а о пользе материала. Да, перевод сделан неплохо и на это потрачено время. Но вот практического смысла я не вижу никакого. Причем вот тут явно переводилась статья этого же автора https://embeddy.ru/2018/01/31/startup-файл-для-cortex-m-в-arm-компиляторе/


        1. RichardMerlock
          28.05.2024 08:29

          Вот вы не смогли мимо пройти, чтобы аргументированно свой КГ/АМ не преподнести, а утверджаете, что не цепляет. Да еще как! В закладки, однозначно!


        1. NutsUnderline
          28.05.2024 08:29

          Да уж полезнее писать про клубнику и пельмени :) :)

          Но вообще возможно было бы чуть пользительнее это самое в разрезе Миландр и НИИЭТ 35. Там документация и русскоязычная и далеко не такая обширная.


  1. qiper
    28.05.2024 08:29
    +1

    Для запуска ядра используется ассемблерный код

    Может все-таки машинный код


    1. RichardMerlock
      28.05.2024 08:29

      По-моему, тут и так понятно, что отсутствует начало "Для написания программы...", то что ядро одни лишь байты хавает вполне себе очевидно.


  1. 100h
    28.05.2024 08:29
    +1

    Побуду немного занудой.

    FPU_IRQHandler (Flash point Unit IRQ).
    Flash point??? Это что-то новенькое.

    Дело в том, что когда ядро CortexM4 стартует, оно сперва копирует первую запись таблицы векторов по адресу указателя стека (MSP – Main Stack Pointer).
    Что такое "Адрес указателя стека" ? Какой адрес у регистра процессора?
    Первое 32-битное значение из таблицы прерываний записывается в регистр MSP.


    1. Evgeny_E Автор
      28.05.2024 08:29
      +1

      Сделал корректировку текста по вашим замечаниям: flash point - очевидно опечатка; "запись первого значения таблицы прерываний в регистр MSP", согласен, так будет понятнее.


  1. tonyk_av
    28.05.2024 08:29
    +1

    Откровенно говоря, не пониманию, для чего писать стартап на асме.
    С GCC идёт пример стартапа полностью на С, даже без вставок на асме. У себя в проектах давно использую написанный по его мотивам стартап. Заинтересовался вопросом, когда понадобилось реализовать обработчики прерываний для С++ в ООП-стиле, что логично и удобно разместить именно в стартапе.


  1. jaha33
    28.05.2024 08:29

    А где выполняется инициализация переменных ОЗУ? По идее это до main() должно быть.

    В SystemInit () не всегда включается тактирование, это есть в каких то семействах STM, но бывает и так что эта функция ничего не делает и все доп инициализации уже выполняются в main()


    1. Evgeny_E Автор
      28.05.2024 08:29

      Думаю, что на ваш вопрос ответы есть тут:

      "One of the purposes of SystemInit() in the CMSIS model is to initialize the clocks and memory subsystems BEFORE initializing the statics into them.

      The GNU startup.s files don't do this, whereas Keil calls SystemInit before __main, and in this case __main is the code that initializes the statics and unpacks the load regions described by the linker before calling the user's main() function."

      И тут:
      "main is your main procedure form main.c file, once __main is an internal procedure created by Keil toolchain which is calling at the end your main, but before it is initializing all variables (copying variables from FLASH to proper positions in RAM). In gcc it is seen explicitly, in Keil you can see it within debug process."


  1. megalloid
    28.05.2024 08:29

    Ещё и ARM-овый ASM, который по сути не доступен сейчас широкому кругу потребителей, только если Keil, и то кряченный, не говоря уж о *nix


  1. VT100
    28.05.2024 08:29

    А точно NOINIT это заполнение нулями в Keil'е?