Перейти к основному содержимому

Преобразование исходного кода в исполняемый файл

Разработчику Архитектору

Преобразование исходного кода в исполняемый файл

Если вы только начинаете, полезно держать в голове одну картинку: текст в редакторепрограмма на диске (.exe или файл без расширения в Linux) → запуск ОС. Между первым и вторым шагом работают четыре "переводчика" подряд. Ошибки компиляции появляются на этапе проверки синтаксиса; ошибки линковки — когда компилятор не нашёл тело функции, которую вы вызываете.

Подробнее об инструментах целиком — в Инструментальной цепочке.


Словарь — что означают слова

ТерминПростыми словами
Исходный кодТекст .c / .h, который пишет человек
Препроцессор"Вставляет" заголовки и подставляет макросы до компиляции
КомпиляторПроверяет язык С и генерирует ассемблер или объектный файл
АссемблерПревращает текст инструкций процессора в байты
Объектный файл (.o, .obj)Кусок машинного кода без "склейки" с остальной программой
Компоновщик (линкер)Собирает все .o и библиотеки в один исполняемый файл
Заголовок (.h)Объявления: "такая функция существует"
Реализация (.c)Определения: "вот как функция устроена внутри"

От исходного кода к исполняемому файлу — этапы сборки

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


1. Препроцессор

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

#include <stdio.h>

Разбор:

  • #include — директива препроцессора, она срабатывает до этапа компиляции.
  • <stdio.h> подключает объявления функций стандартного ввода-вывода (printf, scanf и др.).
  • Угловые скобки означают поиск заголовка в системных путях компилятора, а не в текущей папке проекта.
  • Без этого заголовка вызов printf будет либо ошибкой, либо предупреждением о неявном объявлении функции.

Эта строка заменяется полным текстом заголовочного файла stdio.h, который содержит объявления стандартных функций ввода-вывода, таких как printf и scanf. Заголовочные файлы (с расширением .h) не содержат реализации функций — только их объявления: имена, типы возвращаемых значений и параметров. Это позволяет компилятору проверять корректность вызовов до того, как будет известно, как функция устроена внутри.

Другие часто используемые директивы:

  • #define — определяет макрос или константу;
  • #ifdef, #ifndef, #endif — позволяют условно включать или исключать части кода в зависимости от наличия определённых символов.
#define PI 3.14159
#define SQUARE(x) ((x) * (x))

double area = PI * SQUARE(5);

Разбор:

  • #define PI 3.14159 подставляет константу вместо имени PI на этапе препроцессора.
  • SQUARE(5) раскрывается в ((5) * (5)); скобки в макросе защищают от ошибок при подстановке выражений.
  • После препроцессинга строка с area станет обычным арифметическим выражением для компилятора C.
  • Макросы — это текстовая замена, а не функция: у них нет проверки типов на этапе #define.
  • Для сложной логики предпочтительнее static inline-функции (C99+), но макросы всё ещё встречаются в заголовках API.

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


2. Компилятор

Компилятор принимает обработанный препроцессором код и преобразует его в ассемблерный код, специфичный для целевой архитектуры процессора (x86, ARM, RISC-V и другие). Ассемблер — это язык низкого уровня, где каждая инструкция почти напрямую соответствует одной машинной команде.

Например, выражение на С:

int a = b + c;

Разбор:

  • Объявляется переменная a типа int и сразу инициализируется выражением b + c.
  • Компилятор проверяет, объявлены ли b и c, совместимы ли их типы для сложения и присваивания в int.
  • На уровне машинных инструкций это обычно распадается на загрузку операндов, арифметическую операцию и запись результата.
  • Если b и c уже в регистрах, компилятор может оптимизировать код и сократить количество обращений к памяти.

может быть скомпилировано в последовательность ассемблерных инструкций вроде:

mov eax, DWORD PTR [rbp - 8] ; загрузить значение b в регистр eax
add eax, DWORD PTR [rbp - 12] ; прибавить значение c
mov DWORD PTR [rbp - 4], eax ; сохранить результат в a

Разбор:

  • mov eax, DWORD PTR [rbp - 8] читает 4 байта из стека (локальная переменная b) в регистр eax.
  • add eax, DWORD PTR [rbp - 12] добавляет к eax значение c, тоже лежащее в кадре стека.
  • mov DWORD PTR [rbp - 4], eax записывает итоговую сумму обратно в память, в ячейку переменной a.
  • rbp служит опорной точкой кадра функции, а отрицательные смещения указывают на локальные переменные.
  • Это типичный шаблон неагрессивно оптимизированного кода — сначала "прочитать", затем "посчитать", затем "записать".

Разбор по строкам (архитектура x86-64, соглашение компилятора может отличаться):

СтрокаСмысл
rbpбазовый указатель кадра стека — "якорь" для локальных переменных
[rbp - 8]значение b лежит в памяти по смещению от rbp
mov eax, …скопировать 4 байта в регистр eax
add eax, …прибавить к eax значение c
mov …, eaxзаписать сумму в ячейку переменной a

Регистр — сверхбыстрая ячейка внутри процессора; mov и add — машинные инструкции. Ассемблерный листинг читаем человеку и почти напрямую переводится в байты для CPU.

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


3. Ассемблер и объектный файл

Ассемблер переводит .s в объектный файл (.o в Unix, .obj в Windows). В цепочке GCC/Clang этот шаг обычно скрыт за одной командой gcc. Объектный файл содержит машинный код, но ещё не готов к запуску. В нём находятся:

  • скомпилированные функции и глобальные переменные;
  • таблица символов — список имён функций и переменных, определённых в файле;
  • ссылки на внешние символы, которые будут разрешены позже (например, вызов printf).

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

Промежуточный ассемблерный код (gcc -S) полезен при отладке и изучении оптимизаций компилятора.


4. Компоновщик (линкер)

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

Например, если в одном файле вызывается функция calculate(), а её тело определено в другом, компоновщик свяжет эти два объектных файла. Аналогично он подключает стандартную библиотеку С (libc), чтобы обеспечить работу printf, malloc и других функций.

Результат работы компоновщика — исполняемый файл (в Linux — без расширения или .out, в Windows — .exe), который операционная система может загрузить в память и запустить.


5. Исполняемый файл

Исполняемый файл содержит:

  • машинный код всех функций программы;
  • данные (глобальные переменные, строки и константы);
  • метаданные — точку входа (main), таблицу импорта/экспорта, информацию о сегментах памяти.

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


Мини-пример — один файл от начала до конца

Исходник hello.c:

#include <stdio.h>

int main(void) {
printf("Hello\n");
return 0;
}

Разбор:

  • Подключение stdio.h нужно для корректного объявления printf.
  • int main(void) — стандартная точка входа: int возвращает код завершения процессу/ОС, void означает отсутствие аргументов.
  • printf("Hello\n"); печатает строку и переводит курсор на новую строку благодаря \n.
  • return 0; сигнализирует об успешном завершении программы.
  • Это минимальный законченный пример — заголовок, точка входа, вывод, корректный код возврата.

Команда (GCC или Clang):

gcc hello.c -o hello
./hello

Разбор:

  • gcc hello.c -o hello компилирует и линкует исходник в исполняемый файл hello.
  • ./hello запускает полученный файл из текущей директории (./ явно указывает путь).
  • Если сборка не прошла, второй шаг завершится ошибкой "файл не найден" или аналогичной.
  • Такой двухшаговый шаблон используют постоянно: сначала сборка, потом немедленная проверка запуска.

Что происходит внутри одной команды gcc hello.c -o hello:

  1. Препроцессор открывает stdio.h и подставляет объявление printf.
  2. Компилятор проверяет, что main возвращает int, что printf вызван с подходящими аргументами.
  3. Ассемблер кладёт машинные инструкции во временный .o.
  4. Линкер подключает реализацию printf из стандартной библиотеки С (libc) и записывает готовый hello.

Чтобы увидеть этапы отдельно:

gcc -E hello.c -o hello.i # только препроцессор (файл станет очень большим)
gcc -S hello.c -o hello.s # ассемблерный листинг
gcc -c hello.c -o hello.o # объектный файл без линковки
gcc hello.o -o hello # только линковка

Разбор:

  • -E останавливает конвейер после препроцессора и позволяет увидеть итоговый текст после #include/#define.
  • -S генерирует ассемблер (.s) и помогает понять, как C-конструкции превращаются в инструкции CPU.
  • -c создаёт объектный файл (.o) без финальной линковки, что удобно при модульной сборке.
  • Последняя команда связывает объектник с библиотеками и формирует готовый исполняемый файл.
  • Разделение этапов полезно для диагностики: сразу видно, на какой фазе возникает ошибка.

