Компоновщик

Описанный выше процесс компиляции справедлив для x86/amd64 процессора. В случае с микроконтроллером всё немного усложняется, как минимум из-за того, что скомпилированная программа выполняется не на том же устройстве (англ. target machine), на котором она компилируется (англ. host machine). Такой подход называется кросс-компиляцией (англ. cross-compilation)1. GCC для настольного компьютера знает как работает операционная система, какие ресурсы имеются и как их можно использовать. А вот с микроконтроллерами дело обстоит по-другому. По этой причине, хоть компоновщик и является частью компилятора, его описание вынесено в отдельную подглаву. В большинстве случаев вам не нужно задумываться как он работает, но иногда, всё же, приходится вмешиваться в процесс его работы.

Из всего повествования выше, могло сложиться ложное представление, что программа начинается с вызова функции main(), но это не так! До вызова «главной функции» происходит ещё много чего интересного. К тому же, о файлах *.efl, *.hex, *.map и *.ld мы не говорили совсем.

Процесс линковки

Когда мы запускаем сборку проекта, каждый модуль собирается в объектный файл (англ. object file, *.o). По большей части он состоит из машинного кода под заданную архитектуру, но он не является «автономным», т.е. вы не сможете его запустить. Компилятор помещает туда дополнительную информацию: ссылки на функции и переменные определённые вне модуля в виде таблицы.

Рассмотрим ещё один пример с модификатором extern, но на этот раз более приближенный к реальному использованию. Допустим, вы реализуете функцию задержки, которая опирается на переменную хранящую количество миллисекунд прошедших с момента запуска устройства.

Пока разница текущего времени и времени вызова функции не будет равна или больше переданного в функцию значения ms, цикл while будет сравнивать значения. Переменная current_time_ms, должна увеличиваться каждую миллисекунду на 1. Обычно это реализуют через прерывание таймера.

Все прерывания, для удобства складываются в один файл, в котором никакой current_time_ms не существует. Если вы попробуете скомпилировать модуль stm32f10xx_it.c, то компилятор выдаст ошибку, в то время как utils.c скомпилируется нормально и даже будет «работать». Проблема в том, что current_time_ms используется, но внутри модуля под неё не была выделена память и даже не указан её тип.

Данной строчкой мы говорим компилятору примерно следующее:

У данной переменной тип uint32_t, где она определена – не твои проблемы, просто поставь на её место метку, компоновщик разберётся с ней сам.

После того, как все модули успешно откомпилированы в работу включается компоновщик2, задача которого собрать все файлы в один и решить все внешние зависимости. В случае, если у него этого сделать не получается – возникает ошибка компиляции на этапе линковки. Например, если мы забудем написать модификатор extern компоновщик при попытке сшить два модуля обнаружит, что имеется два экземпляра переменной (или это может быть функция) с одинаковым названием – чего быть не должно.

Каждый объектный файл состоит из одной или нескольких секций (англ. section), в которых хранится либо код, либо данные. Для GCC программный код складывается в секцию .text, проинициализированные глобальные переменные с их значениями складываются в секцию .data и не проинициализированные в секцию .bss.

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

Любая программа, на любом языке, имеет некоторые требования к среде. Для Java это виртуальная машина, для Python интерпретатор, а для Си наличие памяти для стека. (Грубое упрощение). Место под стек, должно быть выделено ДО начала выполнения программы и этим занимается ассемблерная вставка, startup-файл. Он же выполняет и другие задачи, например отключает все прерывания, перемещает нужные данные в оперативную память и задаёт соответствующим прерываниям функции, и только после этого вызывает функцию main (в прочем функция может называться по-другому).

В некоторых компиляторах предусмотрена отдельная утилита, которая занимается локацией адресов, т.е. сопоставлению физических адресов в памяти целевой платформы соответствующим секциям. В GCC она является частью компоновщика.

Информация необходимая для данной процедуры хранится в специальном файле – в скрипте компоновщика (англ. linker script). При работе с интегрированной средой разработки данный файл генерируется автоматически исходя из указанных параметров микроконтроллера. В некоторых случаях бывает полезно изменить его, что бы добиться желаемого результата: например поместить код в оперативную память и выполнять программу от туда.

Рассмотрим основные составляющие данного файла. Первый блок указывает компоновщику с чего начинать запуск:

Функция Reset_Handler определена в startup-файле, её смысл мы поясняли чуть выше.

Далее указывает конечный адрес для стека в оперативной памяти:

startup-файл берёт значение этой переменной именно отсюда.

Затем определяется минимальный размер для кучи и стека.

Куча обычно не используется, поэтому в типичном скрипте в _Min_Heap_Size записывается 0. Эти данные используются в самом конце сборки, когда компоновщик проверяет, достаточно ли места в памяти.

В скрипте так же должны быть перечислены все виды памяти, доступные в системе. В случае с stm32f103c8 это 64 кб флэш и 20 кб оперативной.

Переменная ORIGIN указывает на начальный адрес, а LENGTH на длину области.

Многие микроконтроллеры имеют более одной области памяти, а значит и через скрипт код можно разместить в разных местах. Для сравнения ниже приведён тот же блок, но для stm32f407vg:

Последний блок содержит информацию о секциях .text, .data и т.д.

Приводить полный файл здесь, мы не будем. Запустите среду разработки (например Atollic TrueStudio) и найдите файл *.ld. Более детальное описание того, как работает компоновщик можно найти на сайте gnu.org, а советы по модификации конкретно для stm32 в Atollic TrueStudio в документе Atollic TrueStudio for ARM User Guide.

По завершению работы компоновщика в директории Debug (или Release) появятся некоторые файлы.

Компилятор выдаст примерно следующую информацию:

Для прошивки устройств, т.е. там где отладчик в принципе не нужен, используют другие форматы. Наиболее распространённым является Intel HEX. В отличии от *.elf он не содержит ничего кроме кода и данных с их адресами в ANSII формате. Среда разработки может конвертировать *.elf в *.hex автоматически, всего лишь нужно включить эту возможность в настройках.


1 Кросс-компиляция применяется довольно часто. Например, если вы разрабатываете кроссплатформенное приложение, скажем на Qt под Windows, то вам не обязательно иметь рабочую машину с Linux и/или macOS. Согласитесь, было бы не удобно для сборки переключаться между машинами. Тоже касается и разработки под Android или iOS – операционные системы, как и архитектура процессора отличаются у хост-машины и целевой платформы.
2 В GCC это утилита ld.
3 Есть и другие форматы, но ELF наиболее распространённый.