C2018/Сборка программ на C

Материал из iRunner Wiki

Понятие исполняемого файла

В общем случае исполняемый файл (англ. executable file) — файл, содержащий программу в виде инструкций, которые могут быть исполнены компьютером. Исполняемым файлам противопоставляются файлы с данными (data file) — файлы, которые читаются и парсятся определённой программой, а сами по себе не могут быть исполнены.

Инструкции (код) — это:

  • либо машинные инструкции для выполнения на физическом процессоре;
  • либо исходный код (сценарий, скрипт, псевдокод), записанный на одном из интерпретируемых языков программирования (пример: bash-скрипты, Python-программы, bat-файлы);
  • либо байт-код виртуальной машины (пример: class-файлы JVM, pyc-файлы для Python).

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

Формат и отличительные особенности исполняемых файлов зависят от операционной системы. Однако концепция является общей.

Формат в UNIX

В UNIX-подобных системах (Linux, FreeBSD, ...) исполняемые файлы отличаются по специальному атрибуту execute в файловой системе, расширение файла значение не имеет (обычно не имеют расширения).

В современных UNIX-подобных системах используется формат ELF (Executable and Linkable Format).

Пример ELF-заголовка:

00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  02 00 3e 00 01 00 00 00  c5 48 40 00 00 00 00 00  |..>......H@.....|

Слово ELF в английском также имеет значение "эльф". В продолжение темы средневекового фэнтези, широко используемый формат представления отладочной информации был назван DWARF (англ. "гоблин", "гном"). Изначально проектировался вместе с ELF, название дано в том же стиле, и позже придумана расшифровка Debugging With Attributed Record Formats.

До ELF, в семидесятые годы использовался формат a.out (assembler output). Отсюда пошла традиция, что при компиляции в GCC по умолчанию выходной файл называется a.out, хотя имеет формат ELF.

Формат в Mac OS

В операционных системах от Apple (iOS и Mac OS X) свой формат — Mach-O (сокращение от Mach object).

Формат в Windows

В Windows исполняемые файлы имеют расширение exe.

Формат исполняемых файлов называется PE (Portable Executable). Первые два байта PE файла содержат сигнатуру 0x4D 0x5A — «MZ». PE представляет собой модифицированную версию COFF (Common Object File Format) формата файла для UNIX. PE/COFF — альтернативный термин при разработке Windows.

Сборка простейшей программы

Есть код на C:

#include <stdio.h>
 
int main(void) {
    puts("Hello, world!");
    return 0;
}

Кажется, очень просто получить из него исполняемую программу за один вызов компилятора.

На UNIX:

$ gcc main.c
$ ./a.out
Hello, world!

На выходе a.out — исполняемый файл.

На Windows:

> cl main.c
> main.exe
Hello, world!

На выходе main.exe — исполняемый файл.

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

Стадии

Классический сценарий сборки кода на C включает четыре этапа.

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

О препроцессоре мы говорили в прошлый раз. Он подставляет include-файлы, генерирует код с помощью макросов, заменяет define-константы на их значения.

Посмотреть результат препроцессинга можно через

$ gcc -E main.c

Результат выводится на стандартный вывод.

Вывод может быть большим. Например, на этот 7-строчный файл gcc 5.4.0 генерирует 854 строки. Если инклудов много, там будут тысячи строк.

Компиляция

Обработка исходного кода (уже без директив препроцессора) и преобразование его в команды ассемблера для целевой платформы (например x86).

$ gcc -S main.c

Результат записывается в файл main.s.

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

Преобразование кода на языке ассемблера в бинарный формат — в объектный файл.

$ gcc -c main.c

Результат записывается в файл main.o.

Компоновка (linking)

Объектный код, сгенерированный ассемблером, компонуется с другими объектным кодом, в том числе с библиотеками, для получения исполняемого файла.

Диаграмма

GCC-CompilationProcess.png

Сборка программ из нескольких C-файлов

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

