Стек — это динамическая структура данных, которая позволяет сохранить важную информация о программе, включая локальные переменные, информацию о подпрограммах и временные данные. Процессор x86-64 управляет стеком через специальный регистр RSP (указатель стека). Когда программа начинает выполняться, операционная система инициализирует регистр RSP адресом последней ячейки памяти в сегменте стека.
Стек позволяет экономить использование регистров: мы можем сохранить временно данные в стеке, а регистры использовать непосредственно для вычислений. В то же время стоит помнить, что стек на Linux х86-64 ограничен 2 мегабайтами.
В процессе работы программы данные записываются в сегмент стека или, наоборот, извлекаются из стека. Стек растет от больших адресов к меньшим, то есть при добавлении в стек данных, адрес добавляемых данных будет уменьшаться. Стоит отметить, что по умолчанию при начале работы программы стек выровнен по 16-байтовой границе, то есть адрес, который хранится в стеке кратен 16.
Для добавления данных в стек применяется инструкция push, которая имеет две формы:
pushw # для добавления в стек 16-разрядных чисел pushq # для добавления в стек 64-разрядных чисел
В качестве единственного операнда инструкция принимает добавляемое в стек значение. Мы можем добавить в стек значения 16- и 64-разрядного регистра, 16- и 64-разрядной переменной и 16- и 32-разрядной константы (32-битная констранта расширяется до 64 бит).
При выполнении инструкции push
от значения регистра RSP вычитается размер операнда. А по адресу, который хранится в стеке, помещается значение операнда.
%rsp = %rsp - размер операнда (%rsp) = значение операнда
Инструкция pop позволяет, наоборот, взять из стека значение, адрес которого хранится в текущий момент в регистре RSP. Эта инструкция также имеет две формы:
popw popq
Инструкция в качестве операнда получает место, куда надо сохранить данные из стека. Это может быть или 16- и 64-разрядный регистр, или 16- и 64-разрядная переменная. При выполнении этой инструкции в операнд помещается значение, которое хранится в адресе из RSP. А само значение RSP увеличивается на размер операнда:
operand = (%rsp) %rsp = %rsp + размер операнда
Например, возьмем следующую программу:
.globl _start .text _start: movq $15, %rdx pushq %rdx # в стек помещаем содержимое регистра RDX popq %rdi # значение из вершины стека помещаем в регистр RDI movq $60, %rax syscall
Допустим, регистр RSP
содержит изначально адрес 0x00FF_FFF0. Пусть в регистре RDX хранится некоторое значение, которое с помощи инструкции pushq
заталкивается в стек:
pushq %rdx
В результате в последующие 8 байт начиная с адреса, который хранится в RSP, помещается значение из регистра RDX (в данном случае число 15). А в регистр RSP будет помещен адрес RSP-8, то есть условно 0x00FF_FFE8 и сохранит текущее значение регистра RDX в ячейках памяти начиная с 0x00FF_FFF0 по 0x00FF_FFE8, то есть займет 8 байт.
%rsp -------------------------- 0x00FF_FFE8 pushq %rdx -> | 15 | --------------------------------0x00FF_FFF0
Затем извлекаем из стека значение по адресу, который хранится в RSP, в регистр RDI:
popq %rdi
В результате в RDI помещается число из стека (в данном случае число 15). А в регистр RSP будет помещен адрес RSP+8, то есть условно 00FF_FFF0h.
Наиболее распространенное использование команд push и pop — это сохранение значений регистров во время промежуточных вычислений. Поскольку регистры — лучшее место для хранения временных значений, и регистры также могут потребоваться для других операций, поэтому в процессе программы легко исчерпать регистры. Инструкции push и pop позволяют сохранить начальные значения регистров при старте программы, а при завершении программы восстановить эти значения.
Следует учитывать, что стек представляет структуру LIFO (Last In, First Out или Последний вошел, первый вышел), что значит, что получение данных из стека происходит в порядке, обратном их добавлению. Рассмотрим следующую программу:
.globl _start .text _start: movq $11, %rdi movq $33, %rdx pushq %rdi pushq %rdx popq %rdi popq %rdx movq $60, %rax syscall
Допустим, в самом начале программы до добавления данных стек регистр RSP хранит адрес 0x00FF_FFF0.
Затем добавляем в стек значение регистра RAX:
movq $11, %rdi
Адрес в RSP смещается на 8 байтов и указывает на адрес в памяти, где хранится значение из регистра RDI
%rsp = 0x00FF_FFE8 ------------------------------- -0x00FF_FFE0 | | %rsp --------------------------- 0x00FF_FFE8 pushq %rdi -> | 11 | -------------------------------- 0x00FF_FFF0
Далее добавляем в стек значение регистра RDX:
pushq %rdx
Адрес в RSP смещается на 8 байтов и указывает на адрес значения из регистра RDX
%rsp = 0x00FF_FFE0 %rsp-------------------------- 0x00FF_FFE0 pushq %rdx -> | 33 | ------------------------------ 0x00FF_FFE8 | 11 | -------------------------------0x00FF_FFF0
После добавления мы последовательно извлекаем данные. Первая инструкция извлекает данные, на которые указывает регистр RSP, в регистр RDI:
popq %rdi
%rsp = 0x00FF_FFE8 ----------------------------- 0x00FF_FFE0 popq %rdi <- | 33 | %rsp -------------------------0x00FF_FFE8 | 11 | ------------------------------0x00FF_FFF0
Однако поскольку RSP перед операцией извлечения указывал на адрес последнего добавленного значения - значения регистра RDX, то регистр RDI получит значение регистра RDX. Соответственно при последующей инструкции pop:
popq %rdx
Регистр RDX получить значение регистра RDI, которое было в регистре RDI до добавления в стек.
%rsp = 0x00FF_FFF0 ----------------------------- 0x00FF_FFE8 popq %rdx <- | 11 | %rsp -------------------------0x00FF_FFF0
Поэтому если мы хотим восстановить начальные значения регистров, то нам надо извлекать значения в порядке, обратном добавлению
pushq %rdi pushq %rdx popq %rdx # Последним добавлено значение RDX, поэтому сначала извлекаем в RDX popq %rdi
В любом случае стоит помнить, что количество инструкций push
и pop
должно быть равно, сколько раз мы добавили данные в стек, столько раз мы должны получить данные из стека.
Ассемблер предоставляет дополнительную пару инструкций pushfq и popfq для сохранения и восстановления соответственно регистра RFLAGS (и всех флагов состояния). Например:
.globl _start .text _start: pushfq # сохраняем значения флагов movb $255, %al addb $2, %al # 255 + 2 = 257 - флаг CF будет установлен popfq # восстанавливаем значения флагов jc set # если флаг CF установлен, переход к метке set movq $0, %rdi jmp exit set: movq $1, %rdi exit: movq $60, %rax syscall
Здесь инструкцией pushfq
сначала сохраняем флаги. По умолчанию флаг переноса CF будет равен 0.
Затем выполняем сложение 255 + 2, что даст 257 и что очевидно за пределы разрядности регистра AL, соответственно будет установлен флаг переноса CF. Далее с помощью инструкции
jc set
переходим к метке set, если флаг CF установлен. Однако перед этой инструкцией мы восстанавливаем флаги - popfq
. То есть флаг CF получит свое значение 0, и никакого перехода к метке set
не произойдет.
При завершении программы следует восстановить адрес в RSP. Как выше было показано, для этого мы можем использовать инструкцию pop
. Однако может сложиться ситуация, что данные не требуется извлекать из стека.
Например, в зависимости от некоторых условий данные могут понадобиться, а могут не понадобиться. Если данные не нужны, извлекать каждые 8 байт отдельно с помощью инструкции pop
не имеет смысла, особенно если надо извлечь много данных из стека. И в этом случае мы можем восстановить адрес в RSP, просто прибавив нужное значение - смещение относительно начального
адреса. Например:
.globl _start .text _start: movq $11, %rdi movq $33, %rdx pushq %rdi pushq %rdx addq $16, %rsp # прибавляем к адресу в RSP 16 байт movq $60, %rax syscall
Здесь в стек помещаем значения двух регистров - RDI и RDX, то есть адрес в RSP уменьшится на 16 байт (совокупный размер двух регистров). И чтобы быстро восстановить стек, прибавляем к адресу в RSP 16 байт:
add $16, %rsp
Подобным образом можно вычитать из адреса в RSP определенное число, тем самым резервируя в стеке некоторое пространство:
.globl _start .text _start: subq $16, %rsp # резервируем в стеке 16 байт ### некоторая работа со стеком addq $16, %rsp # восстанавливаем значение стека movq $60, %rax syscall
Поскольку вначалае вычитаем из адреса в rsp 16 байт, то после работы со стеком к адресу в rsp также прибавляется 16 байт.
Как и в случае с любым другим регистром, в отношении регистра стека RSP можно использовать косвенную адресацию и обращаться к данным в стеке без смещения указателя RSP. Например:
.globl _start .text _start: subq $16, %rsp # резервируем в стеке 16 байт movq $11, %rdx movq %rdx, (%rsp) # помещаем в стек значение регистра RDX movq (%rsp), %rdi # в RDI помещаем значение по адресу из RSP - число 11 addq $16, %rsp # восстанавливаем значение стека movq $60, %rax syscall
В данном случае в стек помещаем число из регистра RDX - число 11.
movq %rdx, (%rsp)
Подобную форму размещения данных в стеке можно рассматривать как альтернативу инструкции push
, если нам не надо изменять значение указателя стека RSP. То есть мы можем
сохранить таким образом данные по адресу в RSP, но после этого RSP продолжает хранить тот же адрес.
Далее в регистр RDI помещаем значение, которое располагается по адресу из RSP. Фактически это тот адрес, где располагается число 11.
movq (%rsp), %rdi
Аналогично можно применять смещения и масштабирование. Например:
.globl _start .text _start: pushq $12 pushq $13 pushq $14 pushq $15 movq 16(%rsp), %rdi # 16(%rsp) - адрес значения 13 addq $32, %rsp # восстанавливаем значение стека movq $60, %rax syscall
Здесь в стек последовательно помещаются числа 12, 13, 14, 15. Каждое число будет занимать 8 байт. После добавления адрес в RSP будет указывать на адрес последнего добавленного числа - 15.
%rsp = 0x00FF_FFD0 %rsp----------------------- 0x00FF_FFD0 | 15 | -------------------------- 0x00FF_FFD8 | 14 | -------------------------- 0x00FF_FFE0 | 13 | -------------------------- 0x00FF_FFE8 | 12 | ---------------------------0x00FF_FFF0
И чтобы, например, получить предыдущее число - 14, нам надо к адресу в RSP прибавить 8. А чтобы обратиться к числу 13, надо прибавить 16 байт:
movq 16(%rsp), %rdi
Соотвественно чтобы получить из стека первое число - 12, надо к адресу в RSP прибавить 24:
movq 24(%rsp), %rdi
Другой пример:
.globl _start .text _start: subq $16, %rsp # резервируем в стеке 16 байт movq $12, %rcx movq $13, %rdx movq %rcx, 8(%rsp) # 8(%rsp) = 12 movq %rdx, (%rsp) # (%rsp) = 13 movq (%rsp), %rdi # rdi= 13 addq 8(%rsp), %rdi # rdi = rdi + 12 addq $16, %rsp # восстанавливаем значение стека movq $60, %rax syscall
Здесь по адресу RSP располагается значение региста RCX, а по адресу RSP+8 - регистра RDX. В RDI извлекаем значение по адресу RSP (13), и затем складываем его со значением из RSP+8 (12). Таким образом, в RDI будет число 25.