Стек

Последнее обновление: 14.10.2023

Стек — это динамическая структура данных, которая позволяет сохранить важную информация о программе, включая локальные переменные, информацию о подпрограммах и временные данные. Процессор x86-64 управляет стеком через специальный регистр RSP (указатель стека). Когда программа начинает выполняться, операционная система инициализирует регистр RSP адресом последней ячейки памяти в сегменте стека.

Стек позволяет экономить использование регистров: мы можем сохранить временно данные в стеке, а регистры использовать непосредственно для вычислений. В то же время стоит помнить, что размер стека ограничен и на разных системах он занимает разное пространство. Так, на Linux х86-64 стек ограничен 2 мегабайтами.

В процессе работы программы данные записываются в сегмент стека или, наоборот, извлекаются из стека. Стек растет от больших адресов к меньшим, то есть при добавлении в стек данных, адрес добавляемых данных будет уменьшаться. Стоит отметить, что по умолчанию при начале работы программы стек выровнен по 16-байтовой границе, то есть адрес, который хранится в стеке кратен 16.

Для добавления данных в стек применяется инструкция push. В качестве единственного операнда инструкция принимает добавляемое в стек значение. Мы можем добавить в стек значения 16- и 64-разрядного регистра, адрес в памяти 16- и 64-разрядного числа или значение 16- и 32-разрядной константы (32-битная констранта расширяется до 64 бит).

При выполнении инструкции push от значения регистра RSP вычитается размер операнда. А по адресу, который хранится в стеке, помещается значение операнда.

rsp = rsp - размер операнда
[rsp] = значение операнда

Инструкция pop позволяет, наоборот, взять из стека значение, адрес которого хранится в текущий момент в регистре RSP. Эта инструкция в качестве операнда получает место, куда надо сохранить данные из стека. Это может быть или 16- и 64-разрядный регистр или адрес в памяти 16- и 64-разрядного числа. При выполнении этой инструкции в операнд помещается значение, которое хранится в адресе из RSP. А само значение RSP увеличивается на размер операнда:

operand = [rsp]
rsp = rsp + размер операнда

Например, возьмем следующую программу на Linux:

global _start

section .text
_start:
    mov rdx, 15
    push rdx            ; в стек помещаем содержимое регистра RDX
    pop rdi             ; значение из вершины стека помещаем в регистр RDI
    mov rax, 60
    syscall 

Допустим, регистр RSP содержит изначально адрес 0x00FF_FFF0. Пусть в регистре RDX хранится некоторое значение, которое с помощи инструкции pushq заталкивается в стек:

push rdx

В результате в последующие 8 байт начиная с адреса, который хранится в RSP, помещается значение из регистра RDX (в данном случае число 15). А в регистр RSP будет помещен адрес RSP-8, то есть условно 0x00FF_FFE8 и сохранит текущее значение регистра RDX в ячейках памяти начиная с 0x00FF_FFF0 по 0x00FF_FFE8, то есть займет 8 байт.

rsp -------------------------- 0x00FF_FFE8
    push rdx -> |    15    | 
-------------------------------0x00FF_FFF0

Затем извлекаем из стека значение по адресу, который хранится в RSP, в регистр RDI:

pop rdi

В результате в RDI помещается число из стека (в данном случае число 15). А в регистр RSP будет помещен адрес RSP+8, то есть условно 00FF_FFF0h.

Аналогичная программа на Windows:

global _start

section .text
_start:
    mov rdx, 15
    push rdx            ; в стек помещаем содержимое регистра RDX
    pop rax             ; значение из вершины стека помещаем в регистр RAX
    ret    

Принцип LIFO и сохранение регистров в стек

Наиболее распространенное использование команд push и pop — это сохранение значений регистров во время промежуточных вычислений. Поскольку регистры — лучшее место для хранения временных значений, и регистры также могут потребоваться для других операций, поэтому в процессе программы легко исчерпать регистры. Инструкции push и pop позволяют сохранить начальные значения регистров при старте программы, а при завершении программы восстановить эти значения.

Следует учитывать, что стек представляет структуру LIFO (Last In, First Out или Последний вошел, первый вышел), что значит, что получение данных из стека происходит в порядке, обратном их добавлению. Рассмотрим следующую программу:

global _start

