Введение: Невидимые зодчие программного мира
В повседневной разработке мы регулярно вводим команды вроде:
| |
Этот процесс часто называют «компиляцией», однако компилятор выполняет лишь начальную фазу. Вторую половину работы—бесшовное сшивание десятков объектных файлов и библиотек, назначение виртуальных адресов и загрузку исполняемого файла в оперативную память для передачи управления процессору—выполняют компоновщик (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;).
Правила разрешения:
- Несколько одинаковых сильных символов вызывают ошибку компоновки (
multiple definition). - Сильный символ всегда побеждает слабый.
- Из нескольких слабых символов компоновщик выбирает один наугад (опасность порчи памяти).
Порядок библиотек в командной строке: Компоновщик сканирует аргументы строго слева направо в один проход. Поэтому -lm обязан стоять после main.c.
3. Релокация адресов (Relocation)
Компилятор подставляет нули вместо адресов внешних функций:
| |
($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().
