enter image description here


Привет. Меня зовут Марко, и я системный программист в Badoo. Я очень люблю досконально разбираться в том, как работают те или иные вещи, и тонкости работы разделяемых библиотек в Linux не исключение. Я представляю вам перевод именно такого разбора. Приятного чтения.


Я уже описывал необходимость специальной обработки разделяемых библиотек во время загрузки их в адресное пространство процесса. Если кратко, то, когда линкер создает разделяемую библиотеку, он заранее не знает, в каком месте в памяти она будет загружена. Из-за этого делать ссылки на данные и код внутри библиотеки проблематично: непонятно, как создавать ссылку, чтобы она указывала в правильное место после того, как библиотека будет загружена.


В Linux и в ELF существует два главных способа решить эту проблему:


  1. Релокация во время загрузки (load-time relocation).
  2. Код, не зависящий от адреса (position-independent code (PIC)).

Релокацию во время загрузки мы уже рассмотрели. А сейчас рассмотрим второй подход – PIC.


Изначально я планировал рассказывать и о x86, и о x64 (также известной как x86-64), но статья всё росла и росла, и я решил, что нужно быть более практичным. Так что в этой статье я расскажу только о x86, а о x64 речь пойдёт в другой (я надеюсь, гораздо более короткой). Я взял более старую архитектуру x86, так как в отличие от x64 она разрабатывалась без учета PIC, и реализация PIC в ней чуть более сложная.


Проблемы релокации во время загрузки


Как мы увидели в предыдущей статье, релокация во время загрузки – очень простой и прямолинейный метод. И он работает. Но PIC гораздо более популярен на данный момент и является рекомендуемым способом создания разделяемых библиотек. Почему, спросите вы?


У релокации есть несколько проблем: она занимает время и секция text (содержащая машинный код) уже не подходит для разделения между процессами.


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


Ну, и несколько слов про проблему невозможности расшарить text-секцию. Она несколько серьёзнее. Одна из главных задач существования разделяемых библиотек – сэкономить на памяти. Некоторые библиотеки используются несколькими приложениями одновременно. Если text-секция (где находится машинный код) может быть загружена в память только один раз (и затем добавлена в другие процессы с помощью mmap), то можно сэкономить довольно большое количество оперативной памяти. Но это невозможно при использовании релокации, так как text-секция должна быть изменена во время загрузки, чтобы подставить правильные указатели для конкретного процесса. Получается, для каждого процесса, использующего библиотеку, приходится держать полную копию этой библиотеки в памяти [1]. Никакого разделения не происходит.


Более того, держать text-секцию с правами на запись (а она должна быть с правами на запись, чтобы загрузчик мог подкорректировать ссылки) – плохо с точки зрения безопасности. Сделать эксплоит в этом случае гораздо легче.


Как мы увидим далее, PIC практически полностью решает эти проблемы.


Введение


Идея, которая стоит за PIC, очень проста – добавление в код промежуточного слоя для всех ссылок на глобальные объекты и функции. Если по-умному использовать некоторые артефакты процессов линковки и загрузки, можно сделать раздел text действительно не зависящим от адреса, куда его положат; мы сможем отобразить сегмент с помощью mmap на самые разные адреса в адресном пространстве процесса, и нам не понадобится изменять в нём ни один бит. В следующих нескольких разделах я покажу, как можно этого достичь.


Ключевая идея №1. Смещение между секциями text и data


Одна из ключевых идей, на которых основывается PIC, – смещение между секциями text и data, размер которого известен линкеру во время линковки. Когда линкер объединяет несколько объектных файлов, он собирает их секции вместе (к примеру, все секции text объединяются в одну большую секцию text). Таким образом, линкеру известны и размеры секций, и их относительное расположение.


Например, сразу за секцией text может следовать секция data, и в этом случае смещение от любой инструкции из секции text до начала секции data будет равняться размеру секции text минус смещение до данной инструкции от начала секции text. И все эти размеры и смещения известны линкеру.


enter image description here


На диаграмме выше секция code была загружена по некоторому адресу (неизвестному нам на момент линковки) 0xXXXX0000 (иксы буквально означают «всё равно, что там»), а секция data – сразу после нее по адресу 0xXXXXF000. В этом случае, если какая-то инструкция по смещению 0x80 в секции code захочет указать на что-то в секции data, линкер знает относительное смещение (0xEF80 в данном случае) и может добавить его в инструкцию.


Заметьте, что ничего не изменится, если другая секция будет замаплена между секциями code и data или если секция data будет расположена до секции code. Поскольку линкер знает размеры всех секций и решает, куда их положить, идея остаётся неизменной.


Ключевая идея №2. Делаем так, чтобы смещение относительно IP работало на x86


Всё, о чём было рассказано выше, работает, если мы вообще можем воспользоваться относительными смещениями. Ведь ссылки на данные (например, как в инструкции MOV) на x86 требуют абсолютные адреса. Так что же нам делать?


Если у нас есть относительный адрес, а нужен абсолютный, нам не хватает значения указателя команд, или счётчика команд (instruction pointer – IP). Ведь по определению относительный адрес относителен по отношению к IP. На x86 не существует инструкции для получения IP, но мы можем воспользоваться простой хитростью. Вот небольшой ассемблерный псевдокод, который её демонстрирует:


   call TMPLABEL
TMPLABEL:
    pop ebx

Что здесь происходит:


  1. Процессор выполняет инструкцию call TMPLABEL, которая сохраняет адрес следующей инструкции на стеке (pop ebx), а затем прыгает на лейбл.
  2. Поскольку инструкцией у лейбла является pop ebx, она выполняется следующей. Эта инструкция вытаскивает значение со стека в ebx. Но это и есть адрес самой инструкции. Так что ebx, по сути, теперь содержит значение IP.

Глобальная таблица смещений (GOT)


Теперь у нас есть всё, чтобы, наконец, рассказать о том, как реализована не зависящая от позиции адресация на x86. А реализована она с помощью глобальной таблицы смещений (global offset table или GOT).


GOT – это просто таблица с адресами, которая находится в секции data. Предположим, что какая-то инструкция в секции code хочет обратиться к переменной. Вместо того, чтобы обратится к ней через абсолютный адрес (который потребует релокации), она обращается к записи в GOT. Поскольку GOT имеет строго определённое место в секции data, и линкер знает о нём, это обращение тоже является относительным. А запись в GOT уже содержит абсолютный адрес переменной:


enter image description here


В псевдоассемблере это будет выглядеть как замена абсолютной адресации.


// Положим значение переменной в edx
mov edx, [ADDR_OF_VAR]

на адресацию через регистр и небольшую прокладку:


  1. Каким-то образом найдём адрес GOT и положим его в ebx:


    lea ebx, ADDR_OF_GOT


  2. Предположим, адрес переменной (ADDR_OF_VAR) находится по смещению 0x10 в GOT. В этом случае следующая инструкция положит ADDR_OF_VAR в edx:


    mov edx, DWORD PTR [ebx + 0x10]


  3. Наконец, обратимся к переменной и положим её значение в edx:


    mov edx, DWORD PTR [edx]



Таким образом мы избавились от релокации в секции code путём перенаправления обращений через GOT. Но мы также создали релокацию в секции data. Почему? Потому что GOT в любом случае должна содержать абсолютный адрес переменной, чтобы вышеописанная схема работала. Так где же профит?


А профита, оказывается, много. Релокация в data-секции сопряжена с гораздо меньшим количеством проблем, чем релокация в секции code. Этому есть две причины, соответствующие двум проблемам, возникающим при релокации во время загрузки.


  1. Релокации в секции code необходимы для каждого обращения к переменной, тогда как релокации в GOT – всего лишь для каждой переменной. Обращений к переменным обычно заметно больше, чем переменных, так что это более эффективно.
  2. Секция data уже доступна для записи и не расшарена между процессами, так что релокации в ней ничему не вредят. А вот тот факт, что в секции code больше не будет релокаций, позволяет сделать эту секцию доступной только для чтения и расшарить её между процессами.

PIC с обращениями через GOT (пример)


Сейчас я покажу полноценный пример, который демонстрирует механику PIC:


int myglob = 42;
int ml_func(int a, int b)
{
    return myglob + a + b;
}

Этот блок кода будет скомпилирован в разделяемую библиотеку (используя флаги -fpic и -shared) libmlpic_dataonly.so.


Давайте посмотрим, что сгенерировал компилятор, фокусируясь на функции ml_func:


0000043c <ml_func>:
43c:   55                      push   ebp
 43d:   89 e5                   mov    ebp,esp
 43f:   e8 16 00 00 00          call   45a <__i686.get_pc_thunk.cx>
 444:   81 c1 b0 1b 00 00       add    ecx,0x1bb0
 44a:   8b 81 f0 ff ff ff       mov    eax,DWORD PTR [ecx-0x10]
 450:   8b 00                   mov    eax,DWORD PTR [eax]
 452:   03 45 08                add    eax,DWORD PTR [ebp+0x8]
 455:   03 45 0c                add    eax,DWORD PTR [ebp+0xc]
 458:   5d                      pop    ebp
 459:   c3                      ret

0000045a <__i686.get_pc_thunk.cx>:
 45a:   8b 0c 24                mov    ecx,DWORD PTR [esp]
 45d:   c3                      ret

Я буду указывать на адрес инструкций (самое левое число в выводе). Этот адрес – это смещение от того адреса, на который была замаплена библиотека.


  • На 43f адрес следующей инструкции кладётся в ecx тем самым способом, который был описан выше в разделе «Ключевая идея №2».
  • На 444 известное смещение от инструкции до GOT кладётся в ecx. Таким образом, ecx теперь служит указателем на GOT.
  • На 44a берётся значение из [ecx — 0x10], являющееся записью из GOT, и кладётся в eax. Это адрес переменной myglob.
  • На 450 мы уже берём значение myglob и кладём в eax.
  • Далее параметры a и b прибавляются к myglob, и значение возвращается (таким образом, что мы оставляем его в eax).

Также с помощью readelf -S можно узнать, куда линкер положил GOT:


Section Headers:
  [Nr] Name     Type            Addr     Off    Size   ES Flg Lk Inf Al
  <snip>
  [19] .got     PROGBITS        00001fe4 000fe4 000010 04  WA  0   0  4
  [20] .got.plt PROGBITS        00001ff4 000ff4 000014 04  WA  0   0  4
  <snip>

Давайте достанем калькулятор и проверим компилятор. Ищем myglob. Как я уже упоминал выше, вызов __i686.get_pc_thunk.cx кладёт адрес следующей инструкции в ecx. Это 0x444 [2]. Следующая инструкция прибавляет к нему 0x1bb0 – и в результате в ecx мы получим 0x1ff4. Наконец, чтобы получить элемент GOT, который содержит адрес myglob, делаем [ecx — 0x10]. Элемент, таким образом, имеет адрес 0x1fe4, и это первый элемент в GOT, согласно заголовку секции.


Почему там ещё одна секция, имя которой начинается с .got, я расскажу позже [3]. Заметьте, что компилятор решил положить в ecx адрес после GOT, а затем использовать отрицательное смещение. Это нормально, если в конечном итоге всё сходится. И пока что всё сходится.


Но есть одна вещь, которой нам пока не хватает. Как именно адрес myglob оказывается в элементе GOT по адресу 0x1fe4? Вспомните, что я упоминал релокацию, так что давайте её найдём:


> readelf -r libmlpic_dataonly.so

Relocation section '.rel.dyn' at offset 0x2dc contains 5 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00002008  00000008 R_386_RELATIVE
00001fe4  00000406 R_386_GLOB_DAT    0000200c   myglob
<snip>

Вот она, релокация для myglob, указывающая на адрес 0x1fe4, как мы и ожидали. Релокация имеет тип R_386_GLOB_DAT, который просто говорит загрузчику: «Положи реальное значение симпола (то есть его адрес) по данному смещению». Теперь всё понятно. Осталось только посмотреть как, это всё выглядит при загрузке библиотеки. Мы можем это сделать, создав простой бинарник (driver), который линкуется к libmlpic_dataonly.so и вызывает ml_func, и запустив его через gdb.


> gdb driver
[...] skipping output
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
[...]
(gdb) run
Starting program: [...]pic_tests/driver

Breakpoint 1, ml_func (a=1, b=1) at ml_reloc_dataonly.c:5
5         return myglob + a + b;
(gdb) set disassembly-flavor intel
(gdb) disas ml_func
Dump of assembler code for function ml_func:
   0x0013143c <+0>:   push   ebp
   0x0013143d <+1>:   mov    ebp,esp
   0x0013143f <+3>:   call   0x13145a <__i686.get_pc_thunk.cx>
   0x00131444 <+8>:   add    ecx,0x1bb0
=> 0x0013144a <+14>:  mov    eax,DWORD PTR [ecx-0x10]
   0x00131450 <+20>:  mov    eax,DWORD PTR [eax]
   0x00131452 <+22>:  add    eax,DWORD PTR [ebp+0x8]
   0x00131455 <+25>:  add    eax,DWORD PTR [ebp+0xc]
   0x00131458 <+28>:  pop    ebp
   0x00131459 <+29>:  ret
End of assembler dump.
(gdb) i registers
eax            0x1    1
ecx            0x132ff4       1257460
[...] skipping output

Дебаггер вошёл в ml_func и остановился на IP 0x0013144a [4]. Мы видим, что ecx имеет значение 0x132ff4 (адрес инструкции плюс 0x1bb0). Заметьте, что в данный момент, во время работы, это всё абсолютные адреса – библиотека уже загружена в адресное пространство процесса.


Так, элемент GOT с myglob должен быть на [ecx — 0x10]. Давайте проверим:


(gdb) x 0x132fe4
0x132fe4:     0x0013300c

То есть мы ожидаем что 0x0013300c – это адрес myglob. Проверяем:


(gdb) p &myglob
$1 = (int *) 0x13300c

Так и есть!


Вызов функций в PIC


Итак, мы увидели, как работает PIC для адресов на данные. Но что насчёт функций? Теоретически тот же самый способ будет работать и для функций. Вместо того, чтобы call содержал адрес функции, пусть он содержит адрес элемента из GOT, а элемент уже будет заполнен при загрузке.


Но вызов функций в PIC работает не так, в реальности всё несколько сложнее. Прежде чем я объясню, как именно, в двух словах расскажу о мотивации выбора такого механизма.


Оптимизация: «ленивый» байндинг


Когда разделяемая библиотека использует какую-либо функцию, реальный адрес этой функции ещё не известен. Определение реального адреса называется байндинг (binding), и это то, что загрузчик делает, когда загружает разделяемую библиотеку в адресное пространство процесса. Байндинг не тривиален, так как загрузчику нужно искать символы функций в специальных таблицах [5].


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


Чтобы ускорить этот процесс, и была придумана хитрая схема «ленивого» байндинга. «Ленивая» — это общий термин оптимизаций в IT, когда какая-либо работа откладывается до самого последнего момента. Смысл этой оптимизации в том, чтобы не делать лишнюю работу, которая может быть и не нужна. Примерами такой «ленивой» оптимизации являются механизм copy-on-write и «ленивые» вычисления.


«Ленивая» схема реализована путём добавления ещё одного уровня адресации – PLT.


Procedure Linkage Table (PLT)


PLT – это часть секции text в бинарнике, состоящая из набора элементов (один элемент на одну внешнюю функцию, которую вызывает библиотека). Каждый элемент в PLT – это небольшой кусок выполняемого машинного кода. Вместо вызова функции напрямую вызывается кусок кода из PLT, который уже сам вызывает функцию. Такой подход часто называют «трамплином». Каждый элемент из PLT имеет собственный элемент в GOT, который содержит реальное смещение для функции. После того как загрузчик определит её, конечно.


На первый взгляд всё довольно запутанно, но я надеюсь, что скоро всё станет более понятно – в следующих разделах я расскажу о деталях с диаграммами.


Как я уже упоминал, PLT позволяет делать «ленивое» определение адресов функций. В тот момент, когда разделяемая библиотека впервые загружена, реальные адреса функций ещё не определены:


enter image description here


Объяснение:


  • В коде вызывается функция func. Компилятор переводит этот вызов в вызов func@plt, который является одним из элементов PLT.
  • PLT состоит из специального первого элемента, за которым следуют несколько идентично структурированных элементов, один для каждой функции.
  • Все элементы PLT, кроме первого, содержат следующие части:
    • прыжок по адресу, который указан в соответствующем элементе из GOT;
    • подготовка аргументов для метода «определения»;
    • вызов метода «определения», который находится в первом элементе PLT.
  • Первый элемент PLT – вызов метода «определения», который находится в коде самого загрузчика [6]. Этот метод определяет реальный адрес функции (подробнее об этом – ниже).
  • Перед тем как реальный адрес функции будет определён, N-ный элемент GOT просто указывает на адрес после прыжка. Именно поэтому стрелка на диаграмме выделена другим цветом – это не реальный прыжок, а просто указатель.

Что происходит после того, как func вызвана первый раз:


  • Вызывается PLT[n] – и происходит прыжок по адресу из GOT[n].
  • Этот адрес указывает обратно на PLT[n], на место, где происходит подготовка аргументов для метода «определения».
  • Метод вызывается.
  • Метод определяет реальный адрес функции func, кладёт его в GOT[n] и вызывает func.

После первого раза диаграмма выглядит немного по-другому:


enter image description here


Заметьте, что GOT[n] теперь указывает на реальную func [7] вместо того чтобы указывать обратно в PLT. Так что когда функция вызывается повторно, происходит следующее:


  • Вызывается PLT[n] и происходит прыжок по адресу из GOT[n].
  • GOT[n] указывает на func, так что func просто вызывается.

Другими словами, func теперь попросту вызывается без использования метода «определения» и без лишнего прыжка. Этот механизм позволяет делать «ленивое» определение адресов функций и не делать никакого определения для тех функций, которые не вызываются.


Обратите внимание, библиотека при этом абсолютно не зависит от адреса, по которому она будет загружена, ведь единственное место, где используется абсолютный адрес, – это GOT, а она находится в секции data и будет релоцирована во время загрузки загрузчиком. Даже PLT не зависит от адреса загрузки, так что она может находиться в секции text, доступной только для чтения.


Я не углубляюсь в детали работы метода «определения», но это и не так важно. Метод – это просто кусок низкоуровневого кода в загрузчике, который делает своё дело. Аргументы, которые готовятся перед вызовом метода, дают ему знать, адрес какой функции необходимо определить и куда следует поместить результат.


PIC с вызовом функции через PLT и GOT (пример)


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


Вот код разделяемой библиотеки:


int myglob = 42;

int ml_util_func(int a)
{
    return a + 1;
}

int ml_func(int a, int b)
{
    int c = b + ml_util_func(a);
    myglob += c;
    return b + myglob;
}

Этот код будет скомпилирован в libmlpic.so, и мы сфокусируемся на вызове ml_util_func из ml_func. Дизассемблируем ml_func:


00000477 <ml_func>:
 477:   55                      push   ebp
 478:   89 e5                   mov    ebp,esp
 47a:   53                      push   ebx
 47b:   83 ec 24                sub    esp,0x24
 47e:   e8 e4 ff ff ff          call   467 <__i686.get_pc_thunk.bx>
 483:   81 c3 71 1b 00 00       add    ebx,0x1b71
 489:   8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
 48c:   89 04 24                mov    DWORD PTR [esp],eax
 48f:   e8 0c ff ff ff          call   3a0 <ml_util_func@plt>
 <... snip more code>

Интересная часть – вызов ml_util_func@plt. Заметьте также, что адрес GOT находится в ebx. Вот как выглядит ml_util_func@plt (находится в секции .plt с правами на выполнение):


000003a0 <ml_util_func@plt>:
 3a0:   ff a3 14 00 00 00       jmp    DWORD PTR [ebx+0x14]
 3a6:   68 10 00 00 00          push   0x10
 3ab:   e9 c0 ff ff ff          jmp    370 <_init+0x30>

Вспомните, что каждый элемент PLT состоит из трёх частей:


  • Прыжок по адресу из GOT (это прыжок на [ebx+0x14]).
  • Подготовка аргументов для метода «определения».
  • Вызов метода «определения».

Метод «определения» (элемент 0 в PLT) находится по адресу 0x370, но он нас сейчас не интересует. Гораздо интересно посмотреть, что содержит GOT. Для этого нам снова понадобится калькулятор.


Трюк для получения текущего IP в ml_func был сделан по адресу 0x483, и к нему мы прибавили 0x1b71. Так что GOT находится по адресу 0x1ff4. Мы можем увидеть, что там, с помощью readelf [8]:


> readelf -x .got.plt libmlpic.so

Hex dump of section '.got.plt':
  0x00001ff4 241f0000 00000000 00000000 86030000 $...............
  0x00002004 96030000 a6030000                   ........

Запись в GOT для ml_util_func@plt, похоже, находится по смещению +0x14, или 0x2008. Судя по выводу выше, слово по этому адресу имеет значение 0x3a6, а это адрес push-инструкции в ml_util_func@plt.


Чтобы помочь загрузчику сделать своё дело, в GOT добавлена запись с адресом места в GOT, куда нужно записать адрес ml_util_func:


> readelf -r libmlpic.so
[...] snip output

Relocation section '.rel.plt' at offset 0x328 contains 3 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00002000  00000107 R_386_JUMP_SLOT   00000000   __cxa_finalize
00002004  00000207 R_386_JUMP_SLOT   00000000   __gmon_start__
00002008  00000707 R_386_JUMP_SLOT   0000046c   ml_util_func

Последняя строчка означает, что загрузчику нужно положить адрес символа ml_util_func в 0x2008 (а это, в свою очередь, элемент GOT для данной функции).


Было бы классно увидеть, как происходит эта модификация в GOT. Для этого воспользуемся GDB ещё раз.


> gdb driver
[...] skipping output
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
Breakpoint 1 at 0x80483c0
(gdb) run
Starting program: /pic_tests/driver

Breakpoint 1, ml_func (a=1, b=1) at ml_main.c:10
10        int c = b + ml_util_func(a);
(gdb)

Мы сейчас находимся перед первым вызовом ml_util_func. Вспомните, что адрес GOT находится в ebx. Посмотрим, что там:


(gdb) i registers ebx
ebx            0x132ff4

Смещение для нужного нам элемента находится по адресу [ebx+0x14]:


(gdb) x/w 0x133008
0x133008:     0x001313a6

Да, заканчивается на 0x3a6. Выглядит правильно. Теперь давайте шагнём до вызова ml_util_func и посмотрим ещё раз:


(gdb) step
ml_util_func (a=1) at ml_main.c:5
5         return a + 1;
(gdb) x/w 0x133008
0x133008:     0x0013146c

Значение по адресу 0x133008 поменялось. Получается, что 0x0013146c – реальный адрес ml_util_func, который был положен туда загрузчиком:


(gdb) p &ml_util_func
$1 = (int (*)(int)) 0x13146c <ml_util_func>

Как мы и ожидали.


Управляем определением адреса загрузчиком


Сейчас самое время упомянуть о том, что процесс «ленивого» определения адреса, который осуществляется загрузчиком, может быть настроен несколькими переменными окружения (а также соответствующими аргументами для линкера ld). Иногда эти настройки могут быть полезны для дебаггинга или каких-то специальных требований к производительности.


Переменная LD_BIND_NOW, когда она определена, говорит загрузчику определять все адреса при старте, а не «лениво». Её работу можно проверить, посмотрев вывод gdb для примера выше в том случае, когда она задана. Мы увидим, что элемент из GOT для ml_util_func содержит реальный адрес функции ещё до первого вызова функции.


Напротив, LD_BIND_NOT говорит загрузчику не обновлять GOT никогда. То есть каждый вызов функции в этом случае будет идти через метод «определения».


Загрузчик настраивается и некоторыми другими флагами. Я рекомендую изучить man ld.so. Там много интересной информации.


Стоимость PIC


Мы начали разговор с проблемы релокации во время работы и решения этой проблемы PIC. Но сам PIC, увы, тоже не без проблем. Одна из них – стоимость лишней косвенной адресации. Это лишнее обращение к памяти при каждом обращении к глобальной переменной или функции. «Масштаб бедствия» зависит от компилятора, процессорной архитектуры и собственно приложения.


Другая, менее очевидная, проблема – использование дополнительных регистров для реализации PIC. Чтобы не определять адрес GOT слишком часто, компилятору имеет смысл сгенерировать код, который будет хранить адрес в регистре (например, ebx). Но это значит, что целый регистр уходит только на GOT. Для RISC-архитектур, у которых обычно много регистров общего пользования, это не такая уж большая проблема, чего не скажешь об архитектурах типа x86, у которых мало доступных регистров. Использование PIC означает на один регистр меньше, а значит, нужно будет делать больше обращений к памяти.


Заключение


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


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


Однако, несмотря на недостатки, PIC становится всё более популярным подходом. Некоторые неIntel-архитектуры, такие как SPARC64, требуют обязательного использования PIC для разделяемых библиотек, а многие другие (например, ARM) – имеют IP-зависимую адресацию, чтобы сделать PIC более эффективным. И то, и другое верно для наследницы x86 – x64.


Мы не фокусировались на проблемах производительности и архитектурах процессора. Моя задача была в том, чтобы рассказать, как работает PIC. Если объяснение было недостаточно «прозрачным», дайте мне знать в комментариях – и я постараюсь дать больше информации.


[1] Если, конечно, все до одного приложения не загрузят эту библиотеку по одному и тому же виртуальному адресу. Но так в Linux обычно не делается.

[2] 0x444 (и все другие адреса, которые мы упоминаем здесь) являются относительными адресам по отношению к адресу, куда была загружена библиотека. Этот адрес неизвестен до тех пор, пока библиотека не будет загружена. Заметьте, что это нормально, так как код работает только с относительными адресами.

[3] Внимательный читатель может задуматься, почему .got – это отдельная секция: разве я только что не показал, что он находится в data? На практике это так. Я не хочу вдаваться в различия между секциями ELF и сегментами, так как это тема для отдельной беседы. Но, если кратко, любое количество секций data может быть определено в библиотеке и замаплено в кусок памяти, доступный только для чтения. Это не имеет значения, если ELF-файл корректен. Разделение data-секции логическими кусками добавляет модульности и облегчает работу линкера.

[4] Заметьте, что gdb пропустил кусок, где ecx получает своё значение. Это произошло потому, что предполагается, что это пролог к любой функции (но настоящая причина связана с тем, как gcc структурирует свою debug-информацию, конечно). В функции может быть несколько ссылок на глобальные данные и функции, и для них всех может использоваться один и тот же регистр с адресом GOT.

[5] Объекты разделяемых библиотек ELF на самом деле идут со специальной секцией/ хеш-таблицей для этого.

[6] Динамический загрузчик в Linux – это просто ещё одна разделяемая библиотека, которая загружается в адресное пространство всех работающих процессов.

[7] Я положил func в отдельную секцию, хотя в теории это может быть та секция, где происходит вызов func (то есть в той же самой разделяемой библиотеке). Раздел «Дополнительно» в этой статье содержит информацию о том, почему вызов внешней функции из той же самой разделяемой библиотеки требует PIC (или релокации).

[8] Вспомните, что в первом примере с доступом к данным я обещал объяснить, почему у нас две секции GOT в объектном файле: .got и .got.plt. Сейчас уже должно быть ясно, что это сделано для удобства разделения кусков, требующих только GOT, и кусков, требующих ещё и PLT. Это является причиной того, почему смещение к GOT вычисляется на конец GOT. Таким образом, отрицательное смещение от этого адреса ведёт нас в .got, а положительное – в .got.plt. Это удобно, но совершенно не обязательно – мы могли бы положить всё в одну .got-секцию.
Поделиться с друзьями
-->

Комментарии (4)


  1. Halt
    15.03.2017 11:59
    +6

    Все хорошо, только записи в PLT традиционно называют «трамплином» а не «батутом».


    1. mkevac
      15.03.2017 15:28
      +1

      Вы правы! Спасибо!


  1. ser-mk
    16.03.2017 02:50

    Получается для вызова ф-ций static из библиотеки, PLT не требуется, достаточно только GOT?


    1. mkevac
      25.03.2017 00:13

      Если функция из библиотеки вызывает static функцию из библиотеки, то даже GOT не нужен, на сколько я понимаю. Достаточно offset-а до функции, а он известен.