ELF и линковка
Предпосылки: бит, байт, hex, виртуальная память (mmap, demand paging, ASLR), процессы (exec, fork+exec).
← Управление памятью ядра | Терминалы →
Ядро умеет выделять физические фреймы, строить таблицы страниц, вытеснять страницы в swap. Но когда execve() загружает программу, ядру нужно знать, по каким виртуальным адресам разместить код и данные, где точка входа, какие библиотеки подключить. Эти сведения закодированы в формате исполняемого файла и определяются процессом линковки, который превращает объектные модули в готовый к загрузке бинарник.
Когда процесс вызывает printf("hello\n"), код printf находится не в самом исполняемом файле, а в разделяемой библиотеке libc.so, которую загрузил динамический линкер. Первый вызов printf проходит через два уровня косвенности — PLT (Procedure Linkage Table) и GOT (Global Offset Table) — прежде чем попасть в код libc. Второй вызов обходит линкер и прыгает по адресу напрямую. За этой механикой стоит формат ELF, статическая и динамическая линковка, ленивое связывание и ASLR (Address Space Layout Randomization — рандомизация адресов).
Что внутри исполняемого файла
Скомпилированная программа на диске — не просто последовательность машинных инструкций. Загрузчику ядра нужно знать, куда в виртуальном пространстве отобразить код, где лежат данные, с какого адреса начать выполнение, какие библиотеки подключить. Вся эта информация закодирована в формате 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 (таблица программных заголовков) описывают сегменты — области файла, которые ядро отображает в виртуальную память при загрузке. Каждый заголовок типа PT_LOAD содержит виртуальный адрес, размер в файле, размер в памяти, права доступа (R/W/X) и выравнивание. Типичный исполняемый файл имеет два PT_LOAD: один для кода (read + execute), другой для данных (read + write). Ядру достаточно program headers, чтобы загрузить и запустить программу — section headers ему не нужны.
Section headers (таблица заголовков секций) предназначены для линкера и отладчика. Секции — более мелкая нарезка: .text (код), .data (инициализированные данные), .bss (неинициализированные данные), .rodata (строковые константы), .symtab (таблица символов), .strtab (строки для имён символов), .rel.plt и .rel.dyn (таблицы перемещений для PLT и данных). Утилита readelf -S покажет все секции, а strip может удалить section headers из исполняемого файла (такой бинарник называют stripped — очищенным от символов) — программа продолжит работать, но gdb потеряет информацию о символах.
Два разных взгляда на один файл: сегменты (для загрузки в память) и секции (для линковки и отладки). Один сегмент PT_LOAD может вмещать несколько секций — .text и .rodata часто попадают в один сегмент с правами R+X. Утилита readelf -l покажет program headers и какие секции в какой сегмент попали.
exec() и загрузка ELF
В процессах мы видели, что execve() заменяет адресное пространство процесса новой программой. Теперь можно разобрать, что именно делает ядро с ELF-файлом.
Ядро читает первые 64 байта файла — ELF-заголовок. Проверяет магическую последовательность \x7fELF, архитектуру, тип. Если магические байты не совпадают — ядро пробует другие обработчики: скрипты с #!/bin/bash обрабатывает binfmt_script, Java .class файлы можно зарегистрировать через binfmt_misc. Для ELF ядро разбирает program headers и для каждого сегмента PT_LOAD вызывает mmap(): создаёт VMA (Virtual Memory Area) с виртуальным адресом и правами из заголовка, связывая VMA с файлом на диске. Физических фреймов не выделяется — работает demand paging: страницы загрузятся при первом обращении.
Если ELF-заголовок содержит PT_INTERP (а для динамически слинкованных программ он есть всегда), ядро загружает ещё и динамический линкер — и передаёт управление ему, а не entry point программы. Для статически слинкованных бинарников PT_INTERP отсутствует, и ядро устанавливает регистр rip (instruction pointer) на значение entry point из ELF-заголовка напрямую. Первое обращение вызовет page fault, ядро загрузит страницу с диска — и программа побежит. Программа размером 50 МБ начинает работать после загрузки одной страницы (4 КБ) — остальные загрузятся по требованию.
Статическая линковка: всё в одном файле
Программа hello.c вызывает printf(). Код printf живёт в libc — библиотеке стандартных функций C. Компилятор создаёт объектный файл hello.o, где вызов printf — просто незаполненная ссылка (relocation entry — запись перемещения, указание линкеру «подставь сюда адрес»). Кто-то должен подставить реальный адрес.
Статический линкер ld (вызываемый через gcc -static) решает задачу в лоб: берёт hello.o, находит нужные функции в статической библиотеке libc.a (архив из объектных файлов, по сути tar-подобный контейнер с .o файлами), копирует их машинный код в итоговый исполняемый файл и заполняет все ссылки конкретными адресами. Линкер копирует не всю 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 по умолчанию линкует статически — go-бинарники переносимы между дистрибутивами без зависимостей. В 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 (виртуальная библиотека ядра для быстрых syscall), libc.so.6 (стандартная библиотека C) и /lib64/ld-linux-x86-64.so.2 — динамический линкер.
При загрузке ядро отображает код .so через mmap() с флагами MAP_PRIVATE и PROT_READ|PROT_EXEC. Как описано в виртуальной памяти, несколько процессов, использующих одну библиотеку, разделяют одни и те же физические фреймы кода. Те же 400 процессов, использующих printf — одна копия libc.so в RAM (~2 МБ), а не 400 копий (800 МБ).
Данные библиотеки (.data, .bss) отображаются с MAP_PRIVATE — каждый процесс получает свою копию через copy-on-write при первой записи. Глобальная переменная errno в libc — пример: каждый поток хранит свою копию, и запись в errno одним процессом не затрагивает другие.
Динамический линкер: 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: ленивое связывание
Когда компилятор генерирует код с вызовом printf, он не знает, по какому виртуальному адресу окажется printf в libc.so — ASLR рандомизирует адреса при каждом запуске. Вместо прямого вызова компилятор генерирует вызов заглушки в 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() выполняется---------------------|Первый вызов стоит ~1-5 мкс (поиск символа по хеш-таблицам). Каждый последующий — один косвенный прыжок через GOT: jmp *[адрес в GOT], порядка нескольких наносекунд. На программе, вызывающей printf миллион раз, накладные расходы ленивого связывания — однократные 5 мкс на фоне миллиона вызовов.
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 импортированными функциями потратит ~0.5-2.5 мс на разрешение (при стоимости ~1-5 мкс на символ) — но после старта каждый вызов стабильно быстр.
Есть ещё одно преимущество: если GOT заполнен при старте, его можно пометить как read-only (-z relro -z now, full RELRO — RELocation Read-Only). Это закрывает вектор атаки GOT overwrite — атакующий, нашедший уязвимость записи в произвольную память, не сможет подменить адрес в GOT, потому что страница помечена read-only на уровне MMU (Memory Management Unit).
LD_PRELOAD: подмена библиотек
Динамический линкер ищет символы в определённом порядке: сначала в самой программе, затем в библиотеках по порядку загрузки. Переменная 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 (используется в Firefox, Rust) и 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. Механизм: ядро при exec setuid-бинарника устанавливает AT_SECURE=1 в auxiliary vector (вспомогательный вектор — массив пар ключ-значение, который ядро передаёт процессу при exec), загрузчик видит это и переходит в secure-execution mode — отбрасывает LD_PRELOAD, LD_LIBRARY_PATH и другие опасные переменные. Без этого непривилегированный пользователь мог бы подменить malloc или getuid в программе, работающей с правами root.
Другое применение 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() уменьшает счётчик ссылок; когда он достигает нуля, ядро вызывает 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 (Virtual Dynamically-linked Shared Object), 2 — добавляется рандомизация brk (кучи). Энтропия рандомизации на x86-64 — 28 бит для mmap-области и 22 бита для стека, что даёт порядка 256 миллионов возможных базовых адресов для библиотеки.
Именно ASLR делает GOT необходимым: поскольку адреса библиотек неизвестны во время компиляции и меняются при каждом запуске, код не может содержать прямые вызовы функций из .so. Косвенный вызов через 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