## Содержание - [[#Введение]] - [[#Область видимости]] - [[#Виды]] - [[#Время жизни]] - [[#Модель памяти процессора]] - [[#Стек вызовов]] - [[#Соглашение о вызовах]] - [[#Вызов функции]] - [[#Кадр стека]] - [[#Виртуальная память]] - [[#Страницы]] - [[#Страничное нарушение]] - [[#mmap]] - [[#Формат ELF]] - [[#Из файла в процесс]] - [[#История]] - [[#Практика]] - [[#Расположение переменных]] - [[#Карта памяти процесса]] - [[#Вызов функции глазами компилятора]] - [[#Отображение памяти mmap]] - [[#Заголовки ELF]] - [[#Отложенное связывание]] - [[#Путь от исходника к процессу]] ## Введение В [лекции 3](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Цикл%20трансляции) был подробно описан процесс превращения исходного текста программы в полноценный исполняемый файл. Несмотря на весь этот процесс, часть [символов](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) этого файла остается неразрешенными до запуска программы. Очевидно, исполняемый файл и процесс отличаются друг от друга: первый - всего лишь байты на диске, в то время как второй - полноценный динамический набор из различных регистров, стека, кучи и открытых файлов. Но что именно представляется из себя этот запуск программы? ## Область видимости >**Область видимости** (scope) - часть текста программы, где имя связано с сущностью. Иными словами, область видимости отвечает за то, чтобы каждому имени [символа](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) в программе была сопоставлена определенная переменная программы с ее адресом. ### Виды Очевидно, у область видимости бывают различные виды. Всего их два: лексическая (или статическая) и динамическая. **Лексическая область видимости** ищет имя [символа](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) в тексте программы на основе вложенности блоков. То есть, от места обращения к [символу](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) программа будет проходить от уровня вложенности, где был вызван [символ](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы), все выше и выше, до тех пор, пока необходимый [символ](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) не будет найден. Таким образом, адрес вызываемого [символа](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) будет известен уже сразу при [компиляции](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Компиляция) программы. **Динамическая область видимости** ищет имя [символа](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) на основе цепочки активных вызовов. То есть, такая область видимости возьмет первое попавшееся значение (точнее последнее установленное значение) переменной по имени и передаст именно ее. Таким образом, результат работы программы с такой областью видимости будет зависеть от окружения, в котором данный [символ](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы) был вызван. Большинство языков программирования (к примеру, C, Java, Python, Haskell) имеют лексическую область видимости, ведь она более удобна и предсказуема. Тем не менее, динамическая область видимости все также существует (к примеру, в bash, Emacs Lisp, ранние Lisp), и несмотря на то, что читать и понимать код с такой областью видимости труднее, она является удобным стандартным кейсом. ### Время жизни Время жизни и область видимости - очень похожие понятия, но у них есть существенные различия, поэтому их лучше не путать между собой. Область видимости показывает, где именно можно использовать имя переменной и к какой переменной это будет относится. Время жизни показывает, в какое время исполнения программы данная переменная будет доступна для обращения к ней по имени. К примеру, для локальной переменной программы, находящейся в стеке, область видимости и время жизни совпадают: оба ограничены блоком (хотя для времени жизни было бы правильнее сказать, что переменная "живет", пока выполняется блок). Но вот для локальной `static` переменной, находящейся в [[#Модель памяти процессора|области]] `.data / .bss`, все немного иначе: несмотря на то, что ее область видимости ограничена блоком, "жить" такая переменная будет все время исполнения программы. Другим примером совпадения области видимости и времени жизни является глобальная переменная, хранящаяся в [[#Модель памяти процессора|области]] `.data / .bss`, ведь "жить" такая переменная будет все время исполнения программы, и будет "видна" в любом месте файла программы (очевидно, включая `extern` из файла). Интересным примером является объект, полученный из [[#mmap|памяти]] `malloc`. У самого такого объекта нет имени, имя есть только у указателя на него. "Жить" такой объект будет в [[#Модель памяти процессора|куче]], с момента его объявления (функцией `malloc`), до его освобождения (функцией `free`). Из-за такого несовпадения области видимости и времени жизни иногда возникают так называемые висячие указатели - когда сам объект уже недоступен, но указатель на него все еще существует и "виден". Такое можно наблюдать, если попытаться вернуть из функции адрес локальной переменной. ## Модель памяти процессора >**Адресное пространство** - линейный массив байт, который заполнен не сплошь, а кусками. Адресное пространство разделено на несколько различных по назначению и правам доступа областей. Каждая такая область имеет адрес начала и максимальный размер. Основными областями памяти являются (от наименьшего адреса начала к наибольшему): - `.text` - машинный код программы (права `r-x`) - `.rodata` - строковые литералы или константы (права `r--`) - `.data / .bss` - сегмент статических данных (то есть глобальные и `static` переменные) (права `rw-`) - `Heap` (Куча) - `malloc` / `free` (права `rw-`) - Отображения файлов - `mmap` (права `r-x/rw`) - Стек - локальные переменные, адреса возврата (права `rw-`) Куча растет вниз, к наибольшим адресам. Стек растет в обратную сторону - вверх, к наименьшим адресам. То есть, они растут навстречу друг другу. Если в программе идет обращение к неотображенному адресу, то подается сигнал SIGSEGV ([[#Страничное нарушение|ошибка сегментации]]). ## Стек вызовов ### Соглашение о вызовах В первую очередь, соглашение о вызовах - это именно соглашение, а не какое-то обязательство. Но оно иногда сильно помогает при анализе написанного кода, а также исключает многие ошибки при изменении функциональности подпрограммы и предотвращает непредсказуемое поведение. Одно из правил, которые фиксирует данное соглашение, являются места передаваемых аргументов. Первые шесть аргументов передаются в регистрах `rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9`, строго по порядку. Все остальные аргументы передаются через стек, и складываются таким образом, чтобы сверху были аргументы с наименьшим номером. Если же аргументы являются числами с плавающей точкой (к примеру, типа float или doubel), то для них предусмотрены отдельные регистры от `xmm0` до `xmm7`, опять же по порядку. Место возвращаемого аргумента также описывается соглашением. В основном, все аргументы возвращаются в регистре `rax`, но также можно передавать результат и в регистре `rdx` (то есть, `rdx:rax`). Для числе с плавающей точкой местом возврата является регистр `xmm0`. Помимо этого, соглашение устанавливает, какие регистры во время исполнения функции могут изменяться, а какие должны остаться неизменными после завершения ее работы. Регистры, которые не должны изменяться, называются `callee-saved` (то есть, вызванная функция обязана восстановить данные регистры после завершения своей работы), таковых всего 7: `rbx`, `rbp`, `r12`, `r13`, `r14`, `r15` и `rsp`. Остальные регистры (к примеру, `rax`, `rcx`, `rdx`, `rsi`, `rdi`, `r8`, `r9`, `r10`, `r11`) являются `caller-saved` (то есть, данные регистры могут быть изменены вызовом функции, поэтому должны быть сохранены тем, кто эту функцию вызывает). Также в соглашении рекомендуется, чтобы при вызове функции указатель стека был кратен 16. В основном это сделано для функций, которые работают с 16-байтовыми регистрами или значениями, для которых это может быть важно (к примеру, некоторые SIMD-инструкции внутри libc). ### Вызов функции Очевидно, вызов функции внутри программы можно логически разделить на несколько этапов. В самом начале функции передаются необходимые для ее работы аргументы: некоторые в регистры, некоторые на стек (желательно, соблюдая соглашение). Следующим шагом производится сама команда call, то есть сохранение адреса возврата в стек и переход на по адресу функции. После этого внутри функции выполняется пролог. Старое значение регистра `rbp` сохраняется на вершину стека, текущее значение регистра `rsp` записывается как новое значение регистра `rbp`, а после уменьшением регистра `rsp` на необходимое число байт освобождается фрейм стека под необходимые функции локальные переменные. Далее начинается тело функции, то есть основная ее часть. В ней производится работа с различными аргументами и локальными переменными (если таковые есть), и на выходе получается результат, который записывается в регистр `rax` (а при необходимости, в `rdx:rax`). Перед завершением функции происходит эпилог. Эпилог состоит из двух этапов. На первом освобождается фрейм стека, который использовался для функции (в основном, с помощью прибавления к значению регистра `rsp` необходимого значения). На втором этапе предыдущее значение регистра `rbp` снимается с верхушки стека и возвращается на регистр. В самом конце происходит выход из функции с помощью команды `ret`. Эта команда берет значение с верхушки стека в качестве адреса возврата и переходит по нему, передавая обратно управление программе, которая эту функцию вызвала. Важно помнить, что команда `ret` слепо доверяет стеку, и без проверки переходит по адресу, который там лежит. Это важно учитывать, потому что этим можно воспользоваться и перенаправить программу куда угодно, что может привести к плохим последствиям. ### Кадр стека >**Кадр стека** - область стека одного активного вызова функции. Иными словами, у каждой функции есть специальная отведенная область в памяти, где хранятся все необходимые данные. Кадр стека вызываемой функции состоит из пяти частей. На самой верхушке стека располагаются локальные переменные, специфичные для этой функции. Чаще всего, к ним можно обратится при помощи регистра `rsp`, который обычно указывает на начало области данных с этими переменными. После локальных переменных идут сохраненные в начале функции callee-saved регистры, такие как `rbx`, `r12`, `r13`, `r14` и `r15` (конечно, к callee-saved также относятся `rsp` и `rbp`, но `rsp` вернется на то же место при завершении функции, а `rbp` сохраняется чуть позже). В последней ячейке сохраненных регистров идет `rbp`. При прологе функции регистр `rbp` сохраняется в стек, а новое значение этого регистра ставится на ячейку со значением старого. Далее в памяти располагается адрес возврата функции. По сути, данный адрес является границей между двумя кадрами: вызываемой функцией и вызывающей функцией, ведь именно на этом этапе происходит сам вызов функции, то есть исполнение команды `call`. В конце памяти располагаются аргументы, которые не поместились в регистры и были переданы в через стек. При таком расположении `rbp` очень легко использовать для доступа как к локальным переменным функции (начиная с `[rbp - 8]`, при условии что никакие другие регистры не сохраняются), так и к аргументам, переданным в функцию (начиная с `[rbp + 16]`). Именно с помощью сохранения различных значений `rbp` работает `backtrace` в отладчике `gdb`, который строит цепочку из таких значений и после использует ее для отладки. ## Виртуальная память >**Виртуальная память** - набор адресов, которые видит программа; все адреса там - виртуальные, в перевод в нестоящие осуществляется с помощью MMU оп таблицам, подготовленным ОС. Иными словами, виртуальная память - это ненастоящая память (неким образом связанная с настоящими адресами), которая дается процессу для работы. У существования виртуальной памяти есть три основных назначения. Первым таким назначением является изоляция. Благодаря виртуальной памяти, у каждого процесса есть свое собственное адресное пространство, а один и тот же адрес в двух разных процессах будет ссылаться на две абсолютно разные физические ячейки памяти компьютера. Вторым является простота адресации. Вся память внутри процесса принадлежит только ему, и ему нет необходимости знать реальные физические размеры памяти или то, какие другие процессы также могут обращаться к этим ячейкам памяти. Третьим является экономия. Благодаря виртуальной памяти, процессу нет необходимости знать точные ее размеры. Поэтому чаще всего страницы в памяти выделяются лениво, по первому запросу, а редко используемая память вытесняется на диск. При этом, libc в памяти одна на сотни процессов. ### Страницы Вся виртуальная память на машине хранится по страницам. Каждая страница виртуальной памяти занимает 4 КБ (именно поэтому все границы в карте памяти процесса кратны 0x1000). Права доступа к памяти выдаются постранично, различны между страницами и одинаковы для всей страницы. Перевод из виртуального адреса в физический занимает всего четыре шага. На первом этапе виртуальный адрес разбивается на адрес страницы и на смещение внутри этой страницы (то есть, по факту, просто отбрасывая последние 12 бит до лучших времен), а после передает дальше для поиска адреса страницы. На втором шаге ядро ищет , по которому процессор синтезирует из виртуального адреса настоящий; но процессор кэширует все запросы к страницам с помощью специального кэша страниц - TLB (Translation Lookaside Buffer), поэтому если какая-либо страница доставалась из памяти до этого, она уже будет в кэше и ее не придется искать снова. На третьем этапе определяется физический кадр (то есть физическое пространство в памяти, соответствующее виртуальной странице), а также его права доступа, и производится проверка на, собственно, доступ. Напоследок, если доступ есть, к адресу физического кадра возвращается отброшенное ранее смещение (не измененное), и происходит чтение необходимой ячейки физической памяти. #### Страничное нарушение Очевидно, иногда у ядра случаются промахи в памяти: к примеру, нет такой записи в памяти или банально не хватает прав. На такой случай у ядра есть специальный штатный механизм (не ошибка! - он может отрабатывать до тысяч раз в секунду, и на программу это никак не повлияет) page fault, при возникновении которого в зависимости от причины определяются пути решения. Самая частая причина возникновения page fault - указанного места в памяти не существует. Тогда с помощью карты областей ядро решает, должно ли данное место являться доступным для программы, и если оно действительно доступно, до место в памяти выделяется. Если изначальное обращение было выполнено к стеку или куче, то ядро выделяет ранее обнуленное место в кадре стека. Если же это было обращение к обычной памяти, то ядро сначала загружает эту область памяти в основную память, а после дает к ней доступ программе. Также иногда случается, что ранее загруженная программой страница внезапно при следующем обращении к ней оказывается вытеснена в своп. Такая проблема тоже вызывает механизм page fault. В таком случае ядро загружает память из свопа обратно, и программа продолжает работать, будто ничего и не произошло. Конечно, page fault иногда также возвращает ошибки, если доступ к памяти действительно заблокирован. К таким случаям относятся, к примеру, попытка обращения к адресу вне области процесса, или работа с памятью вне прав доступа (к примеру, запись в область с правами r--). В таких случаях ядро генерирует сигнал SIGSEGV, отправляет его обратно в процесс, и там он становится печально известной ошибкой Segmentation fault. ### mmap Назначение mmap довольно простое - он создает новую область в адресном пространстве процесса. Чаще всего используется отображение файла в память. То есть память с диска напрямую переносится в процесс. При этом можно либо перенести в память процесса копию памяти с диска (`MAP_PRIVATE`), либо сразу работать с памятью на диске (`MAP_SHARED`). В основном, такая память выделяется лениво - то есть загружается в память только при первом обращении к ней с помощью page fault. Помимо отображения файла в память также иногда применяется и анонимная память (`MAP_ANONYMOUS`). В отличие от отображения в памяти, оно, как очевидно из названия, не имеет память в качестве источника, а создается напрямую в памяти процесса (то есть не связана с какими-либо данными вне процесса). При создании такой области все значения в ней заполняются нулями, а фактическое расположение указывает на стек или кучу. Такая память все еще выделяется лениво, но вместо копирования памяти в память процесса просто выделяет кадр в стеке (или куче). Тем не менее, хоть анонимная память и существует только внутри процесса, она также передается при форке - у потомка тоже будет доступ к анонимной памяти родителя. Отображение памяти чаще всего применяется библиотеками или для чтения файлов. Анонимная память больше подходит для больших `malloc`, стеков потоков, а также аллокаторов. В `glibc` большие блоки `malloc` (по умолчанию, $\geq$ 128 КБ) выделяются сразу анонимным образом, а маленькие блоки используют кучу. ### Формат ELF Формат ELF (Executable and Linkable Format) используется для исполняемых и компонуемых файлов (к примеру, объектных файлов типа .o), как понятно из названия. Он состоит из различных секций. в каждой из которых хранятся разные по назначению данные. В начале файла идет заголовок, занимающий первые 64 байта. В нем в основном хранятся магические числа (массив байт для идентификации файла, первые четыре байта `7f 45 4c 46`, что характеризует файл ELF), тип файла, архитектуру процессора для запуска, версия ELF, точку входа программы, дополнительные флаги, а также вся необходимая информация про заголовки таблиц внутри файла. Сразу после заголовка идет таблица программных заголовков. Она используется загрузчиком, и дает ему всю необходимую информацию для того, чтобы понять, что и куда необходимо загрузить. После программных заголовков идут обычные секции файла. Среди них секция `.text` (сам исполняемый код программы), секция `.rodata` (константы для программы), секция `.data` (инициализированные данные программы), а также несколько секций поменьше для символов и отладочной информации (`.symtab`, `.strtab`, `.debug_*`). В конце файла расположена таблица заголовков секций. В ней перечислены все именованные секции программы, а также вся необходимая информация про них (имена, типы, смещения секций). Данная таблица используется компоновщиком или отладчиком для их корректной и быстрой работы. На самом деле, исполняемому файлу не нужны ни таблица программных заголовков, ни разделение на секции. Даже без всего этого, исполняемый файл все еще может быть запущен. Поэтому в объектном файле уже не хранятся программные заголовки, а секции удаляются. #### Из файла в процесс Очевидно, нельзя просто взять и запустить программу из памяти - даже если это уже полноценно сформированный ELF файл. В самом начале текущий исполняемый процесс (чаще всего, оболочка) делает форк, создавая потомка, и уже в этом форке исполняет необходимый файл (с помощью `execev()`). При исполнении такой программы ядро читает заголовок ELF файла, а также все программные заголовки, и получает всю необходимую информацию о том, как этот файл нужно запускать. Следующим шагом идет подготовка к запуску программы. Каждый необходимый `LOAD` загружается в процесс с помощью `mmap`, выполняется обнуление `.bss`, а также стека вместе с `argv` и `envp`. С помощью `INTERP` происходит загрузка `libc.so.6` в процесс и все необходимые релокации. После всех таких подготовок наконец-то происходит исполнение файла. В случае скомпилированного файла из языка C по метке `_start` происходит запуск кода из `main`, а после завершение процесса с помощью `exit`. Таким образом можно заметить, что ядро никогда не копирует программу в память целиком, оно лишь создает необходимые отображения, а страницы подгружает по первому запросу. ### История Первый пример виртуальной памяти появился еще в 1962 году, когда в Манчестерском университете создали компьютер [Atlas](https://ru.wikipedia.org/wiki/Atlas_(компьютер)). Данная машина имела несколько страниц памяти с разным временем доступа до каждого из них, и при этом видела каждый из этих листов. Поэтому она могла перемещать данные и таким образом динамически менять время доступа до разных разделов памяти, в зависимости от того, что приоритетнее. Через пару лет, в 1965 году, впервые появился прообраз mmap - операционная система [Multics](https://ru.wikipedia.org/wiki/Multics) считала все файлы как часть адресного пространства. Ближе к концу 1970-ых годов страничная виртуальная память уже стала нормой в [Unix](https://ru.wikipedia.org/wiki/Unix). В конце 1980-ых годов происходит выход [System V](https://ru.wikipedia.org/wiki/System_V) Release 4, в котором впервые используется формат ELF. Раньше для тех же целей использовались [a.out](https://ru.wikipedia.org/wiki/A.out) и [COFF](https://ru.wikipedia.org/wiki/COFF). В 2000-ых впервые в явном виде описывается соглашение о вызовах, заданное в System V AMD64 с помощью ABI (Application Binary Interface). Иными словами, все необходимые технологии были придуманы достаточно давно. Все, что используется на данный момент в языке C, просто получило удобный способ использования. ## Практика ### Расположение переменных Рассмотрим код на языке C. Для каждой переменной в коде ниже распишем, в каком сегменте данных она находится. ```c int counter = 42; char buffer[4096]; const char *msg = hello; int main(void) { int local = 1; static int calls = 0; int *p = malloc(100); calls++; printf("%p %p %p\n", (void*)&local, (void*)p, (void*)&counter); free(p); return 0; } ``` Первая переменная `counter` является числом, поэтому она сразу записывается в сегмент `.data`. Переменная `buffer` в файле не хранится, обнулена и находится в `.bss`. Во время создания третьей переменной сам литерал `"hello"` записывается в `.rodata`, а `.msg` получает указатель на него и хранит его в секции `.data`. Машинный код функции main хранится в секции `.text`. Локальная переменная `local` попадает в кадр стека функции `main` и хранится там. Следующая переменная `calls` является `static`, поэтому существует на протяжении выполнения всей программы в секции `.data`. Для следующей переменной из кучи выделяется 100 байт, а для указателя `p` выделяется место в кадре стека функции `main` для хранения адреса этого пространства. ### Карта памяти процесса Все занимаемые программами секции памяти можно легко посмотреть через файл `/proc/PID/maps` (где `PID` - номер процесса). Ниже представлен пример такого вывода. ```sh $ cat /proc/$(pgrep -n demo)/maps 55d0c8a00000-55d0c8a01000 r--p 00000000 08:02 1572903 /home/st/demo 55d0c8a01000-55d0c8a02000 r-xp 00001000 08:02 1572903 /home/st/demo 55d0c8a02000-55d0c8a03000 r--p 00002000 08:02 1572903 /home/st/demo 55d0c8a03000-55d0c8a04000 rw-p 00002000 08:02 1572903 /home/st/demo 55d0c9b6e000-55d0c9b8f000 rw-p 00000000 00:00 0 [heap] 7f3a1c400000-7f3a1c428000 r--p 00000000 08:02 2622221 /usr/lib/x86_64-linux-gnu/libc.so.6 7f3a1c428000-7f3a1c5bd000 r-xp 00028000 08:02 2622221 /usr/lib/x86_64-linux-gnu/libc.so.6 7f3a1c7e4000-7f3a1c7e6000 r--p 00000000 08:02 2622218 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 7ffd3e9a1000-7ffd3e9c2000 rw-p 00000000 00:00 0 [stack] ``` Вывод этого файла будет состоять из шести колонок. В первой будет указан диапазон адресов, которые занимает тот или иной процесс. Во втором столбце будут указаны права доступа к секции, а также настройки ее приватности (`p = private`, `s = shared`). В третьей колонке будет указано смещение для файла, в котором хранится память этого процесса. В четвертом столбце будет указано устройство, на котором эта память хранится. В пятой колонке будет указан inode файла хранения памяти. В шестом столбце будет хранится путь до процесса. Важно учитывать, что для такой анонимной памяти, как к примеру стек или куча (которая не сохраняется в файле), смещение, номер устройства и inode всегда будут равны нулю. ### Вызов функции глазами компилятора Предположим, что у нас есть функция на языке C. ```c long sum3(long a, long b, long c) { long s = a + b; return s + c; } ``` Компилятор `gcc` позволяет скомпилировать файл в объектный, а после из него получить инструкции ассемблера. Это можно сделать следующим образом. ```sh gcc -O0 -c sum3.c objdump -d -M intel --no-show-raw-insn sum3.o ``` В результате, получится следующий набор инструкций. ``` : push rbp mov rbp,rsp mov QWORD PTR [rbp-0x18],rdi ; a mov QWORD PTR [rbp-0x20],rsi ; b mov QWORD PTR [rbp-0x28],rdx ; c mov rdx,QWORD PTR [rbp-0x18] mov rax,QWORD PTR [rbp-0x20] add rax,rdx mov QWORD PTR [rbp-0x8],rax ; s mov rdx,QWORD PTR [rbp-0x8] mov rax,QWORD PTR [rbp-0x28] add rax,rdx ; результат в rax pop rbp ret ``` ### Отображение памяти mmap Ниже рассмотрим код на языке C для отображения файла в память и простейшей работы с ним. ```c int fd = open("hello.txt", O_RDONLY); struct stat st; fstat(fd, &st); char* data = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); if (data == MAP_FAILED) { perror("mmap"); } /* файл теперь - обычный массив */ fwrite(data, 1, st.st_size, stdout); mummap(data, st.st_size); close(fd); ``` Данная простая программа отображает (копирует) файл (`fd`) в область памяти (`data`), а после выводит содержимое этого файла (`fwrite`) в стандартный поток вывода (`stdout`). ### Заголовки ELF Как было сказано ранее, в заголовках файла ELF много полезной информации. И конечно, ее можно посмотреть прямо из файла с помощью специальной команды. ```sh $ readelf -h demo Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x1060 $ readelf -lW demo # столбец PhysAddr опущен Type Offset VirtAddr FileSiz MemSiz Flg Align INTERP 0x000318 0x0000000000000318 0x00001c 0x00001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x000000 0x0000000000000000 0x000628 0x000628 R 0x1000 LOAD 0x001000 0x0000000000001000 0x000175 0x000175 R E 0x1000 LOAD 0x002000 0x0000000000002000 0x0000f4 0x0000f4 R 0x1000 LOAD 0x002db8 0x0000000000003db8 0x000258 0x000260 RW 0x1000 ``` По названию столбцов и строк вполне понятно, какой столбец за что отвечает. Также можно отметить, что последний `LOAD` имеет размер памяти больше размера файла. Эта разница заполняется нулями загрузчиком. Этот фрагмент и есть секция `.bss` (сегмент статических данных). ### Отложенное связывание Из-за особенностей исполнения файла программе не всегда заранее известно, по какому адресу будет находится метка. Для этого существует механизм отложенного связывания. При копировании программы в память для GOT (Global Offset Table) отделяется специальное место, куда по нахождению будут записываться адресы меток. В то же время, в самом коде все метки заменяются заглушками из PLT (Procedure Linkage Table). Таким образом, при первом вызове функции загрузчик найдет адрес необходимой метки и запишет его на нужное место в GOT, а после совершит переход. Все же последующие вызовы функции будут происходить сразу, ведь адрес метки уже известен и записан. При этом важно помнить, что GOT открывается только для чтения. Пример программы с GOT и PLT представлен ниже. ```sh $ objdump -d -M intel demo 0000000000001139
: 1139: push rbp 113a: mov rbp,rsp 113d: lea rax,[rip+0xec4] 1144: mov rdi,rax 1147: call 1030 0000000000001030 : 1030: jmp QWORD PTR [rip+0x2fe2] # ячейка GOT для puts ``` ### Путь от исходника к процессу Таким образом, у каждой сущности в C есть различные отображения как в объектном файле, так и в сегменте ELF, а также в уже запущенном процессе. Для каждой такой сущности важно уметь назвать данные отображения. Ниже представлены пара примеров. Для глобальной переменной `int g = 5` можно сказать, что в объектном файле она будет записана в секции `.data`, в файле ELF она будет записана в виде `LOAD RW`, а в процессе она будет представлена как `rw-p` и путь к файлу. Для переменной `char buf[4096]` можно сказать, что в объектном файле она будет записана в секции `.bss`, в файле ELF она будет записана в виде `LOAD RW (MemSiz > FileSiz)`, а в процессе она будет представлена обнуленные страницы. Для функции `main` можно сказать, что в объектном файле она будет записана в секции `.text`, в файле ELF она будет записана в виде `LOAD R E`, а в процессе она будет представлена как `r-xp` и путь к файлу. Для локальной переменной `int x` можно сказать, что в объектном файле она записана не будет, в файле ELF ее тоже не будет, а в процессе она появится как кадр стека `[stack]`. Для аллокации памяти `malloc(100)` можно сказать, что в объектном файле и в файле ELF ее не будет, а в процессе она появится как часть `[heap]` или анонимный `mmap`. Для `puts` из `libc` можно сказать, что в объектном файле она будет представлена в виде символа с его релокацией, в файле ELF она будет представлена в виде `PLT / GOT, INTERP`, а в процессе она будет представлена `libc.so.6` с правами `r-xp`.