Каждый C-файл можно компилировать независимо в свой объектный файл, а затем компоновать.

Это даёт следующие преимущества.

  • Разные C-файлы компилируются параллельно на разных ядрах процессора.
  • Если изменяется один C-файл, достаточно перекомпилировать только его.

5 0000000001.gif

$ gcc -с main.c
$ gcc -с foo.c
$ gcc -с bar.c
$ gcc main.o foo.o bar.o -o program
$ ./program

Каждый подаваемый на вход компилятора исходный текст (отдельный c-файл вместе со включёнными в него заголовочными файлами) называют единицей трансляции (translation unit). В традиционной схеме сборки каждый c-файл компилируется по отдельности, после чего объектные файлы собираются в исполняемый файл компоновщиком.

Функции и переменные в C

Объявление и определение

Необходимо понимать разницу между объявлением и определением.

Определение (definition) связывает имя с реализацией:

  • Определение переменной побуждает компилятор зарезервировать некоторую область памяти (возможно, задав ей некоторое определённое значение).
  • Определение функции заставляет компилятор сгенерировать код для этой функции.
void Foo(void) { }
int x;

Объявление (declaration) говорит компилятору, что определение функции или переменной (с конкретным именем) существует в другом месте программы (вероятно, в другом C-файле).

void Foo(void);
extern int x;

Заметьте, что определение также является объявлением — фактически это объявление, в котором «другое место» программы совпадает с текущим.

Классификация

Интуитивно понятным является понятие области видимости переменной (scope):

  1. глобальные переменнные,
  2. локальные переменные.

Глобальные объявляются вне функции и могут быть доступны из разных функций. Локальные переменные видимы только внутри функции (более того, в рамках блока, ограниченного фигурными скобками).

Мы уже рассматривали три класса хранения (storage class):

  1. статический,
  2. автоматический,
  3. динамический.

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

Динамическое хранение имеют неименованные области памяти, выделяемые через malloc и освобождаемые через free. Поэтому используют указатели — именованные переменные, содержащие адрес неименованной области памяти. Сам указатель может быть локальной или глобальной переменной.

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

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

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

Рассмотрим пример:

/* Определение неинициализированной глобальной переменной */
int x_global_uninit;
 
/* Определение инициализированной глобальной переменной */
int x_global_init = 1;  
 
/* Определение неинициализированной глобальной переменной, к которой 
 * можно обратиться по имени только в пределах этого C файла */
static int y_global_uninit; 
 
/* Определение инициализированной глобальной переменной, к которой 
 * можно обратиться по имени только в пределах этого C файла */ 
static int y_global_init = 2; 
 
/* Объявление глобальной переменной, которая определена где-нибудь
 * в другом месте программы */
extern int z_global;
 
/* Объявлени функции, которая определена где-нибудь другом месте 
 * программы (Вы можете добавить впереди "extern", однако это 
 *  необязательно) */
int fn_a(int x, int y);
 
/* Определение функции. Однако, будучи помеченной как static, её можно 
 * вызвать по имени только в пределах этого C-файла. */ 
static int fn_b(int x)
{
    return x+1;
}
 
/* Определение функции. */
/* Параметр функции считается локальной переменной. */
int fn_c(int x_local)
{
    /* Определение неинициализированной локальной переменной */
    int y_local_uninit;
    /* Определение инициализированной локальной переменной */
    int y_local_init = 3;
 
    /* Код, который обращается к локальным и глобальным переменным,
     * а также функциям по имени */ 
    x_global_uninit = fn_a(x_local, x_global_init);
    y_local_uninit = fn_a(x_local, y_local_init);
    y_local_uninit += fn_b(z_global);
    return (x_global_uninit + y_local_uninit);
}

Что делает компилятор C

Работа компилятора C заключается в конвертировании текста программы на C, понятного человеку, в нечто, что понимает компьютер. На выходе компилятор выдаёт объектный файл.