section .text
_start:
    mov rdi, 11
    mov rdx, 33

    push rdi
    push rdx

    pop rdi     ; rdi = 33
    pop rdx     ; rdx = 11

    mov rax, 60
    syscall 

Допустим, в самом начале программы до добавления данных стек регистр RSP хранит адрес 0x00FF_FFF0.

Затем добавляем в стек значение регистра RAX:

push rdi

Адрес в RSP смещается на 8 байтов и указывает на адрес в памяти, где хранится значение из регистра RDI

rsp = 0x00FF_FFE8

------------------------------- -0x00FF_FFE0
                              |           | 
rsp ---------------------------- 0x00FF_FFE8
push rdi ->      |    11    | 
-------------------------------- 0x00FF_FFF0

Далее добавляем в стек значение регистра RDX:

push rdx

Адрес в RSP смещается на 8 байтов и указывает на адрес значения из регистра RDX

rsp = 0x00FF_FFE0

rsp--------------------------- 0x00FF_FFE0
   push rdx -> |    33    | 
------------------------------ 0x00FF_FFE8
                            |     11    |
-------------------------------0x00FF_FFF0

После добавления мы последовательно извлекаем данные. Первая инструкция извлекает данные, на которые указывает регистр RSP, в регистр RDI:

pop rdi
rsp = 0x00FF_FFE8

----------------------------- 0x00FF_FFE0
   pop rdi <- |    33    | 
rsp --------------------------0x00FF_FFE8
                          |     11   |
------------------------------0x00FF_FFF0

Однако поскольку RSP перед операцией извлечения указывал на адрес последнего добавленного значения - значения регистра RDX, то регистр RDI получит значение регистра RDX. Соответственно при последующей инструкции pop:

pop rdx

Регистр RDX получить значение регистра RDI, которое было в регистре RDI до добавления в стек.

rsp = 0x00FF_FFF0

----------------------------- 0x00FF_FFE8
   pop rdx <- |    11    | 
rsp -------------------------0x00FF_FFF0

Поэтому если мы хотим восстановить начальные значения регистров, то нам надо извлекать значения в порядке, обратном добавлению

push rdi
push rdx

pop rdx   ; Последним добавлено значение RDX, поэтому сначала извлекаем в RDX
pop rdi

В любом случае стоит помнить, что количество инструкций push и pop должно быть равно, сколько раз мы добавили данные в стек, столько раз мы должны получить данные из стека.

Сохранение флагов состояния

Ассемблер предоставляет дополнительную пару инструкций pushfq и popfq для сохранения и восстановления соответственно регистра RFLAGS (и всех флагов состояния). Например:

global _start

section .text
_start:
    pushfq      ; сохраняем значения флагов
    mov al, 255
    add al, 2   ; 255 + 2 = 257 - флаг CF будет установлен 
    popfq       ; восстанавливаем значения флагов
    jc set      ; если флаг CF установлен, переход к метке set
    mov rdi, 11
    jmp exit
set:            
    mov rdi, 22
exit:
    mov rax, 60
    syscall 

Здесь инструкцией pushfq сначала сохраняем флаги. По умолчанию флаг переноса CF будет равен 0.

Затем выполняем сложение 255 + 2, что даст 257 и что очевидно за пределы разрядности регистра AL, соответственно будет установлен флаг переноса CF. Далее с помощью инструкции jc set переходим к метке set, если флаг CF установлен. Однако перед этой инструкцией мы восстанавливаем флаги - popfq. То есть флаг CF получит свое значение 0, и никакого перехода к метке set не произойдет.

Восстановление стека без извлечения данных

При завершении программы следует восстановить адрес в RSP. Как выше было показано, для этого мы можем использовать инструкцию pop. Однако может сложиться ситуация, что данные не требуется извлекать из стека. Например, в зависимости от некоторых условий данные могут понадобиться, а могут не понадобиться. Если данные не нужны, извлекать каждые 8 байт отдельно с помощью инструкции pop не имеет смысла, особенно если надо извлечь много данных из стека. И в этом случае мы можем восстановить адрес в RSP, просто прибавив нужное значение - смещение относительно начального адреса. Например:

global _start

section .text
_start:
    mov rdi, 11
    mov rdx, 33

    push rdi
    push rdx

    add rsp, 16     ; прибавляем к адресу в RSP 16 байт 

    mov rax, 60
    syscall 

