Найти тему
Feeling in Embedded

Процесс компиляции

Оглавление

Краткий экскурс

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

При более подробном рассмотрении этап компиляции разделяется на этап препроцессора и сам этап компиляции. Тогда получается следующая последовательность действий, которая представлена на рисунке 1 (все этапы идут в хронологическом порядке).

                Рис.1 - Последовательность компиляции исходного файла программы
Рис.1 - Последовательность компиляции исходного файла программы

Рассмотрим каждый этап подробнее на примере простой программы, которая написана на компилируемом языке программирования С. Листинг программы представлен ниже.

1 // листинг программы littleTest
2 #include <stdio.h>
3
4 #define TEST_CONST 15
5
6 int main (void) {
7 int a = 4, b = 5;
8 // комментарий для наглядности
9 a = a + b + TEST_CONST;
10 printf("value a = %d \n", a);
11 return 0;
12 }

В данной программе есть все необходимое, чтобы пронаблюдать этапы процесса компиляции: использование директив препроцессора include, использование директивы define, комментарии и использование библиотечной функции printf().

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

На операционной системе Linux компилятор для языка C устанавливается вместе с операционной системой, для установки в системе Windows можно использовать Cygwin или же MinGW (команды компиляции будут одинаковые). Содержимое промежуточных файлов для удобства будет представлен в виде листинга, как и код.

Препроцессор

Первый этап, который начинается, после указания компилятору файла исходного кода и его запуску(компилятора) это препроцессор. Он выполняет следующие функции:

  • замена комментариев пустыми строками;
  • текстовое включение файлов #include;
  • макроподстановки директивы #define;
  • обработка директив условной компиляции (не представлено в нашем примере).

Все вышеперечисленное мы имеем в листинге нашей программы, осталось это пронаблюдать результат этой процедуры. Файл можно скомпилировать следующим образом, введя в терминале Linux или командной строке Windows:

gcc littleTest.c

После данной операции в каталоге вместе с исходным файлом littleTest.c должен появится файл littleTest.out (в ОС Windows little.exe) - это исполняемый файл, заключительное звено процесса компиляции. Чтобы остановить компилятор на этапе препроцессирования требуется дополнить вышеуказанную команду следующей опцией:

gcc -E littleTest.c -o littleTest.ii

В нашем каталоге появился файл littleTest.ii - файл исходного кода прошедший процедуру препроцессинга. Просмотрим его содержимое при помощи команды cat или же откроем его через программу Блокнот:

1 # 0 "litleTest.c"
2 # 0 "<built-in>"
3 # 0 "<command-line>"
4 # 1 "litleTest.c"
5
6 # 1 "/usr/include/stdio.h" 1 3 4
...
// далее идет много строк скопированных из stdio.h
...
953 # 803 "/usr/include/stdio.h" 3 4
954
955 # 3 "litleTest.c" 2
956
957
958
959
960 # 6 "litleTest.c"
961 int main (void) {
962 int a = 4, b = 5;
963
964 a = a + b + 15;
965 printf("value a = %d\n", a);
966 return 0;
967 }

Итак, что мы видим:

  • комментарии исчезли и на их месте появились пустые строки;
  • вместо включения include полностью скопирована библиотека stdio.h, исходный файлик состоял из 12 строк, промежуточный файл включает в себя 967 строк ;
  • константа TEST_CONST была заменена на свой численный эквивалент.

На этом первый этап подготовки исходного файла закончен.

Компиляция

Далее идет сам процесс компиляции. На данном этапе исходный файл после процедуры препроцессора претерпевает следующие изменения:

  • Лексический анализ. Символы файла, прошедшего препроцессинг, преобразуются в последовательности лексем. Лексема представляет собой последовательность допустимых символов для программы транслятора, транслятор в свою очередь это программа, которая преобразует программу на одном языке программирования в к туже программу на другом языке(в нашем случае с языка С на ассемблер);
  • Синтаксический анализ. На данном этапе последовательность лексем преобразуется в дерево разбора;
  • Семантический анализ. Полученное дерево обрабатывается с целью установления семантики (смысла);
  • Оптимизация. Выполняется удаление лишних конструкций и упрощение кода;
  • Генерация кода. Из полученных результатов формируется файл объектного кода.

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

Итак, компиляцию можно разделить на два этапа. Непосредственно сама компиляция формирование объектного кода. Посмотреть промежуточные результаты этих этапов можно при помощи опций -c остановка перед линковкой) и опции -S (остановка перед ассемблированием).

Выполним следующую команду:

gcc -S litleTest.ii -o littleTest.s

В нашем каталоге должен появится файл с расширением .s - наша программа транслированная на язык ассемблера. Листинг представлен ниже:

1 .file "llitleTest.c"
2 .text
3 .def __main; .scl 2; .type 32; .endef
4 .section .rdata,"dr"
5 .LC0:
6 .ascii "value a = %d\12\0"
7 .text
8 .globl main
9 .def main; .scl 2; .type 32; .endef
10 .seh_proc main
11 main:
12 pushq %rbp
...
34 popq %rbp
35 ret
36 .seh_endproc
37 .ident "GCC: (GNU) 11.3.0"
38 .def printf; .scl 2; .type 32; .endef
39

Тут мы можем наблюдать команды работы со стеком pushq и popq, наименование регистров процессора eax и edx и другие. Наша программа представлена в виде инструкций процессору: положить число вон в тот регистр, вызвать функцию main и др. Как и в случае с деревьями разбора и оптимизацией кода, язык ассемблер объемная тема для обсуждения, так же он платформозависимый. В связи с чем останавливаться на содержании и структуре ассемблерного файла мы не будем. А перейдем к созданию файла объектного кода.

Введем следующую команду:

gcc -c litleTest.c

В каталоге появляется файлик с раширением .о - файл объектного кода. Он представляет собой нашу программу на языке машинных инструкций с частичным сохранением символьной информации. Для просмотра его содержимого можно воспользоваться любым hex-редактором (на рисунке 2 представлено расширение для VS Code - Hex Editor).

                                         Рис2. - Файл объектного кода
Рис2. - Файл объектного кода

Просмотреть символьную информацию объектного файла можно так же при помощи утилиты nm в системе Linux или же встроенной в IDE Visual Studio утилитой dumpbin (в данном примере будет использована nm). Введем команду:

nm littleTest.o

В ответ терминал нам выдаст:

0000000000000000 b .bss
0000000000000000 d .data
0000000000000000 p .pdata
0000000000000000 r .rdata
0000000000000000 r .rdata$zzz
0000000000000000 t .text
0000000000000000 r .xdata
U __main
0000000000000000 T main
U printf

Вывод программы состоит из категорий(r,b,p и тд.) и функций (наши функции main и printf), это и есть та символьная информация, что еще осталась в объектном коде

Компоновка или же линковка

Это заключительный этап формирования исполняемого файла. На данном этапе программа комоновкщик связывает все объектные файлы и статические билблиотеки воедино в исполняемом файле.

Комоновщик пользуется такой структурой данных как таблица символов. Она заключается в объектных файлах и содержит символьную информацию (имена функций, переменных), где каждому из вышеуказанных объектов указан его тип и область видимости. Процедуры в других объектных файлах и адреса ссылок так же хранятся внтури таблицы. Благодаря этому ресурсу компоновщик и формирует из множества объектных файлов единый исполняемый.

Исполняемый файл

После всего увиденного можно пройти все этапы разом и создать исполняемый файл:

gcc littleTest.c -o littleTest

Опция компилятор -o перенаправляет выход в файл с заданным именем (если файлика нет то он создается). Теперь у нас в каталоге лежит файл littleTest.exe. запустим же его:

./littleTest.exe
value a = 24

На этом рассмотрение этапов формирования исполняемого файла из файла исходного кода компилируемого языка окончено.