Содержание объектного файла — в сущности, две вещи:

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

Код и данные будут иметь ассоциированные с ними имена — имена функций или переменных, с которыми они связаны определением.

Объектный код — это последовательность машинных инструкций для процессора, которая соответствует C-коду: if'ы и while'ы и пр. Эти инструкции должны манипулировать информацией определённого рода, а информация должна где-нибудь находиться — для этого нам и нужны переменные. Код может также ссылаться на другой код (в частности, на другие C-функции в программе).

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

Работа компоновщика проверить эти обещания. Однако, что компилятор делает со всеми этими обещаниями, когда он генерирует объектный файл?

По существу компилятор оставляет пустые места. Пустое место (ссылка) имеет имя, но значение, соответствующее этому имени, пока неизвестно.

Учитывая это, мы можем изобразить объектный файл, соответствующей программе, приведённой выше, следующим образом:

Linker1.png

Анализ объектного файла

Полезно посмотреть, как это работает на практике.

$ gcc -c example1.c 
$ ls
example1.c  example1.o

Объектный файл, хоть и не может быть запущен напрямую, имеет формат, схожий с форматом исполняемого файла.

На UNIX объектные файлы имеют расширение o. Формат — ELF.

На Windows используется расширение obj. Формат — COFF.

На платформе UNIX основным инструментом для нас будет команда nm, которая выдаёт информацию о символах объектного файла. Для Windows команда dumpbin с опцией /symbols является приблизительным эквивалентом. Также есть портированные под Windows инструменты GNU binutils, которые включают nm.exe.

Давайте посмотрим, что выдаёт nm для объектного файла, полученного из нашего примера выше.

$ nm -S example1.o
                 U fn_a
0000000000000000 000000000000000f t fn_b
000000000000000f 0000000000000059 T fn_c
0000000000000000 0000000000000004 D x_global_init
0000000000000004 0000000000000004 C x_global_uninit
0000000000000004 0000000000000004 d y_global_init
0000000000000000 0000000000000004 b y_global_uninit
                 U z_global

Результат может выглядеть немного по разному на разных платформах.

$ nm -f sysv example1.o

Symbols from example1.o:

Name                  Value           Class        Type         Size             Line  Section

fn_a                |                |   U  |            NOTYPE|                |     |*UND*
fn_b                |0000000000000000|   t  |              FUNC|000000000000000f|     |.text
fn_c                |000000000000000f|   T  |              FUNC|0000000000000059|     |.text
x_global_init       |0000000000000000|   D  |            OBJECT|0000000000000004|     |.data
x_global_uninit     |0000000000000004|   C  |            OBJECT|0000000000000004|     |*COM*
y_global_init       |0000000000000004|   d  |            OBJECT|0000000000000004|     |.data
y_global_uninit     |0000000000000000|   b  |            OBJECT|0000000000000004|     |.bss
z_global            |                |   U  |            NOTYPE|                |     |*UND*

Ключевыми сведениями являются класс каждого символа и его размер (если присутствует).

  • Класс U (от undefined) обозначает неопределённые ссылки, те самые «пустые места», упомянутые выше. Для этого класса существует два объекта: fn_a и z_global.
  • Классы t и T (от слова text) указывают на код, который определён; различие между t и T заключается в том, является ли функция статической (t) (локальной в файле) или нет (T), т.е. была ли функция объявлена как static.
  • Классы d и D (от слова data) содержат инициализированные глобальные переменные. При этом статические переменные принадлежат классу d.
  • Для неинициализированных глобальных переменных мы получаем b, если они статичные, и B или C иначе.

Что делает компоновщик

Компоновщик осуществляет заполнение пустых мест в объектных файлах. Проиллюстрируем это на примере, рассматривая ещё один C файл в дополнение к тому, что был приведён выше.

/* Инициализированная глобальная переменная */
int z_global = 11;
/* Вторая глобальная переменная с именем y_global_init, но они обе static */
static int y_global_init = 2; 
/* Объявление другой глобальной переменной */
extern int x_global_init;
 
