Компилятор GCC
Рассмотрим работу пакета GCC (аббр. GNU Compiler Collection), который по умолчанию входит в состав дистрибутива Ubuntu. Эту ОС не обязательно устанавливать на компьютер: если ваше железо достаточно производительно, то можно попробовать запустить ее в виртуальной машине, такой как VirtualBox.

Запустите терминал, создайте директорию, в которой будет лежать код, и перейдите в нее:
mkdir project
cd project
Далее запустите какой-нибудь консольный текстовый редактор, скажем, nano
.
nano main.c
Самая простая программа на Си выглядит следующим образом:
int main() {
return 0;
}
Наберите её в текстовом редакторе и сохраните комбинацией ctrl
+ X
с именем main.c
. Откомпилировать и получить исполняемый файл можно, выполнив команду:
gcc main.c
В той же директории (наберите команду ls
, чтобы увидеть ее содержимое) появится файл a.out
— это и есть исполняемый файл программы. Для того чтобы получить название, отличное от a.out
, нужно использовать ключ -o
, после которого следует желаемое имя исполняемого файла.
gcc main.c -o main
Запустить программу можно следующей командой:
./main
По умолчанию GCC использует стандарт компилятора c89
(с некоторыми расширениями). Для того чтобы указать версию стандарта явно, нужно добавить флаг -std=
. Ниже приведена строка перекомпиляции той же программы под стандарт c99
:
gcc -std=c99 main.c -o main
Теперь перейдем к модулям. Создадим заголовочный файл ds18b20.h
со следующим содержимым:
#ifndef __DS18B20_H__
#define __DS18B20_H__
float ds18b20_get_temperature(void);
#endif /* __DS18B20_H__ */
И файл исходного кода ds18b20.c
:
#include "ds18b20.h"
float ds18b20_get_temperature(void) {
// TODO: add real code here
return 21.5f;
}
Таким же образом добавим «драйвер» для дисплея lcd.h
(файл stdio.h
входит в стандартную библиотеку языка Си):
#ifndef __LCD_H__
#define __LCD_H__
#include <stdio.h>
void lcd_show_temperature(float);
#endif /* __LCD_H__ */
Содержание файла lcd.c
(функция printf()
определена в заголовочном файле stdio.h
):
#include "lcd.h"
void lcd_show_temperature(float temperature) {
printf("Temperature is %.1f", temperature);
}
Изменим содержимое main.c
, доработав основную программу:
#include "ds18b20.h"
#include "lcd.h"
int main(void) {
lcd_show_temperature(ds18b20_get_temperature());
return 0;
}
Откомпилируем программу и запустим.
gcc -std=c99 main.c ds18b20.c lcd.c -o main
./main
Вроде всё просто, не правда ли? Нет. Всё сложнее, чем кажется.
В языке Си раздельная компиляция, а сама утилита GCC включает в себя некоторое количество инструментов, отсюда и название toolchain (с англ. «набор инструментов»).
Всё, что начинается с символа #
, предварительно обрабатывается препроцессором, т.е. слово include
(с англ. «включить») прямо указывает утилите: «вставь на это место строчки из следующего файла». Запустить препроцессор отдельно можно командой:
gcc -E ds18b20.c > 1.txt
Команда > 1.txt
перенаправит поток вывода в файл. Его содержимое будет следующим:
// ...
float ds18b20_get_temperature(void);
# 2 "ds18b20.c" 2
float ds18b20_get_temperature(void) {
return 21.5f;
}
Далее выполняется трансляция из высокоуровневого описания (язык Си) в ассемблер-код.
gcc -S ds18b20.c
Появится файл ds18b20.s
— ознакомьтесь с его содержимым самостоятельно. После трансляции кода в ассемблерные команды компилятор создает объектный файл *.o
.
Скомпилируем отдельно каждый модуль программы.
gcc -std=c99 -c ds18b20.c
gcc -std=c99 -c lcd.c
gcc -std=c99 -c main.c
Проверьте содержание директории: должны появиться три файла (ds18b20.o
, lcd.o
, main.o
). И только после этого в ход идет компоновщик, задача которого — склеить все файлы в единый исполняемый.
gcc main.o ds18b20.o lcd.o -o main
Для наглядности приведем диаграмму.

Если попробовать графически представить процесс компоновки (линковки, англ. linkage), то всё это можно изобразить следующим образом:

Утилита GCC сшивает *.o
-файлы в один и расставляет ссылки на места, где происходит вызов функции, например lcd_show_temperature()
. Стоит понимать, что printf()
тоже где-то хранится, а именно в libc.a
файле. Фрагмент объектного файла, отвечающий за функцию printf()
, добавляется в вашу программу.
Перед вызовом любой функции или при работе с любой переменной ее сначала необходимо объявить. Реализация функции не обязательно должна быть в том же файле, где она вызывается. Объявление нужно в первую очередь для компоновщика, сшивающего объектные файлы. Объявлять функции и переменные можно непосредственно в начале файла исходного кода, либо в заголовочном файле (англ. header), который, как мы уже говорили, является «интерфейсом». В заголовочных файлах обычно содержится следующее: прототипы функций; константы, определенные с использованием #define
или const
; объявления структур или типов данных; встроенные (inline
) функции.
Если в заголовочном файле содержится определение функции и этот заголовочный файл включен в два других файла, которые являются частью одной программы, в этой программе окажется два определения одной и той же функции, чего быть не должно. Дабы не допустить повторного включения одних и тех же файлов (препроцессор будет делать это бесконечно) нужно использовать «защитные скобки» ifndef
/ endif
(«если не включен, то включить»). Подробнее команды препроцессора будут рассмотрены позже.
Еще раз взгляните на все файлы, что у нас есть, и осознайте процесс.
Компилятору всё равно, как называется переменная в аргументе функции; важен ее тип, поэтому в заголовочном файле не обязательно указывать ее название.
Предположим, наш заголовочный файл лежит не в той же директории, что и main.c
. Создайте каталог config
и создайте там файл device.h
.
#ifndef __DEVICE_H__
#define __DEVICE_H__
#define DEBUG
#endif /* __DEVICE_H__ */
Модифицируем файл main.c
:
#include "ds18b20.h"
#include "lcd.h"
#include "device.h"
int main(void) {
#ifdef DEBUG
lcd_show_temperature(0.0f);
#else
lcd_show_temperature(ds18b20_get_temperature());
#endif
return 0;
}
Теперь, если определен макрос DEBUG
, то вместо «реального» показания температуры функции lcd_show_temperature(float)
будет передаваться значение 0.0
.
При попытке компиляции GCC выдаст ошибку:
main.c:3:20: fatal error: device.h: No such file or directory compilation terminated.
Это произошло из-за того, что компилятор не знает, где лежит device.h
. Исправить это можно, указав полный путь в папке с файлом в директиве include
либо указав расположение заголовочных файлов при помощи ключа -I
.
gcc -std=c99 -Iconfig main.c ds18b20.c lcd.c -o main
Теперь программа успешно компилируется. Последнее, что осталось для осознания процесса сборки программы, это библиотеки. Они бывают двух видов: статические и динамические. В Linux статическая библиотека имеет расширение *.a
(от англ. archive), эквивалент в Windows — *.lib
. *.a
представляет собой набор объектных файлов (архив), и при компоновке утилита GCC вытаскивает нужные куски объектного кода и вставляет в исполняемый файл. В качестве примера давайте соберем из lcd.c
такую библиотеку:
gcc -std=c99 -c lcd.c
mkdir libs && cp lcd.o libs
cd libs
Далее преобразуем lcd.o
в архивный файл:
ar rcs liblcd.a lcd.o
ls
> lcd.o liblcd.a
Вернемся в каталог с файлом main.c
, скомпилируем его и произведем статическую компоновку (для указания пути к папке с библиотекой нужно использовать ключ -L
, а название самой библиотеки пишется после -l
без lib
в начале):
cd ../
gcc -std=c99 -Iconfig main.c -Llibs -llcd -o main
Динамические библиотеки не используются при разработке под микроконтроллеры, поэтому рассматривать работу с ними здесь не имеет смысла. Отметим лишь, что в Linux они называются *.so
(от англ. shared objects), а в Windows — *.dll
(от англ. dynamic-link library).
Компоновщик
Описанный выше процесс компиляции справедлив для x86
/amd64
процессора. В случае с микроконтроллером всё немного усложняется как минимум из-за того, что скомпилированная программа выполняется не на том же устройстве (англ. target machine), на котором она компилируется (англ. host machine). Такой подход называется кросс-компиляцией (англ. cross-compilation). GCC для настольного компьютера знает, как работает операционная система, какие ресурсы имеются и как их можно использовать. А вот с микроконтроллерами дело обстоит по-другому. По этой причине, хоть компоновщик и является частью компилятора, его описание вынесено в отдельную подглаву. В большинстве случаев вам не нужно задумываться, как он работает, но иногда всё же приходится вмешиваться в процесс его работы.
Кросс-компиляция применяется довольно часто. Например, если вы разрабатываете кроссплатформенное приложение, скажем, на Qt под Windows, то вам не обязательно иметь рабочую машину с Linux и/или macOS. Согласитесь, было бы неудобно для сборки переключаться между машинами. То же касается и разработки под Android или iOS — операционные системы, как и архитектура процессора, отличаются у хост-машины и целевой платформы.
Из всего повествования выше могло сложиться ложное представление, что программа начинается с вызова функции main()
, но это не так! До вызова «главной функции» происходит ещё много чего интересного. К тому же о файлах *.efl
, *.hex
, *.map
и *.ld
мы не говорили совсем.
Процесс линковки
Когда мы запускаем сборку проекта, каждый модуль собирается в объектный файл (англ. object file, *.o
). По большей части он состоит из машинного кода под заданную архитектуру, но он не является «автономным», т.е. вы не сможете его запустить. Компилятор помещает туда дополнительную информацию: ссылки на функции и переменные, определённые вне модуля в виде таблицы.
Рассмотрим ещё один пример с модификатором extern
, на этот раз более приближенный к реальному использованию. Допустим, вы реализуете функцию задержки, которая опирается на переменную, хранящую количество миллисекунд, прошедших с момента запуска устройства.
// utils.c
volatile uint32_t current_time_ms = 0;
void delay(uint32_t ms) {
uint32_t start_time_ms = current_time_ms;
while( (current_time_ms - start_time_ms) < ms );
}
Пока разница текущего времени и времени вызова функции не будет равна или больше переданного в функцию значения ms
, цикл while
будет сравнивать значения. Переменная current_time_ms
должна увеличиваться каждую миллисекунду на 1
. Обычно это реализуют через прерывание таймера.
// stm32f1xx_it.h
void SysTick_Handler(void) {
current_time_ms = current_time_ms + 1; // or current_time_ms++;
}
Все прерывания для удобства складываются в один файл, в котором никакой current_time_ms
не существует. Если вы попробуете скомпилировать модуль stm32f10xx_it.c
, компилятор выдаст ошибку, в то время как utils.c
скомпилируется нормально и даже будет «работать». Проблема в том, что current_time_ms
используется, но внутри модуля под неё не была выделена память и даже не указан её тип.
extern uint32_t current_time_ms;
Данной строчкой мы говорим компилятору примерно следующее: у данной переменной тип uint32_t
, где она определена — не твои проблемы, просто поставь на её место метку, компоновщик разберётся с ней сам.
После того как все модули успешно откомпилированы, в работу включается компоновщик, задача которого — собрать все файлы в один и разрешить все внешние зависимости. В случае, если у него этого сделать не получается, возникает ошибка компиляции на этапе линковки. Например, если мы забудем написать модификатор extern
, компоновщик при попытке сшить два модуля обнаружит, что имеется два экземпляра переменной (или это может быть функция) с одинаковым названием — чего быть не должно.
В GCC это утилита
ld
.
Каждый объектный файл состоит из одной или нескольких секций (англ. section), в которых хранится либо код, либо данные. Для GCC программный код складывается в секцию .text
, проинициализированные глобальные переменные со своими значениями складываются в секцию .data
, а не проинициализированные — в секцию .bss
.
На самом деле их больше, но это уже выходит за рамки описываемых здесь процессов.
Выходной файл компоновщика — это такой же объектный файл с точно таким же форматом хранения кода и данных. Другими словами, компоновщик группирует код и данные по секциям, пытаясь разрешить внешние связи. Такой файл, однако, не может быть исполнен на целевой платформе. Проблема в том, что в нём нет информации об адресах, где данные и код должны храниться. Следующим в игру вступает локатор (англ. locator).
Любая программа на любом языке имеет некоторые требования к среде. Для Java это виртуальная машина, для Python — интерпретатор, а для Си — наличие памяти для стека. Место под стек должно быть выделено ДО начала выполнения программы, и этим занимается ассемблерная вставка,
startup
-файл. Он же выполняет и другие задачи, например отключает все прерывания, перемещает нужные данные в оперативную память и задаёт соответствующим прерываниям функции, и только после этого вызывает функциюmain
(впрочем, функция может называться по-другому).
В некоторых компиляторах предусмотрена отдельная утилита, которая занимается локацией адресов, т.е. сопоставлением физических адресов в памяти целевой платформы соответствующим секциям. В GCC она является частью компоновщика.
Информация, необходимая для данной процедуры, хранится в специальном файле — в скрипте компоновщика (англ. linker script). При работе с интегрированной средой разработки данный файл генерируется автоматически, исходя из указанных параметров микроконтроллера. В некоторых случаях бывает полезно изменить его, чтобы добиться желаемого результата: например, поместить код в оперативную память и выполнять программу оттуда или выполнить какой-то код до входа в main()
.
Рассмотрим основные составляющие данного файла. Первый блок указывает компоновщику, с чего начинать запуск:
/* Entry Point */
ENTRY(Reset_Handler)
Функция Reset_Handler
определена в startup
-файле, её смысл мы поясняли чуть выше.
Далее указывается адрес стека в оперативной памяти (он располагается в конце и расширяется вниз по мере наполнения):
/* Highest address of the user mode stack */
_estack = 0x20005000; /* end of 20K RAM */
startup
-файл берёт значение этой переменной именно отсюда.
Затем определяется минимальный размер для кучи и стека.
/* Generate a link error if heap and stack don't fit into RAM */
_Min_Heap_Size = 0; /* required amount of heap */
_Min_Stack_Size = 0x100; /* required amount of stack */
Куча обычно не используется, поэтому в типичном скрипте в _Min_Heap_Size
записывается 0
. Эти данные используются в самом конце сборки, когда компоновщик проверяет, достаточно ли места в памяти.
В скрипте также должны быть перечислены все виды памяти, доступные в системе. В случае с stm32f103c8 это 64 Кб flash-памяти и 20 Кб оперативной.
/* Specify the memory areas */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
MEMORY_B1 (rx) : ORIGIN = 0x60000000, LENGTH = 0K
}
Переменная ORIGIN
указывает на начальный адрес, а LENGTH
— на длину области.
Многие микроконтроллеры имеют более одной области памяти, а значит, и через скрипт код можно разместить в разных местах. Для сравнения ниже приведён тот же блок, но для stm32f407vg:
/* Specify the memory areas */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
MEMORY_B1 (rx) : ORIGIN = 0x60000000, LENGTH = 0K
CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
}
Последний блок содержит информацию о секциях .text
, .data
и т.д.
/* Define output sections */
SECTIONS
{
/* The startup code goes first into FLASH */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector)) /* Startup code */
. = ALIGN(4);
} >FLASH
/* The program code and other data goes into FLASH */
.text :
{
. = ALIGN(4);
*(.text) /* .text sections (code) */
*(.text*) /* .text* sections (code) */
*(.glue_7) /* glue arm to thumb code */
*(.glue_7t) /* glue thumb to arm code */
*(.eh_frame)
KEEP (*(.init))
KEEP (*(.fini))
. = ALIGN(4);
_etext = .; /* define a global symbols at end of code */
} >FLASH
/* Constant data goes into FLASH */
.rodata :
{
. = ALIGN(4);
*(.rodata) /* .rodata sections (constants, strings, etc.) */
*(.rodata*) /* .rodata* sections (constants, strings, etc.) */
. = ALIGN(4);
} >FLASH
/* ... */
Приводить полный файл здесь мы не будем. Запустите среду разработки (например Atollic TrueStudio) и найдите файл *.ld
. Более детальное описание того, как работает компоновщик, можно найти на сайте gnu.org, а советы по модификации конкретно для stm32 в Atollic TrueStudio — в документе Atollic TrueStudio for ARM User Guide.
По завершении работы компоновщика в директории Debug (или Release) появятся некоторые файлы.
- Файл с прошивкой в формате
*.elf
(сокр. от Executable and Linkable Format) — в нём содержится не только сама прошивка, но и данные, необходимые для отладки (название переменных, функций и номеров строк).
Есть и другие форматы, но ELF наиболее распространённый.
- В файл
*.map
записывается список всех частей кода и данных с их адресами. Данный файл может помочь при анализе. Пример:
.text.Reset_Handler
0x08000f44 0x50 startup\startup_stm32f407xx.o
0x08000f44 Reset_Handler
Компилятор выдаст примерно следующую информацию:
Print size information
text data bss dec hex filename
1140 24 308 1472 5c0 test.elf
Для прошивки устройств, т.е. там, где отладчик в принципе не нужен, используют другие форматы. Наиболее распространённым является Intel HEX. В отличие от *.elf
, он не содержит ничего, кроме кода и данных с их адресами в ANSII-формате. Среда разработки может конвертировать *.elf
в *.hex
автоматически, нужно всего лишь включить эту возможность в настройках.
Либо через консоль:
arm-none-eabi-objcopy -O ihex your_filename.elf your_filename.hex
.