Сборка и компоновка: препроцессор, объектные файлы, статические и динамические библиотеки
Почему это отдельная профессия, а не деталь реализации
В языках с модулями сборка почти невидима: компилятор сам знает, где лежат зависимости, сам проверяет типы через границы модулей, сам кладёт результат куда надо. В C ничего этого нет. Язык не знает слова «модуль»: он оперирует единицей трансляции — одним .c после того, как препроцессор вклеил в него все заголовки. Каждая такая единица компилируется в полной изоляции от остальных, и всё, что она может сказать про внешний мир, — это список имён: «мне нужен символ square, я не знаю, что это и где оно».
Отсюда главное следствие, ради которого стоит читать статью целиком: компилятор проверяет типы, компоновщик — только имена. Между этими двумя проверками зияет дыра, куда проваливаются самые неприятные ошибки в C — те, что собираются без единого предупреждения и падают через месяц на чужой машине. Всё, что разобрано в статье про неопределённое поведение, имеет свою сборочную проекцию, и её мы разберём здесь. Второе следствие практическое: gcc main.c — это не одна программа, а четыре, запущенные подряд драйвером. Пока вы не умеете останавливать конвейер на каждом шаге и смотреть на промежуточный результат, любая ошибка сборки — магия. Когда умеете — это просто чтение вывода инструментов.
исходный текст"] --> CPP["Препроцессор cpp
gcc -E"] HDR["stdio.h, свои .h
макросы, -D, -I"] --> CPP CPP --> TU["Единица трансляции
один большой текст без директив"] TU --> CC1["Компилятор cc1
gcc -S"] CC1 --> ASM["main.s
ассемблер целевой платформы"] ASM --> AS["Ассемблер as
gcc -c"] AS --> OBJ["main.o
секции, символы, релокации"] OBJ --> LD["Компоновщик ld
gcc без флагов"] LIBA["libmath.a
архив объектных файлов"] --> LD LIBSO["libc.so.6
разделяемая библиотека"] --> LD CRT["Scrt1.o, crti.o, crtbeginS.o
стартовый код"] --> LD LD --> EXE["a.out
ELF, сегменты, DT_NEEDED"] EXE --> LOADER["ld-linux.so
динамическая компоновка при запуске"]
Обратите внимание на последний узел: часть работы откладывается на момент запуска. Это ключ к половине загадочных ошибок в проде, и к ней мы вернёмся.
Шаг 1. Препроцессор: текстовая машина, ничего не знающая о C
Препроцессор не понимает типы, области видимости и синтаксис C. Он умеет ровно три вещи: подставлять содержимое файлов (#include), заменять токены по правилам (#define) и выбрасывать куски текста (#if). Это делает его одновременно мощным и опасным.
Начните с того, чтобы увидеть масштаб. Двухстрочная программа:
#include <stdio.h>
int main(void) { printf("hi\n"); return 0; }
$ wc -l hello.c
2 hello.c
$ gcc -E hello.c | wc -l
815
$ gcc -E hello.c | grep -vc '^#'
697
Две строки превратились в 815, из которых 697 — настоящий код и объявления. Компилятор каждый раз честно разбирает все 697. Умножьте на количество .c в проекте — и станет понятно, почему сборка C-проектов такая медленная и почему #include в заголовке дороже, чем #include в .c. Кто кого втянул, покажет gcc -c -H — дерево включений с отступами по уровню вложенности.
Защита от повторного включения
Один и тот же заголовок втягивается десятки раз по разным путям, поэтому каждый обязан защищаться от повторного включения — иначе повторные объявления типов сорвут компиляцию:
#ifndef MATH_UTIL_H /* страж: если макрос не определён, определяем */
#define MATH_UTIL_H /* его и читаем файл дальше, иначе пропускаем */
int square(int x); /* объявление: тип известен, тела нет */
extern int call_count; /* переменная определена в другом .c */
#endif
#pragma once короче и не боится опечаток в имени макроса, но формально не входит в стандарт; на практике его понимают GCC, Clang и MSVC. Одна ловушка: он идентифицирует файл по inode, поэтому жёсткие ссылки и хитрые сборочные симлинки способны его обмануть — стражи в этом смысле надёжнее.
Макросы: где они ломаются
Макрос — это текстовая подстановка, а не функция. Отсюда три классические ошибки, каждая из которых стоила кому-то ночи отладки:
#define SQ_BAD(x) x * x /* 1. нет скобок вокруг параметров и тела */
int a = SQ_BAD(1 + 2); /* 1 + 2 * 1 + 2 = 5, а не 9 */
#define SQ(x) ((x) * (x)) /* правильно: скобки везде */
int b = SQ(i++); /* 2. i инкрементируется ДВАЖДЫ — и это UB */
#define LOG_BAD(m) fputs(m, stderr); fflush(stderr) /* 3. два оператора */
if (verbose) LOG_BAD("hi"); /* fflush выполнится ВСЕГДА: он вне if */
#define LOG(m) do { fputs((m), stderr); fflush(stderr); } while (0)
Идиома do { ... } while (0) — не суеверие: она делает из блока один оператор, требующий точку с запятой, то есть ведёт себя как вызов функции в любом синтаксическом контексте. Общее правило: если задачу решает static inline-функция, макрос не нужен — функция типизирована, вычисляет аргументы один раз и видна отладчику. Макросы оставляют для того, что функцией не выражается: условная компиляция, генерация кода, доступ к __FILE__ и __LINE__.
Условная компиляция и предопределённые макросы
Ветвление по платформе (#if defined(__linux__) / #elif defined(__APPLE__) / #else #error) — единственный переносимый способ подключить <sys/epoll.h> там, где он есть, и <sys/event.h> там, где вместо него kqueue. Одна тонкость кусается: #if FOO подставляет неопределённый макрос как 0 и молча уходит в ветку «выключено», так что опечатка в имени не будет замечена — в отличие от #ifdef FOO. Флаг -Wundef превращает такую опечатку в предупреждение; включайте его всегда.
Здесь же начинается «работает на моей машине». Дистрибутивы подмешивают свои умолчания, и увидеть их можно так:
$ gcc -O2 -dM -E - </dev/null | grep -E 'FORTIFY|STDC_VERSION'
#define _FORTIFY_SOURCE 3
#define __STDC_VERSION__ 201710L
На Ubuntu 24.04 _FORTIFY_SOURCE=3 включён по умолчанию, и printf в вашем коде превращается в __printf_chk — функцию с проверкой формата. На голой сборке из исходников этого не будет: один и тот же код на двух машинах компилируется в разный машинный код и по-разному реагирует на переполнение буфера. Дистрибутивные умолчания GCC на Ubuntu описаны на wiki.ubuntu.com/ToolChain/CompilerFlags.
Зависимости по заголовкам — работа препроцессора, а не ваша
Если после правки math_util.h пересобирается только тот .c, который вы тронули руками, вы получите объектные файлы, собранные с разными версиями структуры. Это прямой путь к UB (см. ниже). Компилятор генерирует список зависимостей сам: gcc -MM main.c печатает main.o: main.c math_util.h. В сборке это подключают флагами -MMD -MP — первый пишет .d-файл рядом с .o во время обычной компиляции, второй добавляет фиктивные цели, чтобы удаление заголовка не ломало make. Готовый рецепт — в разделе про Makefile.
Шаг 2. Компиляция: что видит и чего не видит компилятор
Остановите конвейер после компилятора и посмотрите на результат — это самый быстрый способ понять, во что превращается конструкция языка. Подробный разбор ассемблерного вывода ждёт в статье про ассемблер для программиста, здесь достаточно почувствовать масштаб:
$ gcc -O2 -S s.c -o - # int add3(int a,int b,int c){return a+b+c;}
add3: endbr64
addl %esi, %edi
leal (%rdi,%rdx), %eax
ret
Три аргумента пришли в регистрах edi, esi, edx — это соглашение о вызовах System V AMD64 ABI, тот самый контракт, который делает возможной раздельную компиляцию. Ни стека, ни пролога: функция уложилась в две арифметические инструкции.
Объявление, определение и связывание
| Термин | Что делает | Где живёт |
|---|---|---|
| Объявление (declaration) | сообщает компилятору тип имени | в заголовке, повторяемо |
| Определение (definition) | выделяет память или задаёт тело | ровно в одном .c |
| Связывание (linkage) | решает, видно ли имя другим единицам | задаётся static / extern |
int call_count; /* внешнее связывание: имя попадает */
int square(int x) { return x * x; } /* в глобальную таблицу символов */
static int scale = 2; /* внутреннее связывание: */
static int helper(int x) { return x + scale; } /* видно только в этом .c */
void f(void) { int tmp = 0; (void)tmp; } /* без связывания: локальная переменная */
static на уровне файла — самое недооценённое слово в C. Оно превращает символ из части глобального пространства имён в приватную деталь модуля: конфликт имён становится невозможным, компилятор получает право встроить функцию и выбросить её целиком, а читатель сразу видит границу интерфейса. Правило простое: всё, что не объявлено в заголовке, обязано быть static. Проверить эффект можно глазами:
$ gcc -c -O0 math_util.c -o m0.o && nm m0.o
0000000000000000 B call_count # B — .bss, глобальный, обнулён
0000000000000000 d scale # СТРОЧНАЯ буква = локальный символ
0000000000000000 T square # T — .text, глобальный, определён здесь
Регистр буквы в выводе nm — это и есть связывание: заглавная означает глобальный символ, строчная — локальный. Полезно помнить основные метки: T/t — код, D/d — инициализированные данные, B/b — .bss, R/r — константы, U — неопределённый (нужен извне), W — слабый.
Две ловушки, которые ловятся только компоновщиком
Предварительные определения (tentative definitions). Строка int counter; на уровне файла — не объявление, а «предварительное определение». До GCC 10 такие символы складывались в общий блок (-fcommon), и два модуля с int counter; молча склеивались в одну переменную. С GCC 10 умолчанием стало -fno-common:
$ gcc t1.c t2.c -o tent # в обоих файлах есть `int counter;`
/usr/bin/ld: multiple definition of `counter'; first defined here
$ gcc -fcommon t1.c t2.c -o tent2 && echo "собралось"
собралось # старое поведение: две переменные стали одной
Правильно так: в заголовке extern int counter;, ровно в одном .c — int counter = 0;.
inline в C — не то же самое, что в C++. Функция, помеченная просто inline в заголовке, не создаёт внешнего определения, и если компилятор решит не встраивать её, символа не окажется ни в одном объектном файле — сборка упадёт с undefined reference to 'twice'. Рабочих решений два: писать в заголовке static inline (каждая единица трансляции получает свою копию — обычный выбор), либо оставить inline в заголовке и добавить ровно в один .c строку extern int twice(int);, которая и создаст внешнее определение.
Шаг 3. Объектный файл: секции, символы, релокации
Объектный файл на Linux — это ELF типа ET_REL, «перемещаемый». Внутри — секции с содержимым, таблица символов и списки релокаций.
$ readelf -SW main.o
[Nr] Name Type Size Flg
[ 1] .text PROGBITS 00003a AX # A=грузится, X=исполняется
[ 2] .rela.text RELA 000060 I # заявки на правку .text
[ 4] .bss NOBITS 000000 WA # NOBITS: в файле места не занимает
[ 5] .rodata.str1.1 PROGBITS 000007 AMS # строковые литералы
[11] .symtab SYMTAB 0000c0 # таблица символов
Разница между PROGBITS и NOBITS — та самая, из-за которой массив на миллион нулей ничего не стоит в размере файла, но честно занимает память после запуска. Об этом же — статья про динамическую память. Самое интересное — релокации: посмотрим на код main.c вместе с заявками на правку.
$ objdump -dr main.o --section=.text
0000000000000000 <main>:
8: bf 05 00 00 00 mov $0x5,%edi
d: e8 00 00 00 00 call 12 <main+0x12>
e: R_X86_64_PLT32 square-0x4
14: 8b 0d 00 00 00 00 mov 0x0(%rip),%ecx
16: R_X86_64_PC32 call_count-0x4
2b: e8 00 00 00 00 call 30 <main+0x30>
2c: R_X86_64_PLT32 __printf_chk-0x4
Вот он, механизм раздельной компиляции в чистом виде. Инструкция call содержит нули — компилятор не знает адреса square. Рядом лежит запись релокации: «по смещению 0xe подставь адрес символа square минус 4, тип R_X86_64_PLT32». Компоновщик пройдёт по этому списку и впишет числа. Заодно видно, что printf уже превратился в __printf_chk — работа _FORTIFY_SOURCE, о которой шла речь выше.
Шаг 4. Компоновка: разрешение имён и расстановка адресов
У компоновщика ровно две задачи. Разрешение символов: каждому U из всех входных файлов надо найти ровно одно определение — ноль даёт undefined reference, два и больше multiple definition. Раскладка и релокация: одноимённые секции склеиваются с учётом требований выравнивания, каждой назначается адрес, после чего по спискам .rela в код вписываются конкретные числа. Жизненный цикл одного символа удобно держать в голове как автомат состояний:
Разберём реальный отказ и научимся его читать:
$ gcc -O1 main.o -o bad # забыли math_util.o
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0xe): undefined reference to `square'
main.c:(.text+0x16): undefined reference to `call_count'
Сообщение говорит больше, чем кажется: имя main.o — кто ссылается, main — из какой функции, .text+0xe — смещение той самой релокации из objdump -dr. Дальше вопрос ровно один: должно ли это имя вообще существовать. Если да — не подключён нужный .o, .a или -l. Если имя выглядит странно (искажённое C++-имя, @GLIBC_2.34) — расходится ABI, и это уже другой разговор.
Компоновщик не знает типов — и это источник UB
Вот эксперимент, который стоит проделать один раз в жизни, чтобы запомнить навсегда. Два файла объявляют структуру по-разному:
/* a.c */ struct point { int x; int y; }; /* 8 байт */
void show(struct point *p);
int main(void) { struct point p = {1,2}; show(&p); return 0; }
/* b.c */ struct point { int x; int y; int z; }; /* 12 байт */
void show(struct point *p) { printf("%d %d %d\n", p->x, p->y, p->z); }
$ gcc -Wall -Wextra -O2 a.c b.c -o odr # ни одного предупреждения
$ ./odr
1 2 51645952
$ ./odr
1 2 1606409984
Сборка прошла идеально чисто. Программа читает 4 байта за пределами объекта и печатает мусор со стека — разный при каждом запуске из-за ASLR. Компилятор не увидел проблемы, потому что каждая единица трансляции сама по себе безупречна. Компоновщик не увидел, потому что сравнил имя show с именем show и остался доволен: типов в объектном файле нет.
Это «работает на моей машине» в предельной форме: с -O0 мусор может оказаться нулём, и тест пройдёт. Единственная настоящая защита организационная — одно определение структуры в одном заголовке, который включают все, плюс корректные зависимости в сборке. Частично помогает LTO: на этапе компоновки у компилятора снова есть промежуточное представление всех модулей сразу.
$ gcc -Wall -Wextra -O2 -flto c1.c c2.c -o sig_lto
c1.c:1:5: warning: type of 'add' does not match original declaration [-Wlto-type-mismatch]
1 | int add(int a, int b);
c2.c:1:8: note: return value type mismatch
Расхождение сигнатур LTO ловит. Расхождение раскладки структуры из примера выше — нет: имя типа совпало. Так что LTO — полезная страховка и источник межмодульных оптимизаций (как это работает изнутри, разбирает трек про оптимизации компилятора), но не замена дисциплине.
Что gcc подставляет за вашей спиной
$ gcc -O2 hello.c -o /dev/null -v 2>&1 | grep collect2 | tr ' ' '\n' | grep -E 'crt|^-l'
Scrt1.o # точка входа _start: готовит argc/argv, зовёт main, потом exit
crti.o # пролог секций .init/.fini
crtbeginS.o # регистрация конструкторов, суффикс S = сборка для PIE
-lgcc -lgcc_s # рантайм компилятора: 64-битное деление, раскрутка стека
-lc # libc
crtendS.o crtn.o # эпилоги
Ваш main — не точка входа программы, а обычная функция, которую вызывает __libc_start_main. Флаг -nostdlib убирает всю обвязку — так собирают ядра и прошивки, о чём говорит трек встраиваемых систем.
Выбрасывание мёртвого кода
По умолчанию компоновщик оперирует секциями целиком, а весь код одного .c лежит в одной .text. Разложите функции по отдельным секциям — и неиспользуемое будет выброшено:
$ gcc -O2 big.o usemain.o -o nogc && nm nogc | grep -c never_used
2 # мёртвые функции в бинаре
$ gcc -O2 -ffunction-sections -c big.c usemain.c
$ gcc -O2 -Wl,--gc-sections big.o usemain.o -o withgc
$ nm withgc | grep -c never_used
0 # выброшены
Связка -ffunction-sections -fdata-sections -Wl,--gc-sections — стандартный приём для встраиваемых сборок и для контейнеров, где важен размер; -Wl,--print-gc-sections покажет, что именно удалено.
Статические библиотеки: это архив, а не библиотека
.a — просто контейнер формата ar с объектными файлами внутри плюс индекс символов. Никакой магии:
$ ar rcs libmath.a math_util.o # r=добавить, c=создать молча, s=записать индекс
$ ar t libmath.a # список содержимого
math_util.o
$ nm -s libmath.a # индекс: какой символ в каком члене архива
Из этого следуют два неочевидных факта.
Гранулярность втягивания — объектный файл, а не функция. Если вам нужна одна функция из .o, где лежат ещё десять, в бинарь попадут все одиннадцать вместе с их зависимостями. Классическая практика библиотек на C — «одна функция на файл» — растёт именно отсюда (и частично снимается --gc-sections).
Порядок аргументов значим. Компоновщик идёт по командной строке слева направо, поддерживая множество ещё не разрешённых символов. Встретив архив, он берёт из него только те члены, которые закрывают что-то из текущего множества, и идёт дальше — назад он не возвращается:
$ gcc -O1 -L. -lmath main.o -o app # библиотека ПЕРЕД объектником
/usr/bin/ld: main.c:(.text+0xe): undefined reference to `square'
$ gcc -O1 main.o -L. -lmath -o app && ./app # правильный порядок
50 1
$ gcc mainxy.o -L. -lx -ly -o xy1 # взаимная зависимость двух архивов
/usr/bin/ld: ./liby.a(y.o): undefined reference to `x_fn'
$ gcc mainxy.o -Wl,--start-group -L. -lx -ly -Wl,--end-group -o xy3 && echo OK
OK
В первом случае на момент чтения libmath.a символ square ещё никому не был нужен, и архив прошёл мимо. Отсюда железное правило: сначала объектные файлы, потом библиотеки; библиотека — после того, кто её использует.
Взаимные зависимости архивов лечатся повтором (-lx -ly -lx) или группой: --start-group/--end-group заставляет компоновщик крутить содержимое группы, пока в ней разрешаются новые символы. Цена — время сборки, поэтому группу применяют точечно, а не ко всей командной строке. Противоположный по смыслу флаг --whole-archive втягивает архив целиком независимо от нужд — так подключают библиотеки, работающие через конструкторы и саморегистрацию (например, наборы тестов).
Динамические библиотеки: контракт, который проверяется при запуске
Разделяемая библиотека — не «архив, который подключается позже». Это принципиально другая модель, и её выгода видна только на уровне физической памяти.
Числа реальны: hello из двух строк весит 15 960 байт при динамической компоновке и 785 360 байт при -static — почти в 50 раз больше.
Почему обязателен -fPIC
Код разделяемой библиотеки отображается в десятки процессов по разным виртуальным адресам, и физические страницы у них общие и доступны только на чтение. Значит, вписать в код конкретный адрес при загрузке нельзя: правка страницы сделала бы её приватной (copy-on-write), уничтожив всю экономию, и потребовала бы записываемого исполняемого сегмента — подарок для эксплойта. Решение — позиционно-независимый код: все обращения к внешним символам идут через приватную для процесса таблицу.
$ gcc -O2 -fPIC -c math_util.c -o math_pic.o
$ gcc -shared -Wl,-soname,libmath.so.1 math_pic.o -o libmath.so.1.0.0
$ ln -sf libmath.so.1.0.0 libmath.so.1 # то, что ищет загрузчик
$ ln -sf libmath.so.1 libmath.so # то, что ищет компоновщик
$ readelf -dW libmath.so.1.0.0 | head -2
0x000000000000000e (SONAME) Library soname: [libmath.so.1]
Три имени — не бюрократия, а система версионирования. libmath.so нужен только при сборке. SONAME (libmath.so.1) — это контракт бинарной совместимости: он записывается в потребителя, и именно его загрузчик будет искать при запуске. Полное имя с минорной версией позволяет держать рядом несколько сборок. Меняете ABI несовместимо — поднимаете число в SONAME; добавляете функцию, ничего не ломая, — растёт только минорная часть.
Где загрузчик ищет библиотеку
$ gcc -O1 main.o -L. -lmath -o dynapp && readelf -dW dynapp | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libmath.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
$ ./dynapp
./dynapp: error while loading shared libraries: libmath.so.1: cannot open
shared object file: No such file or directory
$ gcc -O1 main.o -L. -lmath -Wl,-rpath,'$ORIGIN' -o rpathapp && ./rpathapp
50 1
Собралось — не значит запустится. -L. действует только при сборке; во время запуска путь ищется заново, в порядке: DT_RPATH (устаревший), LD_LIBRARY_PATH, DT_RUNPATH, кэш /etc/ld.so.cache (обновляется ldconfig), системные каталоги. Правильный ответ для приложений, которые везут библиотеки с собой, — RUNPATH со значением $ORIGIN, то есть каталог самого исполняемого файла. LD_LIBRARY_PATH в продовых скриптах почти всегда означает, что кто-то не смог настроить RUNPATH: переменная действует на весь процесс и его потомков и умеет ломать соседние программы.
PLT и GOT: как вызывается функция, адрес которой неизвестен
$ objdump -d dynapp | grep -E 'call.*square|<square@plt>:' -A2
1176: e8 e5 fe ff ff call 1060 <square@plt>
0000000000001060 <square@plt>:
1060: f3 0f 1e fa endbr64
1064: ff 25 5e 2f 00 00 jmp *0x2f5e(%rip) # 3fc8 <square@Base>
Ваш код вызывает не square, а заглушку square@plt в PLT (Procedure Linkage Table). Заглушка делает косвенный переход по адресу, лежащему в GOT (Global Offset Table) — записываемой таблице в приватной странице процесса. Код остаётся неизменным и общим для всех процессов; меняется только таблица.
Историческое поведение — ленивое связывание: GOT изначально указывала обратно в PLT, первый вызов уходил в _dl_runtime_resolve, тот находил символ и патчил GOT. Это экономило время старта, но оставляло GOT записываемой на всю жизнь процесса — прекрасную цель для атаки. Современные дистрибутивы выбрали безопасность: readelf -dW dynapp показывает FLAGS: BIND_NOW, а readelf -lW — сегмент GNU_RELRO. Вместе это «full RELRO»: все символы связываются на старте, после чего GOT переводится в режим только для чтения. Проверить, что и откуда связалось, можно без отладчика:
$ LD_LIBRARY_PATH=. LD_DEBUG=bindings ./dynapp 2>&1 | grep square
binding file ./dynapp [0] to ./libmath.so.1 [0]: normal symbol `square'
LD_DEBUG понимает libs, bindings, symbols, statistics, all — первое, что стоит запускать, когда программа берёт «не ту» библиотеку. Механику PIC подробно разбирают заметки Эли Бендерского: Position Independent Code in shared libraries.
Видимость символов и герметичность библиотеки
По умолчанию все глобальные символы разделяемой библиотеки экспортируются. Это раздувает таблицы, замедляет старт и, главное, делает деталями публичного ABI то, что вы публичным не считали:
$ gcc -fPIC -shared vis.c -o libvis_all.so && nm -D --defined-only libvis_all.so
0000000000001108 T helper_fn # внутренняя функция торчит наружу
00000000000010f9 T api_fn
$ gcc -fPIC -fvisibility=hidden -shared vis.c -o libvis_hid.so
$ nm -D --defined-only libvis_hid.so
00000000000010f9 T api_fn # только то, что помечено явно
$ gcc -fPIC -shared ub.c -o libub.so && echo "собралось с дырой внутри"
собралось с дырой внутри
$ gcc -fPIC -shared -Wl,--no-undefined ub.c -o libub2.so
/usr/bin/ld: ub.c:(.text+0x9): undefined reference to `missing'
Рабочая схема: собирать библиотеку с -fvisibility=hidden и помечать публичные функции атрибутом __attribute__((visibility("default"))), спрятанным за макросом вроде MYLIB_API. Второй обязательный флаг — -Wl,--no-undefined: без него .so соберётся с дырами и упадёт при загрузке у пользователя, а не у вас.
Версии символов: почему бинарь с новой машины не идёт на старую
glibc versioning позволяет держать в одной библиотеке несколько несовместимых реализаций одной функции, и потребитель фиксирует нужную версию прямо в таблице:
$ objdump -T h_dyn | grep GLIBC
(GLIBC_2.34) __libc_start_main
(GLIBC_2.2.5) puts
Программа требует GLIBC_2.34. На системе с glibc 2.31 вы получите классическое version 'GLIBC_2.34' not found. Обратное направление работает: собранное на старой системе идёт на новой. Отсюда правило релиза: собирайте на самом старом окружении, которое обязаны поддерживать, либо в контейнере, идентичном целевому (см. трек DevOps). Альтернатива — статическая сборка, лучше с musl.
Наконец, dlopen/dlsym/dlclose из <dlfcn.h> позволяют подключить библиотеку во время работы — так устроены плагины, драйверы БД, кодеки. Цена в том, что ошибки переезжают из сборки в рантайм: dlopen с флагом RTLD_NOW связывает всё сразу и падает честно, RTLD_LAZY откладывает сюрприз, а результат каждого вызова обязательно проверяется через dlerror(). Приведение результата dlsym к типу функции формально остаётся UB в C, но POSIX требует, чтобы оно работало.
Сборка на практике: Makefile, флаги, воспроизводимость
Минимальный, но честный Makefile с автоматическими зависимостями по заголовкам:
CC := gcc
CFLAGS := -std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wundef \
-O2 -g -MMD -MP -fno-common
LDFLAGS := -Wl,--as-needed -Wl,-z,relro,-z,now
SRC := $(wildcard src/*.c)
OBJ := $(SRC:.c=.o)
DEP := $(OBJ:.o=.d)
app: $(OBJ)
$(CC) $(OBJ) $(LDFLAGS) -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# отдельная конфигурация с санитайзерами: те же исходники, другие флаги
debug: CFLAGS := -std=c17 -Wall -Wextra -O1 -g3 -MMD -MP \
-fsanitize=address,undefined -fno-omit-frame-pointer
debug: LDFLAGS := -fsanitize=address,undefined
debug: app
-include $(DEP) # подтягиваем сгенерированные зависимости
.PHONY: debug
Ключевая строка — -include $(DEP). Без неё правка заголовка не пересоберёт зависимые модули, и вы получите бинарь, собранный из разных версий структуры, — ровно тот UB, который мы видели выше.
Карта флагов, которую полезно держать в голове целиком (ведущий дефис в именах опущен для читаемости схемы):
Про -Werror отдельно: в CI он обязателен, на машине разработчика вреден — обновление компилятора внезапно ломает сборку всем. Задавайте его переменной окружения в CI, а не в Makefile.
CMake нужен там, где появляются несколько платформ и внешние зависимости. Современный стиль — свойства целей вместо глобальных переменных: add_library(mathutil STATIC ...), затем target_include_directories(mathutil PUBLIC include) и target_link_libraries(app PRIVATE mathutil). Разница PRIVATE/PUBLIC/INTERFACE — ровно вопрос «наследуется ли свойство потребителям цели», и именно она избавляет от ручного управления порядком библиотек. Не забудьте set(CMAKE_EXPORT_COMPILE_COMMANDS ON): полученный compile_commands.json понимают clangd, clang-tidy и большинство редакторов.
Воспроизводимость. Если два запуска сборки из одного коммита дают разные байты, вы не можете доказать, что в проде работает именно ваш код. Источники расхождений — абсолютные пути в __FILE__, временные метки, порядок файлов из wildcard. Лечение: -ffile-prefix-map=$(PWD)=., SOURCE_DATE_EPOCH, сортировка списков, детерминированный режим ar. Практики собраны на reproducible-builds.org, а зачем это нужно для безопасности — в статье про цепочку поставок. По скорости: make -j$(nproc) даёт ускорение почти бесплатно, ccache кэширует компиляцию по хешу препроцессированного текста, а mold или lld вместо ld радикально сокращают время компоновки — однопоточный ld часто оказывается узким местом крупного проекта.
Сборочная проекция неопределённого поведения
Соберём вместе то, что делает C опасным именно на уровне сборки. Общая черта у всех пунктов одна: программа собирается без ошибок, а ломается в другом месте, у другого человека, через месяц.
| Что происходит | Почему компилятор молчит | Как поймать |
|---|---|---|
Разные определения структуры в разных .c |
каждая единица трансляции корректна сама по себе | один заголовок, -MMD -MP, полная пересборка в CI |
Разные значения -D между модулями |
препроцессор отработал до компилятора | флаги задаются в одном месте сборки, а не в скриптах |
| Расхождение сигнатуры функции | компоновщик сравнивает только имена | прототипы только из заголовка, -flto, -Wmissing-prototypes |
assert исчезает в релизе из-за NDEBUG |
так и задумано | никогда не помещать в assert действия с побочными эффектами |
Разный -O меняет проявление UB |
UB не обязано быть стабильным | -fsanitize=undefined в тестах, а не только -O0-прогон |
| Нарушение strict aliasing | оптимизатор имеет право предполагать обратное | -fstrict-aliasing -Wstrict-aliasing, memcpy вместо приведения указателей |
| Смешение библиотек, собранных разными флагами | ABI совпал, семантика — нет | одна система сборки на всё дерево зависимостей |
Отсюда практический вывод, который стоит принять как инженерную дисциплину: безопасность памяти в C — не свойство кода, а свойство процесса. Один набор флагов на все единицы трансляции; сборка в CI на образе, идентичном продовому; отдельная конфигурация с -fsanitize=address,undefined, прогоняемая на каждом коммите; полная пересборка перед релизом. Инструменты, которые ловят оставшееся, — тема следующей статьи; здесь важно, что без правильной сборки они просто не запустятся или соврут. Санитайзеры требуют одинаковых флагов у всех модулей и корректных фреймпоинтеров, valgrind — символов отладки, а gdb бесполезен на strip-нутом бинаре без раздельного файла символов (objcopy --only-keep-debug плюс --add-gnu-debuglink).
Как читать сообщения об ошибках
| Сообщение | Что произошло | Первое действие |
|---|---|---|
undefined reference to 'X' |
нет определения X среди входов компоновщика |
проверить порядок -l, наличие .o, static на определении |
multiple definition of 'X' |
определение в двух единицах трансляции | определение в заголовке? int x; вместо extern int x;? |
relocation R_X86_64_32S against '.rodata' can not be used when making a PIE object |
объектник собран без -fPIC/-fPIE |
пересобрать зависимость с -fPIC |
cannot find -lfoo |
нет libfoo.so/libfoo.a в путях -L |
поставить -dev-пакет, добавить -L |
error while loading shared libraries: libfoo.so.1 |
сборка нашла, запуск — нет | ldd, RUNPATH, ldconfig, LD_DEBUG=libs |
version 'GLIBC_2.34' not found |
целевая система старше сборочной | собирать на старом образе или статически |
undefined symbol: X уже во время работы |
.so собрана без --no-undefined или dlopen без RTLD_NOW |
-Wl,--no-undefined при сборке библиотеки |
| Сборка прошла, поведение бессмысленное | расхождение заголовков или UB | полная пересборка, -flto, санитайзеры |
Мини-итог
- Единица трансляции — атом компиляции в C. Компилятор видит ровно её и ничего больше; всю склейку делает компоновщик, у которого есть имена, но нет типов.
- Препроцессор — текстовая машина. Из двух строк получаются восемьсот; макросы — не функции; зависимости по заголовкам обязаны генерироваться автоматически (
-MMD -MP). - Объектный файл — секции плюс таблица символов плюс списки релокаций.
nm,readelf -S,objdump -drпоказывают всё; догадываться не нужно. staticна уровне файла — граница модуля. Всё, чего нет в заголовке, обязано бытьstatic.- Статическая библиотека — архив с индексом. Втягивается по объектным файлам, порядок аргументов значим, циклы решаются
--start-group. - Разделяемая библиотека — контракт, проверяемый при запуске.
-fPICобязателен, SONAME задаёт совместимость, RUNPATH с$ORIGINлучшеLD_LIBRARY_PATH,-fvisibility=hiddenи--no-undefined— гигиена. - Собирайте на самом старом поддерживаемом окружении и одинаковыми флагами. Расхождение заголовков или флагов между модулями — неопределённое поведение, которое не поймает ни компилятор, ни компоновщик.
Источники
- John R. Levine. Linkers and Loaders — классическая книга целиком про эту тему, черновики открыты: iecc.com/linker
- Ulrich Drepper. How To Write Shared Libraries — исчерпывающе про
.so, PLT/GOT, видимость и стоимость старта: akkadia.org/drepper/dsohowto.pdf - Bryant, O’Hallaron. Computer Systems: A Programmer’s Perspective, глава 7 «Linking»: csapp.cs.cmu.edu
- Ian Lance Taylor. Серия из 20 заметок про устройство компоновщика от автора
gold: airs.com/blog/archives/38 - System V ABI, ELF: refspecs.linuxfoundation.org/elf/gabi4+/contents.html и x86-64 psABI: gitlab.com/x86-psABIs/x86-64-ABI
- Документация GCC (опции сборки и диагностики): gcc.gnu.org/onlinedocs/gcc и GNU ld: sourceware.org/binutils/docs/ld
- GNU Make manual: gnu.org/software/make/manual и CMake: cmake.org/cmake/help/latest
- Reproducible Builds: reproducible-builds.org
Смежные темы на портале: как код превращается в исполнение — в компьютерных науках; генерация машинного кода — в треке компиляторов; отображение сегментов в память и работа execve — в операционных системах и в статье про системные вызовы. Внутри трека: устройство памяти процесса — в памяти и указателях, раскладка структур — в массивах, строках и структурах, а вопрос «когда пора уходить с C» — в современных альтернативах.
Что дальше
Мы научились получать бинарь и понимать, из чего он собран. Следующий шаг — научиться разбираться, почему он ведёт себя не так: читать стек в отладчике, ловить обращения к освобождённой памяти санитайзерами и восстанавливать картину падения по core dump.
Отладка и диагностика: gdb, valgrind, санитайзеры, чтение core dump