Yesterday

Линкер: самый недооценённый этап сборки

Представьте картину. Разработчик Chromium меняет в исходниках одну строчку. Нажимает «собрать». Встаёт, идёт на кухню, заваривает чай. Возвращается — сборка всё ещё идёт. Линкер.

Это не анекдот. В 2020 году на среднем рабочем ноутбуке финальная линковка Chromium через GNU ld занимала около минуты. Через LLVM lld — около одиннадцати секунд. Казалось, что одиннадцать — это предельно быстро: Руи Уэяма, автор lld, потратил на эту цифру годы оптимизаций. Когда вы касаетесь маленькой функции в .cc-файле, компилятор пересобирает один-два объекта за полсекунды, а потом линкер — он не знает, что изменилась строчка — заново перечитывает все тридцать тысяч объектных файлов. Тридцать. Тысяч. И каждый раз проходит по всей программе от начала до конца.

В июне 2021 года этот же Руи Уэяма выкладывает на GitHub новый проект — mold. И линкует Chromium за две секунды.

Точные числа из его бенчмарков на 12-ядерной машине:

Reddit, Hacker News, внутренние чаты Google и Meta просто взрываются. В двадцать пять раз. Не на двадцать пять процентов, а в двадцать пять раз. Без волшебства, без читов, на тех же файлах, на том же железе.

Больше половины комментариев под первой новостью — вариации одной и той же фразы: «А что, линковка вообще может быть такой быстрой?». Это и есть главный симптом проблемы, о которой мы поговорим. Линкер — невидимый, «последний этап», про который никто не думает. Компилятор мы обсуждаем часами (SSA, register allocation, optimization passes). Линкер — просто пробел между сборкой и запуском.

А ведь от него зависит всё: автономен ли бинарник, запустится ли он на соседнем сервере, сколько времени уходит на cargo build, возможна ли кросс-компиляция без мучений с sysroot, заработает ли Docker-образ размером в пять мегабайт. Эта статья — про тихого диктатора, без которого невозможен ни один запущенный исполняемый файл на вашей машине. С разбором PLT/GOT, с историей от EDSAC до Wild, с кодом на Rust, Go, Zig и Nim.

Часть 1. Почему линкер вообще нужен

Игрушечная модель vs реальность

Представьте, что вся ваша программа — один файл на 200 строк. Компилятор читает этот файл, генерирует машинный код, оборачивает его в заголовок исполняемого файла (ELF, Mach-O, PE/COFF — смотря какая ОС), и готово. Линкер как будто не нужен.

Но в жизни так не бывает. Ядро Linux — 36 миллионов строк кода. Chromium — 35 миллионов. LLVM — 10 миллионов. Ваш Cargo-проект со всеми зависимостями запросто набирает пару миллионов через serde, tokio, reqwest. Скомпилировать эту громаду одним куском означает ждать часами каждый раз, когда вы меняете одну строку.

Ровно для этого в 1950-х придумали раздельную компиляцию: каждый исходник превращается в полуфабрикат — объектный файл. В нём уже машинный код, но он ещё не знает, где находятся функции из других файлов. Ссылки на них — «дырки», заполненные нулями с пометкой «сюда вставить адрес printf, когда он станет известен». Эти полуфабрикаты собирает в единое целое линкер.

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

Что реально лежит внутри объектного файла

Возьмём для примера самый простой случай. Файл math.nim:

proc square*(x: int): int = x * x
proc cube*(x: int):   int = x * square(x)

Nim транслирует это в C, GCC/Clang компилирует в math.o. Посмотрим внутрь:

  • Секция .text — машинный код функций square и cube. Это собственно программа.
  • Секция .rodata — read-only data: строковые литералы, константы, таблицы.
  • Секция .data — инициализированные глобальные переменные.
  • Секция .bss — нулевые глобалки (в файле не хранятся, только «зарезервировать N байт»).
  • Таблица символов .symtab — список имён с адресами: «функция square начинается по смещению 0x10, имеет размер 0x24 байта, локальная/глобальная видимость, такого-то типа».
  • Таблица релокаций .rela.text — самое интересное. Для каждой «дырки» в коде здесь запись: «по смещению 0x37 в .text подставить адрес символа square, относительный, 32-битный».

Вот как посмотреть это на реальном бинарнике:

nm ./link_demo | head -5      # таблица символов
#  0000000100004000 d _nim_program_result
#  00000001000015f0 T _NimMainInner
#  00000001000015a0 T _NimMain
#  000000010000cf70 t _my_add       ← наша функция, экспортированная
#  000000010000d370 T _main
readelf -r math.o              # релокации
objdump -d math.o              # дизассемблер

В nm буква T означает «экспортированный символ в секции кода». Маленькая t — локальный (не виден снаружи). D и B — глобальные переменные. Uundefined, то самое «меня нет в этом файле, линкер, найди меня где-нибудь ещё».

Три задачи линкера

Формально линкер решает ровно три задачи. Всё остальное — разнообразие реализаций.

1. Разрешение символов (symbol resolution). Линкер берёт все объектные файлы и все указанные библиотеки, строит глобальную таблицу: какой символ где живёт. Для каждого U-символа нужно найти соответствующий T или D в каком-то другом файле. Нет подходящего — знаменитая ошибка undefined reference to 'foo'. Нашёл два определения одного символа — multiple definition of 'foo'.

Есть ещё слабые символы (weak) — особый тип, который позволяет «вот моё определение, но если найдёшь сильное — бери его». Слабые символы используются для переопределения operator new в C++ или замены malloc на jemalloc/tcmalloc. Здесь линкер из арбитра превращается в дипломата.

2. Размещение (relocation). После того как все символы найдены, нужно дать каждой секции код и данных окончательный виртуальный адрес. .text — в 0x401000, .data — в 0x402000 и так далее. Затем линкер проходит по таблицам релокаций и подставляет настоящие адреса в «дырки».

В зависимости от архитектуры релокации бывают абсолютные (вставить полный 64-битный адрес), относительные к PC (смещение от текущей инструкции), GOT-относительные, TLS-относительные и ещё с пару дюжин разновидностей. Только для x86-64 в ELF описано около 40 типов релокаций.

3. Формирование выходного файла. Склеить всё в формат, понятный операционной системе. На Linux — ELF, на macOS — Mach-O, на Windows — PE/COFF, в браузере — WASM. Форматы разные, идея одна: таблица секций плюс программные заголовки, которые говорят загрузчику «вот это положи в память по такому-то адресу с такими-то правами доступа».

Звучит прозаично. Но за каждой из трёх задач — полвека инженерных войн и дюжина тонких нюансов, которые определяют, будет ли ваш бинарник работать на соседнем сервере.

Часть 2. Семь десятилетий истории

1949: Дэвид Уилер изобретает подпрограмму и линковку

История линкеров начинается не с ENIAC и не с IBM. Она начинается в Кембридже. В мае 1949 года там запустили EDSAC — электронную машину с хранимой программой. И практически сразу Дэвид Уилер, молодой аспирант (позднее — соавтор шифра блочного шифрования Feistel и один из создателей операционной системы CAP), столкнулся с очевидной проблемой: одни и те же куски кода — вычисление синуса, печать числа, ввод данных с ленты — переписываются в каждой программе заново. Руками. С пересчётом всех адресов переходов.

И Уилер придумал две вещи, которые определили всю компьютерную науку дальше.

Первая вещь — подпрограмма. Уилер изобрёл механизм «вызвать блок кода и вернуться обратно» и придумал название для него — subroutine. Именно его инструкция перехода с сохранением обратного адреса получила имя Wheeler jump. Это то, что сегодня мы называем call/ret.

Вторая вещь — линковка. Уилер написал программу, которая называлась initial orders: крошечный загрузчик на одной перфоленте, который читал с другой ленты основную программу, находил в ней «адресные метки», подставлял реальные адреса подпрограмм и клал всё в память. Это первый в мировой истории линкер. Он работал на ЭВМ с 512 словами памяти. За это Уилер позже получил премию Тьюринга.

1952: Грейс Хоппер и слово «библиотека»

В 1952 году Грейс Хоппер — будущая контр-адмирал флота США и крёстная мать COBOL — создаёт A-0 System, один из первых трансляторов. Программист пишет на перфокартах номер нужной подпрограммы, A-0 находит её на магнитной ленте, переписывает в программу и правит все адреса. Полноценный линкер, написанный в 1952 году на одной из первых коммерческих ЭВМ.

Именно отсюда пошло слово «библиотека». Команда Хоппер держала набор готовых подпрограмм на магнитных лентах, физически в шкафу, как книги на полке. Сотрудник подходил, брал нужную ленту, загружал её в машину. Сотрудница назвала эти ленты library — и название пережило 70 лет, перфоленты, саму Хоппер, COBOL и почти всех, кто это видел своими глазами.

1960-е: IBM OS/360, линкер и загрузчик расходятся

В IBM OS/360 впервые появляется каноническое разделение, которое живёт до сих пор:

  • Линкер — собирает из объектных файлов единый исполняемый модуль на диске.
  • Загрузчик (loader) — берёт готовый модуль и кладёт его в память при запуске.

Книга Джона Левайна «Linkers and Loaders» 1999 года, которая до сих пор считается главной энциклопедией в этой предметной области, называется именно так не случайно: это две разные сущности, которые делают очень похожую работу в разные моменты времени.

1970-е: Кен Томпсон пишет ld для Unix

В первых версиях Unix появляется утилита ldlink editor. Написал её Кен Томпсон. С тех пор на всех Unix-системах линкер по умолчанию называется просто ld, и это прямое наследие тех времён. Формат объектных файлов — a.out, примитивный и приземлённый (тот же формат дал имя стандартному выходному файлу компилятора: a.out — и сегодня, если забыть -o, вы получите файл с этим именем).

1988: в AT&T рождается ELF

К концу 1980-х a.out и COFF (Common Object File Format) трещат по швам: нужна нормальная поддержка разделяемых библиотек, debug-информации, многих архитектур. В 1988 году AT&T System Laboratories публикуют ELFExecutable and Linkable Format. Элегантный, расширяемый, одинаковый для объектников и исполняемых файлов. В 1995 году Linux окончательно переходит на ELF, вслед за ним FreeBSD, Solaris, и ELF становится негласным стандартом всех Unix-систем, кроме macOS.

1990-е: рождение DLL Hell

Windows 95, эра CD-ROM, «установите программу — 12 дискет». Системные DLL лежат в одной папке. Установка одной игры может заменить msvcrt.dll на свою, несовместимую с другими приложениями, версию. На форумах появляется термин DLL Hell и становится мемом. Microsoft отвечает пакетной артиллерией:

  • Windows File Protection (Windows 2000) — мешает заменять системные DLL.
  • Side-by-Side Assemblies / WinSxS (Windows XP) — несколько версий одной и той же DLL могут сосуществовать в системе.
  • Манифесты — каждое приложение указывает, какую версию библиотеки оно хочет.
  • .NET GAC — Global Assembly Cache, своё решение в мире .NET.

В итоге — гигантская папка C:\Windows\WinSxS размером в десятки гигабайт, куда сложены все когда-либо установленные версии всех системных библиотек. DLL Hell стал управляемым. Но абсолютно неэлегантным.

2000-е: dependency hell в Unix

В Unix-мире происходит симметричная драма. Программа собрана под libssl.so.1.0.0, а в системе уже libssl.so.3. ABI изменилось, бинарник падает на старте с undefined symbol: EVP_md5. Появляются пакетные менеджеры: apt, rpm, pacman, portage, позднее nix. Они решают проблему — пока вы внутри одного дистрибутива одной версии. Как только нужно взять бинарник, собранный на Ubuntu 20.04, и запустить на CentOS 7 — добро пожаловать в знаменитое /lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.28 not found.

2013-2014: Docker меняет всё

Соломон Хайкс выкладывает Docker. Через полгода за ним подтягивается Kubernetes. Философия «упакуйте приложение со всей своей ОС» даёт ответ dependency hell на уровне совершенно иной абстракции — зачем решать проблемы с линковкой, если можно просто запечатать их в контейнер?

Но контейнер размером 800 МБ ради одного 12-мегабайтного Go-бинарника раздражает. И здесь приходит Go со своей статикой по умолчанию: бинарник без единой внешней зависимости, контейнер FROM scratch, общий размер образа — пять мегабайт. Маятник истории возвращается к статике.

2021-2025: гонка линкеров выходит на новый виток

Руи Уэяма пишет mold. Meta анонсирует Wild на Rust. Apple переходит на свой новый линкер в Xcode 15. wasm-ld становится стандартом для WebAssembly-компонентов. После тридцати лет спокойной эволюции линкеры снова оказываются полем активной инновации.

Часть 3. Статика vs динамика: вечный идеологический спор

Это главный водораздел в мире сборки. Если не понимаете разницу, не поймёте ничего дальше.

Статическая линковка

Весь код библиотек физически вшивается в ваш бинарник. Линкер берёт libssl.a (архив объектных файлов), вытаскивает оттуда только нужные функции и подставляет их прямо в ваш .text. На выходе один файл, который можно скопировать на пустой Alpine Linux без единой библиотеки, и он запустится.

Преимущества на практике:

  • Предсказуемость. Собралось — работает. Сегодня. Завтра. В 2035 году, если процессор выживет.
  • Простейший деплой. scp binary server:/usr/local/bin/ — всё. Никаких зависимостей, никакой установки.
  • Иммунитет к DLL Hell. Версия библиотеки зацементирована в бинарнике навсегда.
  • Старые бинарники работают. Статически собранный бинарник 2008 года запустится на современном ядре Linux.
  • Проще профилирование. Все символы в одном файле, все инлайны видны, вся программа анализируется как единое целое.

Недостатки:

  • Размер. Если сто программ в системе линкуют libc статически — в памяти сто копий libc. На современных машинах с терабайтом RAM это не драматично, но 30 лет назад это было катастрофой.
  • Обновление безопасности — пересборка. Нашли дыру в zlib → пересобрать всё, что её использует. Сорок приложений → сорок пересборок. Компилятор пакетного менеджера не помогает.
  • Лицензионные проблемы с LGPL. Библиотеки под LGPL (включая сам glibc) формально требуют возможности пересобрать приложение с новой версией библиотеки. Статическая линковка это блокирует. Поэтому glibc статически линковать юридически нельзя — musl подставили как выход.

Динамическая линковка

Ссылка на библиотеку остаётся в бинарнике как обещание: «когда меня запустят, найди libssl.so.3, подгрузи в память, подставь реальные адреса». Загрузчик ОС (/lib64/ld-linux-x86-64.so.2 на Linux, dyld на macOS, ntdll.dll на Windows) делает это при запуске программы.

Преимущества:

  • Экономия памяти. Одна копия библиотеки на всю систему. Физические страницы libc разделяются между всеми процессами через mmap.
  • Обновления через пакетный менеджер. apt upgrade libssl — и все программы в системе сразу используют пропатченную библиотеку.
  • Плагины. dlopen() позволяет загружать код в рантайме. Это VST-плагины, расширения редакторов, драйверы устройств.
  • Меньший размер бинарников. Ваша программа — 200 КБ, libc — где-то в системе.

Недостатки:

  • Хрупкость. Несовместимое обновление библиотеки ломает всё, что от неё зависит.
  • Медленный старт. Динамический загрузчик может резолвить сотни символов. На тяжёлых программах это миллисекунды или десятки миллисекунд.
  • Сложный деплой. «Работает на моей машине» — это в 9 случаях из 10 про динамическую линковку.
  • Surface для атак. LD_PRELOAD, GOT overwrite, DLL hijacking — целая коллекция техник злоупотребления динамической линковкой.

Маятник истории

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

В 2014 случился Heartbleed. Критическая дыра в OpenSSL. Динамически слинкованные системы спаслись одним apt upgrade openssl. Статически слинкованные нужно было пересобрать полностью. Каждую программу. Это казалось победным аргументом за динамику.

Но дальше пришёл Docker, пришёл Go, и в 2020-х маятник резко качнулся обратно. Контейнер решает проблему обновлений: пересобираем образ, катим в прод, старый удаляется. Размер 5 МБ против 800 МБ статической Ubuntu внутри контейнера — очевидное преимущество статики. Go сделал статику по умолчанию. Rust, собранный с target x86_64-unknown-linux-musl, — тоже. Zig — тем более. Nim — при желании.

Сегодняшний консенсус где-то посередине:

  • Системные утилиты — динамическая линковка, легко обновляются пакетным менеджером.
  • Приложения на разделяемых хостингах — по традиции динамическая.
  • Приложения в контейнерах — всё чаще полностью статические.
  • CLI-утилиты, которые скачивают как бинарник (rg, fd, hyperfine, bat) — статика, один файл.
  • Браузеры, игры — динамическая (огромные размеры, большие объёмы кода).

Принцип выбора простой: если вы распространяете программу отдельно — статика. Если она устанавливается в составе ОС — динамика.

Часть 4. Анатомия ELF: что на самом деле в бинарнике

Пора залезть внутрь. Все Linux-примеры будут про ELF, но концепции одни и те же для Mach-O и PE/COFF — меняются только имена.

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

В ELF есть две параллельных «карты» файла:

  • Секции (sections) — то, что видит линкер. .text, .data, .rodata, .bss, .symtab, .strtab и ещё с пару десятков. Удобно для сборки.
  • Сегменты (segments) — то, что видит загрузчик ОС. Один сегмент LOAD для кода (read+execute), один LOAD для данных (read+write), INTERP с путём к динамическому загрузчику, DYNAMIC со служебной информацией о рантайм-линковке. Удобно для отображения в память.

Один сегмент LOAD обычно покрывает несколько секций — например, .text, .rodata и .plt все попадают в один read+execute сегмент.

Ключевые секции

Каждая из этих секций — отдельный мир со своими правилами, форматом и назначением. .eh_frame, например, — сложнейший формат на основе DWARF, который описывает, как на каждой инструкции программы восстановить регистры и найти обработчики исключений. Когда в Go или Rust падает паника и вы видите красивый stack trace — это работает .eh_frame плюс отладочная информация.

DT_NEEDED, RPATH, RUNPATH: как программа находит свои библиотеки

Секция .dynamic — табличка, которую читает загрузчик при запуске. Записи DT_NEEDED перечисляют, какие библиотеки нужны:

readelf -d ./my_program | grep NEEDED
# 0x0000000000000001 (NEEDED)  Shared library: [libssl.so.3]
# 0x0000000000000001 (NEEDED)  Shared library: [libcrypto.so.3]
# 0x0000000000000001 (NEEDED)  Shared library: [libc.so.6]

Когда ОС запускает программу, первым в память загружается не сам бинарник, а указанный в .interp динамический загрузчик (ld.so). Он и делает всю работу:

  1. Читает DT_NEEDED — список нужных библиотек.
  2. Ищет каждую по алгоритму:
    • LD_PRELOAD (переменная окружения — принудительные библиотеки).
    • DT_RPATH (если есть — устаревшее).
    • LD_LIBRARY_PATH (переменная окружения).
    • DT_RUNPATH (новое, заменило RPATH).
    • /etc/ld.so.cache (построенная ldconfig таблица всех известных библиотек).
    • /lib и /usr/lib (стандартные пути).
  3. Отображает каждую найденную .so в память через mmap.
  4. Разрешает все недостающие символы.
  5. Вызывает конструкторы (DT_INIT, DT_INIT_ARRAY).
  6. Передаёт управление _start вашего бинарника.

Порядок поиска критичен для безопасности. Если программа ставится с RUNPATH=./, злоумышленник подсовывает в текущую директорию свою libssl.so.3 — и ваша программа загружает чужой код с правами пользователя. Это называется DLL hijacking и давно перестало быть теоретическим.

LD_PRELOAD: светлая и тёмная стороны

LD_PRELOAD=/path/to/my.so ./program — заставляет загрузчик подгрузить указанную библиотеку до всех остальных. Символы из неё имеют приоритет. Это чудесная вещь:

Светлая сторона:

  • Профилирование. LD_PRELOAD=libprofiler.so — оборачиваем все malloc/free для сбора статистики памяти.
  • Трассировка сети. tsocks, proxychains подменяют connect(), чтобы направить трафик через прокси.
  • jemalloc / tcmalloc. Подменить системный malloc на более быстрый — одна строчка переменной окружения, без пересборки.
  • Отладка. LD_PRELOAD обёртки для поиска утечек, гонок данных (ThreadSanitizer, Valgrind частично).

Тёмная сторона:

  • Rootkit’ы. Подменяете readdir() — и ваша директория становится невидимой для ls. Подменяете write() — и логи не пишутся. Классика malware в Linux.
  • Обход проверок. Подменяете getuid() на «всегда 0» — и многие программы думают, что их запустили от root.

Именно поэтому LD_PRELOAD в SUID-программах отключена. И именно поэтому в модных нынче безопасных системах (Android, iOS) — никакого LD_PRELOAD нет в принципе.

Symbol versioning: как glibc убивает ваши бинарники

Вот вам почти детективная история.

Вы собираете бинарник на Ubuntu 22.04 (glibc 2.35) и пытаетесь запустить на Debian 10 (glibc 2.28). Ошибка:

./mybin: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.32' not found
./mybin: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found

Почему так? Ведь memcpy был в glibc со времён динозавров, за что ошибка?

Разгадка в том, что glibc использует symbol versioning. Когда поведение функции меняется (например, добавили флаг или поправили недокументированное поведение), GNU не ломает ABI, они создают новую версию того же символа. Теперь в libc есть одновременно:

  • memcpy@GLIBC_2.2.5 — старая.
  • memcpy@GLIBC_2.14 — новая, с другой семантикой перекрытий буферов.

Когда вы собираете на Ubuntu 22.04, линкер выбирает memcpy@GLIBC_2.14. Когда вы запускаете на Debian 10 с glibc 2.28, там эта версия есть. А вот если где-то в вашем коде glibc вставила вызов символа, появившегося в 2.32 или 2.34, — финиш. Даже если вы никогда не звали эту функцию явно; она могла вылезти из какой-нибудь fprintf или pthread_create.

Обходные пути:

  • musl libc — без symbol versioning, без этой драмы.
  • Сборка на старой системе — Docker-образы с древним glibc.
  • cargo-zigbuild — использовать zig cc как линкер, он умеет прицеливаться в glibc определённой версии.
  • Статическая линковка всей libc (практически возможно только с musl).

Часть 5. PLT и GOT: магия ленивой линковки

А теперь посмотрим, как работает динамический вызов функции на машинном уровне. Это одно из самых элегантных инженерных решений в Unix.

Проблема

Ваш бинарник вызывает printf. В момент компиляции и линковки вы не знаете, где в памяти будет printf, потому что:

  1. Libc загрузится по произвольному адресу (ASLR — рандомизация).
  2. Бинарник может быть PIE (Position Independent Executable) — он сам не знает, куда его загрузили.

Как же на инструкции call printf подставить адрес?

Решение: два уровня непрямой адресации

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

  • GOT (Global Offset Table) — таблица указателей на реальные адреса внешних символов. Заполняется динамическим загрузчиком.
  • PLT (Procedure Linkage Table) — массив маленьких «батутов». Каждая запись — 4-5 инструкций, которые делают косвенный переход через соответствующую ячейку GOT.

Когда ваш код делает call printf, на самом деле он вызывает printf@plt. Посмотрим, что внутри:

printf@plt:
    jmp    QWORD PTR [rip + printf@got]   ; переход по адресу из GOT
    push   0x0                            ; индекс символа
    jmp    _dl_runtime_resolve            ; вызвать резолвер

А в GOT для printf изначально лежит… обратный адрес в PLT (на следующую инструкцию после jmp). То есть при первом вызове:

  1. Код: call printf@plt.
  2. PLT: jmp [printf@got] — но там пока обратный адрес.
  3. PLT продолжает: push индекса и jmp _dl_runtime_resolve.
  4. Резолвер находит настоящий адрес printf в libc, записывает его в GOT и передаёт туда управление.
  5. printf выполняется и возвращается.

При втором и всех последующих вызовах:

  1. Код: call printf@plt.
  2. PLT: jmp [printf@got] — там уже настоящий адрес.
  3. Сразу в printf.

Вся машинерия резолвинга проскакивается одним переходом. Это называется lazy binding — ленивое связывание. Великое изобретение конца 80-х, позволяющее быстро стартовать программы, которые линкуют тысячи символов, но реально используют десятки.

Минус ленивости: атака GOT overwrite

Но тут есть проблема. GOT по умолчанию записываемая память, ведь загрузчик должен в неё писать. А если писать в GOT может загрузчик, то и ваш эксплойт тоже может.

Классическая атака: злоумышленник через buffer overflow получает write primitive, переписывает запись для system@got или exit@got, и при ближайшем вызове ваша программа любезно передаёт управление в shell-код. Вариация, известная как ret2plt, помогла обойти ранние версии ASLR.

RELRO и BIND_NOW: защита от GOT overwrite

Ответ индустрии — две опции линкера:

  • RELRO (Read-Only Relocations) — после того, как загрузчик заполнил GOT, пометить её как read-only через mprotect. Злоумышленник больше не может писать.
  • BIND_NOW — не лениться, резолвить все символы сразу на старте. Медленнее на ~50 мс у большой программы, но GOT полностью готова к моменту запуска main.

Полная защита называется Full RELRO и включается одновременно обе опции:

gcc -Wl,-z,relro -Wl,-z,now  program.c

В современных Linux (Ubuntu, Fedora, Debian) это дефолт для всех системных бинарников. В Go, Rust, Zig при релиз-сборках — тоже по умолчанию. Проверить можно checksec:

RELRO           STACK CANARY   NX      PIE      RPATH    Symbols
Full RELRO      Canary found   NX      PIE      No RPATH No Symbols

Нюанс про производительность

Есть интересный компромисс: Full RELRO замедляет старт программы на 10-100 мс (надо резолвить тысячи символов сразу). Для долгоживущих процессов (веб-сервер, демон) это копейки. Для консольных утилит, которые запускаются миллион раз в день в скриптах (grep, cat), — ощутимо. Поэтому утилиты из GNU coreutils собираются с Partial RELRO — GOT остаётся записываемой, но другие защитные меры включены. Компромисс между безопасностью и скоростью старта.

Часть 6. Удаление мёртвого кода и идентичных функций

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

Секция per function: -ffunction-sections

По умолчанию компилятор складывает весь код одного .c-файла в одну секцию .text. Линкер не может удалить отдельную функцию, не поломав позиции других — они все в одной секции.

Флаги -ffunction-sections и -fdata-sections заставляют компилятор класть каждую функцию в свою секцию (.text.my_func, .text.another_func), а каждую глобалку — в свою. Теперь линкер может оперировать функциями по отдельности.

Garbage collection секций: --gc-sections

С -Wl,--gc-sections линкер находит все достижимые секции, стартуя с main (или нескольких явных корней — например, точек входа DSO), и выбрасывает всё, до чего не добрался. Точно как сборщик мусора в рантайме, только на уровне линковки.

На практике это даёт 10-30% сокращения бинарников из C/C++. В Rust включено по умолчанию. В Go собственный линкер делает DCE в ещё более тонкой гранулярности — на уровне отдельных символов внутри секций. Nim через бэкенд C получает это бесплатно.

Identical Code Folding: --icf

LLD и mold поддерживают ещё одну оптимизацию: ICF (Identical Code Folding). Линкер ищет функции, которые скомпилировались в абсолютно одинаковый машинный код, и оставляет только одну копию, а остальные делает её алиасами.

