Привет, я все еще Марко и все еще системный программист в Badoo. На прошлой неделе я опубликовал перевод о PIC в шареных библиотеках, но есть вторая часть – про разделяемые библиотеки на х64, поэтому решил не оставлять дело незаконченным.
Предыдущая статья объясняла, как работает адресация, не зависящая от адреса (Position Independent Code, PIC) на примерах, скомпилированных под архитектуру x86. Я обещал рассказать и о PIC на x64 [1] в отдельной статье. Вот и она. В этой статье будет гораздо меньше деталей, так как подразумевается, что вы уже понимаете, как 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].
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]. В итоге оверхед для PIC на x64 сильно меньше по сравнению с x86 и это делает PIC еще более популярным. Настолько популярным, что PIC является выбором по умолчанию при создании разделяемых библиотек на этой архитектуре.
Для любопытных: не PIC-код на x64
GCC не только подталкивает вас использовать PIC для разделяемых библиотек на x64, а он требует этого по умолчанию. К примеру, если мы скомпилируем первый пример без -fpic [4] и попробуем собрать разделяемую библиотеку с -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]:
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]. Так что я кратко скажу, что модель кода – это что-то вроде соглашения между программистом и компилятором, в котором программист дает некие обещания компилятору относительно того, какой размер смещений будет использоваться в программе. В обмен компилятор сможет сгенерировать более качественный код.
Получается, для того, чтобы компилятор сгенерировал не 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]. Ну и заметьте, что две инструкции здесь необходимы, чтобы положить значение 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».
Заключение
Эта статья дополняет предыдущую, рассказывая, как работает 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