int fn_a(int x, int y)
{
    return(x+y);
}
 
int main(int argc, char *argv)
{
    const char *message = "Hello, world";
    return fn_a(11,12);
}

Linker2.png

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

Также компоновщик может заполнить все пустые места как показано здесь (на системах UNIX процесс компоновки обычно вызывается командой ld).

Linker3.png

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

Сегменты и секции

В формате ELF есть понятие сегмента и секции.

Понятие сегмента из ELF-файла не имеет ничего общего с сегментацией памяти в архитектуре x86, с сегментными регистрами и дескрипторами процессора. Здесь сегмент — это просто диапазон адресов в памяти.

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

Elf link vs exec view.jpg

Сегмент — группа секций. Сегмент описывается заголовком (program header). В одном сегменте 0 или более секций. Сегмент также содержит информацию, необходимую для исполнения файла (runtime). Куда необходимо загрузить данные в виртуальном адресном пространстве процесса, какие права установить на эти страницы памяти (чтение, запись, исполнение). Мы это более детально будем изучать применительно к архитектуре x86 на последующих занятиях.

$ readelf -l program

.text

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

.data

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

.rodata

Константы (read-only).

.bss

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

Название BSS объясняется историческими причинами. Изначально это сокращение от названия операции "Block Started by Symbol" на древней ЭВМ IBM 704 (1957 г.). Некоторые расшифровывают эту аббревиатуру как "Better Save Space".

Представление программы в памяти

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

Anatomy of a Program in Memory

Приведём иллюстрацию типичного расположения данных в памяти процесса на 32-битном Linux на архитектуре x86.

LinuxFlexibleAddressSpaceLayout.png

Можно заметить, что стек растёт вниз (чем глубже по стеку, тем меньше адрес), а куча растёт вверх.

pmap

Утилита pmap в UNIX-системах позволяет получить карту адресного пространства процесса по его PID (process id).

ASLR

В реальной системе картина может сильно отличаться из-за применения технологии ASLR.

ASLR (англ. address space layout randomization — «рандомизация размещения адресного пространства») — технология, применяемая в операционных системах, при использовании которой случайным образом изменяется расположение в адресном пространстве процесса важных структур данных, а именно образов исполняемого файла, подгружаемых библиотек, кучи и стека.

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

Многопоточная программа

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

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

Проблема повторяющихся символов

В предыдущей главе было упомянуто, что компоновщик выдаёт сообщение об ошибке, если не может найти определение для символа, на который найдена ссылка. А что случится, если найдено два определения для символа во время компоновки?

В C++ решение прямолинейное. Язык имеет ограничение, известное как правило одного определения (ODR — one definition rule), которое гласит, что должно быть только одно определение для каждого символа, встречающегося во время компоновки, ни больше, ни меньше. (Соответствующая глава стандарта C++ также упоминает некоторые исключения, которые мы рассмотрим несколько позже.)

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

Однако, компоновщики должны уметь обходится также и с другими языками кроме C и C++, для которых правило одного определения не обязательно соблюдается.

Ключевое слово static

inline-функции

Язык C++

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

Перегрузка функций и декорирование имён

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

int max(int x, int y) 
{ 
  if (x>y) return x; 
  else return y; 
}
float max(float x, float y) 
{ 
  if (x>y) return x; 
  else return y; 
}
double max(double x, double y) 
{ 
  if (x>y) return x; 
  else return y; 
}

Такое положение вещей определённо затрудняет работу компоновщика: если какой-нибудь код обращается к функции max, какая именно имелась в виду?

Решение к этой проблеме названо декорированием имён (name mangling), потому что вся информация о сигнатуре функции переводится (to mangle — искажать, деформировать) в текстовую форму, которая становится собственно именем символа с точки зрения компоновщика. Различные сигнатуры переводятся в различные имена. Таким образом проблема уникальности имён решена.

