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

UnixForum






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

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

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

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

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

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

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

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

Перед тем, как приступить к основной части, давайте придумаем программу для нашего примера связывания. Допустим, у нас есть два файла, main.c и funct.c.

main.c:
#include <stdio.h>

extern void funct();

int main()
{
    funct();
}

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

void funct()
{
    puts("Hello World.");
}

Вы, наверное, уже поняли, что должно произойти. Это классическая программа "Hello World", которую мы в образовательных целях разбили на два файла. В настоящем проекте для организации и компиляции исходных файлов вам понадобится использовать программу make, однако сейчас нужно проделать все шаги вручную.

Сначала мы откомпилируем файл main.c в объектный файл main.o:

gcc main.c -c -o main.o

Эта команда заставляет GCC компилировать исходный файл, однако не запуская линковщик. Таким образом, у нас получается объектный файл, который будет назван main.o.

Компиляция funct.c происходит так же:

gcc funct.c -c -o funct.o

Теперь мы можем запустить GCC еще раз, однако теперь будет запущен линковщик:

gcc main.o funct.o -o hello

Здесь мы указываем несколько объектных файлов (с расширением ".o"), запрашивая таким образом, чтобы они были связаны воедино, а полученный исполняемый файл будет называться hello.

Будете ли вы удивлены, когда запустив ./hello, вы увидите "Hello World."?

Думаю, нет. Так зачем же мы брали простейшую программу и разбивали ее на два отдельных файла? Ну, потому что это возможно. Такой подход дает определенные преимущества - изменив что-либо в одном файле, нам понадобится откомпилировать лишь его, не притрагиваясь к другим, неизменившимся файлам. Мы просто запускаем линковщик над уже существующими объектными файлами, и получаем новый исполняемый файл. Здесь как раз и полезна программа make, которая отслеживает, какие файлы нужно компилировать вновь (это определяется по временным меткам исходных файлов), а какие можно не трогать.

Предположим, что у нас есть очень большой программный проект. Мы могли бы писать ее в одном большой файле и просто перекомпилировать ее при необходимости. Однако таким образом станет невозможно двум людям работать над одним проектом, ведь в каждый момент времени файл уже будет занят кем-то из разработчиков. Также большой исходный файл значительно увеличивает время компиляции. Представьте, если нам нужно компилировать несколько тысяч строк кода на C! Однако если разбить проект на более мелкие файлы, тогда над ним могут работать несколько людей сразу, а компиляции потребуют лишь те файлы, которые изменились.

Линковщик Linux чрезвычайно мощный. Он способен связывать объектные файлы, как в вышеприведенном примере. Но помимо этого, он может создавать разделяемые библиотеки, которые можно загружать в нашу программу при ее запуске (run time). В этой статье мы не будем заниматься их созданием, а лишь рассмотрим на примере уже имеющихся в системе библиотек.

Для дальнейших примеров возьмем исходный файл из первой статьи, он назывался test.c:

Откомпилируем эту программу:

gcc test.c -o test

Получить список разделяемых библиотек, от которых зависит наша программа, можно с помощью команды ldd:

ldd test

И мы увидими:

    linux-gate.so.1 =>  (0xffffe000)
    libc.so.6 => /lib/libc.so.6 (0xb7e3c000)
    /lib/ld-linux.so.2 (0xb7f9a000)

Легче всего объяснить назначение библиотеки libc.so.6. Это стандартная библиотека C, которая содержит в себе реализации функций puts() и printf(). Здесь также видно, в каком именно файле хранится эта библиотека - /lib/libc.so.6. Две другие библиотеки также интересны. Библиотека ld-linux.so.2 ищет и загружает другие разделяемые библиотеки, в которых нуждается программа, например, уже упомянутая библиотека libc. Запись linux-gate.so.1 также интересна. Эта библиотека на самом деле представляет собой виртуальную библиотеку, созданную ядром Linux. Она дает программе знать, как следует совершать системные вызовы. Некоторые системы поддерживают механизм sysenter. В других системные вызовы совершаются через механизм прерываний, который определенно работает медленнее. Поговорим о системных вызовах.

Системные вызовы - это стандартный интерфейс для взаимодействия с операционной системой. К примеру, как вы распределяете память? Каким образом вы выводите строку в консоль? Как вы читаете данные из файла? Все эти операции не обходятся без системных вызовов. Давайте взглянем на них подробнее.

