Процесс компиляции программ на C++

Цель данной статьи:


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


Все действия будут производиться на Ubuntu версии 16.04.
Используя компилятор g++ версии:


$ g++ --version
g++ (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609

Состав компилятора g++


  • cpp — препроцессор
  • as — ассемблер
  • g++ — сам компилятор
  • ld — линкер

Мы не будем вызывать данные компоненты напрямую, так как для того, чтобы работать с C++ кодом, требуются дополнительные библиотеки, позволив все необходимые подгрузки делать основному компоненту компилятора — g++.


Зачем нужно компилировать исходные файлы?


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


Этапы компиляции:


Перед тем, как приступать, давайте создадим исходный .cpp файл, с которым и будем работать в дальнейшем.


driver.cpp:


#include <iostream>
using namespace std;
#define RETURN return 0

int main() {
    cout << "Hello, world!" << endl;
    RETURN;
}

1) Препроцессинг


Самая первая стадия компиляции программы.


Препроцессор — это макро процессор, который преобразовывает вашу программу для дальнейшего компилирования. На данной стадии происходит происходит работа с препроцессорными директивами. Например, препроцессор добавляет хэдеры в код (#include), убирает комментирования, заменяет макросы (#define) их значениями, выбирает нужные куски кода в соответствии с условиями #if, #ifdef и #ifndef.


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


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


g++ -E driver.cpp -o driver.ii

Взглянув на тело функции main в новом сгенерированном файле, можно заметить, что макрос RETURN был заменен:


int main() {
    cout << "Hello, world!" << endl;
    return 0;
}

driver.ii


В новом сгенерированном файле также можно увидеть огромное количество новых строк, это различные библиотеки и хэдер iostream.


2) Компиляция


На данном шаге g++ выполняет свою главную задачу — компилирует, то есть преобразует полученный на прошлом шаге код без директив в ассемблерный код. Это промежуточный шаг между высокоуровневым языком и машинным (бинарным) кодом.


Ассемблерный код — это доступное для понимания человеком представление машинного кода.


Используя флаг -S, который сообщает компилятору остановиться после стадии компиляции, получим ассемблерный код в выходном файле driver.s:


$ g++ -S driver.ii -o driver.s

driver.s
    .file   "driver.cpp"
    .local  _ZStL8__ioinit
    .comm   _ZStL8__ioinit,1,1
    .section    .rodata
.LC0:
    .string "Hello, world!"
    .text
    .globl  main
    .type   main, @function
main:
.LFB1021:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $.LC0, %esi
    movl    $_ZSt4cout, %edi
    call    _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
    movl    $_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_, %esi
    movq    %rax, %rdi
    call    _ZNSolsEPFRSoS_E
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE1021:
    .size   main, .-main
    .type   _Z41__static_initialization_and_destruction_0ii, @function
_Z41__static_initialization_and_destruction_0ii:
.LFB1030:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    cmpl    $1, -4(%rbp)
    jne .L5
    cmpl    $65535, -8(%rbp)
    jne .L5
    movl    $_ZStL8__ioinit, %edi
    call    _ZNSt8ios_base4InitC1Ev
    movl    $__dso_handle, %edx
    movl    $_ZStL8__ioinit, %esi
    movl    $_ZNSt8ios_base4InitD1Ev, %edi
    call    __cxa_atexit
.L5:
    nop
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE1030:
    .size   _Z41__static_initialization_and_destruction_0ii, .-_Z41__static_initialization_and_destruction_0ii
    .type   _GLOBAL__sub_I_main, @function
_GLOBAL__sub_I_main:
.LFB1031:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $65535, %esi
    movl    $1, %edi
    call    _Z41__static_initialization_and_destruction_0ii
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE1031:
    .size   _GLOBAL__sub_I_main, .-_GLOBAL__sub_I_main
    .section    .init_array,"aw"
    .align 8
    .quad   _GLOBAL__sub_I_main
    .hidden __dso_handle
    .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
    .section    .note.GNU-stack,"",@progbits

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


3) Ассемблирование


Так как x86 процессоры исполняют команды на бинарном коде, необходимо перевести ассемблерный код в машинный с помощью ассемблера.


Ассемблер преобразовывает ассемблерный код в машинный код, сохраняя его в объектном файле.


Объектный файл — это созданный ассемблером промежуточный файл, хранящий кусок машинного кода. Этот кусок машинного кода, который еще не был связан вместе с другими кусками машинного кода в конечную выполняемую программу, называется объектным кодом.


Далее возможно сохранение данного объектного кода в статические библиотеки для того, чтобы не компилировать данный код снова.


Получим машинный код с помощью ассемблера (as) в выходной объектный файл driver.o:


$ as driver.s -o driver.o

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


4) Компоновка


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


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


Получим исполняемый файл driver:


$ g++ driver.o -o driver // также тут можно добавить и другие объектные файлы и библиотеки

5) Загрузка


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


Запустим нашу программу:


$ ./driver 
// Hello, world!

Заключение


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

Комментарии 27

    +7
    Возможно статья не станет откровением для тех, кто хорошо знаком с языком, но мне было интересно почитать.

    Статье не хватает больше подробностей про каждый этап, имхо.
      0
      Статье не хватает приписки «для чайников» в заголовке.
      Очень неожиданно встречать статьи для начинающих.
        0
        Она из песочницы, я отправил приглашение автору, потому что мне показалось что она интересная. Вы всегда можете поделиться своими продвинутыми знаниями в собственной публикации, если считаете нужным.
      +4

      Интерпретаторы С и С++ тоже существуют, например cling, однако носят скорее теоретическую ценность.

        +1
        носят скорее теоретическую ценность.


        PicoC вполне удавалось юзать IRL :)
        0

        дополнение — ассемблер обычно вручную вызывать не надо
        Сразу получаем объектник, а потом линкуем их в правльном порядке, с правильными библиотеками

          +1

          Нужно заметить, что линковка в нужном порядке — особенность не всех линкеров.
          Помнится, линкер студии справлялся с библиотеками без правильного порядка.
          Но это мелочи.

            +1

            Флажки --start-group/--end-group у ld(1) позволяют не думать о зависимостях между объектными файлами.

          +1

          Ух ты, я был уверен, что компиляция с промежуточным компилированием в ассемблер осталась далеко в прошлом.
          А что мешает компилировать сразу в исполняемый код? И, как я понимаю, другие языки такой подход не используют, или я не прав?

            0
            (del, не уверен в написанном)
              +2
              Компиляция с явной генерацией полноценного файла для ассемблера — это подход в первую очередь GCC. Даже во многом близкий к нему Clang не делает это основным методом — он вместо этого строит представление на LLVM IR в памяти и, если не сказано иное флагами, уже из него генерирует объектный файл без ассемблера. (Но его можно попросить выдать как IR в текстовом виде, так и код для ассемблера).
              Компиляторы, созданные в других традициях — например, MSVC — такого не делают; их можно попросить сделать листинг в ассемблере, но этот листинг нельзя потом скормить ассемблеру, чтобы получить объектный файл.

              Причины такой двухстадийности gcc, по-моему, в его истории: у Столлмана получилось нормально сделать и лицензионно, и по сути результата — конверсию в задокументированный ассемблер на целевых платформах первого поколения (таких, как SunOS, HP-UX, AIX), но уже объектный формат имел сложновыясняемые и/или лицензионно недоступные особенности. Сейчас не нагуглилось, так что это обоснование только по воспоминаниям о прочитанном и услышанном около 20 лет назад. Потом же решили не менять этот подход, потому что он оказался вполне недорогим (по сравнению как с парсингом C++, так и с ценой преобразований программы в процессе компиляции), даже с промежуточным файлом.

              И ещё вкусность — наличие полноценного ассемблерного исходника, который можно посмотреть и, если нужно, подправить под себя, очень помогло в росте белой хакерской культуры. Помню это по себе — если что не понимаешь, смотришь ассемблер и экспериментируешь и в нём тоже :)

              С другими языками — опять же, кто компилирует:) Все языки из комплекта GCC (C, C++, Fortran, Ada...) или LLVM-based проходят такую фазу, обязательно или опционально. Аналогично, например, Go, но у него существенно свой ассемблер (причём на всех платформах; это мини-кошмар для системщика на x86 — кроме AT&T и Intel syntax, есть ещё Go syntax). В Unix мире много компиляторов переводят код своего языка на C, и уже этот результат компилируют, чтобы не заморачиваться после этого проблемами оптимизации; тогда тоже есть ещё фаза ассемблера.
                +1
                Компилирование в ассемблер вместо объектного кода — традиционная манера 60-х годов, призванная упростить и уменьшить компилятор за счет времени компиляции.

                Компилятор С исходно был сделан точно так же.
                  0
                  В организацию компилятора в Bell Unix версиях я не успел заглянуть, но верю. Но факт, что в одном мире (Unix) этот подход сохранили или как обязательный, или по крайней мере как доступный по запросу, даже когда давление ресурсов ослабло — а в других (DOS/Windows/etc.) он просто не возник, или не закрепился. Вероятные причины этого, IMHO, совпадают с теми, что я уже описал.
                  «60-е годы» это или чрезмерное обобщение, или надо говорить уже про 70-е. Под OS/360, например, компиляторы не использовали ассемблер в явном виде, у них был прямой «выхлоп» объектным файлом — а это середина 60-х.
                0
                Я таки думаю тут автор намеренно это сделал, дабы показать последовательность.
                0
                Без знания ассемблера бесполезно читать подобные статьи.

                Пока не знаешь, как процессор работает с памятью, как вызываются процедуры… все рассуждения так и останутся пассами рук, магией.

                Для тех, кто хочет реально разобраться, лучший способ что-то понять — сгенерировать компилятором ассемблерный листинг и изучить его, для начала лучше с отключенными оптимизациями. Будет повод узнать про ассемблер, что и как. Потом разберётесь, как устроена реализация объектов ООП и пр. Это действительно красиво.

                Потом станет понятнее, зачем нужны obj-файлы и связывание, а уж как работает загрузка исполняемых файлов…

                … После этого вы станете презирать интерпретаторы ))
                  +2

                  Ну, как бы, интерпретация и компиляция — разные вещи со своими плюсами и минусами.
                  И просто презирать интерпретаторы(кстати, какие именно) попахивает максимализмом юношеским.

                    0
                    попахивает максимализмом юношеским

                    Спасибо за комплимент ))
                    0

                    Сгенерировав асм из плюсов мы лишь узнаем как реализует ООП конкретный компилятор плюсов.
                    Что не помешает другим компиляторам С++ делать это чуть иначе, а другим языкам принципиально по-другому.
                    Загрузка тоже отличается от платформы к платформе, ОС и т.п

                      +1
                      Ну мы обсуждаем C++, не так ли. При реверсе я не замечил принципиальной разницы реализации ООП между MSVC, GCC, а также их версиями x86 и ARM. Отличаются передачей параметров (через регистры и/или стек в разных комбинациях). Ну и естественно RTL, обработка исключений.
                      Просто когда видишь, как производители CPU по крупицам улучшают предсказатели ветвления и бьются за каждый такт, становится грустно, что всё это богатство впоследствии успешно про$ирается программистами через высокоуровневые фронтенды, явы, дотнеты и виртуальные машины.
                      PS: сижу на ноутбуке с Core 2 Duo 1.6-1.8GHz и чувствую это почти физически )))
                        0
                        Ну и естественно RTL
                        … пардон, конечно же RTTI…
                    –6
                    Посмотрите исходник gcc и вы поймете, что вы полные дауны.
                      0

                      Смелое заявление, аргументируете?

                        0
                        Не хочу быть дауном, поэтому не буду смотреть. Чуть не попался…
                      +1
                      Не раскрыт процесс получения bin и hex файлов :)
                        0
                        print «hello world» на ассемблере пишется в ~12+длина строки байт.

                        driver.s
                        call _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
                        movl $_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_, %esi
                        movq %rax, %rdi
                        call _ZNSolsEPFRSoS_E
                        movl $0, %eax
                        popq %rbp

                        Зачем столько мусора в коде?
                          0
                          Это что бы перевести строчку передаётся указатель на функцию endl. Оптимизатор просто разводит руками и оставляет как есть.

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое