Введение в C: От скриптов к компиляции

Курс адаптирует опыт разработчиков на Python/JS для перехода к низкоуровневому программированию. Вы освоите синтаксис C, строгую типизацию и превратите исходный код в исполняемые файлы через процесс компиляции.

1. Архитектура компиляции: превращение текста в машинный код через GCC

Архитектура компиляции: превращение текста в машинный код через GCC

Если вы напишете скрипт на Python или JavaScript, вы можете запустить его мгновенно. Вы набираете python script.py, и виртуальная машина построчно читает ваш текст, на лету переводя его в действия. Язык C работает иначе. Процессор вашего компьютера не понимает текста, циклов for или переменных. Он понимает только электрические сигналы, представленные в виде нулей и единиц — машинных инструкций. Чтобы ваш читаемый C-код превратился в исполняемый бинарный файл, он должен пройти через конвейер трансформаций.

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

Иллюзия единого действия

Обычно компиляция выглядит как одна простая команда в терминале:

Кажется, что gcc (GNU Compiler Collection) берет файл program.c и магическим образом выплевывает готовый исполняемый файл program. На самом деле gcc в данном случае выступает лишь как программа-дирижер. Она по очереди запускает четыре совершенно разных инструмента, каждый из которых выполняет свою узкую задачу.

Этот процесс называется пайплайном компиляции, и он состоит из четырех стадий: препроцессинг, собственно компиляция, ассемблирование и компоновка (линковка).

!Пайплайн компиляции C-программы

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

Стадия 1: Препроцессор (Text substitution)

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

Препроцессор ищет в коде директивы, которые начинаются с символа решетки #. В нашем примере их две: #include и #define.

Когда препроцессор видит #include <stdio.h>, он буквально берет содержимое файла stdio.h (в котором описано, как выглядит функция printf) и вставляет его текст прямо в наш файл вместо самой директивы. Когда он видит #define MESSAGE "Hello, system!", он запоминает это правило. Встретив слово MESSAGE дальше в коде, он тупо заменяет его на "Hello, system!". Также на этой стадии из кода полностью удаляются все комментарии.

Мы можем остановить процесс после первой стадии и посмотреть на результат, передав GCC флаг -E (остановиться после препроцессора):

Если вы откроете файл program.i, вы удивитесь. Ваш код из шести строк превратится в полотно из сотен или тысяч строк (в зависимости от системы). В самом конце этого файла вы увидите свою функцию main, которая теперь выглядит так:

Слово MESSAGE исчезло. Комментариев нет. Для хакера это означает важную вещь: макросы (#define) не существуют в скомпилированной программе. Их невозможно найти в памяти при отладке, потому что они уничтожаются еще до того, как код начинает превращаться в машинные инструкции.

Стадия 2: Компилятор (Translation to Assembly)

Теперь огромный текстовый файл program.i передается настоящему компилятору (внутри GCC эта программа называется cc1). Это самая сложная интеллектуальная часть конвейера.

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

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

Остановим GCC после этой стадии с помощью флага -S:

Файл program.s будет содержать инструкции для конкретной архитектуры (например, x86-64). Функция main превратится в нечто подобное:

Здесь мы видим, как абстрактный C-код опустился на уровень железа. Переменные превратились в регистры (rbp, rsp, rdi), а вызов функции — в инструкцию call. Именно этот код читают реверс-инженеры, когда анализируют закрытое ПО.

Стадия 3: Ассемблер (Machine Code Generation)

Текстовый файл с ассемблерным кодом передается программе-ассемблеру (обычно утилита as). Ее задача предельно механистична: перевести мнемоники (текстовые команды вроде push или mov) в их бинарные эквиваленты (опкоды), понятные процессору.

Остановим процесс на этой стадии флагом -c (compile and assemble, but do not link):

На выходе мы получаем объектный файл program.o. Если вы попытаетесь открыть его в текстовом редакторе, вы увидите нечитаемый мусор. Это уже бинарный формат, содержащий машинный код.

!Трансформация кода от C до машинных инструкций

Однако program.o еще нельзя запустить. Почему? Вспомните нашу программу: мы вызываем функцию printf. Ассемблер перевел этот вызов в машинную инструкцию, которая говорит процессору «прыгни по адресу функции printf». Но ассемблер понятия не имеет, где в памяти операционной системы находится эта функция. В объектном файле вместо реального адреса printf остается пустое место (заглушка).

Стадия 4: Компоновщик (Linking)

Последний шаг выполняет компоновщик, или линкер (утилита ld). Это сборщик пазла.

В реальных проектах программа состоит не из одного .c файла, а из десятков. Каждый из них проходит первые три стадии независимо и превращается в свой собственный .o файл. Зачем так сделано? Ради производительности. Если вы измените одну строчку в проекте из 1000 файлов, компилятору не нужно переваривать весь проект. Он перекомпилирует только один измененный файл, получит новый .o, а затем линкер просто соберет все .o файлы заново.

Задача линкера:

  • Склеить все объектные файлы вашего проекта в один большой бинарник.
  • Найти внешние зависимости (например, функцию printf из стандартной библиотеки C — libc).
  • Заменить все пустые заглушки (адреса вызовов) на реальные смещения и адреса.
  • Если линкер не сможет найти реализацию какой-то функции, он выдаст знаменитую ошибку undefined reference to.... В скриптовых языках подобная ошибка (попытка вызвать несуществующую функцию) возникнет только в момент выполнения скрипта (Runtime). В C она отлавливается на этапе сборки.

    Команда без дополнительных флагов запускает линкер автоматически:

    Результатом становится финальный исполняемый файл. В Linux он имеет формат ELF (Executable and Linkable Format), в Windows — PE (Portable Executable). Этот файл содержит не только машинный код, но и метаданные для операционной системы: где начинается код, какие библиотеки нужно загрузить в память перед стартом, где хранятся константы (наша строка "Hello, system!").

    Отличия от скриптовых языков на практике

    В Python инструкция import math заставляет интерпретатор во время работы программы найти модуль math, загрузить его в память и дать вам к нему доступ.

    В C директива #include <math.h> лишь копирует текст заголовков (описания того, как выглядят функции), чтобы компилятор не ругался на неизвестные слова. А реальное подключение математической библиотеки происходит на этапе линковки (для этого gcc часто передают флаг -lm, который говорит линкеру «найди и приклей библиотеку libm»).

    Скомпилированная программа на C автономна на уровне инструкций. Ей не нужна виртуальная машина для исполнения. Операционная система просто читает ELF-файл, выделяет под него память, загружает туда машинный код и передает управление процессору на точку входа (начало функции main).

    Именно эта близость к железу делает C идеальным языком как для создания операционных систем, так и для их взлома. Когда вы контролируете память в C, между вами и процессором нет прослойки, которая могла бы остановить вас от выполнения некорректной или вредоносной операции. Ошибка работы с памятью в Python приведет к исключению (Exception) и безопасной остановке скрипта. Ошибка в C приведет к падению программы (Segmentation fault) или, в худшем случае, позволит атакующему перехватить поток выполнения, подсунув процессору свои собственные машинные инструкции.