ELF и линковка
Предпосылки: бит, байт, hex, виртуальная память (mmap, demand paging, copy-on-write), процессы (exec, fork+exec), базовое чтение кода на C (функции, указатели, указатели на функции).
← Управление памятью ядра | Терминалы →
Ядро умеет выделять физические фреймы, строить таблицы страниц, вытеснять страницы в swap. Но когда execve() загружает программу, ядру нужно знать, по каким виртуальным адресам разместить код и данные, где точка входа, какие библиотеки подключить. Эти сведения закодированы в формате исполняемого файла и определяются процессом линковки, который превращает объектные модули в готовый к загрузке бинарник.
Когда процесс вызывает printf("hello\n"), код printf находится не в самом исполняемом файле, а в отдельной библиотеке libc.so. Первый вызов printf проходит через цепочку косвенных прыжков, прежде чем попасть в код библиотеки. Второй попадает туда напрямую. За этой разницей стоит формат ELF, процесс линковки и механизмы, которые решают главную проблему: как связать вызов в программе с функцией, чей адрес становится известен только при запуске.
Что внутри исполняемого файла
Скомпилированная программа на диске — не просто последовательность машинных инструкций. Загрузчику ядра нужно знать, куда в виртуальном пространстве отобразить код, где лежат данные, с какого адреса начать выполнение, какие библиотеки подключить. Вся эта информация закодирована в формате ELF (Executable and Linkable Format) — стандартном формате исполняемых файлов, объектных модулей и разделяемых библиотек в Linux.
Первые четыре байта любого ELF-файла — магическая последовательность \x7fELF. Утилита readelf -h /usr/bin/ls покажет ELF-заголовок (ELF header) — компактную структуру размером 64 байта (на 64-битных системах), содержащую:
- тип файла:
ET_EXEC(исполняемый с фиксированными адресами),ET_DYN(позиционно-независимый — сюда попадают и разделяемые библиотеки.so, и современные исполняемые файлы, скомпилированные с-fPIE),ET_REL(объектный модуль — промежуточный результат компиляции отдельного исходного файла); - архитектуру:
EM_X86_64,EM_AARCH64; - точку входа (entry point): виртуальный адрес первой инструкции, обычно
_startиз crt1.o, а неmain— доmainC runtime должен инициализировать stdlib, вызвать конструкторы глобальных объектов, настроитьargc/argv; - смещения двух таблиц — program headers и section headers.
Одни и те же байты файла описываются двумя разными таблицами. Program headers нужны ядру при загрузке: они описывают сегменты — области файла, которые ядро отображает в виртуальную память через mmap(). Каждый заголовок типа PT_LOAD содержит виртуальный адрес, размер в файле, размер в памяти, права доступа (R/W/X) и выравнивание. Типичный исполняемый файл имеет два PT_LOAD: один для кода (read + execute), другой для данных (read + write). Ядру достаточно program headers, чтобы загрузить и запустить программу.
Section headers нужны линкеру и отладчику: они разрезают те же байты на секции — мелкие именованные области с конкретной ролью. Код попадает в .text, инициализированные глобальные переменные — в .data, неинициализированные — в .bss (занимают место в памяти, но не в файле), строковые константы — в .rodata. Имена функций и переменных хранятся в .symtab (таблица символов) вместе с .strtab (строковый пул для этих имён). Таблицы перемещений — .rela.plt и .rela.dyn на x86-64 — запоминают места в коде и данных, куда линкер должен подставить адреса при сборке или загрузке. Секция .got.plt — таблица указателей, через которую выполняются вызовы в разделяемые библиотеки; к ней мы ещё вернёмся. Утилита readelf -S покажет полный список.
Один сегмент PT_LOAD может вмещать несколько секций — .text и .rodata часто попадают в один сегмент с правами R+X. Утилита readelf -l покажет program headers и какие секции в какой сегмент попали. Утилита strip удаляет .symtab и .strtab — такой бинарник называют stripped: программа продолжит работать, но gdb больше не сможет показать имена функций в стеке вызовов.
exec() и загрузка ELF
В процессах мы видели, что execve() заменяет адресное пространство процесса новой программой. Теперь можно разобрать, что именно делает ядро с ELF-файлом.
Ядро читает первые 64 байта файла — ELF-заголовок. Проверяет магическую последовательность \x7fELF, архитектуру, тип. Если магические байты не совпадают — ядро пробует другие обработчики: скрипты с #!/bin/bash обрабатывает binfmt_script, Java .class файлы можно зарегистрировать через binfmt_misc. Для ELF ядро разбирает program headers и для каждого сегмента PT_LOAD вызывает mmap(), связывая виртуальную область с файлом на диске. Копировать содержимое в RAM пока не нужно — demand paging подтянет страницы при первом обращении.
Если ELF-заголовок содержит PT_INTERP (а для динамически слинкованных программ он есть всегда), ядро загружает ещё и динамический линкер — и передаёт управление ему, а не entry point программы. Для статически слинкованных бинарников PT_INTERP отсутствует, и ядро устанавливает регистр rip (instruction pointer) на значение entry point из ELF-заголовка напрямую. Первый же переход по этому адресу вызывает page fault, ядро подтягивает страницу с диска — и программа побежит. Программа размером 50 МБ начинает работать после загрузки одной страницы (4 КБ) — остальные загрузятся по требованию.
Статическая линковка: всё в одном файле
Программа hello.c вызывает printf(). Компилятор обрабатывает hello.c в одиночку: он видит только исходник и заголовочные файлы, но не видит реализацию printf. Результат — объектный файл hello.o, тот самый ET_REL из перечисления типов выше: готовый машинный код, но с дырами вместо адресов внешних функций. Каждая дыра — это запись перемещения (relocation entry) в .rela.text: «здесь должен быть адрес функции по имени printf». Имя — это символ (symbol), строка, которую линкер потом сопоставит с реальной функцией. Пока дыры не заполнены, программа не может работать: машина не умеет прыгать по имени, только по адресу.
Статический линкер ld (вызываемый через gcc -static) решает задачу в лоб: берёт hello.o, находит определение printf в статической библиотеке libc.a (архив из объектных файлов, по сути tar-подобный контейнер с .o файлами), копирует машинный код printf в итоговый исполняемый файл и подставляет адрес этой копии вместо каждой дыры. То же самое проделывается рекурсивно: printf внутри зовёт write, значит и write нужно скопировать. Линкер копирует не всю libc.a целиком — только те объектные файлы, от которых зависит программа. Результат — самодостаточный бинарник, которому не нужны внешние библиотеки.
/* hello.c */
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}$ gcc -static -o hello hello.c
$ ls -lh hello
-rwxr-xr-x 1 alice alice 880K Mar 15 10:00 hello
$ ldd hello
not a dynamic executable
$ file hello
hello: ELF 64-bit LSB executable, x86-64, statically linked
Пять строк исходного кода — 880 КБ бинарника. Линкер скопировал printf, puts, write, __libc_start_main, код форматирования строк, обработку локалей, буферизацию stdio, реализацию malloc для внутренних нужд — всё, от чего транзитивно зависит printf. Утилита ldd подтверждает: динамических зависимостей нет.
Статическая линковка даёт предсказуемость: бинарник работает на любой Linux-системе с совместимым ядром, независимо от установленных библиотек. Чисто Go-программы (без cgo) по умолчанию линкуются статически и переносимы между дистрибутивами без зависимостей — как только в сборке появляется вызов C-кода через cgo, линкер переключается на динамический libc. В Docker-контейнерах статически слинкованные бинарники позволяют использовать образы FROM scratch (пустой базовый образ) без установки каких-либо пакетов.
Обратная сторона — размер и дублирование. На сервере с 400 процессами, каждый из которых использует libc, статическая линковка означает 400 копий одного и того же кода printf в RAM — 400 x 2 МБ = 800 МБ только на libc. Обновление тоже становится проблемой: при обнаружении уязвимости в libc (например, CVE в getaddrinfo()) приходится пересобирать и деплоить каждый бинарник. С динамической линковкой достаточно обновить один файл libc.so.6 и перезапустить сервисы. Нужен способ разделять код библиотеки между процессами.
Динамическая линковка: разделяемые библиотеки
Разделяемая библиотека (shared library, .so — shared object) — ELF-файл типа ET_DYN, содержащий скомпилированный код и данные. В отличие от статической библиотеки .a, .so не копируется в исполняемый файл. Вместо этого исполняемый файл хранит запись: «мне нужна libc.so.6».
$ gcc -o hello hello.c
$ ls -lh hello
-rwxr-xr-x 1 alice alice 16K Mar 15 10:00 hello
$ ldd hello
linux-vdso.so.1 (0x00007ffd3a5f2000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a1c200000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8a1c5f0000)
16 КБ вместо 880 КБ. ldd показывает три зависимости: linux-vdso.so.1 (vDSO — виртуальная библиотека ядра для быстрых syscall), libc.so.6 (стандартная библиотека C) и /lib64/ld-linux-x86-64.so.2 — динамический линкер.
Код .so отображается через mmap() с флагами MAP_PRIVATE и PROT_READ|PROT_EXEC — механизм тот же, что уже описан в виртуальной памяти. Все процессы, отобразившие один и тот же файл libc.so, читают его через одни и те же физические фреймы кода: 400 процессов с printf — одна копия libc в RAM (~2 МБ), а не 400 копий (800 МБ).
Данные библиотеки (.data, .bss) отображаются тоже через MAP_PRIVATE, но их семантика другая: изначально страницы делятся между процессами, но при первой записи включается copy-on-write и процесс получает собственную приватную страницу. Глобальный счётчик в libc — скажем, внутренняя статистика аллокатора — для каждого процесса живёт отдельно: когда один процесс инкрементирует его, он копирует страницу, а остальные продолжают видеть старое значение.
Кто именно запускает этот mmap, откладываем до следующего раздела: в отличие от основного бинарника, которым занимается ядро, разделяемые библиотеки отображает код, работающий в userspace.
Динамический линкер: ld-linux
Кто загружает .so в память? Не само ядро. Ядро знает только об ELF-формате исполняемого файла — оно отображает его сегменты и ищет в program headers запись типа PT_INTERP. Эта запись содержит путь к динамическому линкеру (runtime linker) — обычно /lib64/ld-linux-x86-64.so.2. Ядро загружает линкер в память точно так же, как основную программу — через mmap() его PT_LOAD сегментов — и передаёт ему управление вместо entry point программы.
Динамический линкер (ld-linux.so, часть glibc) выполняет работу до того, как программа начнёт выполняться:
- Читает секцию
.dynamicисполняемого файла — список необходимых библиотек (DT_NEEDED). - Ищет каждую библиотеку по комбинации путей:
DT_RPATH/DT_RUNPATHиз ELF, переменнаяLD_LIBRARY_PATH, кеш/etc/ld.so.cache(собранныйldconfigиз путей в/etc/ld.so.conf), стандартные/libи/usr/lib. - Загружает найденные
.soчерезmmap(). Если у библиотеки есть своиDT_NEEDED— рекурсивно загружает и их. - Разрешает символы: сопоставляет неопределённые символы программы (вроде
printf) с экспортированными символами библиотек. Для этого обходит хеш-таблицы символов (.gnu.hashили.hash) каждой загруженной библиотеки. - Применяет перемещения (relocations): записывает финальные адреса в GOT, заполняет указатели на функции и данные.
- Вызывает конструкторы библиотек (
__attribute__((constructor)),.init_array). - Передаёт управление на entry point программы.
На практике шаги 4 и 5 для функций выполняются лениво — при первом вызове, а не при старте. Это экономит время загрузки: программа может импортировать тысячи символов, но за время работы использовать лишь десятки. Механизм ленивого связывания реализуется через PLT и GOT.
PLT и GOT: ленивое связывание
При статической линковке все адреса известны до запуска. С разделяемыми библиотеками это невозможно: ядро рандомизирует базовый адрес каждой .so при каждом запуске (механизм называется ASLR — Address Space Layout Randomization; подробно разберём его ниже). Адрес printf в libc.so определяется только после загрузки, поэтому компилятор не может вшить прямой вызов в машинный код. Вместо этого он генерирует вызов заглушки в PLT (Procedure Linkage Table, таблица связывания процедур). PLT-заглушка, в свою очередь, прыгает по адресу, записанному в GOT (Global Offset Table, глобальная таблица смещений).
GOT — массив указателей в секции .got.plt, расположенной в сегменте данных (read/write). Каждой внешней функции соответствует одна запись в GOT. При первом вызове запись ещё не содержит реального адреса — в ней лежит адрес второй части PLT-заглушки, которая вызывает динамический линкер. Линкер находит адрес printf в libc.so, записывает его в GOT и прыгает на printf. Эта вторая часть PLT-заглушки называется resolve stub (заглушка разрешения) — код, который передаёт управление динамическому линкеру для поиска символа. При втором вызове GOT уже содержит реальный адрес — прыжок через PLT сразу попадает в printf, без участия линкера.
Первый вызов printf():
main() PLT[printf] GOT[printf] ld-linux
| | | |
|-- call PLT[printf] -->| | |
| |-- jmp *GOT[printf]->| |
| | | (адрес = PLT |
| |<-- возврат в PLT ---| resolve stub) |
| | | |
| |-- call ld-linux ----|---------------->|
| | | поиск printf |
| | | в libc.so |
| | |<-- запись адреса-|
| | | (адрес = printf |
| | | в libc.so) |
| | | |
|<-- printf() выполняется----------------------| |
Второй вызов printf():
main() PLT[printf] GOT[printf]
| | |
|-- call PLT[printf] -->| |
| |-- jmp *GOT[printf]->|
| | | (адрес = printf
| | | в libc.so)
|<-- printf() выполняется---------------------|Первый вызов стоит порядка сотен наносекунд на прогретом кэше до нескольких микросекунд на холодном (поиск символа по хеш-таблицам загруженных библиотек). Каждый последующий — один косвенный прыжок через GOT: jmp *[адрес в GOT], порядка нескольких наносекунд. На программе, вызывающей printf миллион раз, накладные расходы ленивого связывания — однократные микросекунды на фоне миллиона вызовов.
PLT-заглушка для printf на x86-64 — всего три инструкции:
printf@plt:
jmp *printf@GOTPCREL(%rip) ; прыжок по адресу из GOT
push $index ; индекс в таблице перемещений
jmp PLT[0] ; вызов resolve-стаба ld-linuxПервая инструкция прыгает по адресу из GOT. До разрешения GOT указывает на вторую инструкцию — push $index. Это помещает индекс перемещения на стек и прыгает на PLT[0], где вызывается _dl_runtime_resolve() из ld-linux. После разрешения GOT содержит адрес printf в libc.so, и первая инструкция прыгает туда напрямую — вторая и третья инструкции больше никогда не выполняются.
LD_BIND_NOW: связывание при запуске
Ленивое связывание экономит время старта, но создаёт непредсказуемые задержки: первый вызов каждой функции работает медленнее. Для систем с жёсткими требованиями к латентности (финансовые системы, real-time обработка) это неприемлемо — нельзя, чтобы первый запрос клиента проходил через резолв символов.
Переменная окружения LD_BIND_NOW=1 (или флаг -z now при компиляции) заставляет ld-linux разрешить все символы при загрузке. Все записи GOT заполняются реальными адресами до вызова main(). Время старта увеличивается — программа с 500 импортированными функциями потратит от сотен микросекунд до нескольких миллисекунд на разрешение — но после старта каждый вызов стабильно быстр.
Есть ещё одно преимущество: если GOT заполнен при старте, его можно пометить как read-only (-z relro -z now, full RELRO — RELocation Read-Only). Это закрывает вектор атаки GOT overwrite — атакующий, нашедший уязвимость записи в произвольную память, не сможет подменить адрес в GOT, потому что страница помечена read-only на уровне MMU (Memory Management Unit).
LD_PRELOAD: подмена библиотек
До сих пор мы видели, как линкер сам находит символы и подставляет адреса. Но что если нужно заменить функцию без перекомпиляции программы — например, подсунуть свою версию malloc (подробнее об аллокаторах памяти), чтобы отследить утечки памяти?
Динамический линкер ищет символы в определённом порядке: сначала в самой программе, затем в библиотеках по порядку загрузки. Переменная LD_PRELOAD позволяет загрузить библиотеку раньше всех остальных — и её символы перекроют одноимённые символы из libc или любой другой библиотеки.
/* mymalloc.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
void *malloc(size_t size) {
static void *(*real_malloc)(size_t) = NULL;
if (!real_malloc)
real_malloc = dlsym(RTLD_NEXT, "malloc");
void *ptr = real_malloc(size);
fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
return ptr;
}$ gcc -shared -fPIC -o mymalloc.so mymalloc.c -ldl
$ LD_PRELOAD=./mymalloc.so ls
malloc(472) = 0x55a3c4e012a0
malloc(120) = 0x55a3c4e01480
...
LD_PRELOAD=./mymalloc.so загружает mymalloc.so до libc.so. Когда ls вызывает malloc(), динамический линкер находит malloc в mymalloc.so первым. Внутри нашей реализации dlsym(RTLD_NEXT, "malloc") находит «следующий» malloc — из libc — чтобы выполнить реальное выделение памяти.
Это не просто отладочный трюк. Высокопроизводительные аллокаторы jemalloc и tcmalloc (Google) подключаются именно через LD_PRELOAD — никакой перекомпиляции приложения. Серверное приложение на Ruby или Python, страдающее от фрагментации памяти стандартного glibc malloc, часто запускают с LD_PRELOAD=/usr/lib/libjemalloc.so.2 — и фрагментация снижается благодаря арена-аллокатору jemalloc, который оптимизирован под многопоточные нагрузки.
Ограничение: для setuid/setgid-бинарников (/usr/bin/sudo, /usr/bin/passwd) динамический загрузчик (ld-linux) игнорирует LD_PRELOAD. Setuid-бит на файле означает, что при exec программа запускается с правами владельца файла (обычно root), а не вызывающего пользователя. Если бы LD_PRELOAD работал для таких программ, непривилегированный пользователь мог бы подменить getuid или malloc в программе, работающей с правами root. Механизм защиты: ядро при exec setuid-бинарника устанавливает AT_SECURE=1 в auxiliary vector (вспомогательный вектор — массив пар ключ-значение, который ядро передаёт процессу при exec), загрузчик видит это и переходит в secure-execution mode — отбрасывает LD_PRELOAD, LD_LIBRARY_PATH и другие опасные переменные.
Другое применение LD_PRELOAD — инструментирование. Библиотека libfaketime перехватывает clock_gettime() и gettimeofday(), возвращая поддельное время — это позволяет тестировать логику, зависящую от системных часов, без изменения времени на машине. strace и ltrace используют другой механизм (ptrace), но LD_PRELOAD часто проще и быстрее для конкретных функций.
dlopen и dlsym: загрузка в runtime
PLT/GOT и LD_PRELOAD работают при запуске программы — список библиотек определён заранее. Но иногда библиотеку нужно загрузить по требованию: система плагинов загружает расширения по имени файла, Ruby подключает C-расширения через require, Nginx загружает модули из конфигурации.
#include <dlfcn.h>
#include <stdio.h>
int main(void) {
/* загрузка библиотеки */
void *handle = dlopen("libm.so.6", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
/* поиск символа */
double (*cosine)(double) = dlsym(handle, "cos");
if (!cosine) {
fprintf(stderr, "dlsym: %s\n", dlerror());
return 1;
}
printf("cos(0) = %f\n", cosine(0.0));
dlclose(handle);
return 0;
}dlopen() загружает .so в адресное пространство процесса через mmap() — тот же механизм, что использует ld-linux при старте. Флаг RTLD_LAZY откладывает разрешение символов-функций до момента первого вызова (символы-данные разрешаются сразу); RTLD_NOW разрешает все символы немедленно. dlsym() ищет символ по имени в загруженной библиотеке и возвращает его адрес — указатель на функцию, который можно вызвать напрямую. dlclose() уменьшает счётчик ссылок; когда он достигает нуля, ld-linux вызывает munmap() и освобождает виртуальные страницы процесса.
Ошибки dlopen не вызывают SIGSEGV — функция возвращает NULL, а dlerror() описывает причину: библиотека не найдена, неразрешённый символ, несовместимая архитектура. Это позволяет реализовать graceful degradation: попытаться загрузить оптимизированную библиотеку, при неудаче — использовать fallback.
Когда в Ruby пишут require 'json/ext/parser', интерпретатор вызывает dlopen("parser.so", ...), находит через dlsym функцию Init_parser и вызывает её — C-расширение регистрирует свои классы и методы в Ruby VM. Тот же механизм используют Python (ctypes, C-расширения), Nginx (модули), PostgreSQL (расширения вроде PostGIS).
ASLR: рандомизация адресов
Если адрес printf в libc.so один и тот же при каждом запуске, атакующий может воспользоваться уязвимостью buffer overflow (запись за пределы буфера позволяет перезаписать адрес возврата на стеке), заранее вычислить адрес нужной функции (например, system()) и перенаправить на неё выполнение — это return-to-libc атака. Нужен способ сделать адреса непредсказуемыми.
ASLR (Address Space Layout Randomization, рандомизация размещения адресного пространства) — механизм ядра, рандомизирующий базовые адреса стека, кучи, mmap-области и разделяемых библиотек при каждом запуске процесса. Включён по умолчанию (/proc/sys/kernel/randomize_va_space = 2). Позиционно-независимый исполняемый файл (PIE, Position-Independent Executable, скомпилированный с -fPIE) рандомизируется целиком — включая секции кода и данных самой программы.
$ cat /proc/self/maps | grep libc.so
7f3a1c200000-7f3a1c3c0000 r-xp libc.so.6 # первый запуск
$ cat /proc/self/maps | grep libc.so
7f8b4e600000-7f8b4e7c0000 r-xp libc.so.6 # второй запуск
Базовый адрес libc сдвинулся на ~20 ТБ между двумя запусками. Атакующий не знает, где в памяти находится system(), и return-to-libc перестаёт работать — прыжок по угаданному адресу попадёт в невалидную память (SIGSEGV).
Значение randomize_va_space определяет степень рандомизации: 0 — отключено (полезно для отладки: setarch x86_64 -R ./hello запустит процесс без ASLR), 1 — рандомизируются стек, mmap, VDSO, 2 — добавляется рандомизация brk (кучи). Энтропия рандомизации на x86-64 — 28 бит для mmap-области и 22 бита для стека, что даёт порядка 256 миллионов возможных базовых адресов для библиотеки.
Именно ASLR — причина, по которой PLT и GOT необходимы: компилятор не может вшить прямой адрес в машинный код, и косвенный прыжок через таблицу — единственный способ вызвать функцию, чей адрес определяется в runtime.
NixOS: RPATH и /nix/store
На обычном Linux-дистрибутиве (
apt,dnf,pacman) разделяемые библиотеки лежат в стандартных путях —/usr/lib,/lib. Динамический линкер ищет их через/etc/ld.so.cache, собранныйldconfig. Одновременно может существовать только одна версия библиотеки по данному пути: обновлениеlibc.so.6черезapt upgradeзатрагивает все программы в системе.NixOS решает задачу радикально: каждая версия каждой библиотеки живёт в собственном пути внутри
/nix/store, содержащем хеш всех входов сборки:/nix/store/abc123-glibc-2.38/lib/libc.so.6 /nix/store/def456-glibc-2.39/lib/libc.so.6 /nix/store/ghi789-openssl-3.1.4/lib/libssl.so.3Две версии glibc сосуществуют в файловой системе. Программа A может зависеть от glibc 2.38, программа B — от glibc 2.39, и обе работают одновременно.
Как линкер находит правильную версию? Через путь поиска библиотек, записанный прямо в ELF-файл. Исторически это
RPATH, но современные бинарники Nix обычно используют RUNPATH (DT_RUNPATH) — именно его и показываетreadelf:$ readelf -d /nix/store/xyz-hello/bin/hello | grep RUNPATH RUNPATH: /nix/store/abc123-glibc-2.38/libДля обычного процесса динамический линкер сначала учитывает
LD_LIBRARY_PATH, затемDT_RUNPATHбинарника, потом/etc/ld.so.cacheи стандартные каталоги. На NixOSRUNPATHуже содержит абсолютные пути в/nix/store, поэтому бинарник находит именно те библиотеки, с которыми был собран. В/nix/storeнет/usr/lib, нет глобальногоldconfig, нет конфликтов версий. Цена — каждый бинарник несёт абсолютные пути, привязанные к хешам/nix/store, и не работает на обычном дистрибутиве без патчинга ELF (patchelf --set-rpath).
Сценарий целиком: printf от вызова до libc
Соберём всю цепочку. Программа скомпилирована динамически (gcc -o hello hello.c). Пользователь запускает ./hello.
Shell вызывает fork(), потомок вызывает execve("./hello", ...). Ядро читает ELF-заголовок, находит PT_INTERP → /lib64/ld-linux-x86-64.so.2, отображает и линкер, и PT_LOAD-сегменты hello через mmap(), передаёт управление ld-linux.
Динамический линкер разбирает .dynamic секцию hello: DT_NEEDED: libc.so.6. Ищет libc по путям, находит /lib/x86_64-linux-gnu/libc.so.6, загружает через mmap(). ASLR сдвигает базовый адрес libc на случайную величину. Линкер заполняет GOT для глобальных переменных, но записи для функций (PLT) оставляет ленивыми. Вызывает __libc_start_main, которая инициализирует stdlib и вызывает main().
main() выполняет printf("hello\n"). Компилятор сгенерировал call printf@plt. PLT-заглушка прыгает по адресу из GOT — там адрес resolve-стаба. Стаб кладёт индекс printf на стек и вызывает _dl_runtime_resolve(). Линкер ищет printf в хеш-таблице символов libc.so, находит адрес (с учётом ASLR-смещения), записывает его в GOT и прыгает на printf. Функция форматирует строку и вызывает write(1, "hello\n", 6) — системный вызов, который доставляет данные в терминал.
Если программа вызовет printf снова — PLT-заглушка прыгнет по адресу из GOT, где уже лежит адрес printf в libc. Один косвенный прыжок, никакого линкера.
Sources
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapter 41-42: Shared Libraries — https://man7.org/tlpi/
- John R. Levine, 1999, Linkers and Loaders — Chapter 10: Dynamic Linking and Loading — https://www.iecc.com/linker/
man 8 ld-linux.so— https://man7.org/linux/man-pages/man8/ld-linux.so.8.htmlman 3 dlopen— https://man7.org/linux/man-pages/man3/dlopen.3.htmlreadelf(1),ldd(1)— https://man7.org/linux/man-pages/man1/readelf.1.html