Схемы отличаются от платформы к платформе.

Symbols from fn_overload.o:

Name                  Value   Class        Type         Size     Line  Section

__gxx_personality_v0|        |   U  |            NOTYPE|        |     |*UND*
_Z3maxii            |00000000|   T  |              FUNC|00000021|     |.text
_Z3maxff            |00000022|   T  |              FUNC|00000029|     |.text
_Z3maxdd            |0000004c|   T  |              FUNC|00000041|     |.text

Здесь мы видим три функции max, каждая из которых получила отличное имя в объектном файле, и мы можем проявить смекалку и предположить, что две следующие буквы после «max» обозначают типы входящих параметров — «i» как int, «f» как float и «d» как double (однако всё значительно усложняется, если классы, пространства имён, шаблоны и перегруженные операторы вступают в игру).

Также стоит отметить, что обычно есть способ конвертирования между именами, видимых программисту, и именами, видимых компоновщику. Это может быть и отдельная программа (например, c++filt) или опция в командной строке (например --demangle для GNU nm), которая выдаёт что-то похожее на это:

Symbols from fn_overload.o:

Name                  Value   Class        Type         Size     Line  Section

__gxx_personality_v0|        |   U  |            NOTYPE|        |     |*UND*
max(int, int)       |00000000|   T  |              FUNC|00000021|     |.text
max(float, float)   |00000022|   T  |              FUNC|00000029|     |.text
max(double, double) |0000004c|   T  |              FUNC|00000041|     |.text

Область, где схемы декорирования чаще всего заставляют ошибиться, находится в месте переплетения C и C++. Все символы, произведённые C++-компилятором, декорированы; все символы, произведённые C-компилятором, выглядят так же, как и в исходном коде. Чтобы обойти это, язык C++ разрешает поместить

extern "C"

вокруг объявления и определения функций. По сути этим мы сообщаем C++-компилятору, что определённое имя не должно быть декорировано — либо потому что это определение C++-функции, которая будет вызываться кодом C, либо потом что это определение C-функции, которая будет вызываться кодом C++.

Инициализация статических объектов

Следующее выходящее за рамки С свойство C++, которое затрагивает работу компоновщика, — это существование конструкторов объектов. Конструктор — это кусок кода, который задаёт начальное состояние объекта. По сути его работа концептуально эквивалентна инициализации значения переменной, однако с той важной разницей, что речь идёт о произвольных фрагментах кода.

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

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

Чтобы с этим справиться, компилятор помещает немного дополнительной информации в объектный файл для каждого C++-файла; а именно это список конструкторов, которые должны быть вызваны для конкретного файла. Во время компоновки компоновщик объединяет все эти списки в один большой список, а также помещает код, которые проходит через весь этот список, вызывая конструкторы всех глобальных объектов.

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

Шаблоны

Ранее мы приводили пример с тремя различными реализациями функции max, каждая из которых принимала аргументы различных типов. Однако, мы видим, что код тела функции во всех трёх случаях идентичен. А мы знаем, что дублировать один и тот же код — это дурной тон программирования.

C++ вводит понятия шаблона (templates), который позволяет использовать код, приведённый ниже, сразу для всех случаев.

template <class T>
T max(T x, T y) 
{ 
    if (x>y) return x; 
    else return y; 
}
 
int main()
{
    int a=1;
    int b=2;
    int c;
    c = max(a,b);  // Компилятор автоматически определяет, что нужно именно max<int>(int,int)
    double x = 1.1;
    float y = 2.2;
    double z;
    z = max<double>(x,y); // Компилятор не может определить, поэтому требуем max<double>(double,double)
    return 0;
}

Этот написанный на C++ код использует max<int>(int,int) и max<double>(double,double). Однако, какой-нибудь другой код мог бы использовать и другие инстанции этого шаблона.

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

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

Практика

Задачи на тему сборки

Полезные ссылки