Фрейм стека и локальные переменные

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

При вызове процедура имеет доступ ко всему пространству стека, выделенному в программе. Тем не менее каждая процедура имеет свою какую-то специфичную информацию, включая адрес возврата, параметры и локальные переменные. И чтобы разграничить данные каждой отдельной процедуры в рамках стека для процедуры выделяется область стека, которая еще называется фреймом стека (stack frame).

В качестве указателя на базовый адрес фрейма стека в архитектуре x86-64 предназначен регистр RBP (BP - base pointer или базовый указатель).

Создание фрейма стека начинается в коде, который вызывает процедуру. Вызывающий код может передавать через стек значения для параметров. Вне зависимости, передаются ли параметры, инструкция call помещает адрес возврата в стек.

Далее создание фрейма стека продолжается внутри самой процедуры. Что мы можем добавить в этот фрейм в самой процедуре? Во многих языках программирования высокого уровня есть локальные переменные, которые доступны только в рамках какой-то определенной функции или блока кода. В ассемблере нет локальных переменных, все переменные, которые определяются в секции .data и других секциях, являются глобальными, доступны из любой части программы на ассемблере. Но использование стека позволяет определить данные, которые будут применяться только в данной процедуре. То есть такие данные будут локальны по отношению к процедуре. При завершении процедуры в ней очищается данная область стека, и соответственно данные удаляются и недоступны для других процедур. Конечно, для каких-то локальных данных мы могли бы использовать и регистры, но однако данных может быть очень много, для всего регистров может не хватить. Кроме того, немногочисленные регистры лучше использовать для вычислений.

Рассмотрим простейший пример:

.code
sum proc
    ; добавляем в стек число 5 - условная безымянная локальная переменная
    push 5          ; RSP указывает на адрес числа 5
    mov rax, rcx    ; в RAX значение параметра из RCX
    add rax, [rsp]  ; RAX = RAX + 5
    add rsp, 8      ; особождаем стек
    ret
sum endp

main proc
    mov rcx, 11 ; устанавливаем параметр для процедуры sum
    call sum    ; после вызова в RAX - результат сложения
    ret
main endp
end

Здесь процедура sum выполняет сложение двух чисел. Первое число передается через параметр - регистр RCX

Второе число мы сохраняем в стек в самой процедуре:

push 5

Пусть это будет число 5. Поскольку добавляем с помощью инструкции push, то для числа в стеке будет выделено 8 байт. После этого указатель стека RSP будет указвать на адрес, где хранится это число. Соответственно адрес возврата в стеке будет находиться по адресу [RSP+8].

В самой процедуре sum через адрес [rsp] складываем со значением регистра-параметра RCX, а результат помещается в регистр RAX. При этом я отмечу, что в данном случае идет речь не просто о константном числе 5, а именно о локальной переменной - некоторые данные, которые существуют во время работы процедуры и которые могут изменяться. Например, мы можем увеличить эту переменную:

.code
sum proc
    push 5                  ; По адресу [rsp] локальная переменная, которая равна 5
    add byte ptr [rsp], 2   ; увеличиваем переменную на 2 - она равна 7
    mov rax, rcx            ; в RAX значение параметра из RCX
    add rax, [rsp]          ; RAX = RAX + 7
    add rsp, 8              ; особождаем стек
    ret
sum endp

main proc
    mov rcx, 11
    call sum    ; RAX = 18
    ret
main endp
end

Здесь увеличиваем условную локальную переменную на 2:

add byte ptr [rsp], 2

В данном случае рассматриваем ее как значение типа byte, так как число 5 вполне укладывается в диапазон типа byte.

Нередко значения параметров, которые передаются через регистры, также помещаются в локальные переменные. Благодаря этому мы сможем высвободить регистры для вычислений:

.code
sum proc
    sub rsp, 8              ; выделяем для двух переменных в стеке 8 байт
    mov dword ptr [rsp], 5    ; По адресу [rsp] первая локальная переменная, которая равна 5
    mov dword ptr [rsp+4], ecx  ; По адресу [rsp+4] вторая локальная переменная, которая равна ECX

    mov eax, [rsp]        ; в EAX значение первой переменной
    add eax, [rsp+4]        ; EAX = EAX + вторая переменная
    add rsp, 8              ; особождаем стек
    ret
sum endp

main proc
    mov rcx, 11
    call sum    ; RAX = 16
    ret
main endp
end

Для наглядности вместо инструкции push данные добавляются посредством смещений, что позволит выделить меньшую область стека для данных. В частности, предполагаем, что обе переменных будут представлять 4-байтные числа (dword), соответственно нам хватит 8 байт. И сначала выделяем эту область

sub rsp, 8 

Потом помещаем в нее данные с помощью смещений. Учитываем, что поскольку мы не применяем инструкцию push, по умолчанию RSP указывает на адрес возврата. Чтобы обратиться к определенной области, прибавляем к адресу в RSP 0 и 4 (так как наши числа занимают 4 байта):

mov dword ptr [rsp], 5
mov dword ptr [rsp+4], ecx

Затем при операциях с переменными можно использовать смещения относительно RSP:

mov eax, [rsp]
add eax, [rsp+4] 

Установка имен переменных

Выше обе наших локальных переменных были безымянными. Для нас фактически они существуют лишь как смещения относительно указателя стека RSP. Однако манипулировать смещения не очень удобно, в процессе написания программы мы можем перепутать спещения. Но с помощью констант мы можем им назначить переменным определенные имена:

_a equ [rsp]
_b equ [rsp+4]

.code
sum proc
    sub rsp, 8
    mov dword ptr _a, 6    ; переменная по адресу [rsp]
    mov dword ptr _b, ecx  ; переменная по адресу [rsp+4]

    mov eax, _a
    add eax, _b
    add rsp, 8
    ret
sum endp

main proc
    mov rcx, 11
    call sum    ; RAX = 17
    ret
main endp
end

Здесь определены константы для смещений относительно стека под названием _a и _b:

_a equ [rsp]
_b equ [rsp+4]

Названия переменных произвольные (главное, чтобы они не были зарезервированными словами в ассемблере). В итоге везде, где в программе встретятся эти константы, фактически будут применяться адреса [rsp] и [rsp+4]:

mov dword ptr _a, 6 
....................
mov eax, _a

Регистр RBP

Для управления доступом к различным частям фрейма стека Intel предоставляет специальный регистр - RBP (Base Pointer). А для доступа к объектам во фрейме стека можно использовать смещение до нужного объекта относительно адреса из регистра RBP.

Вызывающий процедуру отвечает за выделение памяти для параметров в стеке и перемещение данных параметра в соответствующее место. Инструкция call помещает адрес возврата в стек. Процедура несет ответственность за создание остальной части фрейма, в частности, за добавление локальных переменных. Для этого при вызове процедуры значение RBP помещается в стек (поскольку при вызове процедуры в RBP значение вызывающего кода, и это значение надо сохранить), а значение указателя стека RSP копируется в RBP. Затем в стеке освобождается место для локальных переменных.

push rbp        ; срхраняем старое значение регистра RBP
mov rbp, rsp    ; помещаем указатель стека RSP в регистр RBP
sub rsp, пространство_для_локальных переменны

Локальные переменные не должны быть кратны 8 байтам, однако весь блок локальных переменных должен иметь размер, кратный 8 байтам, чтобы RSP оставался выровненным по 8-байтовой границе (при взаимодействии с функциями C/C++ в Windows согласно Microsoft ABI блок локальных переменных должен быть кратен 16 байтам). Локальные переменные занимают пространство, равное их собственному размеру (символы - 1 байт, слова word - 2 байта и т.д.).

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

mov rsp, rbp    ; удаляем локальные переменные и очищаем стек
pop rbp         ; восстанавливаем в RBP значение для вызывающего кода
ret             ; возвращаемся в вызывающий код

Место для параметров обычно очищается вызывающим кодом. Однако в принципе возможна также очистка памяти параметров и в вызванной процедуре. Для этого оператору ret передается количество байтов стека, которые надо освободить:

mov rsp, rbp    ; удаляем локальные переменные и очищаем стек
pop rbp         ; восстанавливаем в RBP значение для вызывающего кода
ret  N_байтов   ; возвращаемся в вызывающий код и очищаем N байтов в стеке, где лежат параметры

Также Intel представляет специальную инструкцию - leave, которая копирует значение RBP в RSP и извлекает ранее сохраненное значение регистра RBP.

leave
ret N_байтов   ; возвращаемся в вызывающий код и очищаем N байтов в стеке, где лежат параметры

Для доступа к объектам во фрейме стека необходимо использовать смещение до нужного объекта относительно адреса из регистра RBP. Для обращения к параметрам применяется положительное смещение относительно значения регистра RBP; для доступа к локальным переменным - отрицательное смещение. Следует с осторожностью использовать регистр RBP для общих расчетов, потому что если вы произвольно измените значение в регистре RBP, вы можете потерять доступ к параметрам текущей процедуры и локальным переменным.

