Разбираемся в моделях кода архитектуры x64

«Какой моделью кода мне воспользоваться?» — часто возникающий, но нечасто разбираемый вопрос при написании кода для архитектуры х64. Тем не менее, это довольно интересная проблема, и для понимания генерируемого компиляторами машинного кода х64 полезно иметь представление о моделях кода. Кроме того, для тех, кто беспокоится о производительности вплоть до мельчайших команд, выбор модели кода влияет и на оптимизацию.

Информация по этой теме в сети, или где бы то ни было еще, встречается редко. Самым важным из доступных ресурсов является официальный х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.o  Relocation section '.rela.text' at offset 0x62bd8 contains 5 entries:   Offset          Info           Type           Sym. Value    Sym. Name + Addend 00000000002f  001500000002 R_X86_64_PC32     0000000000000000 global_func - 4 000000000038  001100000002 R_X86_64_PC32     0000000000000000 global_arr + 18 000000000041  000300000002 R_X86_64_PC32     0000000000000000 .data + 1b8 00000000004a  001200000002 R_X86_64_PC32     0000000000000340 global_arr_big + 18 000000000053  000300000002 R_X86_64_PC32     0000000000000000 .data + 31098

Давайте в качестве примера полностью декодируем доступ к global_arr. Интересующий нас дизассемблированный сегмент:

  t += global_arr[7]; 36:       8b 05 00 00 00 00       mov    0x0(%rip),%eax 3c:       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),%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)

Поскольку операнд 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 + Addend 000000000030  001500000001 R_X86_64_64       0000000000000000 global_func + 0 00000000003f  001100000001 R_X86_64_64       0000000000000000 global_arr + 0 00000000004f  000300000001 R_X86_64_64       0000000000000000 .data + 1a0 00000000005f  001200000001 R_X86_64_64       0000000000000340 global_arr_big + 0 00000000006f  000300000001 R_X86_64_64       0000000000000000 .data + 31080

Поскольку нет необходимости делать предположения относительно размеров секций кода и данных, большая модель кода довольно унифицирована и одинаковым образом определяет доступ ко всем данным. Давайте вновь взглянем на global_arr:

  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)

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

За уже знакомым нам 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 + Addend 00000000002f  001600000004 R_X86_64_PLT32    0000000000000000 global_func - 4 000000000039  001100000009 R_X86_64_GOTPCREL 0000000000000000 global_arr - 4 000000000045  000300000002 R_X86_64_PC32     0000000000000000 .data + 1b8 00000000004f  001200000009 R_X86_64_GOTPCREL 0000000000000340 global_arr_big - 4 00000000005b  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),%rax 3d:   8b 40 1c                mov    0x1c(%rax),%eax 40:   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),%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)

В ней есть релокация операнда 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),%rbx 25: 49 bb 00 00 00 00 00    movabs $0x0,%r11 2c: 00 00 00 2f: 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,%rax 70:       00 00 00 73:       8b 44 03 1c             mov    0x1c(%rbx,%rax,1),%eax 77:       01 45 ec                add    %eax,-0x14(%rbp)

Релокация первой команды это R_X86_64_GOTOFF64: символ плюс слагаемое минус GOT. В нашем случае это относительный сдвиг между адресом static_arr и адресом GOT. Следующая инструкция добавляет результат в rbx (абсолютный адрес GOT) и сбрасывает ссылку со сдвигом по 0x1c. Для простоты визуализации такого вычисления ниже можно ознакомиться с псевдо-C примером:

// char* static_arr // char* GOT rax = static_arr + 0 - GOT;  // rax now contains an offset eax = *(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,%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)

Этот код несколько длиннее, а релокация отличается от обычной. По сути, 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),%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)

Интересующая нас релокация это 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 + Addend 000000000021  00160000001a R_X86_64_GOTPC32  0000000000000000 _GLOBAL_OFFSET_TABLE_ - 4 000000000037  001700000004 R_X86_64_PLT32    0000000000000000 global_func - 4 000000000041  001200000009 R_X86_64_GOTPCREL 0000000000000000 global_arr - 4 00000000004d  000300000002 R_X86_64_PC32     0000000000000000 .data + 1b8 000000000057  001300000009 R_X86_64_GOTPCREL 0000000000000000 global_arr_big - 4 000000000063  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,%rax 68:   00 00 00 6b:   8b 44 03 1c             mov    0x1c(%rbx,%rax,1),%eax 6f:   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


FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *