Информация по этой теме в сети, или где бы то ни было еще, встречается редко. Самым важным из доступных ресурсов является официальный х64 ABI, скачать его можно по ссылке (далее по тексту он будет упоминаться как ABI). Часть информации также можно найти на
man
-страницах gcc
. Задача данной
статьи предоставить доступные рекомендации по теме, обсудить
связанные с ней вопросы, а так же хорошими примерами через
используемый в работе код продемонстрировать некоторые
концепты.Важное замечание: эта статья не является обучающим материалом для начинающих. Перед ознакомлением рекомендуется уверенное владение C и ассемблером, а так же базовое знакомство с архитектурой х64.
Также смотрите нашу предыдущую публикацию на схожую тему: Как x86_x64 адресует память
Модели кода. Мотивационная часть
В архитектуре х64 отсылки и на код, и на данные ведутся через командно-относительные (или, используя жаргон х64, RIP-относительные) модели адресации. В этих командах сдвиг от RIP ограничен 32 битами, однако могут возникнуть случаи, когда команде при попытке адресовать часть памяти или данных попросту не хватит сдвига в 32 бит, например при работе с программами больше двух гигабайт.
Один из способов решения этой проблемы полный отказ от RIP-относительного режима адресации в пользу полного 64-битного сдвига для всех ссылок на данные и код. Однако, этот шаг обойдется очень дорого: для покрытия (довольно редкого) случая невероятно больших программ и библиотек, даже простейшие операции в рамках вообще всего кода потребуют большего чем обычно числа команд.
Таким образом, компромиссом становятся модели кода. [1] Модель кода это формальное соглашение между программистом и компилятором, в котором программист указывает свои намерения относительно размера ожидаемой программы (или программ), в которую попадет компилируемый в данный момент объектный модуль. [2] Модели кода нужны для того чтобы программист мог сказать компилятору: не волнуйся, этот объектный модуль пойдет только в небольшие программы, так что можно пользоваться быстрыми RIP-относительными режимами адресации. С другой стороны, он может сказать компилятору следующее: мы собираемся компоновать этот модуль в большие программы, так что пожалуйста используй неторопливые и безопасные абсолютные режимы адресации с полным 64-битным сдвигом.
О чем расскажет эта статья
Мы поговорим о двух описанных выше сценариях, малой модели кода и большой модели кода: первая модель говорит компилятору, что 32-битного относительного сдвига должно хватить для всех ссылок на код и данные в объектном модуле; вторая настаивает на использовании компилятором абсолютных 64-битных режимов адресации. Кроме того, существует еще и промежуточный вариант, так называемая средняя модель кода.
Каждая из этих моделей кода представлена в независящих друг от друга PIC и не-PIC вариациях, и мы поговорим про каждую из шести.
Исходный пример на С
Для демонстрации обсуждаемых в этой статье концептов я воспользуюсь представленной ниже программой на С и скомпилирую ее с различными моделями кода. Как можно видеть, функция
main
получает
доступ к четырем разным глобальным массивам и одной глобальной
функции. Массивы отличаются двумя параметрами: размером и
видимостью. Размер важен для объяснения средней модели кода и не
понадобится в работе с малой и большой моделями. Видимость важна
для работы PIC-моделей кода и бывает либо статичной (видимость
только в исходном файле), либо глобальной (видимость всем
скомпонованным в программу объектам).
int global_arr[100] = {2, 3};static int static_arr[100] = {9, 7};int global_arr_big[50000] = {5, 6};static int static_arr_big[50000] = {10, 20};int global_func(int param){ return param * 10;}int main(int argc, const char* argv[]){ int t = global_func(argc); t += global_arr[7]; t += static_arr[7]; t += global_arr_big[7]; t += static_arr_big[7]; return t;}
gcc
использует модель кода как значение опции
-mcmodel
. Кроме того, флагом -fpic
можно
задать PIC компиляцию.Пример компиляции в объектный модуль через большую модель кода с использованием PIC:
> gcc -g -O0 -c codemodel1.c -fpic -mcmodel=large -o codemodel1_large_pic.o
Малая модель кода
Перевод цитаты из man gcc на тему малой модели кода:
-mcmodel=small
Генерация кода для малой модели: программа и ее символы должны быть скомпонованы в нижних двух гигабайтах адресного пространства. Размер указателей 64 бит. Программы могут быть скомпонованы и статически, и динамически. Это основная модель кода.
Другими словами, компилятор может спокойно считать, что код и данные доступны через 32-битный RIP-относительный сдвиг из любой команды в коде. Давайте взглянем на дизассемблированный пример программы на С, которую мы скомпилировали через не-PIC малую модель кода:
> objdump -dS codemodel1_small.o[...]int main(int argc, const char* argv[]){ 15: 55 push %rbp 16: 48 89 e5 mov %rsp,%rbp 19: 48 83 ec 20 sub $0x20,%rsp 1d: 89 7d ec mov %edi,-0x14(%rbp) 20: 48 89 75 e0 mov %rsi,-0x20(%rbp) int t = global_func(argc); 24: 8b 45 ec mov -0x14(%rbp),%eax 27: 89 c7 mov %eax,%edi 29: b8 00 00 00 00 mov $0x0,%eax 2e: e8 00 00 00 00 callq 33 <main+0x1e> 33: 89 45 fc mov %eax,-0x4(%rbp) t += global_arr[7]; 36: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 3c: 01 45 fc add %eax,-0x4(%rbp) t += static_arr[7]; 3f: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 45: 01 45 fc add %eax,-0x4(%rbp) t += global_arr_big[7]; 48: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 4e: 01 45 fc add %eax,-0x4(%rbp) t += static_arr_big[7]; 51: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 57: 01 45 fc add %eax,-0x4(%rbp) return t; 5a: 8b 45 fc mov -0x4(%rbp),%eax} 5d: c9 leaveq 5e: c3 retq
Как можно видеть, доступ ко всем массивам организован одинаково с использованием RIP-относительного сдвига. Однако в коде сдвиг равен 0, поскольку компилятор не знает, где будет размещен сегмент данных, поэтому для каждого такого доступа он создает релокацию:
> readelf -r codemodel1_small.oRelocation section '.rela.text' at offset 0x62bd8 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend00000000002f 001500000002 R_X86_64_PC32 0000000000000000 global_func - 4000000000038 001100000002 R_X86_64_PC32 0000000000000000 global_arr + 18000000000041 000300000002 R_X86_64_PC32 0000000000000000 .data + 1b800000000004a 001200000002 R_X86_64_PC32 0000000000000340 global_arr_big + 18000000000053 000300000002 R_X86_64_PC32 0000000000000000 .data + 31098
Давайте в качестве примера полностью декодируем доступ к
global_arr
. Интересующий нас дизассемблированный
сегмент:
t += global_arr[7];36: 8b 05 00 00 00 00 mov 0x0(%rip),%eax3c: 01 45 fc add %eax,-0x4(%rbp)
RIP-относительная адресация относительна очередной команде, таким образом сдвиг необходимо запатчить в команду
mov
таким
образом, чтобы он соответствовал 0х3с. Нас интересует вторая
релокация, R_X86_64_PC32
, она указывает на операнд
mov
по адресу 0x38
и означает следующее:
берем значение символа, добавляем слагаемое и вычитаем указываемый
релокацией сдвиг. Если вы все корректно посчитали, вы увидите как
результат разместит относительный сдвиг между очередной командой и
global_arr
, плюс 0х1с
. Поскольку
0х1с
означает седьмой int в массиве (в архитектуре х64
размер каждого int
составляет 4 байта), то этот
относительный сдвиг нам и нужен. Таким образом, используя
RIP-относительную адресацию, команда корректно ссылается на
global_arr[7]
.Также интересно отметить следующее: пусть команды доступа к
static_arr
здесь и схожи, его переадресация использует
другой символ, тем самым вместо конкретного символа указывая в
секцию .data
. Виной тому действия компоновщика, он
размещает статический массив в известном месте секции, и таким
образом массив нельзя использовать совместно с другими общими
библиотеками. В итоге компоновщик урегулирует ситуацию с этой
релокацией. С другой стороны, поскольку global_arr
может быть использован (или перезаписан) другой общей библиотекой,
уже динамический загрузчик должен будет разобраться со ссылкой к
global_arr
. [3]Наконец, давайте взглянем на отсылку к
global_func
:
int t = global_func(argc);24: 8b 45 ec mov -0x14(%rbp),%eax27: 89 c7 mov %eax,%edi29: b8 00 00 00 00 mov $0x0,%eax2e: e8 00 00 00 00 callq 33 <main+0x1e>33: 89 45 fc mov %eax,-0x4(%rbp)
Поскольку операнд
callq
тоже RIP-относителен,
релокация R_X86_64_PC32
работает здесь аналогично
размещению фактического относительного сдвига к global_func в
операнд.В заключение отметим, что благодаря малой модели кода компилятор воспринимает все данные и код будущей программы как доступные через 32-битный сдвиг, и тем самым для доступа к всевозможным объектам создает простой и эффективный код.
Большая модель кода
Перевод цитаты из
man
gcc
на тему большой
модели кода:-mcmodel=large
Генерация кода для большой модели: Эта модель не делает предположений относительно адресов и размеров секций.
Пример дизассемблированного кода
main
,
скомпилированного при помощи не-PIC большой модели:
int main(int argc, const char* argv[]){ 15: 55 push %rbp 16: 48 89 e5 mov %rsp,%rbp 19: 48 83 ec 20 sub $0x20,%rsp 1d: 89 7d ec mov %edi,-0x14(%rbp) 20: 48 89 75 e0 mov %rsi,-0x20(%rbp) int t = global_func(argc); 24: 8b 45 ec mov -0x14(%rbp),%eax 27: 89 c7 mov %eax,%edi 29: b8 00 00 00 00 mov $0x0,%eax 2e: 48 ba 00 00 00 00 00 movabs $0x0,%rdx 35: 00 00 00 38: ff d2 callq *%rdx 3a: 89 45 fc mov %eax,-0x4(%rbp) t += global_arr[7]; 3d: 48 b8 00 00 00 00 00 movabs $0x0,%rax 44: 00 00 00 47: 8b 40 1c mov 0x1c(%rax),%eax 4a: 01 45 fc add %eax,-0x4(%rbp) t += static_arr[7]; 4d: 48 b8 00 00 00 00 00 movabs $0x0,%rax 54: 00 00 00 57: 8b 40 1c mov 0x1c(%rax),%eax 5a: 01 45 fc add %eax,-0x4(%rbp) t += global_arr_big[7]; 5d: 48 b8 00 00 00 00 00 movabs $0x0,%rax 64: 00 00 00 67: 8b 40 1c mov 0x1c(%rax),%eax 6a: 01 45 fc add %eax,-0x4(%rbp) t += static_arr_big[7]; 6d: 48 b8 00 00 00 00 00 movabs $0x0,%rax 74: 00 00 00 77: 8b 40 1c mov 0x1c(%rax),%eax 7a: 01 45 fc add %eax,-0x4(%rbp) return t; 7d: 8b 45 fc mov -0x4(%rbp),%eax} 80: c9 leaveq 81: c3 retq
И вновь полезно взглянуть на релокации:
Relocation section '.rela.text' at offset 0x62c18 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend000000000030 001500000001 R_X86_64_64 0000000000000000 global_func + 000000000003f 001100000001 R_X86_64_64 0000000000000000 global_arr + 000000000004f 000300000001 R_X86_64_64 0000000000000000 .data + 1a000000000005f 001200000001 R_X86_64_64 0000000000000340 global_arr_big + 000000000006f 000300000001 R_X86_64_64 0000000000000000 .data + 31080
Поскольку нет необходимости делать предположения относительно размеров секций кода и данных, большая модель кода довольно унифицирована и одинаковым образом определяет доступ ко всем данным. Давайте вновь взглянем на
global_arr
:
t += global_arr[7];3d: 48 b8 00 00 00 00 00 movabs $0x0,%rax44: 00 00 0047: 8b 40 1c mov 0x1c(%rax),%eax4a: 01 45 fc add %eax,-0x4(%rbp)
Двум командам необходимо получить желаемое значение из массива. Первая команда размещает абсолютный 64-битный адрес в
rax
, который, как мы скоро увидим, окажется адресом
global_arr
, тогда как вторая команда загружает слово
из (rax) + 0х1с
в eax
.Так что давайте сфокусируемся на команде по адресу
0x3d
, movabs
, абсолютной 64-битной версии
mov
в архитектуре х64. Она может забросить полную
64-битную константу прямо в регистр, и так как в нашем
дизассемблированном коде значение этой константы равно нулю, за
ответом нам придется обратиться к таблице релокаций. В ней мы
найдем абсолютную релокацию R_X86_64_64
для операнда
по адресу 0x3f
, со следующим значением: размещение
значения символа плюс слагаемого обратно в сдвиг. Другими словами,
rax
будет содержать абсолютный адрес
global_arr
.А что насчет функции вызова?
int t = global_func(argc);24: 8b 45 ec mov -0x14(%rbp),%eax27: 89 c7 mov %eax,%edi29: b8 00 00 00 00 mov $0x0,%eax2e: 48 ba 00 00 00 00 00 movabs $0x0,%rdx35: 00 00 0038: ff d2 callq *%rdx3a: 89 45 fc mov %eax,-0x4(%rbp)
За уже знакомым нам
movabs
следует команда
call
, которая вызывает функцию по адресу в
rdx
. Достаточно взглянуть на соответствующую релокацию
чтобы понять насколько она похожа на доступ к данным.Как можно видеть, в большой модели кода нет каких-либо предположений о размере секций кода и данных, равно как и о финальном расположении символов, она попросту ссылается на символы через абсолютные 64-битные шаги, своего рода безопасную дорожку. Однако обратите внимание как, по сравнению с малой моделью кода, большая модель вынуждена использовать дополнительную команду при обращении к каждому символу. Таковая цена безопасности.
Итак, мы с вами познакомились с двумя полностью противоположными моделями: в то время как малая модель кода предполагает, что все вмещается в нижние два гигабайта памяти, большая модель предполагает, что нет ничего невозможного и любой символ может находиться где угодно во всей полноте 64-битного адресного пространства. Компромиссом между этими двумя моделями становится средняя модель кода.
Средняя модель кода
Как и ранее, давайте взглянем на перевод цитаты из
man
gcc
:-mcmodel=medium
Генерация кода для средней модели: Программа скомпонована в нижних двух гигабайтах адресного пространства. Здесь же расположены и малые символы. Символы размера большего, чем задано через -mlarge-data-threshold, попадают в больше данные или секции bss и могут находиться выше двух гигабайт. Программы могут быть скомпонованы и статически, и динамически.
Аналогично малой модели кода, средняя модель предполагает что весь код скомпонован в двух нижних гигабайтах. Тем не менее, данные разделены на предполагаемо скомпонованные в нижних двух гигабайтах малые данные и неограниченные по размещению в памяти большие данные. Данные попадают в категорию больших при превышении ими предела, по определению равного 64 килобайтам.
Также важно отметить, что при работе со средней моделью кода для больших данных по аналогии с секциями
.data
и
.bss
создаются специальные секции: .ldata
и .lbss
. Это не так важно в призме темы текущей
статьи, однако я собираюсь немного от нее отклониться. Детальнее с
данным вопросом можно ознакомиться в ABI.Теперь становится понятно, почему в примере появились те массивы
_big
: они нужны средней модели для интерпретации
больших данных, которыми они, при размере в 200 килобайт каждый, и
являются. Ниже можно ознакомиться с результатом
дизассемблирования:
int main(int argc, const char* argv[]){ 15: 55 push %rbp 16: 48 89 e5 mov %rsp,%rbp 19: 48 83 ec 20 sub $0x20,%rsp 1d: 89 7d ec mov %edi,-0x14(%rbp) 20: 48 89 75 e0 mov %rsi,-0x20(%rbp) int t = global_func(argc); 24: 8b 45 ec mov -0x14(%rbp),%eax 27: 89 c7 mov %eax,%edi 29: b8 00 00 00 00 mov $0x0,%eax 2e: e8 00 00 00 00 callq 33 <main+0x1e> 33: 89 45 fc mov %eax,-0x4(%rbp) t += global_arr[7]; 36: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 3c: 01 45 fc add %eax,-0x4(%rbp) t += static_arr[7]; 3f: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 45: 01 45 fc add %eax,-0x4(%rbp) t += global_arr_big[7]; 48: 48 b8 00 00 00 00 00 movabs $0x0,%rax 4f: 00 00 00 52: 8b 40 1c mov 0x1c(%rax),%eax 55: 01 45 fc add %eax,-0x4(%rbp) t += static_arr_big[7]; 58: 48 b8 00 00 00 00 00 movabs $0x0,%rax 5f: 00 00 00 62: 8b 40 1c mov 0x1c(%rax),%eax 65: 01 45 fc add %eax,-0x4(%rbp) return t; 68: 8b 45 fc mov -0x4(%rbp),%eax} 6b: c9 leaveq 6c: c3 retq
Обратите внимание на то, как ведется доступ к массивам: обращение к массивам
_big
идет через методы большой модели кода,
тогда как обращение к остальным массивам идет через методы малой
модели. Обращение к функции тоже ведется по методу малой модели
кода, а релокации настолько аналогичны предыдущим примерам, что я
даже не буду их демонстрировать.Средняя модель кода это умелый компромисс между большой и малой моделями. Навряд ли код программы окажется слишком велик [4], так что подвинуть его сверх лимита в два гигабайта могут разве что статически скомпонованные в него большие куски данных, возможно как часть некоего объемного табличного поиска. Так как средняя модель кода отсеивает такие объемные куски данных и особым образом их обрабатывает, то вызовы кодом функций и малых символов будут так же эффективны, как и в малой модели кода. Только обращения к большим символам, по аналогии с большой моделью, потребуют от кода воспользоваться полным 64-битным методом большой модели.
Малая PIC-модель кода
Теперь давайте посмотрим на PIC варианты моделей кода, и как и раньше мы начнем с малой модели. [5] Ниже можно видеть пример кода, скомпилированного через малую PIC-модель:
int main(int argc, const char* argv[]){ 15: 55 push %rbp 16: 48 89 e5 mov %rsp,%rbp 19: 48 83 ec 20 sub $0x20,%rsp 1d: 89 7d ec mov %edi,-0x14(%rbp) 20: 48 89 75 e0 mov %rsi,-0x20(%rbp) int t = global_func(argc); 24: 8b 45 ec mov -0x14(%rbp),%eax 27: 89 c7 mov %eax,%edi 29: b8 00 00 00 00 mov $0x0,%eax 2e: e8 00 00 00 00 callq 33 <main+0x1e> 33: 89 45 fc mov %eax,-0x4(%rbp) t += global_arr[7]; 36: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax 3d: 8b 40 1c mov 0x1c(%rax),%eax 40: 01 45 fc add %eax,-0x4(%rbp) t += static_arr[7]; 43: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 49: 01 45 fc add %eax,-0x4(%rbp) t += global_arr_big[7]; 4c: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax 53: 8b 40 1c mov 0x1c(%rax),%eax 56: 01 45 fc add %eax,-0x4(%rbp) t += static_arr_big[7]; 59: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 5f: 01 45 fc add %eax,-0x4(%rbp) return t; 62: 8b 45 fc mov -0x4(%rbp),%eax} 65: c9 leaveq 66: c3 retq
Релокации:
Relocation section '.rela.text' at offset 0x62ce8 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend00000000002f 001600000004 R_X86_64_PLT32 0000000000000000 global_func - 4000000000039 001100000009 R_X86_64_GOTPCREL 0000000000000000 global_arr - 4000000000045 000300000002 R_X86_64_PC32 0000000000000000 .data + 1b800000000004f 001200000009 R_X86_64_GOTPCREL 0000000000000340 global_arr_big - 400000000005b 000300000002 R_X86_64_PC32 0000000000000000 .data + 31098
Поскольку в малой модели кода различия между большими и малыми данными не играют никакой роли, мы сфокусируемся на важных при генерации кода через PIC моментах: различиях между локальными (статичными) и глобальными символами.
Как можно заметить, нет никакой разницы между сгенерированным для статичных массивов кодом и кодом в не-PIC случае. Это один из плюсов архитектуры x64: благодаря IP-относительному доступу к данным, мы бонусом получаем PIC, по крайней мере до тех пор пока не требуется внешний доступ к символам. Все команды и релокации остаются теми же, так что обрабатывать их лишний раз не нужно.
Интересно обратить внимание на глобальные массивы: стоит напомнить, что в PIC глобальные данные должны проходить через GOT, поскольку в какой-то момент их могут хранить, или пользоваться ими, общие библиотеки [6]. Ниже можно видеть код для доступа к
global_arr
:
t += global_arr[7];36: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax3d: 8b 40 1c mov 0x1c(%rax),%eax40: 01 45 fc add %eax,-0x4(%rbp)
Интересующая нас релокация это
R_X86_64_GOTPCREL
:
позиция входа символа в GOT плюс слагаемое, минус сдвиг за
применение релокации. Другими словами, в команду патчится
относительный сдвиг между RIP (следующей инструкции) и
зарезервированного для global_arr
в GOT слота. Таким
образом, в rax
в команду по адресу 0x36
размещается фактический адрес global_arr
. Следом за
этим шагом идет сброс ссылки на адрес global_arr
плюс
сдвиг на его седьмой элемент в eax
.Теперь давайте взглянем на вызов функции:
int t = global_func(argc);24: 8b 45 ec mov -0x14(%rbp),%eax27: 89 c7 mov %eax,%edi29: b8 00 00 00 00 mov $0x0,%eax2e: e8 00 00 00 00 callq 33 <main+0x1e>33: 89 45 fc mov %eax,-0x4(%rbp)
В ней есть релокация операнда
callq
по адресу
0x2e
, R_X86_64_PLT32
: адрес PLT входа для
символа плюс слагаемое, минус сдвиг за применение релокации.
Другими словами, callq
должен корректно вызывать PLT
трамплин для global_func
.Обратите внимание, какие неявные предположения совершает компилятор: что к GOT и PLT можно получить доступ через RIP-относительную адресацию. Это будет важно при сравнении этой модели с другими PIC-вариантами моделей кода.
Большая PIC-модель кода
Дизассемблирование:
int main(int argc, const char* argv[]){ 15: 55 push %rbp 16: 48 89 e5 mov %rsp,%rbp 19: 53 push %rbx 1a: 48 83 ec 28 sub $0x28,%rsp 1e: 48 8d 1d f9 ff ff ff lea -0x7(%rip),%rbx 25: 49 bb 00 00 00 00 00 movabs $0x0,%r11 2c: 00 00 00 2f: 4c 01 db add %r11,%rbx 32: 89 7d dc mov %edi,-0x24(%rbp) 35: 48 89 75 d0 mov %rsi,-0x30(%rbp) int t = global_func(argc); 39: 8b 45 dc mov -0x24(%rbp),%eax 3c: 89 c7 mov %eax,%edi 3e: b8 00 00 00 00 mov $0x0,%eax 43: 48 ba 00 00 00 00 00 movabs $0x0,%rdx 4a: 00 00 00 4d: 48 01 da add %rbx,%rdx 50: ff d2 callq *%rdx 52: 89 45 ec mov %eax,-0x14(%rbp) t += global_arr[7]; 55: 48 b8 00 00 00 00 00 movabs $0x0,%rax 5c: 00 00 00 5f: 48 8b 04 03 mov (%rbx,%rax,1),%rax 63: 8b 40 1c mov 0x1c(%rax),%eax 66: 01 45 ec add %eax,-0x14(%rbp) t += static_arr[7]; 69: 48 b8 00 00 00 00 00 movabs $0x0,%rax 70: 00 00 00 73: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax 77: 01 45 ec add %eax,-0x14(%rbp) t += global_arr_big[7]; 7a: 48 b8 00 00 00 00 00 movabs $0x0,%rax 81: 00 00 00 84: 48 8b 04 03 mov (%rbx,%rax,1),%rax 88: 8b 40 1c mov 0x1c(%rax),%eax 8b: 01 45 ec add %eax,-0x14(%rbp) t += static_arr_big[7]; 8e: 48 b8 00 00 00 00 00 movabs $0x0,%rax 95: 00 00 00 98: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax 9c: 01 45 ec add %eax,-0x14(%rbp) return t; 9f: 8b 45 ec mov -0x14(%rbp),%eax} a2: 48 83 c4 28 add $0x28,%rsp a6: 5b pop %rbx a7: c9 leaveq a8: c3 retq
Релокации:
Relocation section '.rela.text' at offset 0x62c70 contains 6
entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000027 00150000001d R_X86_64_GOTPC64 0000000000000000
_GLOBAL_OFFSET_TABLE_ + 9
000000000045 00160000001f R_X86_64_PLTOFF64 0000000000000000
global_func + 0
000000000057 00110000001b R_X86_64_GOT64 0000000000000000
global_arr + 0
00000000006b 000800000019 R_X86_64_GOTOFF64 00000000000001a0
static_arr + 0
00000000007c 00120000001b R_X86_64_GOT64 0000000000000340
global_arr_big + 0
000000000090 000900000019 R_X86_64_GOTOFF64 0000000000031080
static_arr_big + 0
В этот раз различия между большими и малыми данными все еще не имеют значения, поэтому мы сфокусируемся на
static_arr
и global_arr
. Но прежде необходимо обратить внимание в
этом коде на пролог, ранее мы не сталкивались с подобным:
1e: 48 8d 1d f9 ff ff ff lea -0x7(%rip),%rbx25: 49 bb 00 00 00 00 00 movabs $0x0,%r112c: 00 00 002f: 4c 01 db add %r11,%rbx
Ниже можно прочесть перевод связанной с этим цитаты из ABI:
В малой модели кода ко всем адресам (включая GOT) можно получить доступ через предоставленную архитектурой AMD64 IP-относительную адресацию. Именно поэтому нет нужды в явном указателе GOT и таким образом нет нужды в устанавливающем его прологе в функции. В большой и средней моделях кода необходимо определить регистр для хранения адреса GOT в независимых от расположения объектах, поскольку AMD64 ISA не поддерживает мгновенное перемещение размером больше чем 32 бит.
Давайте посмотрим на то, как описанный выше пролог вычисляет адрес GOT. Во-первых, команда по адресу
0x1e
загружает свой
собственный адрес в rbx
. Затем совместно с релокацией
R_X86_64_GOTPC64
совершается абсолютный 64-битный шаг
в r11
. Эта релокация означает следующее: берем адрес
GOT, вычитаем перемещаемый сдвиг и добавляем слагаемое. Наконец,
команда по адресу 0x2f
складывает оба результата
вместе. Итогом становится абсолютный адрес GOT в rbx
.
[7]Зачем же мучиться с вычислением адреса GOT? Во-первых, как отмечено в цитате, в большой модели кода мы не можем предполагать, что 32-битного RIP-относительного сдвига будет достаточно для адресации GOT, из-за чего нам и требуется полный 64-битный адрес. Во-вторых, мы все еще хотим работать с PIC-вариацией, так что мы не можем попросту поместить абсолютный адрес в регистр. Скорее сам адрес должен быть вычислен относительно RIP. Для этого и нужен пролог: он совершает 64-битное RIP-относительное вычисление.
В любом случае, раз у нас в
rbx
теперь есть адрес GOT,
давайте посмотрим на то как получить доступ к
static_arr
:
t += static_arr[7];69: 48 b8 00 00 00 00 00 movabs $0x0,%rax70: 00 00 0073: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax77: 01 45 ec add %eax,-0x14(%rbp)
Релокация первой команды это
R_X86_64_GOTOFF64
: символ
плюс слагаемое минус GOT. В нашем случае это относительный сдвиг
между адресом static_arr
и адресом GOT. Следующая
инструкция добавляет результат в rbx
(абсолютный адрес
GOT) и сбрасывает ссылку со сдвигом по 0x1c
. Для
простоты визуализации такого вычисления ниже можно ознакомиться с
псевдо-C примером:
// char* static_arr// char* GOTrax = static_arr + 0 - GOT; // rax now contains an offseteax = *(rbx + rax + 0x1c); // rbx == GOT, so eax now contains // *(GOT + static_arr - GOT + 0x1c) or // *(static_arr + 0x1c)
Обратите внимание на интересный момент: адрес GOT используется как привязка к
static_arr
. Обычно GOT не содержит в себе
адрес символа, и так как static_arr
не является
внешним символом, нет причин хранить его внутри GOT. Тем не менее,
в данном случае GOT используется как привязка к относительной
адресу символа секции данных. Этот адрес, который кроме всего
прочего не зависит от расположения, можно найти полным 64-битным
сдвигом. Компоновщик в состоянии урегулировать эту релокацию, так
что модифицировать секцию кода во время загрузки нет
необходимости.Но что насчет
global_arr
?
t += global_arr[7];55: 48 b8 00 00 00 00 00 movabs $0x0,%rax5c: 00 00 005f: 48 8b 04 03 mov (%rbx,%rax,1),%rax63: 8b 40 1c mov 0x1c(%rax),%eax66: 01 45 ec add %eax,-0x14(%rbp)
Этот код несколько длиннее, а релокация отличается от обычной. По сути, GOT используется здесь более традиционным образом: релокация
R_X86_64_GOT64
для movabs
лишь говорит
функции разместить сдвиг в GOT, там где в rax
расположен адрес global_arr
. Команда по адресу
0x5f
берет адрес global_arr
из GOT и
помещает его в rax
. Следующая команда сбрасывает
ссылку на global_arr[7]
и помещает значение в
eax
.Теперь давайте взглянем на ссылку кода для
global_func
. Вспомним что в большой модели кода мы не
могли делать предположений относительно размера секций кода, так
что нам следует полагать что даже для доступа к PLT нам потребуется
абсолютный 64-битный адрес:
int t = global_func(argc);39: 8b 45 dc mov -0x24(%rbp),%eax3c: 89 c7 mov %eax,%edi3e: b8 00 00 00 00 mov $0x0,%eax43: 48 ba 00 00 00 00 00 movabs $0x0,%rdx4a: 00 00 004d: 48 01 da add %rbx,%rdx50: ff d2 callq *%rdx52: 89 45 ec mov %eax,-0x14(%rbp)
Интересующая нас релокация это
R_X86_64_PLTOFF64
:
адрес PLT входа для global_func
минус адрес GOT.
Результат размещается в rdx
, куда затем помещается
rbx
(абсолютный адрес GOT). В итоге мы получаем адрес
PLT входа для global_func
в rdx
.Обратите внимание что вновь GOT используется как привязка, на этот раз для обеспечения независящей от адреса отсылки к сдвигу PLT входа.
Средняя PIC-модель кода
Наконец, мы разберем сгенерированный для средней PIC-модели код:
int main(int argc, const char* argv[]){ 15: 55 push %rbp 16: 48 89 e5 mov %rsp,%rbp 19: 53 push %rbx 1a: 48 83 ec 28 sub $0x28,%rsp 1e: 48 8d 1d 00 00 00 00 lea 0x0(%rip),%rbx 25: 89 7d dc mov %edi,-0x24(%rbp) 28: 48 89 75 d0 mov %rsi,-0x30(%rbp) int t = global_func(argc); 2c: 8b 45 dc mov -0x24(%rbp),%eax 2f: 89 c7 mov %eax,%edi 31: b8 00 00 00 00 mov $0x0,%eax 36: e8 00 00 00 00 callq 3b <main+0x26> 3b: 89 45 ec mov %eax,-0x14(%rbp) t += global_arr[7]; 3e: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax 45: 8b 40 1c mov 0x1c(%rax),%eax 48: 01 45 ec add %eax,-0x14(%rbp) t += static_arr[7]; 4b: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 51: 01 45 ec add %eax,-0x14(%rbp) t += global_arr_big[7]; 54: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax 5b: 8b 40 1c mov 0x1c(%rax),%eax 5e: 01 45 ec add %eax,-0x14(%rbp) t += static_arr_big[7]; 61: 48 b8 00 00 00 00 00 movabs $0x0,%rax 68: 00 00 00 6b: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax 6f: 01 45 ec add %eax,-0x14(%rbp) return t; 72: 8b 45 ec mov -0x14(%rbp),%eax} 75: 48 83 c4 28 add $0x28,%rsp 79: 5b pop %rbx 7a: c9 leaveq 7b: c3 retq
Релокации:
Relocation section '.rela.text' at offset 0x62d60 contains 6 entries: Offset Info Type Sym. Value Sym. Name + Addend000000000021 00160000001a R_X86_64_GOTPC32 0000000000000000 _GLOBAL_OFFSET_TABLE_ - 4000000000037 001700000004 R_X86_64_PLT32 0000000000000000 global_func - 4000000000041 001200000009 R_X86_64_GOTPCREL 0000000000000000 global_arr - 400000000004d 000300000002 R_X86_64_PC32 0000000000000000 .data + 1b8000000000057 001300000009 R_X86_64_GOTPCREL 0000000000000000 global_arr_big - 4000000000063 000a00000019 R_X86_64_GOTOFF64 0000000000030d40 static_arr_big + 0
Для начала давайте уберем вызов функции. Аналогично малой модели, в средней модели мы предполагаем, что ссылки на код не превышают пределов 32-битного RIP сдвига, следовательно, код для вызова
global_func
полностью аналогичен такому же коду в
малой PIC-модели, равно как и для случаев массивов малых данных
static_arr
и global_arr
. Поэтому мы
сфокусируемся на массивах больших данных, но сначала поговорим о
прологе: здесь он отличается от пролога большой модели данных.
1e: 48 8d 1d 00 00 00 00 lea 0x0(%rip),%rbx
Это весь пролог: чтобы при помощи релокации
R_X86_64_GOTPC32
разместить GOT адрес в
rbx
, потребовалась всего одна команда (по сравнению с
тремя в большой модели). В чем же разница? Дело в том, что так как
в средней модели GOT не является частью секций больших данных, мы
предполагаем его доступность в рамках 32-битного сдвига. В большой
модели мы не могли совершать подобные предположения, и были
вынуждены пользоваться полным 64-битным сдвигом.Вызывает интерес тот факт, что код для доступа к
global_arr_big
похож на такой же код в малой
PIC-модели. Это происходит по той же причине, почему пролог средней
модели короче пролога большой модели: мы полагаем доступность GOT в
рамках 32-битной RIP-относительной адресации. Действительно, к
самому global_arr_big
нельзя получить такой доступ, но
этот случай все равно покрывает GOT, так как фактически
global_arr_big
в нем и находится, причем в виде
полного 64-битного адреса.Ситуация, тем не менее, отличается для
static_arr_big
:
t += static_arr_big[7];61: 48 b8 00 00 00 00 00 movabs $0x0,%rax68: 00 00 006b: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax6f: 01 45 ec add %eax,-0x14(%rbp)
Этот случай похож на большую PIC-модель кода, поскольку тут мы все же получаем абсолютный адрес символа, которого нет в самом GOT. Так как это большой символ, о котором нельзя предположить его нахождение в нижних двух гигабайтах, нам, как и в большой модели, требуется 64-битный PIC сдвиг.
Примечания:
[1] Не стоит путать модели кода с 64-битными моделями данных и моделями памяти Intel, все это разные темы.
[2] Важно помнить: собственно команды создает компилятор, и режимы адресации фиксируются именно на этом шаге. Компилятор не может знать в какие программы или общие библиотеки попадет объектный модуль, одни могут оказаться малыми, а другие большими. Компоновщику известен размер итоговой программы, но уже слишком поздно: компоновщик может лишь патчить сдвиг команд релокацией, а не менять сами команды. Таким образом, соглашение модели кода должно быть подписано программистом на этапе компиляции.
[3] Если что-то осталось непонятным, ознакомьтесь со следующей статьей.
[4] Тем не менее, объемы постепенно растут. Когда я в последний раз проверял Debug+Asserts билд Clang, он почти достигал одного гигабайта, за что во многом спасибо автогенерируемому коду.
[5] Если вы еще не знаете как работает PIC (как в целом, так и в частности для архитектуры x64), самое время ознакомиться со следующими статьями по теме: раз и два.
[6] Таким образом, компоновщик не может самостоятельно разрешить ссылки, и вынужден переложить обработку GOT на динамический загрузчик.
[7] 0x25 0x7 + GOT 0x27 + 0x9 = GOT