Процесс трансляции

Кувшинов Д.Р.

2015


Общее оглавление


Основные определения

Транслятор translator –– программа или устройство, переводящее текст с одного языка (исходный язык source language) на другой язык (целевой язык target language). Процесс работы транслятора называют трансляцией translation.


Исходный код source code –– фрагмент текста, написанный на исходном языке для некоторого транслятора, “программа” как текст.


Единица трансляции translation unit –– файл исходного кода, обрабатываемый транслятором как единое целое. В случае языка C единица трансляции (файл .c) является модулем.


Интерпретатор interpreter –– транслятор, целевым языком которого является множество действий. Иными словами, интерпретатор не создаёт файл программы на некотором целевом языке, а сразу исполняет программу, поданную ему на вход. Процесс работы интерпретатора называют интерпретацией interpreting.


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

Если для компилятора помимо собственно целевого языка имеет значение способ оформления результирующих файлов и иные особенности операционной среды (обычно задаваемые выбором целевой ОС), то говорят о целевой платформе. Если целевая платформа не совпадает (не является надмножеством) платформы, на которой запущен сам компилятор, то процесс такой компиляции называется кросс-компиляцией (например, на GNU/Linux компилируется код, который будет затем запускаться на Windows).


Раздельная компиляция separate compilation — компиляция единиц трансляции по отдельности независимо друг от друга (в произвольном порядке, в том числе параллельно). Какая-то часть из них может быть откомпилирована заранее. В случае C и C++ это означает независимую компиляцию разных .c и .cpp файлов, включенных в проект. Когда все единицы трансляции скомпилированы, можно выполнить их компоновку в конечный продукт — например, исполняемый файл.


Ассемблер assembler — транслятор, исходным языком которого является стандартизованная текстовая форма машинного кода (удобная для человека — ассемблерный код assembly), а целевым языком — машинный код. Ассемблер можно считать видом компилятора, но традиционно термин “компилятор” применяется только по отношению к трансляторам с исходных языков высокого уровня.


Дизассемблер disassembler — транслятор, исходным языком которого является машинный код, а целевым языком — ассемблерный код. Дизассемблеры, как правило, принимают на вход исполняемые файлы и библиотеки в машинном коде.


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

“Object code” тоже можно перевести как “целевой код”, и он действительно является целевым для компилятора. Однако такой перевод может вызвать путаницу с точки зрения разделения “целевого языка” вообще или фрагмента программы на этом языке и промежуточного файла, принимаемого компоновщиком (см. ниже) — “объекта”.

Исторически слово “object”, вероятно, связано с языком BCPL, который можно считать предком языка C. Компилятор BCPL транслировал программы на BCPL в файлы на специальном промежуточном языке низкого уровня O-code (“object code” — “целевой код”), которые уже затем могли транслироваться в исполняемые файлы для конкретных ОС и процессоров отдельными трансляторами. Такой подход облегчал перенос компилятора на новые системы: сам компилятор можно было не трогать, достаточно было обновить (или написать новый) транслятор O-code. Данный подход затем применялся неоднократно (например, для Pascal, Basic, Java, .NET).


Машинный код machine code –– язык, интерпретатором которого является некоторый процессор (“машина”).


Компоновщик linker –– программа, выполняющая построение готового к запуску исполняемого файла, либо стандартным образом оформленной библиотеки в машинном коде из набора объектных файлов, библиотек в машинном коде, а также любых дополнительных данных. Процесс работы компоновщика называют компоновкой linking.


Исполняемый файл executable –– оформленная в виде файла стандартного формата (для определённой ОС) программа в машинном коде, которую можно немедленно запустить на исполнение. В ОС Windows исполняемые файлы традиционно помечаются разрешением exe.


Модуль module — многозначное слово. В зависимости от контекста применения возможны следующие толкования:


Сборка building — полный многоэтапный процесс порождения исполняемых файлов (модулей) из файлов исходного кода, библиотек и файлов ресурсов. Необходимый для сборки конкретный набор программ называют (это распространённый жаргонизм) тулчейном toolchain. Обычно это компилятор и компоновщик. Нередко в состав тулчейна добавляют отладчик. Естественно, отладчик не задействуется в процессе сборки непосредственно, однако, если мы хотим эффективно применять некоторый отладчик к результирующему машинному коду, то поддержка этого отладчика должна быть заложена уже на этапе сборки. Конкретные реализации стандартных библиотек также могут включаться в тулчейн (просто потому, что любая реализация языка обязана поддерживать его стандартную библиотеку).


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


Определение definition — полное описание некоторой сущности (переменной, функции, типа) на языке программирования.

Правило одного определения one definition rule, ODR в C++:

  1. Любая глобальная сущность может быть определена лишь однажды.
  2. Любая локальная сущность может быть определена лишь однажды в своей области видимости.

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

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


Библиотека library –– самостоятельный фрагмент кода, предназначенный для использования в составе другого ПО. Библиотека может существовать как в исходном коде, подключаемом во время компиляции и транслируемом вместе с кодом ПО, использующего библиотеку, так и в специально оформленном машинном коде (“модуле”), подключаемом либо во время компоновки (статические библиотеки static libraries), либо во время работы ПО (динамически связываемые библиотеки dynamically linked libraries, DLL, shared objects, SO).


Точка входа entry point –– адрес в модуле (исполняемом файле или библиотеке), с которого можно начать выполнение кода. Адрес может иметь символическое имя, записанное в специальной таблице внутри модуля. Часто это имя просто совпадает с именем соответствующей функции в исходном коде. В C и C++ определена стандартная точка входа для исполняемых файлов — функция main.


Процесс трансляции

  1. Каждая единица трансляции (.cpp и .c файлы) обрабатывается независимо.
  2. Препроцессор просматривает файл и ищет в нём директивы препроцессора (строки, начинающиеся на знак #) и макросы (идентификаторы, для которых определены замены). Данный процесс может производиться рекурсивно. Например, пусть есть три файла a.h, b.h и hw.cpp. Последний является единицей трансляции, поэтому все операции будут выполняться с ним.

Файл a.h

#ifndef A_H_INCLUDED
#define A_H_INCLUDED
#define ENTRY int main()
#define BEGIN {
#define END }
#endif//A_H_INCLUDED

Файл b.h

#ifndef B_H_INCLUDED
#define B_H_INCLUDED
#include "a.h"
#include <iostream>
#define PRINT std::cout <<
#endif//B_H_INCLUDED

Файл hw.cpp

#include "a.h"
#include "b.h"

ENTRY BEGIN
PRINT "Hello, world!";
END

Какой исходный код в итоге получит компилятор? Мы можем проделать работу препроцессора (с некоторыми упрощениями) вручную. Начинается работа с чтения единицы трансляции — файла hw.cpp. Заголочные файлы единицами трансляции не являются.

Препроцессор начинает просматривать hw.cpp и на первой же строчке находит директиву

#include "a.h"

Это директива include (“включить”), означающая, что препроцессор должен найти указанный в ней файл и его содержимое включить как текст непосредственно вместо строчки с данной директивой (и продолжить работу уже с включенным текстом). Использование двойных кавычек "a.h" означает, что файл a.h относится к собираемому проекту, а не является библиотечным. Т.е. его следует искать не в библиотеках, а в том же каталоге, где лежит транслируемый в данный момент .cpp-файл.

Файл hw.cpp — шаг подстановки 1

#ifndef A_H_INCLUDED
#define A_H_INCLUDED
#define ENTRY int main()
#define BEGIN {
#define END }
#endif//A_H_INCLUDED
#include "b.h"

ENTRY BEGIN
PRINT "Hello, world!";
END

Теперь препроцессор видит строчку

#ifndef A_H_INCLUDED

Эта директива означает следующее: если макрос A_H_INCLUDED не определён (а он не определён), то надо оставить весь текст до соответствующей директивы #endif. Данные директивы могут быть вложенными и проверять разные условия (родственные директивы: #if, #ifdef, #else, #elif и ключевое слово препроцессора defined). Применим данное правило.

Файл hw.cpp — шаг подстановки 2

#define A_H_INCLUDED
#define ENTRY int main()
#define BEGIN {
#define END }
#include "b.h"

ENTRY BEGIN
PRINT "Hello, world!";
END

Смотрим далее и видим директиву

#define A_H_INCLUDED

Директива #define определяет новый макрос (понятный препроцессору идентификатор, которому соответствует строковая подстановка). В данном случае формально мы говорим, что идентификатору A_H_INCLUDED соответствует подстановка на пустую строку. Нередко так делают в общем-то не для того, чтобы использовать макрос для подстановок, а просто для того, чтобы было определено некоторое имя, которое позволяет управлять процессом включения в программу тех или иных фрагментов. Например, при компиляции под Windows определён макрос WIN32, поэтому программа, предоставляющая разные реализации какого-то фрагмента под Windows и, например, POSIX-совместимых систем может выбирать нужный фрагмент директивами препроцессора вроде той же #ifndef (условная компиляция).

#if defined(WIN32)
// вариант кода для Windows
#elif defined(_POSIX_VERSION)
// вариант кода для POSIX-систем
#else
// Ошибка компиляции: неизвестная система
#error Unknown system
#endif

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

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

А вот следующие директивы #define уже задают настоящие текстовые подстановки.

#define ENTRY int main()
#define BEGIN {
#define END }

Файл hw.cpp — шаг подстановки 3

#include "b.h"

ENTRY BEGIN
PRINT "Hello, world!";
END

Файл hw.cpp — шаг подстановки 4

#ifndef B_H_INCLUDED
#define B_H_INCLUDED
#include "a.h"
#include <iostream>
#define PRINT std::cout <<
#endif//B_H_INCLUDED

ENTRY BEGIN
PRINT "Hello, world!";
END

Файл hw.cpp — шаг подстановки 5 (макрос B_H_INCLUDED — страж включения файла b.h)

#define B_H_INCLUDED
#include "a.h"
#include <iostream>
#define PRINT std::cout <<

ENTRY BEGIN
PRINT "Hello, world!";
END

Файл hw.cpp — шаг подстановки 6

#include "a.h"
#include <iostream>
#define PRINT std::cout <<

ENTRY BEGIN
PRINT "Hello, world!";
END

Файл hw.cpp — шаг подстановки 7

#ifndef A_H_INCLUDED
#define A_H_INCLUDED
#define ENTRY int main()
#define BEGIN {
#define END }
#endif//A_H_INCLUDED
#include <iostream>
#define PRINT std::cout <<

ENTRY BEGIN
PRINT "Hello, world!";
END

Файл hw.cpp — шаг подстановки 8. На этот раз макрос A_H_INCLUDED уже определён, поэтому весь текст между #ifndef и #endif отбрасывается.

#include <iostream>
#define PRINT std::cout <<

ENTRY BEGIN
PRINT "Hello, world!";
END

Файл hw.cpp — шаг подстановки 9. Угловые скобки в <iostream> говорят о том, что этот файл — библиотечный, и следует искать его в первую очередь не рядом с hw.cpp, а в каталогах, где располагаются библиотеки (включая стандартную библиотеку). В примере его содержимое заменено комментарием, так как файл большой и его содержимое своё в каждой реализации Стандартной библиотеки.

// Содержимое стандартного файла iostream
// ...
#define PRINT std::cout <<

ENTRY BEGIN
PRINT "Hello, world!";
END

В конце концов, препроцессор доберётся до текста

ENTRY BEGIN
PRINT "Hello, world!";
END

Просматривая строчки, он подставит все идентификаторы в соответствии с ранее прочитанными директивами #define, получив программу

int main() {
std::cout << "Hello, world!";
}

Эта программа далее поступает компилятору.

  1. Компилятор транслирует каждую единицу трансляции в файл объектного кода (традиционно обозначаемые расширением .obj в Windows, .o в Unix-подобных системах). Это самая сложная операция во всём процессе, производящаяся в несколько стадий (фаз трансляции), но на данном этапе мы не будем погружаться в подробности.
  2. Компоновщик формирует результирующий модуль, соединяя файлы объектного кода и файлы статически-компонуемых библиотек (традиционно обозначаемые расширением .lib в Windows, .a в Unix-подобных системах).
  3. При запуске исполняемого файла создаётся объект операционной системы — процесс, который представляет собой исполняющееся приложение и распоряжается всеми ресурсами, выделенными приложению операционной системой. В частности, процесс обладает собственной памятью, в которую загружается исходный код исполняемого файла и дополнительных модулей библиотек (динамически-компонуемых библиотек, обозначаемых расширением .dll в Windows, .so в Unix-подобных системах). В случае, если исполняемый файл запускается в режиме отладки, то запущенный процесс контролируется отладчиком (который исполняется в отдельном процессе).
Трансляция и исполнение
Трансляция и исполнение

Общее оглавление

Кувшинов Д.Р. © 2015