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

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

При вызове функции доступен весь стек, выделенный в программе. Но каждая функция может иметь свою особенную информацию, например, локальные переменные, параметры и прочие. И для хранения этой информации для функции определяется фрейм стека (stack frame) - то есть некоторая область в стеке, которая предназначена для текущей функции, включая адрес возврата, параметры и локальные переменные. Этот блок данных называется Фрейм стека (stack frame). Для доступа к фрейму стека в архитектуре x86-64 предназначен регистр RBP (BP - base pointer или базовый указатель), который представляет указатель на базовый адрес фрейма стека.

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

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

Рассмотрим простейшую программу для Linux:

global _start

section .data
nums dq 10, 20, 30, 15, 15
count equ ($-nums)/numSize    ; количество элементов
numSize equ 8   ; размер каждого элемента

section .text
_start:
    mov rdi, 11       ; в RDI параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    mov rdi, rax     ; помещаем результат в RDI
    mov rax, 60
    syscall 

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

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

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

push 5

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

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

sum:
    ; добавляем в стек число 5 - условная безымянная локальная переменная
    push 5          ; RSP указывает на адрес числа 5
    add byte [rsp], 2 ; увеличиваем переменную на 2 - она равна 7
    mov rax, rdi    ; в RAX значение параметра из RDI
    add rax, [rsp]  ; rax = rax + [rsp] = rax + 5
    add rsp, 8      ; особождаем стек
    ret

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

add byte [rsp], 2

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

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

global _start

section .text
_start:
    mov rdi, 11       ; в RDI параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    mov rdi, rax     ; помещаем результат в RDI
    mov rax, 60
    syscall 

sum:
    sub rsp, 8         ; резервируем для двух переменных в стеке 8 байт

    mov dword [rsp+4], 5       ; По адресу [rsp+4] первая локальная переменная, которая равна 5
    mov dword [rsp], edi     ; По адресу [rsp] вторая локальная переменная, которая равна EDI

    mov eax, [rsp+4]    ; в EAX значение первой переменной (5)
    add eax, [rsp]     ; EAX = EAX + вторая переменная (edi)

    add rsp, 8           ; особождаем стек
    ret

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

sub rsp, 8

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

mov dword [rsp+4], 5 
mov dword [rsp], edi 

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

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

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

global _start

section .text
_start:
    mov rcx, 11       ; в RCX параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    ret

sum:
    sub rsp, 8         ; резервируем для двух переменных в стеке 8 байт

    mov dword [rsp+4], 5       ; По адресу [rsp+4] первая локальная переменная, которая равна 5
    mov dword [rsp], ecx     ; По адресу [rsp] вторая локальная переменная, которая равна ECX

    mov eax, [rsp+4]    ; в EAX значение первой переменной (5)
    add eax, [rsp]     ; EAX = EAX + вторая переменная (ecx)

    add rsp, 8           ; особождаем стек
    ret

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

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

global _start

_a equ 4    ; смещение переменной _a относительно rsp
_b equ 0    ; смещение переменной _b относительно rsp

section .text
_start:
    mov rdi, 12       ; в RDI параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    mov rdi, rax     ; помещаем результат в RDI
    mov rax, 60
    syscall 

sum:
    sub rsp, 8         ; резервируем для двух переменных в стеке 8 байт

    mov dword [rsp+_a], 5      ; По адресу (rsp+4) первая локальная переменная, которая равна 5
    mov dword [rsp + _b], edi    ; По адресу (rsp) вторая локальная переменная, которая равна EDI

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

    add rsp, 8           ; особождаем стек
    ret

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

_a equ 4    ; смещение переменной _a относительно rsp
_b equ 0    ; смещение переменной _b относительно rsp

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

mov dword [rsp+_a], 5      ; По адресу (rsp+4) первая локальная переменная, которая равна 5
......................................
mov eax, [rsp+_a]     ; в EAX значение первой переменной

Регистр RBP

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

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

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

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

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

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

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

global _start

section .text
_start:
    mov rdi, 11       ; в RDI параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    mov rdi, rax     ; помещаем результат в RDI
    mov rax, 60
    syscall 

sum:
    push rbp              ; сохраняем старое значение RBP в стек
    mov rbp, rsp         ; копируем текущий адрес из RSP в RBP
    sub rsp, 16          ; выделяем место для двух переменных по 8 байт

    mov qword[rbp-8] , 7      ; По адресу [rbp-8] первая локальная переменная, равная 7
    mov qword [rbp-16], rdi    ; По адресу [rbp-16] вторая локальная переменная, равная RDI

    mov rax, [rbp-8]    ; в RAX значение из [rbp-8]  - первая локальная переменная
    add rax, [rbp-16]    ; RAX = RAX + [rbp-16] - вторая локальная переменная

    mov rsp, rbp         ; восстанавливаем ранее сохраненное значение RSP 
    pop rbp               ; восстанавливем RBP
    
    ret

В функции sum первым делом сохраняем старое значение rbp и копируем в регистр rbp текущее значение указателя стека rsp - оно же старое значение rbp

push rbp              ; сохраняем старое значение RBP в стек
mov rbp, rsp         ; копируем текущий адрес из RSP в RBP

После этого регистр rbp указывает на текущее значение rsp.

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

sub rsp, 16

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

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

mov qword [rbp-8] , 7      ; По адресу [rbp-8] первая локальная переменная, равная 7
mov qword [rbp-16], rdi    ; По адресу [rbp-16] вторая локальная переменная, равная RDI

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

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

mov rax, [rbp-8]    ; в RAX значение из [rbp-8]  - первая локальная переменная
add rax, [rbp-16]    ; RAX = RAX + [rbp-16] - вторая локальная переменная

Визуально это можно представить следующим образом:

------------------------------------------------------------
 rsp ->    | 2-я локальная переменная: [rbp-16]  | 0x00E8
------------------------------------------------------------------
                | 1-я локальная переменная: [rbp-8]    | 0x00E8
------------------------------------------------------------------
 rbp ->    | Предыдущее значение rbp                  | 0x00F0
------------------------------------------------------------------
                | Адрес возврата                                    | 0x00F8
------------------------------------------------------------------

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

global _start

_a equ -8
_b equ -16

section .text
_start:
    mov rdi, 11       ; в RDI параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    mov rdi, rax     ; помещаем результат в RDI
    mov rax, 60
    syscall 

sum:
    push rbp              ; сохраняем старое значение RBP в стек
    mov rbp, rsp         ; копируем текущий адрес из RSP в RBP
    sub rsp, 16          ; выделяем место для двух переменных по 8 байт

    mov qword[rbp+_a] , 7      ; По адресу [rbp-8] первая локальная переменная, равная 7
    mov qword [rbp+_b], rdi    ; По адресу [rbp-16] вторая локальная переменная, равная RDI

    mov rax, [rbp+_a]    ; в RAX значение из [rbp-8]  - первая локальная переменная
    add rax, [rbp+_b]    ; RAX = RAX + [rbp-16] - вторая локальная переменная

    mov rsp, rbp         ; восстанавливаем ранее сохраненное значение RSP 
    pop rbp               ; восстанавливем RBP    
    ret

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

global _start

_a equ -8
_b equ -16

section .text
_start:
    mov rcx, 11       ; в RCX параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    ret

sum:
    push rbp              ; сохраняем старое значение RBP в стек
    mov rbp, rsp         ; копируем текущий адрес из RSP в RBP
    sub rsp, 16          ; выделяем место для двух переменных по 8 байт

    mov qword[rbp+_a] , 7      ; По адресу [rbp-8] первая локальная переменная, равная 7
    mov qword [rbp+_b], rcx    ; По адресу [rbp-16] вторая локальная переменная, равная RCX

    mov rax, [rbp+_a]    ; в RAX значение из [rbp-8]  - первая локальная переменная
    add rax, [rbp+_b]    ; RAX = RAX + [rbp-16] - вторая локальная переменная

    mov rsp, rbp         ; восстанавливаем ранее сохраненное значение RSP 
    pop rbp               ; восстанавливем RBP
    
    ret

Инструкции enter и leave

Поскольку данная схема работа с регистром %rbp довольно распространена, то для упрощения ассемблер NASM предоставляет две дополнительные инструкции. Так, вместо кода:

push rbp
mov rbp, rsp
sub rsp, N_байтов 

Можно применять следующую инструкцию:

enter N_байтов, 0

Инструкции enter передается выделяемое в стеке количество байт, а второй параметр - число 0. При выполнении эта инструкция сама сохранит старое значение %rbp в стек, скопирует значение rsp в rbp и выделит в стеке N_байтов.

А вместо кода

mov rsp, rbp
pop rbp 

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

leave

Так, перепишем предыдущий пример, использовав эти инструкции:

global _start

_a equ -8
_b equ -16

section .text
_start:
    mov rdi, 12       ; в RDI параметр для функции sum
    call sum            ; после вызова в RAX - результат сложения
    mov rdi, rax     ; помещаем результат в RDI
    mov rax, 60
    syscall 

sum:
    enter 16, 0       ; сохраняем значения RSP и RBP и выделяем в стеке 16 байт

    mov qword[rbp+_a] , 7      ; По адресу [rbp-8] первая локальная переменная, равная 7
    mov qword [rbp+_b], rdi    ; По адресу [rbp-16] вторая локальная переменная, равная RDI

    mov rax, [rbp+_a]    ; в RAX значение из [rbp-8]  - первая локальная переменная
    add rax, [rbp+_b]    ; RAX = RAX + [rbp-16] - вторая локальная переменная

    leave       ; восстанавливаем ранее сохраненное значение RSP и RBP
    ret

На практике считается, что инструкция enter работает медленнее, чем заменяемый ею код. Поэтому ее использование можно встречить не часто. А вот инструкция leave работает быстрее, чем заменяемый ею код, поэтому она достаточно распространена.

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