Есть способ видеть, какие системные вызовы совершает программа, и делается это с помощью команды strace. Давайте посмотрим, что делает наша программа test:

strace ./test

Будут выведены строки системных вызовов. Для удобства чтения я лишь добавил номера строк:

Строки 1 и 2 - это просто вызовы, которые нужны оболочке для запуска внешней команды. В строках с 3 по 8 система загружает необходимые разделяемые библиотеки. На строке 8 система пытается загрузить libc. Строка 9 отображает результат первой операции чтения файла библиотеки. В строках 8-15 происходит отображение содержимого файла libc в память. То есть, система считывает библиотеку в память и передает нашей программе указатель на область памяти, в которую была загружена разделяемая библиотека. Теперь наша программа может вызывать функции из библиотеки glibc так, как будто они являются частью нашей программы.

На строке 22 система выделяет нашей программе tty-терминал, на который будет отображаться вывод программы.

Наконец, в строках 24-32 происходит собственно вывод. Команда strace позволяет нам видеть, что происходит в недрах системы. Подобные приемы полезны не только для изучения собственной системы, как в этой статье, но также может быть полезен при диагностике неисправных, незапускающихся программ. У меня было несколько случаев, когда с помощью strace мне удавалось определить, что программа "зависала" на попытке чтения какого-либо файла или чего-то подобного. Как вы поняли, strace - хорошее средство диагностики "капризных" программ.

Перейдем к оптимизациям, которые может нам предоставить компилятор GCC. Сначала нужно понять, что же это такое.

Взгляните на следующую программу, test1.c:

#include <stdio.h>

int main()
{
    int i;

    for(i=0;i<4;i++)
    {
        puts("Hello");
    }

    return 0;
}

После трансляции программы на язык ассемблера (с помощью команды gcc -S) мы получим:

Здесь мы видим цикл for, начинающийся с метки .L3. Он работает до тех пор, пока верно условие оператора jle, после метки .L2. Давайте теперь вновь оттранслируем программу, но на этот раз включим оптимизацию -O3:

gcc -S -O3 test.c

Вот что мы получим:

main:
        leal    4(%esp), %ecx
        andl    $-16, %esp
        pushl   -4(%ecx)
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ecx
        subl    $4, %esp
        movl    $.LC0, (%esp)
        call    puts
        movl    $.LC0, (%esp)
        call    puts
        movl    $.LC0, (%esp)
        call    puts
        movl    $.LC0, (%esp)
        call    puts
        movl    $.LC0, (%esp)
        call    puts
        addl    $4, %esp
        movl    $1, %eax
        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

В приведенном коде видно, что цикл for полностью устранен - gcc заменил его на 5 последовательных вызовов функции puts. Компилятор убрал целый цикл! Классно.

GCC - очень хитрый компилятор, к примеру, он может устранять переменные, которые не изменяются в цикле. Взгляните на этот кусок кода:

for (i=0; i<5; i++)
{
    x=23;
    do_something();
}

Если вы не поленитесь написать программу подобной структуры, вы увидите, что присвоение переменной x значения вынесется за тело цикла, ведь значение x внутри цикла не используется. По сути, с использованием оптимизации -O3 компилятор переписывает код следующим образом:

x=23;
for (i=0; i<5; i++)
{
    do_something();
}

Дополнительный балл тому, кто угадает, что сделает gcc -O3 с этой программой:

#include <stdio.h>

int main ()
{
    int i;
    int j;

    for(i=0;i<4;i++)
    {
        j=j+2;
    }

    return 0;
}

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

        .file   "t3.c"
        .text
        .p2align 4,,15
.globl main
        .type   main, @function
main:
        leal    4(%esp), %ecx
        andl    $-16, %esp
        pushl   -4(%ecx)
        xorl    %eax, %eax
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ecx
        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

Как можно видеть, программа запускается и сразу же завершается. Цикл for устранен, то же самое с ненужной переменной j. Здорово.

Итак, GCC - весьма сложный компилятор, способный справляться с большими проектами и производящий хитроумные оптимизации исходных файлов. Надеюсь, что после прочтения этих двух статей вы поймете, насколько умен компилятор Linux, и как с его помощью можно делать свои программы лучше.

Обсуждение статьи на ЛОР.