- PVSM.RU - https://www.pvsm.ru -
Сегодня я собираюсь поговорить про адресацию памяти: один, казалось бы, небольшой, и тем не менее удивительно непростой элемент семантики команд архитектуры х86_64. В особенности хочется поговорить про команду mov
и то, как через только одну эту команду х86_64 пользователю становятся доступны различные методы адресации памяти.
Я не буду говорить про остальные затрагивающие память команды (то есть, благодаря CISC, почти все остальные), команды которые пишут массивные фрагменты памяти (это о тебе, fxsave
), или иные касающиеся темы вопросы (модели кода, независящий от адреса код, и бинарная релокация). Я также не буду затрагивать исторические режимы адресации или режимы, которые активны при работе процессора x86_64 не в 64-битном режиме (т.е. любые отличные от long mode с 64-битным кодом).
Несмотря на кошмарное наследие кодирования команд х86_64, а может и благодаря ему, у адресации памяти есть некоторые ограничения.
Начнем с хорошего:
В остальном все не столь радужно:
0x67
), мы можем пользоваться 32-битными регистрами вместо 64-битных.Я не имею представления как назвать этот режим, поэтому называю его “Scale-Index-Base-Displacement”. Насколько я понимаю, ни Intel, ни AMD вообще не воспринимают его как единый режим и вместо этого упоминают в виде типичного набора связных режимов с широким диапазоном различных методов кодирования.
Но сегодня мы говорим не о кодировании, а о семантике, и семантически каждый из этих трех связных режимов сводится к комбинации нескольких из четырех следующих параметров:
Эти четыре параметра можно объединить в несколько различных комбинаций, полный список в порядке увеличения сложности можно увидеть ниже:
Displacement
Base
Base + Index
Base + Displacement
Base + Index + Displacement
Base + (Index * Scale)
(Index * Scale) + Displacement
Base + (Index * Scale) + Displacement
Давайте разберем каждую комбинацию по порядку.
Это, пожалуй, самый простой механизм адресации в семье х86: displacement
обрабатывается как абсолютный адрес памяти, и сам по себе, к несчастью, совершенно бесполезен в архитектуре х86_64. Помните, мы говорили, что displacement
почти всегда ограничен 32 битами? Так как абсолютный адрес в х86_64 это 64 бита (на самом деле 48, но это неважно), он попросту не поместится в displacement
, однако в виде исключения можно использовать 64-битный displacement с регистром a*
.
Синтаксис Intel:
; store the qword at 0x00000000000000ff into rax
mov rax, [0xff]
; store the dword at 0x00000000000000ff into eax
mov eax, [0xff]
; store the word at 0x00000000000000ff into ax
mov ax, [0xff]
; store the byte at 0x00000000000000ff into al
mov al, [0xff]
gas
(GNU ассемблер) и в 32, и в 64-битном режимах ссылается на них как на movabs
.
Для начала, из-за моделей кода, не имеющих отношения к данному посту. Подробнее по теме можно прочесть в замечательном посте Eli Bendersky по ссылке [1].
А теперь по делу: у большинства программ есть как минимум несколько статичных адресов, которые определяются в compile-time, аналогично глобальным переменным. Например, результатом следующей тривиальной программы:
extern long var;
void f(long x) { var = x; }
…будет:
f:
mov rax, rdi
movabs QWORD PTR [var], rax
ret
(Посмотреть на Godbolt [2].)
Адресация через регистр base добавляет еще один уровень неопределенности поверх абсолютной адресации: вместо использования закодированного в поле displacement
команды абсолютного адреса, адрес загружается из указанного регистра общего пользования (Причем любого такого регистра! Ура!)
Эта неопределенность позволяет нам проводить абсолютную адресацию с произвольным регистром назначения по следующему шаблону:
; store the immediate (not displacement) into rbx
mov rbx, 0xacabacabacabacab
; store the qword at the address stored in rbx into rcx
mov rcx, [rbx]
Однако учитывая сколько еще режимов адресации нас ждет впереди, нет особого смысла пользоваться таким методом.
Затем что порой после очередной операции мы уже посчитали адрес и хотим им воспользоваться.
Разобрав вариант в displacement
, мы можем найти хороший пример такого приема:
mov rax, qword ptr [rax]
Эта комбинация аналогична регистру base
за тем исключением, что мы добавляем значение регистра index
. Пример:
; store the qword in rcx into the memory address computed
; as the sum of the values in rax and rbx
mov [rax + rbx], rcx
Найти подходящий пример для такого режима оказалось для меня непростой задачей, поэтому мои коллеги, конечно же, сразу же подобрали один:
int foo(char * buf, int index) {
return buf[index];
}
Результат:
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov dword ptr [rbp - 12], esi
mov rax, qword ptr [rbp - 8] ; rax is buf
movsxd rcx, dword ptr [rbp - 12] ; rcx is index
movsx eax, byte ptr [rax + rcx] ; store buf[index] into eax
pop rbp
ret
(Посмотреть на Godbolt [3].)
Оглядываясь назад, мы можем заметить очевидное: в случаях, когда ни стартовый адрес массива, ни сдвиг в массив не зафиксированы в compile-time, Base + Index
идеально подходит для моделирования доступов к массиву.
Больше неопределенности! Если вы еще не догадались, подсчет актуального адреса и регистром base
, и полем displacement
соответствует двум следующим операциям:
base
displacement
Затем мы берем полученную сумму за фактический адрес. Пример:
; add 0xcafe to the value stored in rax
; then, store the qword at the computed address into rbx
mov rbx, [rax + 0xcafe]
Некоторые режимы адресации, как мы уже видели на примере Base + Index
, естественным образом отображают семантику С-подобных массивов. Base + Displacement
можно рассматривать в таком же ключе, но со стороны структурной семантики: регистр base
содержит адрес к началу структуры, а поле displacement
содержит фиксированный сдвиг в эту структуру. Пример:
struct foo {
long a;
long b;
};
long bar(struct foo *foobar) {
return foobar->b;
}
Результат:
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rax, qword ptr [rbp - 8] ; rax is foobar
mov rax, qword ptr [rax + 8] ; rax + 8 is foobar->b; store back into rax
pop rbp
ret
(Посмотреть на Godbolt [4].)
Если задуматься о конструкции и планировке стека в начале каждой функции как о самостоятельной структуре, пример выше становится очевиден: доступы вида [rbp - N]
это по сути stack->objN
.
Если вы разобрались в предыдущем режиме, логичным следующим шагом будет добавить к нему значение регистра index
, создавая новый, почти аналогичный семантически режим.
Как в примере выше, но с еще одним регистром, результат будет выглядеть следующим образом:
; add 0xcafe to the values stores in rax and rcx
; then, store the qword at the computer address into rbx
mov 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, 4
mov rax, qword ptr [rdi + rsi + 8] ; rdi is foos, rsi is i, 8 is the field offset
ret
(Посмотреть на Godbolt [5].)
Наше первое умножение!
Поле 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 [6].)
Почти как на примере выше, эта комбинация без лишних сложностей меняет регистр base
на поле displacement
.
(Index * Scale) + Displacement
естественным образом моделирует особый случай доступа к массиву: когда массив можно статически (т.е. глобально) адресовать, а размер элементов можно вычислить через scale
. Пример:
int tbl[10];
int foo(int i) {
return tbl[i];
}
Результат:
movsxd rax, edi
mov eax, dword ptr [4*rax + tbl] ; rax is i, 4 is the scale (sizeof(int) == 4)
ret
(Посмотреть на Godbolt [7].)
Мы наконец-то добрались до последней и самой сложной формы адресации в архитектуре 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, 4
mov rax, qword ptr [rax + 8*rsi + tbl]
ret
(Посмотреть на Godbolt [8].)
Выше мы описали режим адресации, который почти идентичен своему историческому эквиваленту в х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 убрала всю сегментацию, но только почти. Благодаря плоскому адресному пространству регистры сегментов более не требуются, но местами все еще проявляются:
fs
в пользовательском пространстве для доступа к настраиваемым ядром сегментам TLS. Спецификацию этих сегментов можно обнаружить в per-CPU конфигурации GDT (ссылка [9]). Если предположить что ничего в glibc (или любой вашей libc) не использует gs
, вы можете свободно им пользоваться.gs
для хранения основного адреса региона per-CPU переменной. Мы можем наблюдать это в определении макроса PER_CPU_VAR
(ссылка [10]):
#define PER_CPU_VAR(var) %__percpu_seg:var
%gs:var
Так что, к несчастью, нам все еще следует следить за сегментацией. С другой стороны, это не так уж и сложно, поскольку все сводится к добавлению значения из регистра сегмента к вычислению всего адреса.
Кстати, вот пример локально-поточной переменной:
int __thread x = 0;
int foo(void) {
int *y = &x;
return *y;
}
Результат:
push rbp
mov rbp, rsp
mov rax, qword ptr fs:[0] ; grab the base address of the thread-local storage area
lea rax, [rax + x@TPOFF] ; calculate the effective address of x within the TLS
mov qword ptr [rbp - 8], rax ; store the address of x into y
mov rax, qword ptr [rbp - 8]
mov eax, dword ptr [rax]
pop rbp
ret
(Прочесть на Godbolt [11].)
Автор: Дата-центр "Миран"
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/354282
Ссылки в тексте:
[1] по ссылке: https://eli.thegreenplace.net/2012/01/03/understanding-the-x64-code-models
[2] Godbolt: https://godbolt.org/z/4QMtpo
[3] Godbolt: https://gcc.godbolt.org/z/zphZpZ
[4] Godbolt: https://godbolt.org/z/ytzBW2
[5] Godbolt: https://godbolt.org/z/uZ4s6o
[6] Godbolt: https://godbolt.org/z/aX7lYG
[7] Godbolt: https://godbolt.org/z/5iiqa8
[8] Godbolt: https://godbolt.org/z/hhtPdz
[9] ссылка: https://elixir.bootlin.com/linux/v5.7.2/source/arch/x86/include/asm/segment.h#L58
[10] ссылка: https://elixir.bootlin.com/linux/v5.7.2/source/arch/x86/include/asm/percpu.h#L31
[11] Godbolt: https://godbolt.org/z/Jp6nV6
[12] Источник: https://habr.com/ru/post/507720/?utm_source=habrahabr&utm_medium=rss&utm_campaign=507720
Нажмите здесь для печати.