Системное программирование на C Сборка и компоновка: препроцессор, объектные файлы, статические и динамические библиотеки
0%

Сборка и компоновка: препроцессор, объектные файлы, статические и динамические библиотеки

Сборка и компоновка: препроцессор, объектные файлы, статические и динамические библиотеки

Почему это отдельная профессия, а не деталь реализации

В языках с модулями сборка почти невидима: компилятор сам знает, где лежат зависимости, сам проверяет типы через границы модулей, сам кладёт результат куда надо. В C ничего этого нет. Язык не знает слова «модуль»: он оперирует единицей трансляции — одним .c после того, как препроцессор вклеил в него все заголовки. Каждая такая единица компилируется в полной изоляции от остальных, и всё, что она может сказать про внешний мир, — это список имён: «мне нужен символ square, я не знаю, что это и где оно».

Отсюда главное следствие, ради которого стоит читать статью целиком: компилятор проверяет типы, компоновщик — только имена. Между этими двумя проверками зияет дыра, куда проваливаются самые неприятные ошибки в C — те, что собираются без единого предупреждения и падают через месяц на чужой машине. Всё, что разобрано в статье про неопределённое поведение, имеет свою сборочную проекцию, и её мы разберём здесь. Второе следствие практическое: gcc main.c — это не одна программа, а четыре, запущенные подряд драйвером. Пока вы не умеете останавливать конвейер на каждом шаге и смотреть на промежуточный результат, любая ошибка сборки — магия. Когда умеете — это просто чтение вывода инструментов.

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

Шаг 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;, ровно в одном .cint 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 — гигиена.
  • Собирайте на самом старом поддерживаемом окружении и одинаковыми флагами. Расхождение заголовков или флагов между модулями — неопределённое поведение, которое не поймает ни компилятор, ни компоновщик.

Источники

Смежные темы на портале: как код превращается в исполнение — в компьютерных науках; генерация машинного кода — в треке компиляторов; отображение сегментов в память и работа execve — в операционных системах и в статье про системные вызовы. Внутри трека: устройство памяти процесса — в памяти и указателях, раскладка структур — в массивах, строках и структурах, а вопрос «когда пора уходить с C» — в современных альтернативах.

Что дальше

Мы научились получать бинарь и понимать, из чего он собран. Следующий шаг — научиться разбираться, почему он ведёт себя не так: читать стек в отладчике, ловить обращения к освобождённой памяти санитайзерами и восстанавливать картину падения по core dump.

Отладка и диагностика: gdb, valgrind, санитайзеры, чтение core dump

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов