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 — до main C 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) выполняет работу до того, как программа начнёт выполняться:

  1. Читает секцию .dynamic исполняемого файла — список необходимых библиотек (DT_NEEDED).
  2. Ищет каждую библиотеку по комбинации путей: DT_RPATH/DT_RUNPATH из ELF, переменная LD_LIBRARY_PATH, кеш /etc/ld.so.cache (собранный ldconfig из путей в /etc/ld.so.conf), стандартные /lib и /usr/lib.
  3. Загружает найденные .so через mmap(). Если у библиотеки есть свои DT_NEEDED — рекурсивно загружает и их.
  4. Разрешает символы: сопоставляет неопределённые символы программы (вроде printf) с экспортированными символами библиотек. Для этого обходит хеш-таблицы символов (.gnu.hash или .hash) каждой загруженной библиотеки.
  5. Применяет перемещения (relocations): записывает финальные адреса в GOT, заполняет указатели на функции и данные.
  6. Вызывает конструкторы библиотек (__attribute__((constructor)), .init_array).
  7. Передаёт управление на 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.

Сценарий целиком: 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


Управление памятью ядра | Терминалы