Многие из начинающих разработчиков софта для микроконтроллеров реализуют свои проекты исключительно в средствах разработки, которые предоставляются производителем. Многое скрыто от пользователя и очень хорошо скрыто, из-за чего некоторые воспринимают эти процессы сродни настоящей магии. Я, в свою очередь, как человек в пытливым умом и природной любознательностью, решил попробовать собрать проект без использования IDE и различного рода автоматизаций.
Так родилась идея для этой статьи: не используя ничего, кроме текстового редактора и командной строки, собрать проект мигания светодиодом на отладочной плате STM32F0-Discovery. Поскольку я не до конца понимал, как происходит процесс компиляции и сборки проекта, мне пришлось восполнять пробелы в знаниях. Разобравшись с этим вопросом, я подумал — а почему бы не рассказать другим об этом?
Всем кому интересно — добро пожаловать под кат! 🙂
Дисклеймер. Сразу же хотелось бы оставить за собой право на ошибки, а также размытые и не полные интерпретации вещей, о которых собираюсь рассказать т.к. я не являюсь профессиональным программистом и специалистом в Embedded-системах. Но с другой стороны, любые конструктивные замечания или исправления дадут почву для саморазвития и самокоррекции, и ваша обоснованная обратная связь будет очень ценной для меня.
Чтобы получить файл прошивки, который мы в итоге загружаем на микроконтроллер, помимо IDE, используется определенный набор инструментов. Опишу кратко, что представляет из себя этот набор инструментов.
Поскольку микроконтроллеры STM32 изготовлены с использованием процессорного ядра ARM Cortex-M, одним из вариантов набора используемых инструментов является ARM GNU Toolchain, который сможет подготовить исполняемые файлы на x86/64 платформе для платформы ARM. В составе тулчейна имеется все необходимое: компилятор, ассемблер, линковщик и целая куча других полезных утилит.
Ранее я задавался вопросом: а почему компилятор так называется — gcc-arm-none-eabi? Оказалось, что все эти слова несут за собой вполне конкретный и определенный смысл:
Помимо набора утилит, для загрузки и проверки полученного исполняемого файла нам понадобится набор утилит ST-Link и сервер отладки OpenOCD.
Итак. Представим, что вы разобрались в том, как сделать первый микроконтроллерный Hello World — как поморгать светодиодом в автоматизированной среде разработки. Но теперь хочется разобраться, как под капотом происходит флоу трансформации исходных кодов в то, что может быть загружено в микроконтроллер.
Я накидал вот такую блок-схему:
На первом этапе подготавливаются исходные коды программ и библиотек. После того, как программа готова — идет процесс формирования файла, пригодного для исполнения на целевом микроконтроллере.
Сначала в работу включается препроцессор и готовит полный текст программы. Далее эта программа передается компилятору, который формирует ассемблерные листинги из которых получаются объектные файлы (либо сразу объектные файлы, минуя этап выдачи ассемблерных листингов), и, по заранее объявленным правилам, линковщик собирает из них выполняемый файл, который потом можно передать в программатор и залить на микроконтроллер. Возможны, конечно, и другие вариации этапов, но такой вариант более пригоден для того, чтобы подробно разобрать весь процесс.
Кажется, все вполне понятно и очевидно. Перейдем к деталям, коснувшись каждого этапа по отдельности.
Проще и нагляднее всего рассматривать работу каждого инструмента по отдельности и на конкретном примере. Давайте создадим простую программу, к которой подключим свою библиотеку.
Суть программы проста — инициализируем минимум необходимой периферии и мигаем светодиодом с помощью простой задержки. Максимально просто.
Создаем файл main.c:
Далее создаем два файла: delay.h и delay.c.
В delay.h вставляем:
В delay.c вставляем:
Для начала просто попробуем скомпилировать полученное без каких-либо дополнительных опций:
И из этого ничего не выйдет, потому что GCC по умолчанию линкует приложение с libc из newlib и в нем не может найти имплементацию целого вороха функций, которые реализуют системные вызовы:
Поскольку мы пишем приложение baremetal, то необходимо явно указать компилятору, чтобы он не использовал стандартные библиотеки, а взял только заглушки для оных:
Останется другая ошибка, которую мы пофиксим позже, когда настроим скрипт линковки:
Так. Исходники компилируются. Идём дальше.
После запуска процесса компиляции в IDE, в первую очередь в работу вступает препроцессор. Его задача — выполнить все указанные в исходном коде директивы, т.е. специальные команды, которые препроцессор распознает и исполняет:
Давайте посмотрим, что получается в ходе работы препроцессора. Для этого необходимо вызвать компилятор с опцией -E:
Теперь можно посмотреть, что получилось. Пролистайте файл pp.out — тут можно увидеть много интересного. Он, по своей сути, представляет листинг всего необходимого для работы программы в одном файле.
Первое, на что можно обратить внимание — объявление всех используемых типов данных, которые будут понятны компилятору, причем можно наблюдать те самые маркеры строк из п.7 (см. список выше), в которых указаны те или иные объявления. Приведу некоторые кусочки из этого внушительного списка:
Видно, как препроцессор “раздел” константы и подставил в них объявленные значения:
В общем и целом, получается очень наглядное представление того, что получается после сборки исходного кода в один файл. Можно поэкспериментировать с добавлением разных директив препроцессора и посмотреть, какой получается результат.
Например, добавим в код main.c следующее:
При просмотре результата работы препроцессора вы найдете функцию dummy_defined(), но не найдете dummy_notdefined(), и наоборот — если убрать объявление константы DUMMY_FUNCTION то в коде появится функция dummy_notdefined() и пропадет dummy_defined(). Очень наглядный эксперимент, но идём дальше.
Перейдем к этапу компиляции. Просто скомпилировать полученный вывод препроцессора не получится, будет выдана ошибка. И, чтобы скомпилировать полученный результат работы препроцессора, нужно скормить файл c выводом и добавить флаг, что препроцессинг уже пройден:
По итогу, будет сформированы объектные файлы, которые можно отправлять линковщику на компоновку. Но это не все, что нам необходимо. Чтобы был скомпилирован корректный файл, компилятору нужно указать некоторые флаги, которые влияют на конечный результат. Рассмотрим эти параметры.
Архитектура. Для указания целевой архитектуры можно использовать опции -march= или -mcpu= с аргументом cortex-m0. И, поскольку Cortex-M0 поддерживает только набор команд Thumb, обязательно нужно использовать опцию -mthumb. В дополнение к этому, необходимо указать, что работа с числами с плавающей запятой осуществляется софтовым образом (т.к. в Cortex-M0 процессорах нет аппаратного Floating Point Unit) через опцию -mfloat-abi=soft.
Стандарт GNU. Указывается очень просто: -std=gnu11.
Библиотеки. Библиотеки GNU ARM используют newlib для обеспечения стандартной реализации библиотек C. Чтобы уменьшить размер кода и сделать его независимым от аппаратного обеспечения, в микроконтроллерах используется облегченная версия newlib-nano. Однако newlib-nano не предоставляет реализацию низкоуровневых системных вызовов, которые используются стандартными библиотеками C, такими как print() или scan(), но позволяет существенно сократить размер исполняемого файла. Соответственно чтобы использовать библиотеку newlib-nano и nosys нужно добавить следующее: --specs=nano.specs --specs=nosys.specs.
Предупреждения во время компиляции. Чтобы видеть потенциальные ошибки, необходимо включить выдачу всех предупреждений во время компиляции: -Wall.
Уровень отладки. Для того, чтобы включить отладку, нужно добавить флаг -g.
Получится достаточно длинная строка с параметрами:
Также можно рассмотреть результат трансляции нашего кода в код на языке ассемблера, выполнив команду:
Разбирать содержимое мы не будем, но будем помнить, что именно таким образом можно посмотреть ассемблерный исходник, преобразующийся в ELF-файл, который, по идее чтобы стать работоспособным, должен быть правильно слинкован.
Следующий этап, который происходит при компиляции — формирование объектных ELF-файлов с разрешением *.o. Рассмотрим содержимое полученного ELF-файла по частям. Кстати, подробнее про ELF-файлы можно почитать тут.
Первый составной элемент ELF-файла, полученного после компиляции — заголовок. Вывести его не сложно:
Разбирать структуру и значения полей не будем, иначе статья превратится в книгу. Идем дальше 🙂.
Очень интересную информацию о составе полученного объектного файла можно получить, используя программу arm-none-eabi-objdump. Выполним сначала операцию отдельно для main.c, скомпилировав его:
И для файла delay.c:
Внесу немного ясности и поясню, что тут выведено. Если говорить простым языком — это вывод информации о том, каким образом разложен код функций, переменные и константы по полученному объектному файлу, который превратится в будущем в прошивку. У каждого полученного объектного файла свой набор программных секций, которые потом соединяются воедино.
Каждая из секций имеет собственное уникальное имя и набор атрибутов, определяющих целый ряд параметров:
Кратко рассмотрим эти секции.
Секция .text. Код и данные. Она содержит код выполняемых инструкций, которые будут располагаться во Flash памяти и константные значения, закодированные в “сырые” байты в конце функций.
Секция .data. Инициализированные переменные. Она содержит переменные, которые проинициализированы на старте и при запуске программы будут перенесены в RAM. Например, переменная uint32_t loop_enable = 1 будет аккурат размещена в этой секции. Размер данной секции обычно равен сумме размеров инициализированных переменных.
Секция .bss. Неинициализированные переменные. Некоторые переменные не имеют значения на старте и нет необходимости сохранять их значения — под них нужно лишь зарезервировать память. Например, переменная uint32_t delay_conter будет размещена в этой секции и будет занимать 4 байта.
Секция .rodata. Данные только для чтения. Содержит переменные с постоянным значением, которые будут сложены во Flash-памяти. Например, в этой секции будет сохранено значение переменной const uint32_t DELAY_MAX = 0x7A120;
Секция .comment. Содержит информацию о версии компилятора.
Секция .ARM.attributes. Содержит служебные сведения, которые в конечной прошивке не используются.
Могут быть сгенерированы так же и другие секции, но мы их пока рассматривать не будем т.к. необходимо будет достаточно глубоко заныривать в процесс работы тулчейна. Поэтому идем дальше.
Для того, чтобы просмотреть содержимое конкретной секции в объектном файле, можно сделать следующее:
Помимо секций, в объектном файле так же имеется таблица символов. Вывести ее не сложно:
Данная таблица содержит информацию, необходимую для поиска и перемещения символьных определений и ссылок программы.
Конечно, это не все, что содержится в ELF-файле, но для общего развития пока этого будет достаточно. Идём дальше.
С этим набором объектных файлов, у каждого из которых есть свои секции, необходимо что-то делать, чтобы всё заработало на целевой платформе. Конечно же, если попробовать превратить этот объектный файл в бинарь — ничего работать не будет.
Настало время рассмотреть, что такое линкер. Линкер используется для того, чтобы соединить секции в нескольких объектных файлах и правильно разместить их в памяти.
Например, после компиляции у нас получается следующий набор секций в файле main.o:
И такой же набор секций в файле delay.o:
Их необходимо правильно “склеить”, чтобы получить финальный исполняемый файл:
Для этого необходимо написать скрипт линковки и описать в нем, как будет это все размещено в памяти. Давайте по шагам разберем как это делается. Для того, чтобы правильно составить скрипт, необходимо создать файл линковки linker.ld и начать вносить в него содержимое.
Файл состоит из трех секций — ENTRY, MEMORY, SECTIONS. Начнем накидывать наш скрипт линковки, описав каждую из них.
Секция ENTRY. Она сообщает точку входа и указывает первую инструкцию, которая должна быть исполнена. В нашем случае это будет функция reset_handler, которую мы опишем позже:
Секция MEMORY. Описывает различные участки памяти целевой системы, такие как SRAM и Flash. Откроем Datasheet на STM32F051R8T6 и найдем раздел описания архитектуры:
Видим, что адрес начала SRAM 0x2000 0000, а у Flash — 0x0800 0000. После найдем указание размера этих участков памяти:
Таким образом, размер SRAM у данного микроконтроллера 8KB, размер Flash — 64KB. Укажем это:
В скобках описания отдельного региона памяти указывается режим доступа к памяти:
Далее указывается аппаратный адрес, где размещается данный блок памяти и его размер. Всё просто.
После необходимо передать указатель адреса “Stack Pointer”, который будет использоваться для инструкций PUSH и POP:
Секция SECTIONS. Создает раскладку содержания секций объектных файлов в памяти и указывает, каким образом данные секций будут расположены, и как будут загружаться. Общий синтаксис описания содержимого данной секции выглядит следующим образом:
Если вспомнить то, о чем я писал в разделе описания секций, получается следующее:
Поскольку специальных инструкций для указания адреса мы не используем, то секции будут располагаться в порядке их описания:
В этом скрипте, помимо объявленных секций, также объявляются важные служебные символы:
К каждому символу идет соответствующее определение, использующее счетчик местоположения памяти. Эти значения мы будем применять в startup-файле, чтобы правильно скопировать данные программы в RAM и занулить область секции .bss. Также, при создании скрипта линковки, необходимо указать инструкции выравнивания по 4-байтовой границе, чтобы предотвратить неверный доступ к памяти и не вызвать исключение, которое приведет к остановке программы.
Так. С этапом линковки разобрались. Теперь нужно настроить стартовую инициализацию. После сброса микроконтроллера и сигнала BOOT0, выставленного в значение логического нуля, происходит отражение региона памяти Flash 0x0800 0000 на начало адресного пространства 0x0000 0000 и считывается значение по адресу 0x0000 0000, а после это значение подставляется в MSP (указатель основного стека).
Затем контроллер прерываний NVIC начинает отрабатывать вектор RESET, который загружает в регистр PC адрес вектора reset_handler, находящийся по адресу 0x0000 0004 и передает управление ядру. После этого ядро считает команду по адресу, на который указывает регистр PC, и начнет выполнение программы.
В первую очередь, адрес основного указателя стека должен быть сохранен как первое слово в таблице векторов прерываний.
Помимо начального адреса указателя основного стека, таблица векторов прерываний должна содержать 15 слов для системных обработчиков прерываний ядра Cortex-M, и плюсом, столько же слов, сколько периферийных блоков используется в конкретной реализации микроконтроллера, для обработки прерываний и от них тоже. Иногда указываются зарезервированные вектора, то там необходимо проставить нули вместо этих индексов.
Информацию о прерываниях можно найти в Reference Manual на используемый микроконтроллер, в разделе в котором описаны прерывания. Например, для STM32F0 — найти все необходимые значения можно в таблице Vector Table:
Поскольку нам не понадобятся все прерывания от периферии, оставим только самые необходимые. Укажем их с weak-инструкцией, чтобы потом, при необходимости, можно было бы их переобъявить в коде основной программы.
Заметьте, что в коде, приведенном ниже, указан атрибут section, присваивающий содержимое секции isr_vector, чтобы функция гарантировано попала в нужный раздел памяти.
И последнее, что необходимо сделать — реализовать reset_handler который указан в качестве точки входа в скрипте компоновщика. Первым делом необходимо при старте скопировать содержимое .data-секции из Flash в SRAM, начиная с _sdata и заканчивая _edata, начав запись с адреса _etext, а после записать нули в адресное пространство размером отведенным под секцию .bss, с _sbss до _ebss.
Ну и последним шагом нужно вызвать функцию main.
Создадим файл startup.c, который выполнит все, что нам нужно:
Все. Теперь можем с уверенностью стартовать программу на МК. В итоге, если смешать все воедино: компиляцию, расположение секций, инициализацию, заполнение памяти и старт программы — получится следующая картина:
Перейдем к подготовке бинарного файла и заливке его в микроконтроллер.
Для того, чтобы откомпилировать полученные исходные файлы, необходимо выполнить уже знакомую нам команду:
При компиляции будет выдано предупреждение:
Оно, в нашем случае, является безвредным, и его можно проигнорировать. Теперь можно полученный файл конвертировать в bin-файл:
И прошить его в наш микроконтроллер:
После чего, можем наблюдать, что программа запустилась и светодиод начал радостно моргать. Профит.
Теперь разберем, что получилось. В первую очередь пробежимся еще раз по получившемуся ELF-файлу. Как мы разбирали выше — данный файл является обёрткой для bin-файла и содержит кучу служебной информации, такой как, например, таблица символов. Давайте посмотрим, как это выглядит:
Пользуясь данной таблицей, можно просмотреть значения переменных или адреса функций. Например, функция reset_handler находится по адресу 0x0800 0164, а обработчик исключительных ситуаций default_handler находится по адресу 0x0800 015c.
Давайте заглянем внутрь бинарного файла. В нем например, можно найти isr_vector по адресу 0x0800 0000 и посмотреть его содержимое:
Команда выведет значение группами по 4 байта, используя формат little-endian от 0 до 32 байта:
Мы видим, что значение указателя стека MSP, находящегося по адресу 0x0000 0000, имеет значение 0x2000 2000 и указывает на конечный адрес RAM. Плюсом, reset_handler, который является точкой входа, записанный по адресу 0x0000 0004 указывает на адрес 0x0800 0165, что на единицу больше, чем это указано в таблице символов. LSB выставленный в логическую единицу указывает, что процессор запускается с набором команд Thumb.
Также, можно посмотреть значение константы DELAY_MAX по адресу 0x0800 01E8, которая используется для задержки:
Можно еще раз просмотреть получившийся ассемблерный листинг и сравнить его с тем, который был вначале:
Пробежимся по нему дебаггером при выполнении на реальной железке. Запустим сервер отладки OpenOCD, который мы устанавливали в прошлой статье:
К нему можно выполнять два разных типа подключения:
Попробуем оба:
Тем временем OpenOCD выведет свои сообщения о происходящем:
Теперь попробуем отладиться через GDB-клиент. Для этого, в первую очередь, необходимо сбилдить прошивку с debug-опций, которая создаст соответствующие секции и все необходимое для отладки:
После запускаем GDB-отладчик с указанием пути к ELF-файлу, в котором будут содержаться необходимые данные для отладки — такие, например, как таблица символов:
Подключаемся к OpenOCD-серверу:
Запишем в микроконтроллер прошивку и выполним несколько интересных команд:
После отправим сигнал на сброс и на старт программы:
Светодиод начнет моргать, но нам интереснее выполнить программу по шагам. Для начала, после сброса, можно прочитать по 8 байтов по адресам 0x0000 0000 и 0x0800 0000:
Данной командой x (eXamine) можно прочитать значения памяти по указанному адресу. Через слэш указываем формат вывода, и сообщаем, что хотим прочитать 2 4-байтовых значения и вывести их в 16-ричном формате. По обоим адресам лежат одинаковые данные, и, если верить описанию старта микроконтроллера, в регистре SP должно быть значение 0x2000 2000, а в регистре PC — значение 0x0800 0165:
Все верно. Теперь можно выполнить дизассемблирование инструкции, которая сейчас указана в PC-регистре:
Добавим breakpoint в функции main и скажем, чтобы она выполнялась по шагам, отправляя команду n:
Казалось бы, зачем все эти заморочки, ведь современные IDE могут всё это генерить автоматом и не потребуется никаких ковыряний и такого объема работ. Но с другой стороны — теперь предельно ясно, что происходит под капотом, и как это все хозяйство собирается.
Мы разобрались в тонкостях процесса компиляции и того, что происходит перед началом выполнения программы, которую мы пишем в main.c файле.
Разумеется, все перечисленное выше не часто будет применяться в работе с микроконтроллерами, но для общего развития, я думаю, будет очень полезно понимать, как происходит всё поэтапно и в деталях.
Возможно, захочется почитать и это:
Так родилась идея для этой статьи: не используя ничего, кроме текстового редактора и командной строки, собрать проект мигания светодиодом на отладочной плате STM32F0-Discovery. Поскольку я не до конца понимал, как происходит процесс компиляции и сборки проекта, мне пришлось восполнять пробелы в знаниях. Разобравшись с этим вопросом, я подумал — а почему бы не рассказать другим об этом?
Всем кому интересно — добро пожаловать под кат! 🙂
Дисклеймер. Сразу же хотелось бы оставить за собой право на ошибки, а также размытые и не полные интерпретации вещей, о которых собираюсь рассказать т.к. я не являюсь профессиональным программистом и специалистом в Embedded-системах. Но с другой стороны, любые конструктивные замечания или исправления дадут почву для саморазвития и самокоррекции, и ваша обоснованная обратная связь будет очень ценной для меня.
❯ Набор инструментов
Чтобы получить файл прошивки, который мы в итоге загружаем на микроконтроллер, помимо IDE, используется определенный набор инструментов. Опишу кратко, что представляет из себя этот набор инструментов.
Поскольку микроконтроллеры STM32 изготовлены с использованием процессорного ядра ARM Cortex-M, одним из вариантов набора используемых инструментов является ARM GNU Toolchain, который сможет подготовить исполняемые файлы на x86/64 платформе для платформы ARM. В составе тулчейна имеется все необходимое: компилятор, ассемблер, линковщик и целая куча других полезных утилит.
Ранее я задавался вопросом: а почему компилятор так называется — gcc-arm-none-eabi? Оказалось, что все эти слова несут за собой вполне конкретный и определенный смысл:
- gcc — это название компилятора;
- arm — целевая архитектура процессора, под которую будет производиться компиляция;
- none — означает, что компилятор не вносит никакого дополнительного bootstrap-кода от себя;
- eabi — сообщает, что код соответствует спецификации двоичного интерфейса EABI.
Помимо набора утилит, для загрузки и проверки полученного исполняемого файла нам понадобится набор утилит ST-Link и сервер отладки OpenOCD.
Процесс установки и настройки тулчейна и программатора с отладчиком под LInux я описал в прошлой статье.
❯ Процесс билда прошивки
Итак. Представим, что вы разобрались в том, как сделать первый микроконтроллерный Hello World — как поморгать светодиодом в автоматизированной среде разработки. Но теперь хочется разобраться, как под капотом происходит флоу трансформации исходных кодов в то, что может быть загружено в микроконтроллер.
Я накидал вот такую блок-схему:
На первом этапе подготавливаются исходные коды программ и библиотек. После того, как программа готова — идет процесс формирования файла, пригодного для исполнения на целевом микроконтроллере.
Сначала в работу включается препроцессор и готовит полный текст программы. Далее эта программа передается компилятору, который формирует ассемблерные листинги из которых получаются объектные файлы (либо сразу объектные файлы, минуя этап выдачи ассемблерных листингов), и, по заранее объявленным правилам, линковщик собирает из них выполняемый файл, который потом можно передать в программатор и залить на микроконтроллер. Возможны, конечно, и другие вариации этапов, но такой вариант более пригоден для того, чтобы подробно разобрать весь процесс.
Кажется, все вполне понятно и очевидно. Перейдем к деталям, коснувшись каждого этапа по отдельности.
❯ Пример программы
Проще и нагляднее всего рассматривать работу каждого инструмента по отдельности и на конкретном примере. Давайте создадим простую программу, к которой подключим свою библиотеку.
Суть программы проста — инициализируем минимум необходимой периферии и мигаем светодиодом с помощью простой задержки. Максимально просто.
Создаем файл main.c:
#include <stdint.h>
#include "delay.h"
/* Clock */
#define RCC_APB1ENR *((volatile uint32_t*) (0x4002101C))
#define RCC_AHBENR *((volatile uint32_t*) (0x40021014))
/* GPIO C */
#define GPIOC_MODER *((volatile uint32_t*) (0x48000800))
#define GPIOC_ODR *((volatile uint32_t*) (0x48000814))
/* Global initialized variable */
uint32_t loop_enable = 1;
int main() {
RCC_APB1ENR |= (1 << 28); /* Enable clock on Power Interface */
RCC_AHBENR |= (0x00080014); /* Enable clock on GPIOC */
GPIOC_MODER |= (1 << (9*2)); /* Set GPIO PC9 to Output Mode */
while(loop_enable) {
GPIOC_ODR = 0x100;
delay();
GPIOC_ODR = 0x200;
delay();
}
return 0;
}
Далее создаем два файла: delay.h и delay.c.
В delay.h вставляем:
#define DELAY_FUNCTIONS_ON
#ifdef DELAY_FUNCTIONS_ON
/* Simple delay function */
void delay();
#endif
В delay.c вставляем:
#include <stdint.h>
/* Global Read-only variable */
const uint32_t DELAY_MAX = 0x7A120;
/* Global Uninitialized variable */
uint32_t delay_conter;
void delay() {
for(delay_conter = DELAY_MAX; delay_conter--;);
}
Для начала просто попробуем скомпилировать полученное без каких-либо дополнительных опций:
arm-none-eabi-gcc main.c delay.c -o main.elf
И из этого ничего не выйдет, потому что GCC по умолчанию линкует приложение с libc из newlib и в нем не может найти имплементацию целого вороха функций, которые реализуют системные вызовы:
/opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/bin/ld: /opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/lib/libc.a(libc_a-exit.o): in function `exit':
exit.c:(.text.exit+0x28): undefined reference to `_exit'
/opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/bin/ld: /opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/lib/libc.a(libc_a-writer.o): in function `_write_r':
writer.c:(.text._write_r+0x24): undefined reference to `_write'
/opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/bin/ld: /opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/lib/libc.a(libc_a-closer.o): in function `_close_r':
closer.c:(.text._close_r+0x18): undefined reference to `_close'
/opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/bin/ld: /opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/lib/libc.a(libc_a-lseekr.o): in function `_lseek_r':
lseekr.c:(.text._lseek_r+0x24): undefined reference to `_lseek'
/opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/bin/ld: /opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/lib/libc.a(libc_a-readr.o): in function `_read_r':
readr.c:(.text._read_r+0x24): undefined reference to `_read'
/opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/bin/ld: /opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/lib/libc.a(libc_a-sbrkr.o): in function `_sbrk_r':
sbrkr.c:(.text._sbrk_r+0x18): undefined reference to `_sbrk'
collect2: error: ld returned 1 exit status
Поскольку мы пишем приложение baremetal, то необходимо явно указать компилятору, чтобы он не использовал стандартные библиотеки, а взял только заглушки для оных:
arm-none-eabi-gcc main.c delay.c -o main.elf -nostdlib
Останется другая ошибка, которую мы пофиксим позже, когда настроим скрипт линковки:
/opt/gcc-arm-none-eabi/bin/../lib/gcc/arm-none-eabi/13.2.1/../../../../arm-none-eabi/bin/ld: warning: cannot find entry symbol _start; defaulting to 00008000
Так. Исходники компилируются. Идём дальше.
❯ Препроцессор
После запуска процесса компиляции в IDE, в первую очередь в работу вступает препроцессор. Его задача — выполнить все указанные в исходном коде директивы, т.е. специальные команды, которые препроцессор распознает и исполняет:
- Удаление комментариев из кода;
- Подключение файлов через директивы #include, #include_next;
- Условное подключение/удаление фрагментов кода: #if, #ifdef, #ifndef, #else, #elif, #endif;
- Вывод диагностических сообщении: #error, #warning, #line;
- Передача инструкций компилятору: #pragma;
- Определение макросов: #define;
- Расстановка специальных маркеров, которые помогают передавать указания на конкретные строки (помогает указывать на строки в которых содержатся ошибки);
- Прочие служебные функции.
Давайте посмотрим, что получается в ходе работы препроцессора. Для этого необходимо вызвать компилятор с опцией -E:
arm-none-eabi-gcc main.c delay.c -nostdlib -E > pp.out
Теперь можно посмотреть, что получилось. Пролистайте файл pp.out — тут можно увидеть много интересного. Он, по своей сути, представляет листинг всего необходимого для работы программы в одном файле.
Первое, на что можно обратить внимание — объявление всех используемых типов данных, которые будут понятны компилятору, причем можно наблюдать те самые маркеры строк из п.7 (см. список выше), в которых указаны те или иные объявления. Приведу некоторые кусочки из этого внушительного списка:
# 41 "/opt/gcc-arm-none-eabi/arm-none-eabi/include/machine/_default_types.h" 3 4
typedef signed char __int8_t;
typedef unsigned char __uint8_t;
# 55 "/opt/gcc-arm-none-eabi/arm-none-eabi/include/machine/_default_types.h" 3 4
typedef short int __int16_t;
typedef short unsigned int __uint16_t;
# 77 "/opt/gcc-arm-none-eabi/arm-none-eabi/include/machine/_default_types.h" 3 4
typedef long int __int32_t;
typedef long unsigned int __uint32_t;
# 103 "/opt/gcc-arm-none-eabi/arm-none-eabi/include/machine/_default_types.h" 3 4
typedef long long int __int64_t;
typedef long long unsigned int __uint64_t;
# 134 "/opt/gcc-arm-none-eabi/arm-none-eabi/include/machine/_default_types.h" 3 4
typedef signed char __int_least8_t;
typedef unsigned char __uint_least8_t;
# 160 "/opt/gcc-arm-none-eabi/arm-none-eabi/include/machine/_default_types.h" 3 4
typedef short int __int_least16_t;
Видно, как препроцессор “раздел” константы и подставил в них объявленные значения:
int main() {
*((volatile uint32_t*) (0x4002101C)) |= (1 << 28);
*((volatile uint32_t*) (0x40021014)) |= (0x00080014);
*((volatile uint32_t*) (0x48000800)) |= (1 << (9*2));
while(loop_enable) {
*((volatile uint32_t*) (0x48000814)) = 0x100;
delay();
*((volatile uint32_t*) (0x48000814)) = 0x200;
delay();
}
return 0;
}
В общем и целом, получается очень наглядное представление того, что получается после сборки исходного кода в один файл. Можно поэкспериментировать с добавлением разных директив препроцессора и посмотреть, какой получается результат.
Например, добавим в код main.c следующее:
#define DUMMY_FUNCTION 0
#ifdef DUMMY_FUNCTION
void dummy_defined(){
return 0;
}
#else
void dummy_notdefined(){
return 0;
}
#endif
При просмотре результата работы препроцессора вы найдете функцию dummy_defined(), но не найдете dummy_notdefined(), и наоборот — если убрать объявление константы DUMMY_FUNCTION то в коде появится функция dummy_notdefined() и пропадет dummy_defined(). Очень наглядный эксперимент, но идём дальше.
❯ Компиляция
Перейдем к этапу компиляции. Просто скомпилировать полученный вывод препроцессора не получится, будет выдана ошибка. И, чтобы скомпилировать полученный результат работы препроцессора, нужно скормить файл c выводом и добавить флаг, что препроцессинг уже пройден:
arm-none-eabi-gcc main.c delay.c -nostdlib -E > main.i
arm-none-eabi-gcc main.i -o main.o -nostdlib -fpreprocessed
По итогу, будет сформированы объектные файлы, которые можно отправлять линковщику на компоновку. Но это не все, что нам необходимо. Чтобы был скомпилирован корректный файл, компилятору нужно указать некоторые флаги, которые влияют на конечный результат. Рассмотрим эти параметры.
Архитектура. Для указания целевой архитектуры можно использовать опции -march= или -mcpu= с аргументом cortex-m0. И, поскольку Cortex-M0 поддерживает только набор команд Thumb, обязательно нужно использовать опцию -mthumb. В дополнение к этому, необходимо указать, что работа с числами с плавающей запятой осуществляется софтовым образом (т.к. в Cortex-M0 процессорах нет аппаратного Floating Point Unit) через опцию -mfloat-abi=soft.
Стандарт GNU. Указывается очень просто: -std=gnu11.
Библиотеки. Библиотеки GNU ARM используют newlib для обеспечения стандартной реализации библиотек C. Чтобы уменьшить размер кода и сделать его независимым от аппаратного обеспечения, в микроконтроллерах используется облегченная версия newlib-nano. Однако newlib-nano не предоставляет реализацию низкоуровневых системных вызовов, которые используются стандартными библиотеками C, такими как print() или scan(), но позволяет существенно сократить размер исполняемого файла. Соответственно чтобы использовать библиотеку newlib-nano и nosys нужно добавить следующее: --specs=nano.specs --specs=nosys.specs.
Предупреждения во время компиляции. Чтобы видеть потенциальные ошибки, необходимо включить выдачу всех предупреждений во время компиляции: -Wall.
Уровень отладки. Для того, чтобы включить отладку, нужно добавить флаг -g.
Получится достаточно длинная строка с параметрами:
arm-none-eabi-gcc main.i -o main.o \
-nostdlib \
-fpreprocessed \
-mcpu=cortex-m0 \
-mthumb \
-mfloat-abi=soft \
-std=gnu11 \
-Wall \
--specs=nano.specs \
--specs=nosys.specs \
-g
❯ Ассемблерный листинг
Также можно рассмотреть результат трансляции нашего кода в код на языке ассемблера, выполнив команду:
arm-none-eabi-gcc -s main.c -o main.s \
-S \
-nostdlib \
-mcpu=cortex-m0 \
-mthumb \
-mfloat-abi=soft \
-std=gnu11 \
-Wall \
--specs=nano.specs \
--specs=nosys.specs
Результат на языке ассемблера
.cpu cortex-m0
.arch armv6s-m
.fpu softvfp
.eabi_attribute 20, 1
.eabi_attribute 21, 1
.eabi_attribute 23, 3
.eabi_attribute 24, 1
.eabi_attribute 25, 1
.eabi_attribute 26, 1
.eabi_attribute 30, 6
.eabi_attribute 34, 0
.eabi_attribute 18, 4
.file "main.c"
.text
.global loop_enable
.data
.align 2
.type loop_enable, %object
.size loop_enable, 4
loop_enable:
.word 1
.text
.align 1
.global main
.syntax unified
.code 16
.thumb_func
.type main, %function
main:
@ args = 0, pretend = 0, frame = 0
@ frame_needed = 1, uses_anonymous_args = 0
push {r7, lr}
add r7, sp, #0
ldr r3, .L5
ldr r2, [r3]
ldr r3, .L5
movs r1, #128
lsls r1, r1, #21
orrs r2, r1
str r2, [r3]
ldr r3, .L5+4
ldr r2, [r3]
ldr r3, .L5+4
ldr r1, .L5+8
orrs r2, r1
str r2, [r3]
ldr r3, .L5+12
ldr r2, [r3]
ldr r3, .L5+12
movs r1, #128
lsls r1, r1, #11
orrs r2, r1
str r2, [r3]
b .L2
.L3:
ldr r3, .L5+16
movs r2, #128
lsls r2, r2, #1
str r2, [r3]
bl delay
ldr r3, .L5+16
movs r2, #128
lsls r2, r2, #2
str r2, [r3]
bl delay
.L2:
ldr r3, .L5+20
ldr r3, [r3]
cmp r3, #0
bne .L3
movs r3, #0
movs r0, r3
mov sp, r7
@ sp needed
pop {r7, pc}
.L6:
.align 2
.L5:
.word 1073877020
.word 1073877012
.word 524308
.word 1207961600
.word 1207961620
.word loop_enable
.size main, .-main
.ident "GCC: (Arm GNU Toolchain 13.2.rel1 (Build arm-13.7)) 13.2.1 20231009"
Разбирать содержимое мы не будем, но будем помнить, что именно таким образом можно посмотреть ассемблерный исходник, преобразующийся в ELF-файл, который, по идее чтобы стать работоспособным, должен быть правильно слинкован.
❯ ELF-файлы
Следующий этап, который происходит при компиляции — формирование объектных ELF-файлов с разрешением *.o. Рассмотрим содержимое полученного ELF-файла по частям. Кстати, подробнее про ELF-файлы можно почитать тут.
Первый составной элемент ELF-файла, полученного после компиляции — заголовок. Вывести его не сложно:
arm-none-eabi-readelf -h main.o
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: ARM
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 632 (bytes into file)
Flags: 0x5000000, Version5 EABI
Size of this header: 52 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 40 (bytes)
Number of section headers: 10
Section header string table index: 9
Разбирать структуру и значения полей не будем, иначе статья превратится в книгу. Идем дальше 🙂.
❯ Программные секции
Очень интересную информацию о составе полученного объектного файла можно получить, используя программу arm-none-eabi-objdump. Выполним сначала операцию отдельно для main.c, скомпилировав его:
arm-none-eabi-gcc -c main.c -o main.o \
-nostdlib \
-mcpu=cortex-m0 \
-mthumb \
-mfloat-abi=soft \
-std=gnu11 \
-Wall \
--specs=nano.specs \
--specs=nosys.specs
arm-none-eabi-objdump -h main.o
main.o: file format elf32-littlearm
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000070 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000004 00000000 00000000 000000a4 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 000000a8 2**0
ALLOC
3 .comment 00000045 00000000 00000000 000000a8 2**0
CONTENTS, READONLY
4 .ARM.attributes 0000002c 00000000 00000000 000000ed 2**0
CONTENTS, READONLY
И для файла delay.c:
arm-none-eabi-gcc -c delay.c -o delay.o
-nostdlib \
-mcpu=cortex-m0 \
-mthumb \
-mfloat-abi=soft \
-std=gnu11 \
-Wall \
--specs=nano.specs \
--specs=nosys.specs
arm-none-eabi-objdump -h delay.o
delay.o: file format elf32-littlearm
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000002c 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 00000060 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 00000000 00000000 00000060 2**2
ALLOC
3 .rodata 00000004 00000000 00000000 00000060 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 00000045 00000000 00000000 00000064 2**0
CONTENTS, READONLY
5 .ARM.attributes 0000002c 00000000 00000000 000000a9 2**0
CONTENTS, READONLY
Внесу немного ясности и поясню, что тут выведено. Если говорить простым языком — это вывод информации о том, каким образом разложен код функций, переменные и константы по полученному объектному файлу, который превратится в будущем в прошивку. У каждого полученного объектного файла свой набор программных секций, которые потом соединяются воедино.
Каждая из секций имеет собственное уникальное имя и набор атрибутов, определяющих целый ряд параметров:
- Тип содержимого, который определяет где секция будет размещаться, в памяти программ или данных;
- Тип позиционирования секции в пределах своей области памяти, она может быть абсолютной или относительной;
- Адрес размещения секции, по абсолютному адресу или по смещению;
- Режим соединения одноименных секций из разных исходных файлов;
- Размер самой секции.
Кратко рассмотрим эти секции.
Секция .text. Код и данные. Она содержит код выполняемых инструкций, которые будут располагаться во Flash памяти и константные значения, закодированные в “сырые” байты в конце функций.
Секция .data. Инициализированные переменные. Она содержит переменные, которые проинициализированы на старте и при запуске программы будут перенесены в RAM. Например, переменная uint32_t loop_enable = 1 будет аккурат размещена в этой секции. Размер данной секции обычно равен сумме размеров инициализированных переменных.
Секция .bss. Неинициализированные переменные. Некоторые переменные не имеют значения на старте и нет необходимости сохранять их значения — под них нужно лишь зарезервировать память. Например, переменная uint32_t delay_conter будет размещена в этой секции и будет занимать 4 байта.
Секция .rodata. Данные только для чтения. Содержит переменные с постоянным значением, которые будут сложены во Flash-памяти. Например, в этой секции будет сохранено значение переменной const uint32_t DELAY_MAX = 0x7A120;
Секция .comment. Содержит информацию о версии компилятора.
Секция .ARM.attributes. Содержит служебные сведения, которые в конечной прошивке не используются.
Могут быть сгенерированы так же и другие секции, но мы их пока рассматривать не будем т.к. необходимо будет достаточно глубоко заныривать в процесс работы тулчейна. Поэтому идем дальше.
Для того, чтобы просмотреть содержимое конкретной секции в объектном файле, можно сделать следующее:
arm-none-eabi-objdump -s -j .text main.o
main.o: file format elf32-littlearm
Contents of section .text:
0000 80b500af 144b1a68 134b8021 49050a43 .....K.h.K.!I..C
0010 1a60124b 1a68114b 11490a43 1a60114b .`.K.h.K.I.C.`.K
0020 1a68104b 8021c902 0a431a60 0be00e4b .h.K.!...C.`...K
0030 80225200 1a60fff7 feff0b4b 80229200 ."R..`.....K."..
0040 1a60fff7 feff094b 1b68002b efd10023 .`.....K.h.+...#
0050 1800bd46 80bdc046 1c100240 14100240 ...F...F...@...@
0060 14000800 00080048 14080048 00000000 .......H...H....
❯ Таблица символов
Помимо секций, в объектном файле так же имеется таблица символов. Вывести ее не сложно:
arm-none-eabi-objdump --syms main.o
main.o: file format elf32-littlearm
SYMBOL TABLE:
00000000 l df *ABS* 00000000 main.c
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000000 l d .comment 00000000 .comment
00000000 l d .ARM.attributes 00000000 .ARM.attributes
00000000 g O .data 00000004 loop_enable
00000000 g F .text 00000070 main
00000000 *UND* 00000000 delay
Данная таблица содержит информацию, необходимую для поиска и перемещения символьных определений и ссылок программы.
Конечно, это не все, что содержится в ELF-файле, но для общего развития пока этого будет достаточно. Идём дальше.
❯ Линковка
С этим набором объектных файлов, у каждого из которых есть свои секции, необходимо что-то делать, чтобы всё заработало на целевой платформе. Конечно же, если попробовать превратить этот объектный файл в бинарь — ничего работать не будет.
Настало время рассмотреть, что такое линкер. Линкер используется для того, чтобы соединить секции в нескольких объектных файлах и правильно разместить их в памяти.
Например, после компиляции у нас получается следующий набор секций в файле main.o:
main.c --> main.o {
.text,
.data,
.bss,
.rodata
}
И такой же набор секций в файле delay.o:
delay.c --> delay.o {
.text,
.data,
.bss,
.rodata
}
Их необходимо правильно “склеить”, чтобы получить финальный исполняемый файл:
blink.elf = main.o + delay.o = {
.text = .text(main) + .text(delay)}
.data = .data(main) + .data(delay)}
.bss = .bss(main) + .bss(delay)}
.rodata = .rodata(main) + .rodata(delay)}
}
Для этого необходимо написать скрипт линковки и описать в нем, как будет это все размещено в памяти. Давайте по шагам разберем как это делается. Для того, чтобы правильно составить скрипт, необходимо создать файл линковки linker.ld и начать вносить в него содержимое.
Файл состоит из трех секций — ENTRY, MEMORY, SECTIONS. Начнем накидывать наш скрипт линковки, описав каждую из них.
Секция ENTRY. Она сообщает точку входа и указывает первую инструкцию, которая должна быть исполнена. В нашем случае это будет функция reset_handler, которую мы опишем позже:
ENTRY(reset_handler)
Секция MEMORY. Описывает различные участки памяти целевой системы, такие как SRAM и Flash. Откроем Datasheet на STM32F051R8T6 и найдем раздел описания архитектуры:
Видим, что адрес начала SRAM 0x2000 0000, а у Flash — 0x0800 0000. После найдем указание размера этих участков памяти:
Таким образом, размер SRAM у данного микроконтроллера 8KB, размер Flash — 64KB. Укажем это:
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 8K
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
}
В скобках описания отдельного региона памяти указывается режим доступа к памяти:
- x — доступно для исполнения;
- r — доступно для чтения;
- w — доступно для записи.
Далее указывается аппаратный адрес, где размещается данный блок памяти и его размер. Всё просто.
После необходимо передать указатель адреса “Stack Pointer”, который будет использоваться для инструкций PUSH и POP:
_estack = ORIGIN(RAM) + LENGTH(RAM);
Секция SECTIONS. Создает раскладку содержания секций объектных файлов в памяти и указывает, каким образом данные секций будут расположены, и как будут загружаться. Общий синтаксис описания содержимого данной секции выглядит следующим образом:
SECTIONS
{
<symbol> = LOADADDR(<symbol>);
.<section>:
{
<symbol> = .;
*(.sub_section);
. = ALIGN(n);
} ><Run Location> [AT> Storage Location]
}
Если вспомнить то, о чем я писал в разделе описания секций, получается следующее:
- Секция .isr_vector — это служебная секция, которая не создается по умолчанию и ее необходимо создать вручную. По сути, в ней указывается вектор обработчика прерываний ISR, который должен находиться по адресу 0x0000 0000;
- Секция .text — это исполняемый код, находящийся во Flash;
- Секция .data — это переменные, размещенные в SRAM;
- Секция .rodata — это константы, размещенные в Flash;
- Секция .bss — это объявленные, но не инициализированные переменные, то есть с нулевым значением при старте, которые будут размещены в SRAM.
Поскольку специальных инструкций для указания адреса мы не используем, то секции будут располагаться в порядке их описания:
SECTIONS
{
.isr_vector :
{
KEEP(*(.isr_vector))
} >FLASH
.text :
{
. = ALIGN(4);
*(.text)
. = ALIGN(4);
_etext = .;
} >FLASH
.rodata :
{
. = ALIGN(4);
*(.rodata)
} >FLASH
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data)
. = ALIGN(4);
_edata = .;
} >RAM AT> FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
. = ALIGN(4);
_ebss = .;
} >RAM
}
В этом скрипте, помимо объявленных секций, также объявляются важные служебные символы:
- _etext — конец секции .text;
- _sdata — старт секции .data;
- _edata — конец секции .data;
- _sbss — старт секции .bss;
- _ebss — конец секции .bss.
К каждому символу идет соответствующее определение, использующее счетчик местоположения памяти. Эти значения мы будем применять в startup-файле, чтобы правильно скопировать данные программы в RAM и занулить область секции .bss. Также, при создании скрипта линковки, необходимо указать инструкции выравнивания по 4-байтовой границе, чтобы предотвратить неверный доступ к памяти и не вызвать исключение, которое приведет к остановке программы.
❯ Таблица векторов прерываний и Startup-файл
Так. С этапом линковки разобрались. Теперь нужно настроить стартовую инициализацию. После сброса микроконтроллера и сигнала BOOT0, выставленного в значение логического нуля, происходит отражение региона памяти Flash 0x0800 0000 на начало адресного пространства 0x0000 0000 и считывается значение по адресу 0x0000 0000, а после это значение подставляется в MSP (указатель основного стека).
Затем контроллер прерываний NVIC начинает отрабатывать вектор RESET, который загружает в регистр PC адрес вектора reset_handler, находящийся по адресу 0x0000 0004 и передает управление ядру. После этого ядро считает команду по адресу, на который указывает регистр PC, и начнет выполнение программы.
В первую очередь, адрес основного указателя стека должен быть сохранен как первое слово в таблице векторов прерываний.
Помимо начального адреса указателя основного стека, таблица векторов прерываний должна содержать 15 слов для системных обработчиков прерываний ядра Cortex-M, и плюсом, столько же слов, сколько периферийных блоков используется в конкретной реализации микроконтроллера, для обработки прерываний и от них тоже. Иногда указываются зарезервированные вектора, то там необходимо проставить нули вместо этих индексов.
Информацию о прерываниях можно найти в Reference Manual на используемый микроконтроллер, в разделе в котором описаны прерывания. Например, для STM32F0 — найти все необходимые значения можно в таблице Vector Table:
Поскольку нам не понадобятся все прерывания от периферии, оставим только самые необходимые. Укажем их с weak-инструкцией, чтобы потом, при необходимости, можно было бы их переобъявить в коде основной программы.
Заметьте, что в коде, приведенном ниже, указан атрибут section, присваивающий содержимое секции isr_vector, чтобы функция гарантировано попала в нужный раздел памяти.
И последнее, что необходимо сделать — реализовать reset_handler который указан в качестве точки входа в скрипте компоновщика. Первым делом необходимо при старте скопировать содержимое .data-секции из Flash в SRAM, начиная с _sdata и заканчивая _edata, начав запись с адреса _etext, а после записать нули в адресное пространство размером отведенным под секцию .bss, с _sbss до _ebss.
Ну и последним шагом нужно вызвать функцию main.
Создадим файл startup.c, который выполнит все, что нам нужно:
#include <stdint.h>
#define SRAM_START (0x20000000U) // Адрес начала SRAM
#define SRAM_SIZE (8U * 1024U) // Размер SRAM
#define SRAM_END (SRAM_START + SRAM_SIZE) // Конец SRAM
#define STACK_POINTER_INIT_ADDRESS (SRAM_END) // Указатель стека
#define ISR_VECTOR_SIZE_WORDS 48 // Количество векторов прерываний
// Объявление векторов прерывания
void default_handler(void);
void reset_handler(void);
void nmi_handler(void) __attribute__((weak, alias("default_handler")));
void hard_fault_handler(void) __attribute__((weak, alias("default_handler")));
void svcall_handler(void) __attribute__((weak, alias("default_handler")));
void pendsv_handler(void) __attribute__((weak, alias("default_handler")));
void systick_handler(void) __attribute__((weak, alias("default_handler")));
// Объявим функцию которая будет расположена в секции .isr_vector
uint32_t isr_vector[ISR_VECTOR_SIZE_WORDS] __attribute__((section(".isr_vector"))) =
{
STACK_POINTER_INIT_ADDRESS,
(uint32_t)&reset_handler,
(uint32_t)&nmi_handler,
(uint32_t)&hard_fault_handler,
0,
0,
0,
0,
0,
0,
0,
(uint32_t)&svcall_handler,
0,
0,
(uint32_t)&pendsv_handler,
(uint32_t)&systick_handler,
// Можно продолжить описание остальных периферийных векторов...
};
// Объявим обработчик прерываний по умолчанию
void default_handler(void)
{
while(1);
}
extern uint32_t _etext;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
// Подключаем функцию main
extern int main(void);
// Объявим функцию которая будет выполнена после сброса и передаст управление в main
void reset_handler(void)
{
// Копируем .data из FLASH в SRAM
uint32_t data_size = (uint32_t)&_edata - (uint32_t)&_sdata;
uint8_t *flash_data = (uint8_t*) &_etext;
uint8_t *sram_data = (uint8_t*) &_sdata;
for (uint32_t i = 0; i < data_size; i++)
{
sram_data[i] = flash_data[i];
}
// Заполняем нулями .bss секцию в SRAM
uint32_t bss_size = (uint32_t)&_ebss - (uint32_t)&_sbss;
uint32_t *bss = (uint32_t*) &_sbss;
for (uint32_t i = 0; i < bss_size; i++)
{
bss[i] = 0;
}
// Переходим в main-функцию
main();
}
Все. Теперь можем с уверенностью стартовать программу на МК. В итоге, если смешать все воедино: компиляцию, расположение секций, инициализацию, заполнение памяти и старт программы — получится следующая картина:
Перейдем к подготовке бинарного файла и заливке его в микроконтроллер.
❯ Компиляция бинарного файла
Для того, чтобы откомпилировать полученные исходные файлы, необходимо выполнить уже знакомую нам команду:
arm-none-eabi-gcc main.c delay.c startup.c \
-T linker.ld \
-o blink.elf \
-nostdlib \
-mcpu=cortex-m0 \
-mthumb \
-mfloat-abi=soft \
-std=gnu11 \
-Wall
При компиляции будет выдано предупреждение:
ld: warning: blink.elf has a LOAD segment with RWX permissions
Оно, в нашем случае, является безвредным, и его можно проигнорировать. Теперь можно полученный файл конвертировать в bin-файл:
arm-none-eabi-objcopy -O binary blink.elf blink.bin
И прошить его в наш микроконтроллер:
st-flash write blink.bin 0x08000000
После чего, можем наблюдать, что программа запустилась и светодиод начал радостно моргать. Профит.
❯ Дебаггер и выполнение программы по шагам
Теперь разберем, что получилось. В первую очередь пробежимся еще раз по получившемуся ELF-файлу. Как мы разбирали выше — данный файл является обёрткой для bin-файла и содержит кучу служебной информации, такой как, например, таблица символов. Давайте посмотрим, как это выглядит:
arm-none-eabi-nm blink.elf
0800015c W bus_fault_handler
0800015c W debug_monitor_handler
0800015c T default_handler
08000130 T delay
20000004 B delay_conter
080001e8 R DELAY_MAX
20000008 B _ebss
20000004 D _edata
20002000 D _estack
080001e8 T _etext
0800015c W hard_fault_handler
08000000 D isr_vector
20000000 D loop_enable
080000c0 T main
0800015c W nmi_handler
0800015c W pendsv_handler
08000164 T reset_handler
20000004 B _sbss
20000000 D _sdata
0800015c W svcall_handler
0800015c W systick_handler
0800015c W usage_fault_handler
Пользуясь данной таблицей, можно просмотреть значения переменных или адреса функций. Например, функция reset_handler находится по адресу 0x0800 0164, а обработчик исключительных ситуаций default_handler находится по адресу 0x0800 015c.
Давайте заглянем внутрь бинарного файла. В нем например, можно найти isr_vector по адресу 0x0800 0000 и посмотреть его содержимое:
xxd -g4 -e -s0 -l32 blink.bin
Команда выведет значение группами по 4 байта, используя формат little-endian от 0 до 32 байта:
00000000: 20002000 08000165 0800015d 0800015d . . e...]...]...
00000010: 0800015d 0800015d 00000000 00000000 ]...]...........
Мы видим, что значение указателя стека MSP, находящегося по адресу 0x0000 0000, имеет значение 0x2000 2000 и указывает на конечный адрес RAM. Плюсом, reset_handler, который является точкой входа, записанный по адресу 0x0000 0004 указывает на адрес 0x0800 0165, что на единицу больше, чем это указано в таблице символов. LSB выставленный в логическую единицу указывает, что процессор запускается с набором команд Thumb.
Также, можно посмотреть значение константы DELAY_MAX по адресу 0x0800 01E8, которая используется для задержки:
# cat delay.c | grep DELAY_MAX -m 1
const uint32_t DELAY_MAX = 0x1A120;
# xxd -g4 -e -s0x1e8 -l4 blink.bin
000001e8: 0001a120 ...
Можно еще раз просмотреть получившийся ассемблерный листинг и сравнить его с тем, который был вначале:
arm-none-eabi-objdump --disassemble blink.elf
Длинный листинг на языке ассемблера
blink.elf: file format elf32-littlearm
Disassembly of section .text:
080000c0 <main>:
80000c0: b580 push {r7, lr}
80000c2: af00 add r7, sp, #0
80000c4: 4b14 ldr r3, [pc, #80] @ (8000118 <main+0x58>)
80000c6: 681a ldr r2, [r3, #0]
80000c8: 4b13 ldr r3, [pc, #76] @ (8000118 <main+0x58>)
80000ca: 2180 movs r1, #128 @ 0x80
80000cc: 0549 lsls r1, r1, #21
80000ce: 430a orrs r2, r1
80000d0: 601a str r2, [r3, #0]
80000d2: 4b12 ldr r3, [pc, #72] @ (800011c <main+0x5c>)
80000d4: 681a ldr r2, [r3, #0]
80000d6: 4b11 ldr r3, [pc, #68] @ (800011c <main+0x5c>)
80000d8: 4911 ldr r1, [pc, #68] @ (8000120 <main+0x60>)
80000da: 430a orrs r2, r1
80000dc: 601a str r2, [r3, #0]
80000de: 4b11 ldr r3, [pc, #68] @ (8000124 <main+0x64>)
80000e0: 681a ldr r2, [r3, #0]
80000e2: 4b10 ldr r3, [pc, #64] @ (8000124 <main+0x64>)
80000e4: 2180 movs r1, #128 @ 0x80
80000e6: 02c9 lsls r1, r1, #11
80000e8: 430a orrs r2, r1
80000ea: 601a str r2, [r3, #0]
80000ec: e00b b.n 8000106 <main+0x46>
80000ee: 4b0e ldr r3, [pc, #56] @ (8000128 <main+0x68>)
80000f0: 2280 movs r2, #128 @ 0x80
80000f2: 0052 lsls r2, r2, #1
80000f4: 601a str r2, [r3, #0]
80000f6: f000 f81b bl 8000130 <delay>
80000fa: 4b0b ldr r3, [pc, #44] @ (8000128 <main+0x68>)
80000fc: 2280 movs r2, #128 @ 0x80
80000fe: 0092 lsls r2, r2, #2
8000100: 601a str r2, [r3, #0]
8000102: f000 f815 bl 8000130 <delay>
8000106: 4b09 ldr r3, [pc, #36] @ (800012c <main+0x6c>)
8000108: 681b ldr r3, [r3, #0]
800010a: 2b00 cmp r3, #0
800010c: d1ef bne.n 80000ee <main+0x2e>
800010e: 2300 movs r3, #0
8000110: 0018 movs r0, r3
8000112: 46bd mov sp, r7
8000114: bd80 pop {r7, pc}
8000116: 46c0 nop @ (mov r8, r8)
8000118: 4002101c .word 0x4002101c
800011c: 40021014 .word 0x40021014
8000120: 00080014 .word 0x00080014
8000124: 48000800 .word 0x48000800
8000128: 48000814 .word 0x48000814
800012c: 20000000 .word 0x20000000
08000130 <delay>:
8000130: b580 push {r7, lr}
8000132: af00 add r7, sp, #0
8000134: 4a07 ldr r2, [pc, #28] @ (8000154 <delay+0x24>)
8000136: 4b08 ldr r3, [pc, #32] @ (8000158 <delay+0x28>)
8000138: 601a str r2, [r3, #0]
800013a: 46c0 nop @ (mov r8, r8)
800013c: 4b06 ldr r3, [pc, #24] @ (8000158 <delay+0x28>)
800013e: 681b ldr r3, [r3, #0]
8000140: 1e59 subs r1, r3, #1
8000142: 4a05 ldr r2, [pc, #20] @ (8000158 <delay+0x28>)
8000144: 6011 str r1, [r2, #0]
8000146: 2b00 cmp r3, #0
8000148: d1f8 bne.n 800013c <delay+0xc>
800014a: 46c0 nop @ (mov r8, r8)
800014c: 46c0 nop @ (mov r8, r8)
800014e: 46bd mov sp, r7
8000150: bd80 pop {r7, pc}
8000152: 46c0 nop @ (mov r8, r8)
8000154: 0001a120 .word 0x0001a120
8000158: 20000004 .word 0x20000004
0800015c <default_handler>:
800015c: b580 push {r7, lr}
800015e: af00 add r7, sp, #0
8000160: 46c0 nop @ (mov r8, r8)
8000162: e7fd b.n 8000160 <default_handler+0x4>
08000164 <reset_handler>:
8000164: b580 push {r7, lr}
8000166: b088 sub sp, #32
8000168: af00 add r7, sp, #0
800016a: 4a1a ldr r2, [pc, #104] @ (80001d4 <reset_handler+0x70>)
800016c: 4b1a ldr r3, [pc, #104] @ (80001d8 <reset_handler+0x74>)
800016e: 1ad3 subs r3, r2, r3
8000170: 617b str r3, [r7, #20]
8000172: 4b1a ldr r3, [pc, #104] @ (80001dc <reset_handler+0x78>)
8000174: 613b str r3, [r7, #16]
8000176: 4b18 ldr r3, [pc, #96] @ (80001d8 <reset_handler+0x74>)
8000178: 60fb str r3, [r7, #12]
800017a: 2300 movs r3, #0
800017c: 61fb str r3, [r7, #28]
800017e: e00a b.n 8000196 <reset_handler+0x32>
8000180: 693a ldr r2, [r7, #16]
8000182: 69fb ldr r3, [r7, #28]
8000184: 18d2 adds r2, r2, r3
8000186: 68f9 ldr r1, [r7, #12]
8000188: 69fb ldr r3, [r7, #28]
800018a: 18cb adds r3, r1, r3
800018c: 7812 ldrb r2, [r2, #0]
800018e: 701a strb r2, [r3, #0]
8000190: 69fb ldr r3, [r7, #28]
8000192: 3301 adds r3, #1
8000194: 61fb str r3, [r7, #28]
8000196: 69fa ldr r2, [r7, #28]
8000198: 697b ldr r3, [r7, #20]
800019a: 429a cmp r2, r3
800019c: d3f0 bcc.n 8000180 <reset_handler+0x1c>
800019e: 4a10 ldr r2, [pc, #64] @ (80001e0 <reset_handler+0x7c>)
80001a0: 4b10 ldr r3, [pc, #64] @ (80001e4 <reset_handler+0x80>)
80001a2: 1ad3 subs r3, r2, r3
80001a4: 60bb str r3, [r7, #8]
80001a6: 4b0f ldr r3, [pc, #60] @ (80001e4 <reset_handler+0x80>)
80001a8: 607b str r3, [r7, #4]
80001aa: 2300 movs r3, #0
80001ac: 61bb str r3, [r7, #24]
80001ae: e007 b.n 80001c0 <reset_handler+0x5c>
80001b0: 687a ldr r2, [r7, #4]
80001b2: 69bb ldr r3, [r7, #24]
80001b4: 18d3 adds r3, r2, r3
80001b6: 2200 movs r2, #0
80001b8: 701a strb r2, [r3, #0]
80001ba: 69bb ldr r3, [r7, #24]
80001bc: 3301 adds r3, #1
80001be: 61bb str r3, [r7, #24]
80001c0: 69ba ldr r2, [r7, #24]
80001c2: 68bb ldr r3, [r7, #8]
80001c4: 429a cmp r2, r3
80001c6: d3f3 bcc.n 80001b0 <reset_handler+0x4c>
80001c8: f7ff ff7a bl 80000c0 <main>
80001cc: 46c0 nop @ (mov r8, r8)
80001ce: 46bd mov sp, r7
80001d0: b008 add sp, #32
80001d2: bd80 pop {r7, pc}
80001d4: 20000004 .word 0x20000004
80001d8: 20000000 .word 0x20000000
80001dc: 080001e8 .word 0x080001e8
80001e0: 20000008 .word 0x20000008
80001e4: 20000004 .word 0x20000004
Пробежимся по нему дебаггером при выполнении на реальной железке. Запустим сервер отладки OpenOCD, который мы устанавливали в прошлой статье:
openocd -f /usr/local/share/openocd/scripts/interface/stlink.cfg \
-f /usr/local/share/openocd/scripts/board/stm32f0discovery.cfg
К нему можно выполнять два разных типа подключения:
- В качестве GBD-клиента по порту 3333, используя для отладки какой-либо внешний софт (например, из IDE);
- Telnet-клиентом по порту 4444, отправляя команды отладчику напрямую.
Попробуем оба:
telnet localhost 4444
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Open On-Chip Debugger
> flash write_image erase /home/megalloid/STM32/manual-build/blink.elf
device id = 0x20006440
flash size = 64 KiB
Adding extra erase range, 0x080001f0 .. 0x080003ff
auto erase enabled
wrote 496 bytes from file /home/megalloid/STM32/manual-build/blink.elf in 0.078516s (6.169 KiB/s)
> reset halt
Unable to match requested speed 1000 kHz, using 950 kHz
Unable to match requested speed 1000 kHz, using 950 kHz
[stm32f0x.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0x08000164 msp: 0x20002000
> resume
Тем временем OpenOCD выведет свои сообщения о происходящем:
Open On-Chip Debugger 0.12.0+dev-01496-gea2e26f7d (2024-01-20-20:28)
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
Warn : Interface already configured, ignoring
Error: already specified hl_layout stlink
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
srst_only separate srst_nogate srst_open_drain connect_deassert_srst
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : clock speed 1000 kHz
Info : STLINK V2J37S0 (API v2) VID:PID 0483:3748
Info : Target voltage: 2.874616
Info : [stm32f0x.cpu] Cortex-M0 r0p0 processor detected
Info : [stm32f0x.cpu] target has 4 breakpoints, 2 watchpoints
Info : [stm32f0x.cpu] Examination succeed
Info : starting gdb server for stm32f0x.cpu on 3333
Info : Listening on port 3333 for gdb connections
[stm32f0x.cpu] halted due to breakpoint, current mode: Thread
xPSR: 0x61000000 pc: 0x080000fa msp: 0x20001fd0
Info : accepting 'telnet' connection on tcp/4444
Info : device id = 0x20006440
Info : flash size = 64 KiB
Warn : Adding extra erase range, 0x080001f0 .. 0x080003ff
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : Unable to match requested speed 1000 kHz, using 950 kHz
[stm32f0x.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0x08000164 msp: 0x20002000
Теперь попробуем отладиться через GDB-клиент. Для этого, в первую очередь, необходимо сбилдить прошивку с debug-опций, которая создаст соответствующие секции и все необходимое для отладки:
arm-none-eabi-gcc main.c delay.c startup.c \
-T linker.ld \
-o blink-debug.elf \
-nostdlib \
-mcpu=cortex-m0 \
-mthumb \
-mfloat-abi=soft \
-std=gnu11 \
-Wall \
-g
После запускаем GDB-отладчик с указанием пути к ELF-файлу, в котором будут содержаться необходимые данные для отладки — такие, например, как таблица символов:
arm-none-eabi-gdb blink-debug.elf
Подключаемся к OpenOCD-серверу:
(gdb) target extended-remote localhost:3333
Запишем в микроконтроллер прошивку и выполним несколько интересных команд:
(gdb) monitor flash write_image erase /home/megalloid/STM32/manual-build/blink.elf
Adding extra erase range, 0x080001f0 .. 0x080003ff
auto erase enabled
wrote 496 bytes from file /home/megalloid/STM32/manual-build/blink.elf in 0.080308s (6.031 KiB/s)
После отправим сигнал на сброс и на старт программы:
(gdb) monitor reset halt
Unable to match requested speed 1000 kHz, using 950 kHz
Unable to match requested speed 1000 kHz, using 950 kHz
[stm32f0x.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0x08000164 msp: 0x20002000
(gdb) monitor resume
Светодиод начнет моргать, но нам интереснее выполнить программу по шагам. Для начала, после сброса, можно прочитать по 8 байтов по адресам 0x0000 0000 и 0x0800 0000:
(gdb) monitor reset halt
(gdb) x/2z 0x00000000
0x0: 0x20002000 0x08000165
(gdb) x/2z 0x08000000
0x8000000: 0x20002000 0x08000165
Данной командой x (eXamine) можно прочитать значения памяти по указанному адресу. Через слэш указываем формат вывода, и сообщаем, что хотим прочитать 2 4-байтовых значения и вывести их в 16-ричном формате. По обоим адресам лежат одинаковые данные, и, если верить описанию старта микроконтроллера, в регистре SP должно быть значение 0x2000 2000, а в регистре PC — значение 0x0800 0165:
(gdb) print/z $sp
$1 = 0x20002000
(gdb) print/z $pc
$2 = 0x08000164
Все верно. Теперь можно выполнить дизассемблирование инструкции, которая сейчас указана в PC-регистре:
(gdb) x/i $pc
=> 0x800016a <reset_handler+6>: ldr r2, [pc, #104] @ (0x80001d4 <reset_handler+112>)
Добавим breakpoint в функции main и скажем, чтобы она выполнялась по шагам, отправляя команду n:
Длинный отладочный листинг
(gdb) monitor reset halt
Unable to match requested speed 1000 kHz, using 950 kHz
Unable to match requested speed 1000 kHz, using 950 kHz
[stm32f0x.cpu] halted due to debug-request, current mode: Thread
xPSR: 0xc1000000 pc: 0x08000164 msp: 0x20002000
(gdb) br main
Breakpoint 2 at 0x80000c4: file main.c, line 17.
(gdb) stepi
halted: PC: 0x08000166
halted: PC: 0x08000168
halted: PC: 0x0800016a
reset_handler () at startup.c:52
52 uint32_t data_size = (uint32_t)&_edata - (uint32_t)&_sdata;
(gdb) n
halted: PC: 0x0800016c
halted: PC: 0x0800016e
halted: PC: 0x08000170
halted: PC: 0x08000172
53 uint8_t *flash_data = (uint8_t*) &_etext;
(gdb) n
halted: PC: 0x08000174
halted: PC: 0x08000176
54 uint8_t *sram_data = (uint8_t*) &_sdata;
(gdb) n
halted: PC: 0x08000178
halted: PC: 0x0800017a
56 for (uint32_t i = 0; i < data_size; i++)
(gdb) n
halted: PC: 0x0800017c
halted: PC: 0x0800017e
halted: PC: 0x08000196
halted: PC: 0x08000198
halted: PC: 0x0800019a
halted: PC: 0x0800019c
halted: PC: 0x08000180
58 sram_data[i] = flash_data[i];
(gdb) n
halted: PC: 0x08000182
halted: PC: 0x08000184
halted: PC: 0x08000186
halted: PC: 0x08000188
halted: PC: 0x0800018a
halted: PC: 0x0800018c
halted: PC: 0x0800018e
halted: PC: 0x08000190
56 for (uint32_t i = 0; i < data_size; i++)
(gdb) n
halted: PC: 0x08000192
halted: PC: 0x08000194
halted: PC: 0x08000196
halted: PC: 0x08000198
halted: PC: 0x0800019a
halted: PC: 0x0800019c
halted: PC: 0x08000180
58 sram_data[i] = flash_data[i];
(gdb) n
halted: PC: 0x08000182
halted: PC: 0x08000184
halted: PC: 0x08000186
halted: PC: 0x08000188
halted: PC: 0x0800018a
halted: PC: 0x0800018c
halted: PC: 0x0800018e
halted: PC: 0x08000190
56 for (uint32_t i = 0; i < data_size; i++)
(gdb) n
halted: PC: 0x08000192
halted: PC: 0x08000194
halted: PC: 0x08000196
halted: PC: 0x08000198
halted: PC: 0x0800019a
halted: PC: 0x0800019c
halted: PC: 0x08000180
58 sram_data[i] = flash_data[i];
(gdb) n
halted: PC: 0x08000182
halted: PC: 0x08000184
halted: PC: 0x08000186
halted: PC: 0x08000188
halted: PC: 0x0800018a
halted: PC: 0x0800018c
halted: PC: 0x0800018e
halted: PC: 0x08000190
56 for (uint32_t i = 0; i < data_size; i++)
(gdb) n
halted: PC: 0x08000192
halted: PC: 0x08000194
halted: PC: 0x08000196
halted: PC: 0x08000198
halted: PC: 0x0800019a
halted: PC: 0x0800019c
halted: PC: 0x08000180
58 sram_data[i] = flash_data[i];
(gdb) n
halted: PC: 0x08000182
halted: PC: 0x08000184
halted: PC: 0x08000186
halted: PC: 0x08000188
halted: PC: 0x0800018a
halted: PC: 0x0800018c
halted: PC: 0x0800018e
halted: PC: 0x08000190
56 for (uint32_t i = 0; i < data_size; i++)
(gdb) n
halted: PC: 0x08000192
halted: PC: 0x08000194
halted: PC: 0x08000196
halted: PC: 0x08000198
halted: PC: 0x0800019a
halted: PC: 0x0800019c
halted: PC: 0x0800019e
Breakpoint 1, reset_handler () at startup.c:62
62 uint32_t bss_size = (uint32_t)&_ebss - (uint32_t)&_sbss;
(gdb) n
halted: PC: 0x080001a0
halted: PC: 0x080001a2
halted: PC: 0x080001a4
halted: PC: 0x080001a6
63 uint8_t *bss = (uint8_t*) &_sbss;
(gdb) n
halted: PC: 0x080001a8
halted: PC: 0x080001aa
65 for (uint32_t i = 0; i < bss_size; i++)
(gdb) n
halted: PC: 0x080001ac
halted: PC: 0x080001ae
halted: PC: 0x080001c0
halted: PC: 0x080001c2
halted: PC: 0x080001c4
halted: PC: 0x080001c6
halted: PC: 0x080001b0
67 bss[i] = 0;
(gdb) n
halted: PC: 0x080001b2
halted: PC: 0x080001b4
halted: PC: 0x080001b6
halted: PC: 0x080001b8
halted: PC: 0x080001ba
65 for (uint32_t i = 0; i < bss_size; i++)
(gdb) n
halted: PC: 0x080001bc
halted: PC: 0x080001be
halted: PC: 0x080001c0
halted: PC: 0x080001c2
halted: PC: 0x080001c4
halted: PC: 0x080001c6
halted: PC: 0x080001b0
67 bss[i] = 0;
(gdb) n
halted: PC: 0x080001b2
halted: PC: 0x080001b4
halted: PC: 0x080001b6
halted: PC: 0x080001b8
halted: PC: 0x080001ba
65 for (uint32_t i = 0; i < bss_size; i++)
(gdb) n
halted: PC: 0x080001bc
halted: PC: 0x080001be
halted: PC: 0x080001c0
halted: PC: 0x080001c2
halted: PC: 0x080001c4
halted: PC: 0x080001c6
halted: PC: 0x080001b0
67 bss[i] = 0;
(gdb) n
halted: PC: 0x080001b2
halted: PC: 0x080001b4
halted: PC: 0x080001b6
halted: PC: 0x080001b8
halted: PC: 0x080001ba
65 for (uint32_t i = 0; i < bss_size; i++)
(gdb) n
halted: PC: 0x080001bc
halted: PC: 0x080001be
halted: PC: 0x080001c0
halted: PC: 0x080001c2
halted: PC: 0x080001c4
halted: PC: 0x080001c6
halted: PC: 0x080001b0
67 bss[i] = 0;
(gdb) n
halted: PC: 0x080001b2
halted: PC: 0x080001b4
halted: PC: 0x080001b6
halted: PC: 0x080001b8
halted: PC: 0x080001ba
65 for (uint32_t i = 0; i < bss_size; i++)
(gdb) n
halted: PC: 0x080001bc
halted: PC: 0x080001be
halted: PC: 0x080001c0
halted: PC: 0x080001c2
halted: PC: 0x080001c4
halted: PC: 0x080001c6
halted: PC: 0x080001c8
70 main();
(gdb) n
halted: PC: 0x080000c0
Breakpoint 2, main () at main.c:17
17 RCC_APB1ENR |= (1 << 28); /* Enable clock on Power Interface */
(gdb) n
halted: PC: 0x080000c6
halted: PC: 0x080000c8
halted: PC: 0x080000ca
halted: PC: 0x080000cc
halted: PC: 0x080000ce
halted: PC: 0x080000d0
halted: PC: 0x080000d2
18 RCC_AHBENR |= (0x00080014); /* Enable clock on GPIOC */
(gdb) n
halted: PC: 0x080000d4
halted: PC: 0x080000d6
halted: PC: 0x080000d8
halted: PC: 0x080000da
halted: PC: 0x080000dc
halted: PC: 0x080000de
20 GPIOC_MODER |= (1 << (9*2)); /* Set GPIO PC9 to Output Mode */
(gdb) n
halted: PC: 0x080000e0
halted: PC: 0x080000e2
halted: PC: 0x080000e4
halted: PC: 0x080000e6
halted: PC: 0x080000e8
halted: PC: 0x080000ea
halted: PC: 0x080000ec
22 while(loop_enable) {
(gdb) n
halted: PC: 0x08000106
halted: PC: 0x08000108
halted: PC: 0x0800010a
halted: PC: 0x0800010c
halted: PC: 0x080000ee
24 GPIOC_ODR = 0x100;
(gdb) n
halted: PC: 0x080000f0
halted: PC: 0x080000f2
halted: PC: 0x080000f4
halted: PC: 0x080000f6
25 delay();
(gdb) n
halted: PC: 0x08000130
^[[A27 GPIOC_ODR = 0x200;
(gdb) n
halted: PC: 0x080000fc
halted: PC: 0x080000fe
halted: PC: 0x08000100
halted: PC: 0x08000102
28 delay();
(gdb)
❯ Заключение
Казалось бы, зачем все эти заморочки, ведь современные IDE могут всё это генерить автоматом и не потребуется никаких ковыряний и такого объема работ. Но с другой стороны — теперь предельно ясно, что происходит под капотом, и как это все хозяйство собирается.
Мы разобрались в тонкостях процесса компиляции и того, что происходит перед началом выполнения программы, которую мы пишем в main.c файле.
Разумеется, все перечисленное выше не часто будет применяться в работе с микроконтроллерами, но для общего развития, я думаю, будет очень полезно понимать, как происходит всё поэтапно и в деталях.