mov
и то, как через только
одну эту команду х86_64 пользователю становятся доступны
различные методы адресации памяти.Я не буду говорить про остальные затрагивающие память команды (то есть, благодаря CISC, почти все остальные), команды которые пишут массивные фрагменты памяти (это о тебе,
fxsave
), или
иные касающиеся темы вопросы (модели кода, независящий от адреса
код, и бинарная релокация). Я также не буду затрагивать
исторические режимы адресации или режимы, которые активны при
работе процессора x86_64 не в 64-битном режиме (т.е. любые отличные
от long mode с 64-битным кодом).Некоторые ограничения
Несмотря на кошмарное наследие кодирования команд х86_64, а может и благодаря ему, у адресации памяти есть некоторые ограничения.
Начнем с хорошего:
- На достаточно высоком уровне в архитектуре х86_64 есть всего два режима адресации.
- Все регистры в обоих режимах адресации должны быть строго одинакового размера. Другими словами, мы не можем странным образом смешивать 64, 32 и 16-битные регистры и получать актуальный адрес в кодировании х86_64 для подобного маневра попросту нет места.
В остальном все не столь радужно:
- Один из этих двух режимов адресации все еще абсурдно сложен.
- Регистры должны быть одинакового размера, но не обязаны быть
той же разрядности что и режим процессора. Например, если мы
добавим в нашем кодировании префикс байт (
0x67
), мы можем пользоваться 32-битными регистрами вместо 64-битных.
Адресация Scale-Index-Base-Displacement
Я не имею представления как назвать этот режим, поэтому называю его Scale-Index-Base-Displacement. Насколько я понимаю, ни Intel, ни AMD вообще не воспринимают его как единый режим и вместо этого упоминают в виде типичного набора связных режимов с широким диапазоном различных методов кодирования.
Но сегодня мы говорим не о кодировании, а о семантике, и семантически каждый из этих трех связных режимов сводится к комбинации нескольких из четырех следующих параметров:
- Scale: 2-битный коэффициент константы, обычно равный 1, 2, 4 или 8.
- Index: Любой регистр общего назначения (rax, rbx, &c).
- Base: Любой регистр общего назначения.
- Displacement: Интегральный сдвиг. Обычно даже в 64-битном режиме он ограничен 32 битами, но с использованием некоторых методов кодирования может быть и 64-битным. Мы об этом еще поговорим.
Эти четыре параметра можно объединить в несколько различных комбинаций, полный список в порядке увеличения сложности можно увидеть ниже:
Displacement
Base
Base + Index
Base + Displacement
Base + Index + Displacement
Base + (Index * Scale)
(Index * Scale) + Displacement
Base + (Index * Scale) + Displacement
Давайте разберем каждую комбинацию по порядку.
Displacement
Это, пожалуй, самый простой механизм адресации в семье х86:
displacement
обрабатывается как абсолютный адрес
памяти, и сам по себе, к несчастью, совершенно бесполезен в
архитектуре х86_64. Помните, мы говорили, что
displacement
почти всегда ограничен 32 битами? Так как
абсолютный адрес в х86_64 это 64 бита (на самом деле 48, но это
неважно), он попросту не поместится в displacement
,
однако в виде исключения можно использовать 64-битный displacement
с регистром a*
.Синтаксис Intel:
; store the qword at 0x00000000000000ff into raxmov rax, [0xff]; store the dword at 0x00000000000000ff into eaxmov eax, [0xff]; store the word at 0x00000000000000ff into axmov ax, [0xff]; store the byte at 0x00000000000000ff into almov al, [0xff]
gas
(GNU ассемблер) и в 32, и в 64-битном режимах
ссылается на них как на movabs
.Зачем мне (или моему компилятору) пользоваться этим режимом?
Для начала, из-за моделей кода, не имеющих отношения к данному посту. Подробнее по теме можно прочесть в замечательном посте Eli Bendersky по ссылке.
А теперь по делу: у большинства программ есть как минимум несколько статичных адресов, которые определяются в compile-time, аналогично глобальным переменным. Например, результатом следующей тривиальной программы:
extern long var;void f(long x) { var = x; }
будет:
f: mov rax, rdi movabs QWORD PTR [var], rax ret
(Посмотреть на Godbolt.)
Base
Адресация через регистр base добавляет еще один уровень неопределенности поверх абсолютной адресации: вместо использования закодированного в поле
displacement
команды
абсолютного адреса, адрес загружается из указанного регистра общего
пользования (Причем любого такого регистра! Ура!)Эта неопределенность позволяет нам проводить абсолютную адресацию с произвольным регистром назначения по следующему шаблону:
; store the immediate (not displacement) into rbxmov rbx, 0xacabacabacabacab; store the qword at the address stored in rbx into rcxmov rcx, [rbx]
Однако учитывая сколько еще режимов адресации нас ждет впереди, нет особого смысла пользоваться таким методом.
Зачем мне (или моему компилятору) пользоваться этим режимом?
Затем что порой после очередной операции мы уже посчитали адрес и хотим им воспользоваться.
Разобрав вариант в
displacement
, мы можем найти
хороший пример такого приема:
mov rax, qword ptr [rax]
Base + Index
Эта комбинация аналогична регистру
base
за тем
исключением, что мы добавляем значение регистра index
.
Пример:
; store the qword in rcx into the memory address computed; as the sum of the values in rax and rbxmov [rax + rbx], rcx
Зачем мне (или моему компилятору) пользоваться этим режимом?
Найти подходящий пример для такого режима оказалось для меня непростой задачей, поэтому мои коллеги, конечно же, сразу же подобрали один:
int foo(char * buf, int index) { return buf[index];}
Результат:
push rbpmov rbp, rspmov qword ptr [rbp - 8], rdimov dword ptr [rbp - 12], esimov rax, qword ptr [rbp - 8] ; rax is bufmovsxd rcx, dword ptr [rbp - 12] ; rcx is indexmovsx eax, byte ptr [rax + rcx] ; store buf[index] into eaxpop rbpret
(Посмотреть на Godbolt.)
Оглядываясь назад, мы можем заметить очевидное: в случаях, когда ни стартовый адрес массива, ни сдвиг в массив не зафиксированы в compile-time,
Base + Index
идеально подходит для
моделирования доступов к массиву.Base + Displacement
Больше неопределенности! Если вы еще не догадались, подсчет актуального адреса и регистром
base
, и полем
displacement
соответствует двум следующим
операциям:- Мы загружаем значение из регистра
base
- Мы добавляем загруженное значение к значению поля
displacement
Затем мы берем полученную сумму за фактический адрес. Пример:
; add 0xcafe to the value stored in rax; then, store the qword at the computed address into rbxmov rbx, [rax + 0xcafe]
Зачем мне (или моему компилятору) пользоваться этим режимом?
Некоторые режимы адресации, как мы уже видели на примере
Base
+ Index
, естественным образом отображают семантику
С-подобных массивов. Base + Displacement
можно
рассматривать в таком же ключе, но со стороны структурной
семантики: регистр base
содержит адрес к началу
структуры, а поле displacement
содержит фиксированный
сдвиг в эту структуру. Пример:
struct foo { long a; long b;};long bar(struct foo *foobar) { return foobar->b;}
Результат:
push rbpmov rbp, rspmov qword ptr [rbp - 8], rdimov rax, qword ptr [rbp - 8] ; rax is foobarmov rax, qword ptr [rax + 8] ; rax + 8 is foobar->b; store back into raxpop rbpret
(Посмотреть на Godbolt.)
Если задуматься о конструкции и планировке стека в начале каждой функции как о самостоятельной структуре, пример выше становится очевиден: доступы вида
[rbp - N]
это по сути
stack->objN
.Base + Index + Displacement
Если вы разобрались в предыдущем режиме, логичным следующим шагом будет добавить к нему значение регистра
index
,
создавая новый, почти аналогичный семантически режим.Как в примере выше, но с еще одним регистром, результат будет выглядеть следующим образом:
; add 0xcafe to the values stores in rax and rcx; then, store the qword at the computer address into rbxmov rbx, [rax + rcx + 0xcafe]
Зачем мне (или моему компилятору) пользоваться этим режимом?
Base + Index + Displacement
естественным образом
моделирует доступ к структуре внутри массива, точно так же как
Base + Displacement
естественным образом моделирует
доступ к структуре, а Base + Index
естественным
образом моделирует доступ к массиву.Не без труда, но с помощью
-O1
мне удалось заставить
clang
скомпилировать пример в Godbolt:
struct foo { long a; long b;};long square(struct foo foos[], long i) { struct foo x = foos[i]; return x.b;}
Краткий результат:
shl rsi, 4mov rax, qword ptr [rdi + rsi + 8] ; rdi is foos, rsi is i, 8 is the field offsetret
(Посмотреть на Godbolt.)
Base + (Index * Scale)
Наше первое умножение!
Поле
scale
похоже на поле displacement
тем, что они оба являются закодированным в нашу команду
коэффициентом константы, однако scale
, в отличие от
displacement
, сильно ограничен: так как его диапазон
составляет всего два бита, значения у scale может быть всего
четыре: 1, 2, 4 или 8.Как можно догадаться из названия, поле
scale
используется для скалирования, т.е. умножения, другого поля на
себя. Если говорить точнее, оно всегда скалирует регистр
index
, и не может без него использоваться.Зачем мне (или моему компилятору) пользоваться этим режимом?
В отличие от массива структур из предыдущих примеров,
Base +
(Index * Scale)
, кроме всего прочего, естественным образом
моделирует доступ к массиву указателей. Пример:
struct foo { long a; long b;};long bar(struct foo *foos[], long i) { struct foo *x = foos[i]; return x->b;}
Результат:
mov rax, qword ptr [rdi + 8*rsi] ; rdi is foos, rsi is i, 8 is the scale (pointer-sized!)mov rax, qword ptr [rax + 8]ret
(Посмотреть на Godbolt.)
(Index * Scale) + Displacement
Почти как на примере выше, эта комбинация без лишних сложностей меняет регистр
base
на поле
displacement
.Зачем мне (или моему компилятору) пользоваться этим режимом?
(Index * Scale) + Displacement
естественным образом
моделирует особый случай доступа к массиву: когда массив можно
статически (т.е. глобально) адресовать, а размер элементов можно
вычислить через scale
. Пример:
int tbl[10];int foo(int i) { return tbl[i];}
Результат:
movsxd rax, edimov eax, dword ptr [4*rax + tbl] ; rax is i, 4 is the scale (sizeof(int) == 4)ret
(Посмотреть на Godbolt.)
Base + (Index * Scale) + Displacement
Мы наконец-то добрались до последней и самой сложной формы адресации в архитектуре x86_64, однако в ней нет абсолютно ничего концептуально нового, она всего лишь вводит еще одну арифметическую операцию поверх режимов адресации с тремя параметрами.
Зачем мне (или моему компилятору) пользоваться этим режимом?
Base + (Index * Scale) + Displacement
естественным
образом моделирует доступ к двумерному массиву. Пример:
long tbl[10][10];long foo(long i, long j) { return tbl[i][j];}
Результат:
lea rax, [rdi + 4*rdi]shl rax, 4mov rax, qword ptr [rax + 8*rsi + tbl]ret
(Посмотреть на Godbolt.)
RIP-относительная адресация
Выше мы описали режим адресации, который почти идентичен своему историческому эквиваленту в х86_32, и отличается в первую очередь использованием 64-битных регистров GPR, и порой 64-битными смещениями. Однако главным отличием является добавление абсолютно нового режима адресации под названием RIP-относительная (RIP-relative) адресация.
Почему этот режим называется RIP-относительным? Потому что он кодирует смещение относительно значения регистра RIP (в особенности RIP не текущей, а следующей команды). Обычно это выражается уже знакомым нам синтаксисом
[Base + Displacement]
,
вот только вместо GPR регистром base
теперь является
rip
. Пример:
mov rax, [rip + 16]
Зачем мне (или моему компилятору) пользоваться этим режимом?
Причины, по которым используется этот режим, я обещал не описывать в данном посте: это независящий от адреса код и модели кода. Но мы сделаем небольшое исключение: RIP-относительная адресация упрощает и сокращает независящий от адреса код и идеально подходит для малых (и базовых) моделей кода, в которых весь код и данные должны быть адресуемы в пределах 32-битного сдвига.
Пример компиляции с использованием
-O1
и
-fpic
:
long tbl[10];int foo(int i) { return tbl[i];}
Для него в архитектуре х86_64 нам потребуются всего два
mov
:
foo: mov rax, qword ptr [rip + tbl@GOTPCREL] mov rax, qword ptr [rax + 8*rdi] ret
Однако в архитектуре х86_32 нам их потребуется три, плюс шаблоны:
foo: call .L0$pb.L0$pb: pop eax.Ltmp0: add eax, offset _GLOBAL_OFFSET_TABLE_+(.Ltmp0-.L0$pb) mov ecx, dword ptr [esp + 4] mov eax, dword ptr [eax + tbl@GOT] mov eax, dword ptr [eax + 4*ecx] ret
Напоследок: сегментация
Архитектура х86_64 убрала всю сегментацию, но только почти. Благодаря плоскому адресному пространству регистры сегментов более не требуются, но местами все еще проявляются:
- Linux (точнее glibc) использует
fs
в пользовательском пространстве для доступа к настраиваемым ядром сегментам TLS. Спецификацию этих сегментов можно обнаружить в per-CPU конфигурации GDT (ссылка). Если предположить что ничего в glibc (или любой вашей libc) не используетgs
, вы можете свободно им пользоваться. - В пространстве ядра Linux использует
gs
для хранения основного адреса региона per-CPU переменной. Мы можем наблюдать это в определении макросаPER_CPU_VAR
(ссылка):#define PER_CPU_VAR(var) %__percpu_seg:var
- В х86_64 определение растет:
%gs:var
Так что, к несчастью, нам все еще следует следить за сегментацией. С другой стороны, это не так уж и сложно, поскольку все сводится к добавлению значения из регистра сегмента к вычислению всего адреса.
Кстати, вот пример локально-поточной переменной:
int __thread x = 0;int foo(void) { int *y = &x; return *y;}
Результат:
push rbpmov rbp, rspmov rax, qword ptr fs:[0] ; grab the base address of the thread-local storage arealea rax, [rax + x@TPOFF] ; calculate the effective address of x within the TLSmov qword ptr [rbp - 8], rax ; store the address of x into ymov rax, qword ptr [rbp - 8]mov eax, dword ptr [rax]pop rbpret
(Прочесть на Godbolt.)