Как происходит компиляция. Часть 1.

В этой статье я подробно объясню ход процесса компиляции исходного текста в исполняемую программу. Я не буду заострять внимание на таких моментах, как окружение Make, или Revision Control, хотя это было обязательно на тех университетских занятиях. Здесь будет лишь разобрано, что происходит после отдачи команды gcc test.c.

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

Перед тем как обсуждать компиляцию программы, нужно иметь собственно программу. Наша программа должна быть простой, но не настолько, чтобы лишить нас удовольствия обсудить все интересующие нас детали компиляции. К примеру, вот такая программа:
#include

// Это комментарий.

#define STRING "This is a test"
#define COUNT (5)

int main ()
{
int i;

for (i=0; i test.txt

# 1 "test.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 28 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/features.h" 1 3 4
# 330 "/usr/include/features.h" 3 4
# 1 "/usr/include/sys/cdefs.h" 1 3 4
# 348 "/usr/include/sys/cdefs.h" 3 4
# 1 "/usr/include/bits/wordsize.h" 1 3 4
# 349 "/usr/include/sys/cdefs.h" 2 3 4
# 331 "/usr/include/features.h" 2 3 4
# 354 "/usr/include/features.h" 3 4
# 1 "/usr/include/gnu/stubs.h" 1 3 4

# 653 "/usr/include/stdio.h" 3 4
extern int puts (__const char *__s);

int main ()
{
int i;

for (i=0; i<5; i++)
{
puts("This is a test");
}

return 1;
}

Сразу видно, что препроцессор C дописал к нашей простой программе много новых строк. Я привел сокращенную версию, на самом деле после обработки препроцессором выходной файл содержал более 750 строк. Итак, что же было добавлено и почему? Начнем с того, что мы запросили включение заголовочного файла stdio.h. В свою очередь, stdio.h запросил включение других заголовочных файлов, и так далее. Препроцессор сделал отметки о том, включение какого файла и на какой строке было запрошено. Эта информация будет использована на следующих этапах компиляции. Так, строки
# 28 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/features.h" 1 3 4

означают, что файл features.h был запрошен на строке 28 файла stdio.h. Препроцессор создает эту отметку перед соответствующим "интересным" местом, так что если встретится ошибка, компилятор сможет нам сообщить, где именно произошла ошибка.

Теперь посмотрим на эти строки:
# 653 "/usr/include/stdio.h" 3 4
extern int puts (__const char *__s);

Здесь puts() объявлена как внешняя функция (extern), возвращающая целочисленной значение и принимающая массив постоянных символов в качестве параметра. Если бы случилась несостыковка, касающаяся этой функции, тогда компилятор смог бы сообщить нам, что данная функция была объявлена в файле stdio.h на строке 653. Интересно, что на данном этапе функция puts() не определена, а лишь объявлена. Здесь пока нет реального кода, который будет работать при вызове функции puts(). Определение функций будет происходить позже.

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

Результат трансляции можно увидеть с помощью ключа -S:
gcc -S test.c

Будет создан файл с именем test.s, содержащий реализацию нашей программы на языке ассемблера. Давайте поглядим, что в нем.
.file "test.c"
.section .rodata
.LC0:
.string "This is a test"
.text
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $20, %esp
movl $0, -8(%ebp)
jmp .L2
.L3:
movl $.LC0, (%esp)
call puts
addl $1, -8(%ebp)
.L2:
cmpl $4, -8(%ebp)
jle .L3
movl $1, %eax
addl $20, %esp
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.2.4 (Gentoo 4.2.4 p1.0)"
.section .note.GNU-stack,"",@progbits

Я не силен в языке ассемблера, однако некоторые моменты можно выделить сразу. Можно видеть, что строка сообщения была перемещена в другую область памяти и стала называться .LC0. Основную часть кода занимают операции, от начала выполнения программы и до ее завершения. Очевидна реализация цикла for на метке .L2: это просто проверка (cmpl) и инструкция "переход, если меньше" ("Jump if Less Than", jle). Инициализация цикла осуществляется оператором movl перед меткой .L3. Между метками .L3 и .L2 очевиден вызов функции puts(). Ассемблер знает, что вызов функции puts() по имени здесь корректен, и что это не метка памяти, как например .L2. Обсудим этот механизм далее, когда будем говорить о заключительном этапе компиляции, связывании. Наконец, наша программа завершается операцией возвращения (ret).

Следующий шаг компиляции - это ассемблирование в объектный файл. Объектные файлы будут рассмотрены далее, когда мы дойдем до процесса связывания. Достаточно сказать, что ассемблирование - это процесс преобразования программы на языке ассемблера в машинный код.

Связывание - это последний этап, который либо ведет к получению исполняемого файла, либо объектного файла, который можно объединить с другим объектным файлом, и таким образом получить исполняемый файл. Проблема с вызовом функции puts() разрешается именно на этапе связывания. Помните, в stdio.h функция puts() была объявлена как внешняя функция? Это и означает, что функция будет определена (или реализована) в другом месте. Если бы у нас было несколько исходных файлов нашей программы, мы могли бы объявить некоторые функции как внешние и реализовать их в различных файлах; такие функции можно использовать в любом месте нашего кода, ведь они объявлены как внешние. До тех пор пока компилятор не знает, откуда берется реализация такой функции, в получаемом коде лишь остается ее "пустой" вызов. Линковщик разрешит все эти зависимости и в процессе работы подставит в это "пустое" место реальный адрес функции.

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

Если скомпилировать нашу программу как указано выше, мы получим исполняемый файл размером 6885 байт. Однако если указать компилятору, чтобы он пропустил этап связывания, с помощью ключа -c:
gcc -c test.c -o test.o
тогда мы получим объектный файл размеров всего 888 байт. Разницу составляет как раз код для запуска и завершения программы, а также вызов функции puts() из библиотеки libc.so.

Итак, мы более-менее подробно рассмотрели процесс компиляции. Надеюсь, было интересно. В следующий раз мы обсудим более подробно процесс связывания и рассмотрим, как компилятор gcc оптимизирует программы.

Источник: http://rus-linux.net