343 lines
48 KiB
Markdown
343 lines
48 KiB
Markdown
## Содержание
|
||
- [[#Введение]]
|
||
- [[#Область видимости]]
|
||
- [[#Виды]]
|
||
- [[#Время жизни]]
|
||
- [[#Модель памяти процессора]]
|
||
- [[#Стек вызовов]]
|
||
- [[#Соглашение о вызовах]]
|
||
- [[#Вызов функции]]
|
||
- [[#Кадр стека]]
|
||
- [[#Виртуальная память]]
|
||
- [[#Страницы]]
|
||
- [[#Страничное нарушение]]
|
||
- [[#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
|
||
```
|
||
|
||
В результате, получится следующий набор инструкций.
|
||
```
|
||
<sum3>:
|
||
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 <main>:
|
||
1139: push rbp
|
||
113a: mov rbp,rsp
|
||
113d: lea rax,[rip+0xec4]
|
||
1144: mov rdi,rax
|
||
1147: call 1030 <puts@plt>
|
||
|
||
0000000000001030 <puts@plt>:
|
||
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`. |