Кажется, что это редкая ситуация. Но на деле C++ и Rust генерируют тонны идентичных функций через шаблоны и генерики. Vec<u32>::push и Vec<i32>::push на уровне машинного кода могут быть неразличимы. ICF их схлопывает в одну. На Chromium это экономит около 10% размера .text.

Включается: -Wl,--icf=all (lld, mold). В Rust добавляется через:

[profile.release]
strip = true
lto = "thin"
# ICF включается автоматически при strip + lto на lld/mold

Гендерные и вуду-эффекты

Одна из дивных шуток ICF: если две функции идентичны по коду, но идеологически разные (fire_missile и print_warning), их адреса после ICF совпадают. Код типа if (fn_ptr == &fire_missile) может начать работать не так, как вы ожидали. Флаг --icf=safe пытается учитывать сравнения адресов функций, но это остаётся тонкой гранью.

Часть 7. Гонка линкеров: кто, когда и зачем

GNU ld (он же ld.bfd)

Самый распространённый линкер в мире Unix. Написан на C, использует библиотеку абстракций BFD (Binary File Descriptor), которая поддерживает около 70 разных форматов и 50 архитектур — от VAX до z/Architecture.

BFD — и благословение, и проклятие одновременно. Благословение — GNU ld собирает всё и под всё. Проклятие — BFD настолько многослойная, что добавляет огромный оверхед. Плюс линкер однопоточный. С Chromium разбирается почти минуту. Но для 99% мелких проектов этого хватает и никто не жалуется.

GNU gold (2008)

Иэн Тейлор из Google пишет новый линкер ELF на C++. Выбрасывает BFD, фокусируется только на ELF и нескольких архитектурах. В 5 раз быстрее GNU ld. Был включён в binutils в 2008 году.

Gold был прорывом своего времени, но к 2020 году развитие фактически остановилось. Тейлор ушёл из Google, в binutils его никто не заменил, параллельно LLVM делал lld, и интерес сообщества сместился туда. В 2024 году Fedora начала обсуждать удаление gold как не поддерживаемого.

LLVM lld (2015+)

Руи Уэяма, бывший инженер Google, начинает в LLVM новый проект — универсальный линкер. Ключевые цели:

  • Кросс-линкер по умолчанию. Одна сборка lld линкует и ELF, и PE/COFF, и Mach-O, и WASM. На практике это четыре разных «фронтенда» под общим именем: ld.lld, lld-link, ld64.lld, wasm-ld — одна кодовая база, четыре бинарника.
  • Минимализм кода. LLD — 21 тысяча строк C++ против 198 тысяч у gold. Проще читать, проще менять, проще находить баги.
  • Быстрее всего что было. В 2+ раза быстрее gold, параллельный, кэш-дружелюбный.
  • LTO из коробки. Нет отдельной логики для thinLTO — это первоклассная фича.

LLD стал дефолтом во FreeBSD (2018), дефолтом для Chrome OS, дефолтом в Android NDK. Rust на Windows использует lld-link вместо MSVC-линкера. На Apple Silicon всё больше проектов уходят на ld64.lld вместо системного. LLD — новая норма везде, кроме консервативного GNU-мира.

mold (2021): а можно ещё быстрее?

Через несколько лет работы над lld Уэяма задаёт себе вопрос: а что, если бы я писал линкер сейчас, с нуля, зная всё, что знаю? Так родился mold.

Ключевые идеи, которые сделали его настолько быстрым:

  1. Максимальный параллелизм на каждой фазе. Lld имеет последовательные фазы между параллельными блоками. Mold пытается делать параллельно всё что можно.
  2. Lock-free структуры данных. Хэш-таблицы символов используют атомарные операции вместо мьютексов.
  3. Mmap вместо read. Объектные файлы отображаются в память, без копирования.
  4. Mmap выходного файла. Mold создаёт пустой файл нужного размера через ftruncate, мэпит его и пишет напрямую через массивы в памяти — ядро само отправит грязные страницы на диск в фоне, часто уже после завершения линкера.
  5. Оптимизированные парсеры ELF. Написанные с учётом cache line размеров.
  6. Инкрементальная фаза --thinlto-* — максимально параллельная обработка IR в ThinLTO.

Результат на Chromium (2.1 ГБ входных данных, 30 тысяч объектных файлов, 500 тысяч символов):

  • GNU ld: 53,86 с.
  • gold: 12,96 с.
  • lld: 11,03 с.
  • mold: 2,09 с.

Один инженер. Один репозиторий. 26-кратное ускорение против GNU ld. Это тот случай, когда «хорошие инженерные решения» звучит слишком скромно — это переосмысление задачи с чистого листа.

Интересный поворот лицензии: изначально mold вышел под AGPL (коммерческая лицензия, запрещающая SaaS-замыкание). Одновременно Уэяма делал платный порт для macOS под названием sold — за 100 долларов лицензии на машину. В 2024 году он перевёл и mold, и sold под MIT и полностью открыл код. Mold стал стандартом де-факто для ускорения Rust-сборок.

Wild (2024-2025): линкер на Rust

Meta анонсирует Wild — экспериментальный линкер на Rust от Дэвида Латтимора. Цели:

  • Первоклассная инкрементальная линковка: перелинковывать только изменившиеся части бинарника. Это то, о чём давно мечтали все, но никто не сделал нормально — mold, lld делают inкрементальность крайне ограниченно.
  • Hot reload в перспективе: менять работающую программу на лету.
  • Проверить, даст ли Rust выигрыш в безопасности и параллелизме для инструмента такого масштаба.

Wild на ранних бенчмарках близок к mold по скорости и на некоторых сценариях обгоняет благодаря инкрементальности. Проект молодой (2024), но активно развивается.

Apple ld-prime (Xcode 15, 2023)

Пока мир смотрел на mold в Linux, Apple тихо переписывала свой линкер для macOS. В Xcode 15 появился новый ld, примерно в 5 раз быстрее старого на типовых проектах Swift/Objective-C. Детали не публиковались, но из дизассемблирования инженеры реверсят: параллелизм, mmap, кэширование — те же идеи, что в mold, без прямого упоминания mold.

Сводная таблица

Цифры — приблизительные и сильно зависят от проекта. Соотношение между линкерами сохраняется на большинстве больших C++/Rust-проектов.

Часть 8. Оптимизации после компиляции: LTO, PGO, BOLT

В классическом пайплайне каждый .c-файл компилируется отдельно, и компилятор видит только его содержимое. Оптимизатор не может заинлайнить функцию из соседнего файла, не может выбросить неиспользуемую глобальную переменную, не может девиртуализовать вызов через границу модуля. Современные тулчейны взламывают эту изоляцию тремя способами.

LTO: видеть всю программу одним куском

Link-Time Optimization — компилятор вместо машинного кода выдаёт промежуточное представление (LLVM IR у Clang/Rust, GIMPLE у GCC, свой формат у Nim). Линкер собирает всё это IR, вызывает оптимизатор на всей программе как на единой трансляционной единице, и только потом из IR получается машинный код.

Fat LTO. Весь IR грузится в одну гигантскую единицу компиляции. Максимально агрессивные инлайны, выбрасывание мёртвого кода на всю программу, девиртуализация виртуальных вызовов. Платите вы памятью: на проекте в сотни мегабайт IR оптимизатор может съесть 30 ГБ RAM и думать полчаса. Это норма.

Thin LTO (LLVM, 2016). Каждый модуль оптимизируется отдельно, но с доступом к сводке (summary) других модулей — краткой информации: имена функций, их размеры, ссылки на внешние символы. Параллельно. Медленнее Fat LTO в 3-5 раз (на финальной скорости кода), но требует в 10 раз меньше памяти и работает в разы быстрее. Thin LTO — разумный компромисс для подавляющего большинства продовых сборок.

В Rust LTO включается парой строк в Cargo.toml:

[profile.release]
lto = "thin"           # или "fat", или true
codegen-units = 1      # обычно с LTO ставят 1 единицу кодогенерации
strip = true           # попутно убираем debug-инфу

Разница на реальных проектах: прирост скорости кода 5-20%, но время сборки вырастает в 2-4 раза. Для ежедневной разработки — отключено. Для CI с релизными артефактами — включено.

PGO: компилятор учится на профиле

Profile-Guided Optimization — двухфазная сборка:

  1. Собираете «инструментированный» бинарник (-fprofile-generate), который сам собирает профиль выполнения.
  2. Гоняете его на типичных нагрузках. Получаете файлы профилей: какие ветки как часто срабатывают, какие функции горячие.
  3. Пересобираете с -fprofile-use=profiles/ — компилятор использует эти данные для решений.

Что он оптимизирует с профилем? Размещение функций (горячие — рядом), порядок базовых блоков (ветка «с лайком» до ветки «без лайка»), inline-эвристики (если функция вызывается редко — не инлайнить, экономя icache), индирект-вызовы через виртуалки. Типовой прирост — 10-15% скорости. Clang сам собирается через PGO, Firefox собирается через PGO, браузеры Chromium — через PGO.

AutoFDO (Google) — вариация без инструментирования. Собираете обычный бинарник, запускаете под sampling-профилировщиком (Linux perf), получаете профиль в другом формате, компилятор его тоже понимает. Главный плюс — профиль снимается с продакшена.

BOLT: пост-линковочная оптимизация

В 2016 году Facebook выкатывает BOLTBinary Optimization and Layout Tool. Идея на грани фола: берём уже готовый бинарник, снимаем профиль perf, и пост-линковочно переставляем функции и базовые блоки внутри них по профилю. Никакой пересборки — только переупаковка готовых инструкций.

Зачем? Кажется, что PGO делает то же самое. Но между PGO и BOLT есть существенная разница: PGO работает на уровне IR до генерации машинного кода. BOLT — на уровне готового машинного кода и может видеть вещи, которые недоступны компилятору (например, фактическую компоновку кода после линковки со всеми LTO и прочим).

Реальные результаты у Facebook: HHVM (runtime для Hack/PHP) после BOLT работает на 7-10% быстрее, чем то же самое с агрессивным PGO. Clang сам себя собирает через pipeline LTO + PGO + BOLT. Google стали использовать BOLT для ядра Linux на серверах — инструкционный icache hit rate вырос настолько, что это ощутимо по общему потреблению CPU в дата-центре.

Минус BOLT в том, что он требует glibc определённой версии, стабильности формата, не работает на musl. И пользоваться им неудобно: один лишний шаг в CI. Но для топовой оптимизации серверного софта это безальтернативный инструмент.

Часть 9. WebAssembly: линковка, родившаяся в браузере

WASM — отдельная вселенная. Это не просто новая архитектура, здесь изначально другая философия работы с зависимостями.

Каждый WASM-модуль — это таблица импортов и экспортов. Вместо «компилируй и линкуй» — «упакуй, а хост даст тебе нужные функции». Хостом может быть браузер (где функции — это JS и Web API) или WASI-рантайм (где они — ограниченный системный API).

wasm-ld — часть LLD — делает следующее:

  • Собирает несколько объектных .o-файлов (WASM-объектов) в один .wasm модуль.
  • Строит таблицы импортов и экспортов.
  • Обрабатывает релокации — у WASM есть свои типы, отличные от ELF.
  • Поддерживает --export, --import-memory, --shared-memory для нестандартных сценариев.

Интересная деталь: динамическая линковка в WASM не стандартизирована до конца. Emscripten придумал свою схему (MAIN_MODULE/SIDE_MODULE) с собственными PLT-аналогами поверх JS-glue. WASI Component Model (2024-2025) строит совсем другую модель — композицию компонентов через WIT-интерфейсы. Это уже не линковка в классическом смысле, это пакетное связывание модулей с явными контрактами типов — ближе к Protocol Buffers, чем к .so.

Rust, Go, Zig, C/C++ — все умеют компилироваться в WASM через wasm32-unknown-unknown или wasm32-wasi target. Nim — через nimcache, собирая C-бэкенд в WASM через Emscripten. Тема настолько большая, что заслуживает отдельной статьи.

Часть 10. Языки и их линкеры

Каждый современный язык сделал свой выбор в этом большом зоопарке. Посмотрим на четырёх ключевых игроков.

Go: собственный линкер по наследству от Plan 9

Go использует собственный линкер cmd/link, написанный на Go (с перегруппировкой после самобутстрапа в 2015). Это прямое наследие Plan 9 — операционной системы Bell Labs. Над Plan 9 работали Роб Пайк, Кен Томпсон, Дэннис Ричи — те же имена, что и в Unix. В Plan 9 был свой компилятор 8c/6c/5c и свой линкер 8l/6l/5l. Когда Пайк и Томпсон создавали Go, они просто взяли код этого тулчейна и адаптировали. Это дало мгновенно работающий компилятор — а потом уже много лет выпрямляли и модернизировали.

Уникальные преимущества своего линкера:

  • Кросс-компиляция без тулчейна. GOOS=linux GOARCH=arm64 go build на macOS — готов бинарник для Linux ARM64. Нет нужды в кросс-компиляторах, sysroot, glibc-headers-for-target. Go linker сам знает формат ELF, сам генерирует правильные структуры.
  • Go-специфичные оптимизации. DCE на уровне отдельных символов (не функций, не файлов). Собственная генерация .gopclntab — таблицы для стектрейсов, рефлексии, сборщика мусора. Собственный DWARF-генератор.
  • Полная статика по умолчанию. CGO_ENABLED=0 go build — и получаете один файл, работающий на пустом FROM scratch контейнере Docker. Это один из ключевых факторов успеха Go в облачной эпохе.
  • Встроенная поддержка инъекции переменных. go build -ldflags="-X main.version=$(git rev-parse HEAD) -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" — и в бинарник вошиты версия и время сборки. Без костылей с conf-файлами.
  • Воспроизводимые сборки. -trimpath убирает абсолютные пути, сборка побайтно идентична при одинаковом исходнике.
  • Уменьшение размера. -ldflags="-s -w": -s убирает таблицу символов, -w — DWARF-инфу. Бинарник уменьшается на 20-30%.
  • Плагины. go build -buildmode=plugin — собирает .so-файлы, которые можно загрузить в рантайме через plugin.Open. Работает только на Linux и macOS; на Windows плагины Go до сих пор не поддерживают.