Здесь в стек помещаем значения двух регистров - RDI и RDX, то есть адрес в RSP уменьшится на 16 байт (совокупный размер двух регистров). И чтобы быстро восстановить стек, прибавляем к адресу в RSP 16 байт:

add rsp, 16

Подобным образом можно вычитать из адреса в RSP определенное число, тем самым резервируя в стеке некоторое пространство:

global _start

section .text
_start:
    sub rsp, 16  ; резервируем в стеке 16 байт
    
    ; некоторая работа со стеком

    add rsp, 16     ; восстанавливаем значение стека
    mov rax, 60
    syscall 

Поскольку вначалае вычитаем из адреса в rsp 16 байт, то после работы со стеком к адресу в rsp также прибавляется 16 байт.

Косвенная адресация в стеке

Как и в случае с любым другим регистром, в отношении регистра стека RSP можно использовать косвенную адресацию и обращаться к данным в стеке без смещения указателя RSP. Например, следующая программа на Linux помещает в стек число из регистра rdx, а затем сохраняет это число из стека в регистр rdi:

global _start

section .text
_start:
    sub rsp, 16  ; резервируем в стеке 16 байт
    
    mov rdx, 11
    mov [rsp], rdx       ; помещаем в стек значение регистра RDX
    mov rdi, [rsp]       ; в RDI помещаем значение по адресу из RSP - число 11 

    add rsp, 16     ; восстанавливаем значение стека
    mov rax, 60
    syscall 

В данном случае в стек помещаем число из регистра RDX - число 11.

mov [rsp], rdx

Подобную форму размещения данных в стеке можно рассматривать как альтернативу инструкции push, если нам не надо изменять значение указателя стека RSP. То есть мы можем сохранить таким образом данные по адресу в RSP, но после этого RSP продолжает хранить тот же адрес.

Далее в регистр RDI помещаем значение, которое располагается по адресу из RSP. Фактически это тот адрес, где располагается число 11.

mov rdi, [rsp]

Аналогичная программа на Windows:

global _start

section .text
_start:
    sub rsp, 16  ; резервируем в стеке 16 байт
    
    mov rdx, 11
    mov [rsp], rdx       ; помещаем в стек значение регистра RDX
    mov rax, [rsp]       ; в RAX помещаем значение по адресу из RSP - число 11 

    add rsp, 16     ; восстанавливаем значение стека
    ret    

Аналогично можно применять смещения и масштабирование. Например, используем смещение в программе на Linux:

global _start

section .text
_start:
    push 12
    push 13
    push 14
    push 15

    mov rdi, [rsp+16]      ; [rsp+16] - адрес значения 13

    add rsp, 32     ; восстанавливаем значение стека

    mov rax, 60
    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 байт:

mov rdi, [rsp+16]

Соотвественно чтобы получить из стека первое число - 12, надо к адресу в RSP прибавить 24:

mov rdi, [rsp+24]

Аналогичная программа на Windows:

global _start

section .text
_start:
    push 12
    push 13
    push 14
    push 15

    mov rax, [rsp+16]      ; [rsp+16] - адрес значения 13

    add rsp, 32     ; восстанавливаем значение стека
    ret    

Другой пример (на Linux):

global _start

section .text
_start:
    sub rsp, 16     ; резервируем в стеке 16 байт

    mov rcx, 12
    mov rdx, 13

    mov [rsp + 8], rcx     ; [rsp + 8] = 12
    mov [rsp], rdx      ; [rsp] = 13

    mov rdi, [rsp]      ; rdi= 13
    add rdi, [rsp+8]     ; rdi = rdi + 12

    add rsp, 16     ; восстанавливаем значение стека

    mov rax, 60
    syscall 

Здесь по адресу RSP располагается значение региста RCX, а по адресу RSP+8 - регистра RDX. В RDI извлекаем значение по адресу RSP (13), и затем складываем его со значением из RSP+8 (12). Таким образом, в RDI будет число 25.

Аналогичный пример на Windows:

global _start

section .text
_start:
    sub rsp, 16     ; резервируем в стеке 16 байт

    mov rcx, 12
    mov rdx, 13

    mov [rsp + 8], rcx     ; [rsp + 8] = 12
    mov [rsp], rdx      ; [rsp] = 13

    mov rax, [rsp]      ; rax= 13
    add rax, [rsp+8]     ; rax = rax + 12

    add rsp, 16     ; восстанавливаем значение стека
    ret    

Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850