- PVSM.RU - https://www.pvsm.ru -

Position-independent code (PIC) в разделяемых библиотеках на x64

enter image description here

Привет, я все еще Марко и все еще системный программист в Badoo. На прошлой неделе я опубликовал перевод [1] о PIC в шареных библиотеках, но есть вторая часть – про разделяемые библиотеки на х64, поэтому решил не оставлять дело незаконченным.

Предыдущая статья объясняла, как работает адресация, не зависящая от адреса (Position Independent Code, PIC) на примерах, скомпилированных под архитектуру x86. Я обещал рассказать и о PIC на x64 [1] [2] в отдельной статье. Вот и она. В этой статье будет гораздо меньше деталей, так как подразумевается, что вы уже понимаете, как PIC работает в теории. По сути идея одинакова для обеих архитектур, но некоторые детали отличаются из-за их особенностей.

Адресация относительно RIP

На x86-обращения к функциям (с помощью инструкции call) используют смещения относительно IP, однако обращения к данным (с помощью инструкции mov) поддерживают только абсолютные адреса. Из предыдущей статьи вы знаете, что это делает PIC чуть менее эффективным, так как PIC по сути своей требует, чтобы все смещения были относительны. Абсолютные адреса и независимость от адреса не идут рука об руку.

x64 решает эту проблему с помощью нового типа адресации относительно RIP, которая является адресацией по умолчанию для всех 64-битных mov-инструкций, обращающихся к памяти (она используется и для других инструкций, таких как, например, lea). Вот цитата из «Intel Architecture Manual vol 2a» (одного из основных документов по Intel-архитектуре):

RIP (relative instruction-pointer или относительно указателя на текущую инструкцию) – новый тип адресации, реализованный в 64-битном режиме. Окончательный адрес формируется путем добавления смещения к 64-битному указателю к следующей инструкции.

Смещение, используемое в RIP-относительном режиме имеет размер 32 бита, так как оно может использоваться как в отрицательную, так и в положительную сторону. Получается, что максимальное смещение относительно RIP, которое поддерживается в этом режиме адресации, составляет ± 2GB.

x64 PIC с обращением к данным. Пример

Для более простого сравнения я буду использовать тот же самый пример на C, как и в примере в прошлой статье:

int myglob = 42;

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

Давайте посмотрим на дизассемблированный вид ml_func:

00000000000005ec <ml_func>:
 5ec:   55                      push   rbp
 5ed:   48 89 e5                mov    rbp,rsp
 5f0:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
 5f3:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
 5f6:   48 8b 05 db 09 20 00    mov    rax,QWORD PTR [rip+0x2009db]
 5fd:   8b 00                   mov    eax,DWORD PTR [rax]
 5ff:   03 45 fc                add    eax,DWORD PTR [rbp-0x4]
 602:   03 45 f8                add    eax,DWORD PTR [rbp-0x8]
 605:   c9                      leave
 606:   c3                      ret

Самая интересная инструкция здесь находится по адресу 0x5f6: она размещает адрес myglob в rax, обращаясь к элементу из GOT. Как мы видим, она использует RIP-относительную адресацию. Поскольку она относительная по отношению к адресу следующей инструкции, мы на самом деле получаем 0x5fd + 0x2009db = 0x200fd8. Таким образом элемент GOT, содержащий в себе адрес myglob, находится по адресу 0x200fd8. Проверим, насколько наши расчеты далеки от реальности:

$ readelf -S libmlpic_dataonly.so
There are 35 section headers, starting at offset 0x13a8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align

[...]
  [20] .got              PROGBITS         0000000000200fc8  00000fc8
       0000000000000020  0000000000000008  WA       0     0     8
[...]

GOT начинается на 0x200fc8, так что myglob находится в третьем элементе. И мы можем посмотреть релокацию, добавленную в бинарник для myglob:

$ readelf -r libmlpic_dataonly.so

Relocation section '.rela.dyn' at offset 0x450 contains 5 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
[...]
000000200fd8  000500000006 R_X86_64_GLOB_DAT 0000000000201010 myglob + 0
[...]

Мы видим запись о релокации для адреса 0x200fd8, говорящую линкеру добавить адрес myglob туда, когда он будет знать окончательный адрес для символа.
Теперь должно быть понятно, как адрес myglob получается в коде. Следующая инструкция по адресу 0x5fd разыменовывает указатель для того, чтобы получить окончательный адрес, и кладет его в eax [2] [3].

x64 PIC с обращением к функциям. Пример

Давайте теперь посмотрим, как вызовы функций работают с PIC на x64. И воспользуемся таким же примером, как в прошлой статье:

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;
}

Дизассемблируя ml_func, мы получаем:

000000000000064b <ml_func>:
 64b:   55                      push   rbp
 64c:   48 89 e5                mov    rbp,rsp
 64f:   48 83 ec 20             sub    rsp,0x20
 653:   89 7d ec                mov    DWORD PTR [rbp-0x14],edi
 656:   89 75 e8                mov    DWORD PTR [rbp-0x18],esi
 659:   8b 45 ec                mov    eax,DWORD PTR [rbp-0x14]
 65c:   89 c7                   mov    edi,eax
 65e:   e8 fd fe ff ff          call   560 <ml_util_func@plt>
 [... snip more code ...]

Вызов, как и прежде, выглядит как ml_util_func@plt. Посмотрим, что там:

0000000000000560 <ml_util_func@plt>:
 560:   ff 25 a2 0a 20 00       jmp    QWORD PTR [rip+0x200aa2]
 566:   68 01 00 00 00          push   0x1
 56b:   e9 d0 ff ff ff          jmp    540 <_init+0x18>

Получается, что GOT-запись, содержащая реальный адрес ml_util_func, находится по адресу 0x200aa2 + 0x566 = 0x201008. И запись о релокации – тоже на месте, как и ожидается:

$ readelf -r libmlpic.so

Relocation section '.rela.dyn' at offset 0x480 contains 5 entries:
[...]

Relocation section '.rela.plt' at offset 0x4f8 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
[...]
000000201008  000600000007 R_X86_64_JUMP_SLO 000000000000063c ml_util_func + 0

Производительность

В обоих примерах можно увидеть, что PIC на x64 требует меньше инструкций, чем такой же код на x86. На x86 адрес GOT грузится в регистр (ebx согласно соглашению) в две стадии: сначала мы адрес инструкции получаем специальным вызовом, а затем добавляем смещение до GOT. Ни одна из этих стадий не нужна на x64, так как относительное смещение до GOT известно линкеру и он просто может его использовать в инструкции с RIP-относительной адресацией.

Когда мы вызываем функцию, также не нужно готовить адрес GOT в ebx для трамплина, в отличие от x86, так как трамплин просто обращается к элементу в GOT напрямую через RIP-относительную адресацию.

Получается, что PIC на x64 все еще требует дополнительные инструкции по сравнению с кодом без PIC, но оверхед меньше. Затраты, которые заключалась в использовании целого регистра для хранения указателя на GOT, также больше не нужны. RIP-относительная адресация не требует дополнительных регистров [3] [4]. В итоге оверхед для PIC на x64 сильно меньше по сравнению с x86 и это делает PIC еще более популярным. Настолько популярным, что PIC является выбором по умолчанию при создании разделяемых библиотек на этой архитектуре.

Для любопытных: не PIC-код на x64

GCC не только подталкивает вас использовать PIC для разделяемых библиотек на x64, а он требует этого по умолчанию. К примеру, если мы скомпилируем первый пример без -fpic [4] [5] и попробуем собрать разделяемую библиотеку с -shared, мы получим ошибку от линкера:

/usr/bin/ld: ml_nopic_dataonly.o: relocation R_X86_64_PC32 against symbol `myglob' can not be used when making a shared object; recompile with -fPIC
/usr/bin/ld: final link failed: Bad value
collect2: ld returned 1 exit status

Что происходит? Давайте посмотрим на дизассемблированный вид ml_nopic_dataonly.o [5] [6]:

0000000000000000 <ml_func>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
   a:   8b 05 00 00 00 00       mov    eax,DWORD PTR [rip+0x0]
  10:   03 45 fc                add    eax,DWORD PTR [rbp-0x4]
  13:   03 45 f8                add    eax,DWORD PTR [rbp-0x8]
  16:   c9                      leave
  17:   c3                      ret

Обратите внимание, как происходит обращение к myglob здесь в инструкции по адресу 0xa. Ожидается, что линкер поместит реальный адрес на myglob в операнд (то есть без GOT):

$ readelf -r ml_nopic_dataonly.o

Relocation section '.rela.text' at offset 0xb38 contains 1 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
00000000000c  000f00000002 R_X86_64_PC32     0000000000000000 myglob - 4
[...]

А вот релокация R_X86_64_PC32, на которую жаловался линкер. Он не может слинковать объект с такой релокацией в разделяемую библиотеку. Почему? Потому что смещение, которое мы делаем относительно rip, должно умещаться в 32 бита, а мы не может утверждать, что этого всегда хватит. Ведь у нас полноценная 64-битная архитектура с огромным адресным пространством. Символ в конце концов может оказаться в какой-нибудь разделяемой библиотеке, которая находится настолько далеко, и нам не хватит 32 бит, чтобы к нему обратиться. Так что релокация R_X86_64_PC32 не является подходящей для разделяемых библиотек на x64.

Но можем ли мы каким-либо образом создать не PIC-код на x64? Можем! Нам нужно сказать компилятору, чтобы он использовал так называемую «модель с большим кодом» (large code model). Это делается с помощью добавления флага -mcmodel=large. Тема моделей кода безусловно интересна, но ее объяснение уведет нас слишком далеко от цели этой статьи [6] [7]. Так что я кратко скажу, что модель кода – это что-то вроде соглашения между программистом и компилятором, в котором программист дает некие обещания компилятору относительно того, какой размер смещений будет использоваться в программе. В обмен компилятор сможет сгенерировать более качественный код.

Получается, для того, чтобы компилятор сгенерировал не PIC-код на x64, который бы устроил линкер, подходит только «модель большого кода» как самая нетребовательная. Помните мое объяснение, почему простая релокация недостаточно хороша на x64 из-за того, что смещение может быть больше 32 бит? Вот «модель большого кода» просто-напросто ничего не предполагает и использует самое большое 64-битное смещение для всех обращений к данным. Это позволяет говорить, что релокации безопасны, и не использовать PIC-код на x64. Давайте посмотрим на дизассемблированный вид первого примера, собранного без -fpic и с -mcmodel=large:

0000000000000000 <ml_func>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
   a:   48 b8 00 00 00 00 00    mov    rax,0x0
  11:   00 00 00
  14:   8b 00                   mov    eax,DWORD PTR [rax]
  16:   03 45 fc                add    eax,DWORD PTR [rbp-0x4]
  19:   03 45 f8                add    eax,DWORD PTR [rbp-0x8]
  1c:   c9                      leave
  1d:   c3                      ret

Инструкция по адресу 0xa кладет адрес на myglob в eax. Заметьте, что ее аргумент пока еще равен нулю, и это говорит о том, что здесь следует ожидать релокации. К тому же у нее полноценный 64-битный аргумент. Абсолютный, а не RIP-относительный [7] [8]. Ну и заметьте, что две инструкции здесь необходимы, чтобы положить значение myglob в eax. Это одна из причин, почему «модель большого кода» менее эффективна по сравнению с альтернативами.
Посмотрим теперь на релокации:

$ readelf -r ml_nopic_dataonly.o

Relocation section '.rela.text' at offset 0xb40 contains 1 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
00000000000c  000f00000001 R_X86_64_64       0000000000000000 myglob + 0
[...]

Тип релокации поменялся на R_X86_64_64. Это релокация с абсолютным адресом, имеющая 64-битное значение. Линкер теперь счастлив и с радостью согласится слинковать этот объект в разделяемую библиотеку.

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

Еще вопрос состоит в том, почему нет никаких проблем с PIC при использовании «модели маленького кода»? Причина в том, что GOT всегда находится в той же разделяемой библиотеке, где и код, который к нему обращается. И, если разделяемая библиотека не настолько большая, чтобы уместиться в 32-битное адресное пространство, не должно быть никаких проблем с адресацией. Настолько большие разделяемые библиотеки маловероятны, но в случае, если у вас есть такая, в ABI для AMD64 есть «модель большого кода с PIC».

Заключение

Эта статья дополняет предыдущую [1], рассказывая, как работает PIC на x64-архитектуре. Эта архитектура использует новую модель адресации, которая помогает сделать PIC быстрее и поэтому является более предпочтительной для разделяемых библиотек (по сравнению с x86). Это очень важно знать, так как x64 – наиболее популярная архитектура для серверов, десктопов и ноутбуков на данный момент.

[1]

Как всегда, я использую x64 как удобное короткое имя для архитектуры, известной как x86-64, AMD64 или Intel 64.

[2]

В eax, а не rax, так как у myglob тип int, который на x64 тоже 32-битный.

[3]

Кстати, использование регистра было бы гораздо менее проблемным в x64, ведь у нее в два раза больше регистров по сравнению с x86.

[4]

Это также происходит, если мы явно указываем, что не хотим PIC передачей -fno-pic в качестве аргумента для gcc.

[5]

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

[6]

Более подробная информация доступна в AMD64 ABI и man gcc.

[7]

Некоторые ассемблеры называют эту инструкцию movabs, чтобы отличать ее от других mov-инструкций, принимающих относительные адреса. Инструкция к Intel-архитектурам, тем не менее называет ее просто mov. Ее opcode-формат REX.W + B8 + rd.

Автор: Badoo

Источник [9]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/linux/250658

Ссылки в тексте:

[1] На прошлой неделе я опубликовал перевод: https://habrahabr.ru/company/badoo/blog/323904/

[2] [1]: https://habrahabr.ru/company/badoo/blog/324616/#1

[3] [2]: https://habrahabr.ru/company/badoo/blog/324616/#2

[4] [3]: https://habrahabr.ru/company/badoo/blog/324616/#3

[5] [4]: https://habrahabr.ru/company/badoo/blog/324616/#4

[6] [5]: https://habrahabr.ru/company/badoo/blog/324616/#5

[7] [6]: https://habrahabr.ru/company/badoo/blog/324616/#6

[8] [7]: https://habrahabr.ru/company/badoo/blog/324616/#7

[9] Источник: https://habrahabr.ru/post/324616/