Files
Info/2 курс/Языки программирования/Память и модели вычислений.md
T

48 KiB
Raw Blame History

Содержание

  • #Введение
  • #Область видимости
    • #Виды
    • #Время жизни
  • #Модель памяти процессора
  • #Стек вызовов
    • #Соглашение о вызовах
    • #Вызов функции
    • #Кадр стека
  • #Виртуальная память
    • #Страницы
      • #Страничное нарушение
    • #mmap
    • #Формат ELF
      • #Из файла в процесс
    • #История
  • #Практика
    • #Расположение переменных
    • #Карта памяти процесса
    • #Вызов функции глазами компилятора
    • #Отображение памяти mmap
    • #Заголовки ELF
    • #Отложенное связывание
    • #Путь от исходника к процессу

Введение

В лекции 3 был подробно описан процесс превращения исходного текста программы в полноценный исполняемый файл. Несмотря на весь этот процесс, часть символов этого файла остается неразрешенными до запуска программы.

Очевидно, исполняемый файл и процесс отличаются друг от друга: первый - всего лишь байты на диске, в то время как второй - полноценный динамический набор из различных регистров, стека, кучи и открытых файлов. Но что именно представляется из себя этот запуск программы?

Область видимости

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

Иными словами, область видимости отвечает за то, чтобы каждому имени символа в программе была сопоставлена определенная переменная программы с ее адресом.

Виды

Очевидно, у область видимости бывают различные виды. Всего их два: лексическая (или статическая) и динамическая.

Лексическая область видимости ищет имя символа в тексте программы на основе вложенности блоков. То есть, от места обращения к символу программа будет проходить от уровня вложенности, где был вызван символ, все выше и выше, до тех пор, пока необходимый символ не будет найден. Таким образом, адрес вызываемого символа будет известен уже сразу при компиляции программы.

Динамическая область видимости ищет имя символа на основе цепочки активных вызовов. То есть, такая область видимости возьмет первое попавшееся значение (точнее последнее установленное значение) переменной по имени и передаст именно ее. Таким образом, результат работы программы с такой областью видимости будет зависеть от окружения, в котором данный символ был вызван.

Большинство языков программирования (к примеру, 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. Данная машина имела несколько страниц памяти с разным временем доступа до каждого из них, и при этом видела каждый из этих листов. Поэтому она могла перемещать данные и таким образом динамически менять время доступа до разных разделов памяти, в зависимости от того, что приоритетнее.

Через пару лет, в 1965 году, впервые появился прообраз mmap - операционная система Multics считала все файлы как часть адресного пространства.

Ближе к концу 1970-ых годов страничная виртуальная память уже стала нормой в Unix.

В конце 1980-ых годов происходит выход System V Release 4, в котором впервые используется формат ELF. Раньше для тех же целей использовались a.out и COFF.

В 2000-ых впервые в явном виде описывается соглашение о вызовах, заданное в System V AMD64 с помощью ABI (Application Binary Interface).

Иными словами, все необходимые технологии были придуманы достаточно давно. Все, что используется на данный момент в языке 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 - номер процесса). Ниже представлен пример такого вывода.

$ 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.

long sum3(long a, long b, long c) {
	long s = a + b;
	return s + c;
}

Компилятор gcc позволяет скомпилировать файл в объектный, а после из него получить инструкции ассемблера. Это можно сделать следующим образом.

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 для отображения файла в память и простейшей работы с ним.

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 много полезной информации. И конечно, ее можно посмотреть прямо из файла с помощью специальной команды.

$ 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 представлен ниже.

$ 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.