Обратная сторона — скорость и качество кода. Линкер Go медленнее mold и lld. На монорепо Uber с десятками тысяч Go-пакетов линкер съедал гигабайты RAM и тратил минуты. Остин Клементс из Google написал в 2019 году развёрнутый дизайн-док о переписывании — в 2020-2021 годах линкер Go стал быстрее и экономнее по памяти в несколько раз, но до mold всё равно далеко. Плюс — Go не делает LTO в классическом смысле; оптимизации компилятора остаются внутри пакетов. На скорости runtime это сказывается против Rust/C++.

Если CGO_ENABLED=1 (Go вызывает C-код через cgo), Go переключается в режим внешнего линкера: собирает весь Go-код в один .o, затем вызывает gcc/clang для финальной линковки. См. link_demo.go.

Zig: линкер + кросс-компилятор + libc всех цветов

Zig пошёл дальше Go. Он не просто имеет свой линкер, он включает полноценный кросс-тулчейн прямо в одном бинарнике zig:

  • Встроенный линкер на основе LLD (ELF, Mach-O, PE/COFF, WASM).
  • Заголовочные файлы и исходники libc для 40+ платформ — musl, glibc (различных версий), MinGW, Bionic (Android), mimicks macOS libSystem. Всё внутри дистрибутива.
  • zig cc — drop-in замена для gcc/clang с кросс-компиляцией: zig cc -target x86_64-linux-musl hello.c, zig cc -target aarch64-macos -isysroot ....

Это такой сдвиг парадигмы, что многие проекты на Rust (не Zig!) ставят zig cc в качестве линкера через cargo-zigbuild — именно ради кросс-компиляции без мучений с sysroot и glibc-versioning. Vagrant, разработчики CLI-инструментов, инди-игры — используют связку Rust + zig cc для выпуска бинарников под все платформы с одного ноутбука.

@cImport() — ещё одна вещь, меняющая подход к FFI. Zig парсит .h-файлы во время компиляции и делает C-функции напрямую доступными в Zig. Не как в Rust, где нужен bindgen, генерирующий биндинги. Не как в Go, где cgo требует отдельной //#include магии. Zig читает .h и понимает его. См. link_demo.zig.

Инкрементальная компиляция — одна из ключевых долгосрочных целей Zig. Линкер проектируется так, чтобы перелинковывать только изменившиеся участки. На момент Zig 0.16 (2026 год) эта работа ещё не завершена полностью, но направление взято серьёзно.

Rust: гибкость и арбитраж

Rust по умолчанию использует системный линкер: GNU ld на Linux, Apple ld на macOS, link.exe (или lld-link) на Windows. Но любой шаг в сторону — и Cargo позволяет переопределить всё через .cargo/config.toml:

