Featured image of post Как разрозненные куски кода собираются воедино: Внутреннее устройство линкеров и загрузчиков

Как разрозненные куски кода собираются воедино: Внутреннее устройство линкеров и загрузчиков

Введение: Невидимые зодчие программного мира

В повседневной разработке мы регулярно вводим команды вроде:

1
gcc -O2 main.c utils.c math.c -o myprogram

Этот процесс часто называют «компиляцией», однако компилятор выполняет лишь начальную фазу. Вторую половину работы—бесшовное сшивание десятков объектных файлов и библиотек, назначение виртуальных адресов и загрузку исполняемого файла в оперативную память для передачи управления процессору—выполняют компоновщик (Linker) и загрузчик (Loader).

  flowchart TD
    subgraph BuildPipeline ["От исходного кода к исполнению"]
        SourceA["main.c"] -->|Компилятор| ObjA["main.o (Перемещаемый объект)"]
        SourceB["utils.c"] -->|Компилятор| ObjB["utils.o (Перемещаемый объект)"]
        Lib["libc.so / libm.a\n(Библиотеки)"]

        ObjA --> Linker["★ Компоновщик (ld)\n[Разрешение символов & Релокация]"]
        ObjB --> Linker
        Lib --> Linker

        Linker --> Exec["myprogram (Исполняемый ELF-файл)"]
    end

    Exec -->|execve| Loader["★ Загрузчик (Ядро & ld-linux.so)\n[Отображение в память & Динамическая линковка]"]
    Loader --> CPU["Исполнение CPU (_start -> main)"]

    style BuildPipeline fill:#f9f9f9,stroke:#333,stroke-width:2px
    style Linker fill:#ffe6e6,stroke:#d9534f,stroke-width:2px
    style Loader fill:#e6f3ff,stroke:#0066cc,stroke-width:2px

Без компоновщика модульное программирование было бы невозможным: нам пришлось бы писать миллионы строк кода в одном огромном файле.


1. Анатомия объектного файла ELF

Промежуточный файл (.o) представляет собой перемещаемый объектный файл формата ELF (Executable and Linkable Format):

  • .text: Машинный код процессора.
  • .rodata: Константы и строковые литералы только для чтения.
  • .data: Инициализированные глобальные и статические переменные.
  • .bss: Неинициализированные глобальные переменные. Не занимает места на диске! Память выделяется и обнуляется только во время запуска программы.
  • .symtab: Таблица символов (реестр функций и переменных).
  • .rela.text: Записи релокации, содержащие адреса-заглушки, подлежащие корректировке.

2. Разрешение символов (Symbol Resolution)

Компоновщик сопоставляет внешние ссылки с их фактическими определениями.

Сильные и слабые символы:

  • Сильные: Определения функций и инициализированные глобальные переменные (int x = 1;).
  • Слабые: Неинициализированные глобальные переменные (int x;).

Правила разрешения:

  1. Несколько одинаковых сильных символов вызывают ошибку компоновки (multiple definition).
  2. Сильный символ всегда побеждает слабый.
  3. Из нескольких слабых символов компоновщик выбирает один наугад (опасность порчи памяти).

Порядок библиотек в командной строке: Компоновщик сканирует аргументы строго слева направо в один проход. Поэтому -lm обязан стоять после main.c.


3. Релокация адресов (Relocation)

Компилятор подставляет нули вместо адресов внешних функций:

1
callq 0x00000000  # Временное смещение
$$\text{Значение} = S + A - P$$

($S$: адрес символа, $A$: адденд, $P$: адрес инструкции).


4. Динамическая компоновка: PIC, GOT и PLT

Чтобы не дублировать код библиотек в памяти, используются разделяемые библиотеки (.so). Они требуют позиционно-независимого кода (PIC), работающего через две таблицы:

  • GOT (Global Offset Table): Хранит абсолютные адреса в области данных.
  • PLT (Procedure Linkage Table): Таблица переходов в области кода.

Благодаря отложенному связыванию (Lazy Binding) динамический компоновщик (ld-linux.so) разрешает адрес функции только при её первом реальном вызове.


5. Загрузчик: От _start к main()

При запуске программы системный вызов execve отображает сегменты ELF в виртуальную память (mmap), настраивает стек (argc, argv, envp) и передает управление в точку входа _start. Среда выполнения C исполняет глобальные конструкторы из секции .init_array перед вызовом функции main().

comments powered by Disqus