Рассмотрим простейший пример:

sum proc
    push rbp
    mov rbp, rsp
    sub rsp, 8                  ; выделяем место для двух переменных

    mov dword ptr[rbp-4], 7     ; По адресу [rsp-4] первая локальная переменная, равная 7
    mov [rbp-8], ecx            ; По адресу [rsp-8] вторая локальная переменная, равная ECX

    mov eax, [rbp-4]    ; в EAX значение переменной из [rbp-4]
    add eax, [rbp-8]    ; EAX = EAX + [rbp-8]

    mov rsp, rbp        ; освобождаем стек
    pop rbp             ; восстанавливем RBP
    ret
sum endp

main proc
    mov rcx, 12
    call sum    ; RAX = 19
    ret
main endp
end

Опять же у нас функция sum использует две локальных переменных. Пусть обе переменных будут представлять тип dword, соответственно для них нужно в совокупности 8 байт:

push rbp
mov rbp, rsp
sub rsp, 8

Затем определяем значения локальных переменных в стеке, используя смещение относительно RBP:

mov dword ptr [rbp-4], 7
mov [rbp-8], ecx

То есть первая локальная переменная, которая равна 7, будет располагаться в стеке по адресу [rbp-4], а вторая переменная, которая получает значение из ECX - по адресу [rbp-8].

Далее для обращения к этим переменным применяется эти же адреса:

mov eax, [rbp-4]
add eax, [rbp-8]

В данном случае мы опять же могли бы использовать константы для именования переменных:

_a equ [rbp-4]
_b equ [rbp-8]
.code
sum proc
    push rbp
    mov rbp, rsp
    sub rsp, 8              ; выделяем место для двух переменных

    mov dword ptr _a, 7     ; локальная переменная _a равна 7
    mov _b, ecx             ; локальная переменная _b равна ECX

    mov eax, _a    ; в EAX значение переменной _a
    add eax, _b    ; EAX = EAX + _b

    mov rsp, rbp        ; освобождаем стек
    pop rbp             ; восстанавливем RBP
    ret
sum endp

main proc
    mov rcx, 12
    call sum    ; RAX = 19
    ret
main endp
end

Директива local

Для упрощения создания локальных переменных MASM предоставляет специальную директиву local, которая имеет следующий синтаксис:

local переменная1: тип, переменная2: тип, ... переменнаяN: тип

После директивы local указываются определения переменных, где для каждой переменной указывается имя и через двоеточие ее тип.

Директива local должна идти в процедуре сразу же после объявления процедуры до остальных инструкций. Процедура может иметь более одной директивы local. ; если имеется более одной локальной директивы, все они должны появиться вместе после объявления proc. Пример использования директивы локальных переменных:

.code
sum proc
    local _a:word, _b:dword    ; определяем две переменных
    push rbp
    mov rbp, rsp
    sub rsp, 8                  ; выделяем место для двух переменных

    mov _a, 5
    mov _b, ecx 

    movzx eax, _a
    add eax, _b

    mov rsp, rbp        ; освобождаем стек
    pop rbp             ; восстанавливем RBP
    ret
sum endp

main proc
    mov rcx, 22
    call sum    ; RAX = 27
    ret
main endp
end

Здесь определяются две переменных - _a и _b, которые представляют типы word и dword. MASM автоматически связывает соответствующие смещения с каждой переменной из директивы local. MASM назначает смещения переменным, вычитая размер переменной из текущего смещения (начиная с нуля), а затем округляя до смещения, кратного размеру объекта. При этом все равно надо выделять в стеке место для хранения локальных переменных.

Поскольку установка значения для регистра RBP, выделение места в стеке, а затем освобождение места и восстановление RBP представляют рутинную работу, которая может повторяться из процедуры в процедуру, то для упрощения управления стеком MASM позволяет сократить код, используя директиву option:

option prologue:PrologueDef
option epilogue:EpilogueDef

Данные выражения ставится перед процедурой. В итоге MASM будет автоматически выполнять все действия по выделению и освобождению места в стеке и сохранению/восстановлению регистра RBP:

.code
option prologue:PrologueDef
option epilogue:EpilogueDef     ; вручную выделять память больше не надо
sum proc
    local _a:word, _b:dword

    mov _a, 6
    mov _b, ecx 

    movzx eax, _a
    add eax, _b
    ret
sum endp

main proc
    mov rcx, 22
    call sum    ; RAX = 28
    ret
main endp
end
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850