Заголовочные файлы и объявление функций

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

Объявление функции (function declaration) указывает компилятору:

  • имя функции;
  • тип возвращаемого значения;
  • количество и типы параметров.

Пример:

int add(int a, int b);

Разбор:

  • Это объявление функции (прототип), а не её реализация.
  • int перед именем задаёт тип возвращаемого значения.
  • (int a, int b) фиксирует количество и типы параметров, чтобы компилятор проверял корректность вызова.
  • Точка с запятой завершает именно объявление; тела функции здесь нет.

Объявление (declaration) — обещание компилятору: функция с таким именем, такими типами параметров и таким типом результата где-то есть. Само по себе объявление не создаёт машинный код.

Определение (definition) — тело функции в фигурных скобках; именно оно попадает в объектный файл.

Разбор строки объявления:

ЧастьЗначение
intфункция возвращает целое
addимя функции
(int a, int b)два аргумента типа int

Реализация может находиться в другом .c-файле:

int add(int a, int b) {
return a + b;
}

Разбор:

  • Это уже определение функции: тут есть тело в фигурных скобках, и компилятор генерирует для него машинный код.
  • return a + b; вычисляет сумму параметров и возвращает результат вызывающему коду.
  • Имена параметров a и b локальны для функции и существуют только в её области видимости.
  • Сигнатура должна совпадать с объявлением в заголовке, иначе возможны ошибки линковки или предупреждения компилятора.

Без объявления компилятор не сможет проверить корректность вызова add(5, 3). Заголовочный файл собирает такие объявления и делает их доступными через #include.

Хорошая практика — каждый .c-файл сопровождать соответствующим .h-файлом. Например, модуль math_utils.c имеет заголовок math_utils.h, который включают все, кто хочет использовать его функции.

Мини-схема разделения "заголовок / реализация":

counter.h:

#ifndef COUNTER_H
#define COUNTER_H

void counter_inc(void);
int counter_get(void);

#endif

counter.c:

#include "counter.h"

static int value = 0;

void counter_inc(void) {
value++;
}

int counter_get(void) {
return value;
}

main.c:

#include <stdio.h>
#include "counter.h"

int main(void) {
counter_inc();
counter_inc();
printf("%d\n", counter_get());
return 0;
}

Разбор:

  • Include guard (#ifndef COUNTER_H) защищает от повторного включения одного заголовка.
  • static int value виден только внутри counter.c, но API модуля остаётся публичным через функции.
  • main.c знает только объявления из .h, а реализация скрыта в .c.
  • Линкер связывает вызовы counter_inc/counter_get из main.o с телами из counter.o.
  • Сборка: gcc main.c counter.c -o app (ожидаемый вывод: 2).

Файл сборки

В простых проектах сборка может выполняться одной командой:

gcc main.c utils.c -o program

Разбор:

  • Команда компилирует два исходника (main.c и utils.c) и линкует их в один исполняемый файл.
  • Это типичный случай модульного проекта, где функции из utils.c вызываются из main.c.
  • Линкер на этом шаге разрешает внешние символы между файлами (например, вызовы утилитных функций).
  • -o program задаёт итоговое имя бинарника и делает запуск предсказуемым в скриптах и CI.

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

Традиционный инструмент — Makefile, используемый с утилитой make. Он описывает правила:

  • из каких исходных файлов получаются объектные;
  • как их связать;
  • куда поместить результат.

Современные системы сборки включают CMake, Meson, Ninja. Они генерируют платформо-независимые файлы сборки и упрощают кросс-компиляцию, управление зависимостями и сборку под разные конфигурации (отладка, релиз).

Файл сборки — это "рецепт" превращения исходного кода в работающую программу. Он делает процесс воспроизводимым, автоматизированным и переносимым.


Этот взгляд "под капот" сборки помогает понять, почему программа на С работает так, как работает. Каждый этап — от #include до запуска .exe — имеет своё назначение, свою логику и свою историю. Освоив эти механизмы, программист получает не только технические знания, но и глубокое уважение к элегантности и продуманности языка С.