Наши партнеры

UnixForum






Книги по Linux (с отзывами читателей)

Библиотека сайта rus-linux.net

На главную -> MyLDP -> Тематический каталог -> Программирование и алгоритмические языки в Linux

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

Оригинал: Examining the Compilation Process. Part 1.
Автор: Mike Diehl
Дата: 6 октября 2008
Перевод: Александр Тарасов aka oioki
Дата перевода: 11 ноября 2008

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

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

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

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

#include <stdio.h>

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

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

int main ()
{
    int i;

    for (i=0; i<COUNT; i++)
    {
        puts(STRING);
    }

   return 1;
}

Сохранив эту программу в файле с именем test.c, мы сможем ее скомпилировать с помощью простой команды:

gcc test.c

На выходе у нас окажется исполняемый файл, имеющий имя a.out. Почему именно такое имя - есть небольшая история. Во времена компьютера PDP, a.out означало "assembler output" - "вывод ассемблера". В данный момент это означает просто старый формат исполняемого файла. В современных версиях Unix и Linux используется формат ELF. Он намного более сложен. Хотя получившийся файл и носит имя "a.out", но на самом деле исполняемый файл имеет формат ELF. Но вернемся к нашей программе. Запустим ее с помощью команды ./a.out.

This is a test
This is a test
This is a test
This is a test
This is a test

Конечно, тут для нас нет никаких сюрпризов. Поэтому обсудим этапы, по которым прошел gcc в процессе создания файла a.out из исходника test.c.

Как было указано ранее, первым этапом работы компилятора является обработка исходного кода препроцессором языка C. Препроцессор выполняет 3 операции: текстовые замены, вырезание комментариев и включение файлов. Текстовые замены и включения файлов запрашиваются программистом с помощью директив препроцессора. Директивы препроцессора - это строки, начинающиеся с символа "#". Первая директива в нашем примере подключает стандартный заголовочный файл stdio.h, его содержимое подставляется в наш исходный файл. Вторая и третья директивы заменяют строки в нашем коде. Запустив gcc с ключом "-E", можно остановить его работу после первого этапа и увидеть результаты работы препроцессора над нашим кодом. Файл stdio.h довольно велик, поэтому я опущу некоторые ненужные для нас строки.

gcc -E test.c > 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 оптимизирует программы.