[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"

Переход с GNU ld на mold на крупных Rust-проектах (например, rust-analyzer, bevy, сам компилятор Rust) превращает минуты линковки в секунды. Это то изменение конфига, которое окупает себя в первые же часы после внедрения.

Типы крейтов Rust — отдельная тема. Cargo умеет собирать:

  • bin — обычный исполняемый файл.
  • lib / rlib — Rust-архив, линкуется только другими Rust-крейтами.
  • staticlib — архив .a с C ABI, для статической линковки с C.
  • cdylib.so/.dll/.dylib с C ABI, для динамической линковки с C.
  • dylib.so с Rust ABI (не стабилизировано, используется внутри rustc).
  • proc-macro — macro-крейт.

Для FFI критичны staticlib и cdylib. Плюс атрибуты:

#[no_mangle]
pub extern "C" fn my_add(a: i32, b: i32) -> i32 {
    a + b
}

#[link(name = "ssl", kind = "static")]
extern "C" {
    fn SSL_library_init() -> i32;
}

Для полной статики — target x86_64-unknown-linux-musl: линковка с musl вместо glibc, результат как у Go, один автономный файл без symbol versioning и других glibc-специфичных страданий. См. link_demo.rs.

Nim: высокоуровневый синтаксис с честной нативной линковкой через C

А теперь кое-что необычное. Nim не имеет собственного бэкенда. Он транслирует свой код в C (по умолчанию), C++ или Objective-C, и дальше эстафету принимает обычный C-компилятор и обычный системный линкер. Если у вас установлены gcc и mold, Nim будет использовать mold. Если вы на macOS — получите быстрый Apple ld-prime. На Windows — MSVC или MinGW по выбору.

Это даёт Nim комбинацию свойств, которой нет у других «высокоуровневых» языков:

1. FFI без биндингов вообще. Прагма {.importc.} объявляет символ внешним. {.header.} включает .h прямо в сгенерированный C-код. Никаких bindgen, никакой генерации врапперов, никаких отдельных шагов:

proc strlen(s: cstring): csize_t {.importc: "strlen", header: "<string.h>".}
echo strlen("hello, linker".cstring)  # 13

Хотите использовать SQLite? import sqlite3 или напрямую объявить пару прагм — и вы вызываете C-библиотеку как родные Nim-функции. Работает настолько нативно, что стирается граница между «C-код» и «Nim-код».

2. Экспорт в линкер под нужным именем. {.exportc.} выставляет Nim-функцию как видимый C-символ без name mangling:

proc nimAdd(a, b: cint): cint {.exportc: "nim_add", cdecl.} =
  a + b

nm покажет: T nim_add. Без всякого _ZN3...E — чистый C-символ, который может вызвать кто угодно. Это фундамент для того, чтобы писать Python-расширения, Node.js-модули или Nintendo Switch игры на Nim.

3. Динамическая линковка на лету. Прагма {.dynlib.} — эквивалент dlopen/LoadLibrary, символ резолвится в момент первого вызова:

when defined(linux):   const libc = "libc.so.6"
elif defined(macosx):  const libc = "libSystem.dylib"
proc getpid(): cint {.importc, dynlib: libc.}

echo "pid = ", getpid()

4. Встраивание чужого C-кода. Прагма {.compile: "vendor/foo.c".} добавляет C-файл прямо в сборку; {.emit: """ ... """.} позволяет встроить C-код буквально в Nim-файл. Граница между Nim и C — чисто синтаксическая, линкер видит один большой C-проект.

5. Агрессивный dead code elimination. Nim при -d:release не тащит в бинарник всё, что вы импортировали — только реально используемые процедуры. Результат: hello world с полной стандартной библиотекой на macOS arm64:

6. Сборщик мусора на выбор — и каждый меняет рантайм.

  • --mm:orc (дефолт с 2022) — ARC + цикл-детектор. Детерминированное освобождение памяти, без stop-the-world пауз.
  • --mm:arc — чистый ARC без цикл-детектора. Максимальная скорость, если вы сами следите за циклическими ссылками.
  • --mm:none — вообще никакого GC. Для embedded, ядер ОС, real-time систем.
  • --mm:boehm — классический boehm-gc для совместимости с C-библиотеками.

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

7. Полная статика через musl. Как и Rust с musl-target, одной строкой:

nim c -d:release --cc:musl-gcc --passC:-static --passL:-static app.nim

Результат — один ELF-файл, запускающийся на любом Linux без зависимостей. Nim собирают так для Nintendo Switch, embedded-устройств, CLI-утилит, распространяемых как один файл.

Вся живая демонстрация — в link_demo.nim. Компилируется и запускается одной командой: nim c -r link_demo.nim.

Часть 11. Практические рекомендации

Для ежедневной разработки

  • Rust + mold на Linux. .cargo/config.toml из примера выше. На средних проектах — сотни миллисекунд вместо секунд. На крупных (rust-analyzer, bevy) — минуты превращаются в секунды. Установка:cargo install --locked cargo-binstall cargo binstall mold # или apt install mold / brew install mold
  • Rust + sold на macOS. sold (mold для Mach-O) даёт похожий эффект. Или lld через Homebrew.
  • Инкрементальная компиляция. Rust и Nim имеют её по умолчанию. Go — частично. Zig — работа в процессе.
  • sccache — кеширование компиляции между сессиями. Не ускоряет линковку, но ускоряет всё до неё.

Для продакшн-сборок

  • Rust. Thin LTO + strip + codegen-units = 1. Примерно 10-15% ускорения с 2-3-кратным увеличением времени сборки. Для критичных по скорости проектов — добавить Fat LTO и PGO.
  • Go. -ldflags="-s -w -X main.version=$(git describe --tags)". Плюс -trimpath для воспроизводимых сборок.
  • Zig. По умолчанию zig build -Doptimize=ReleaseFast уже делает достаточно. Для минимального размера — ReleaseSmall.
  • Nim. nim c -d:release --opt:speed --passL:-s или --opt:size в зависимости от целей.

Для Docker-контейнеров

  • Go. CGO_ENABLED=0 GOOS=linux go build -o app ., затем FROM scratch или FROM gcr.io/distroless/static. Финальный образ — единицы мегабайт.
  • Rust. Target x86_64-unknown-linux-musl, затем FROM scratch или FROM alpine. Gotcha: крейты с openssl-sys по умолчанию требуют system openssl; используйте rustls или feature openssl/vendored.
  • Nim. nim c -d:release --cc:musl-gcc --passL:-static на Linux с установленным musl-gcc.
  • Zig. zig build-exe -target x86_64-linux-musl main.zig -Doptimize=ReleaseSafe.

Для кросс-компиляции

  • zig cc как линкер для всего. Rust через cargo-zigbuild, Go через CC="zig cc -target x86_64-linux-musl" CGO_ENABLED=1 go build. Один ноутбук собирает под все платформы без настройки тулчейна.
  • Docker с buildx. QEMU-based кросс-сборка, медленнее, но без настройки окружения.

Для отладки и анализа

Инструменты, которые стоит иметь в арсенале:

  • nm — список символов. nm ./bin | grep ' T ' — все экспортированные функции.
  • objdump -d — дизассемблер. objdump -d ./bin | less — смотрим машинный код.
  • readelf -a — всё про ELF-файл: секции, сегменты, символы, релокации, динамическая секция.
  • otool (macOS) — аналог для Mach-O: otool -L ./bin покажет зависимости.
  • ldd (Linux) — какие .so подтянет программа при запуске. Осторожно: ldd фактически запускает программу, не используйте на недоверенных бинарниках.
  • checksec — проверка защитных механизмов (RELRO, stack canaries, NX, PIE).
  • bloaty от Google — самый удобный анализатор «почему мой бинарник такой большой». Разделяет по секциям, по C++ namespace, по Rust crate.
  • cargo bloat — аналог для Rust, выводит топ самых тяжёлых функций и крейтов.
  • twiggy — bloaty для WASM.
  • perf — Linux sampling profiler, для PGO и BOLT-профилей.

Как писать код, дружелюбный к линкеру

  • -fvisibility=hidden в C/C++ — скрывайте всё по умолчанию, экспортируйте явно. Меньше символов, быстрее линковка, меньше attack surface для DLL hijacking.
  • В Rust — pub(crate) по умолчанию, pub только для API.
  • В Nim — без * функция не экспортируется из модуля. Это по умолчанию; код у вас дружелюбен к линкеру.
  • В Go — маленькая буква в начале = приватный. Опять по умолчанию.
  • Для больших C/C++ проектов — -ffunction-sections -fdata-sections -Wl,--gc-sections. 10-30% уменьшения размера.

Эпилог

Линкер — это невидимый диктатор всей индустрии. От него зависит почти всё, что вам важно:

  • Автономен ли ваш бинарник или требует набор библиотек.
  • Сколько секунд уходит на каждое «собрать и запустить».
  • Возможна ли кросс-компиляция без настройки тулчейна.
  • Работает ли Docker-образ размером 5 МБ вместо 800 МБ.
  • Как быстро стартует программа после двойного клика.
  • Насколько защищён бинарник от ROP-атак и подмены GOT.
  • Запустится ли ваша программа, собранная сегодня, через двадцать лет.

Go, Zig, Rust и Nim сделали разные архитектурные выборы в этой точке. Go — полностью свой линкер ради кросс-компиляции и статики. Zig — LLD плюс встроенный тулчейн на все случаи жизни. Rust — гибкий выбор системного линкера с мощной системой типов крейтов. Nim — честное партнёрство с C-инфраструктурой через importc/exportc/dynlib. Каждый выбор отражает глубинную философию языка.

А параллельно тихо продолжается гонка: Руи Уэяма в одиночку сделал mold и сдвинул планку в 25 раз. Дэвид Латтимор в Meta пишет Wild и пытается сделать то же самое с инкрементальной линковкой. Apple втихую переписывает ld в Xcode. BOLT от Facebook выжимает дополнительные 10% скорости у уже собранных бинарников. WebAssembly строит совсем другую модель компонентов.

За каждой строчкой «Linking…» в статус-баре вашей IDE — семьдесят лет инженерной эволюции, начавшейся в Кембридже в 1949 году с перфоленты и двадцатилетнего аспиранта по имени Дэвид Уилер.

Следующий раз, когда будете жаловаться на медленную сборку — вспомните, что именно линкер, а не компилятор, скорее всего, этому виной. И знайте: есть mold.

Примеры кода

  • link_demo.nim — FFI через {.importc.}, экспорт через {.exportc.}, динамическая линковка {.dynlib.}, dead code elimination, выбор GC.
  • link_demo.rs — name mangling, LTO, настройка линкера через .cargo/config.toml, musl-target для полной статики.
  • link_demo.go — CGO-переключатель, статическая линковка по умолчанию, внутреннее устройство Go-линкера.
  • link_demo.zig — кросс-компиляция без тулчейна, zig cc, @cImport() для прямого импорта C-заголовков.

Источники