Компилятор 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.


Изменено: