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

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

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

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

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

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

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

.globl _start 

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

    movq $60, %rax
    syscall

sum:
    # добавляем в стек число 5 - условная безымянная локальная переменная
    pushq $5          # RSP указывает на адрес числа 5
    movq %rdi, %rax    # в RAX значение параметра из RDI
    addq (%rsp), %rax    # rax = rax + (%rsp) = rax + 5
    addq $8, %rsp      # особождаем стек
    ret

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

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

pushq $5

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

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

.globl _start 

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

    movq $60, %rax      # RDI = 18
    syscall

sum:
    # добавляем в стек число 5 - условная безымянная локальная переменная
    pushq $5          # RSP указывает на адрес числа 5
    addb $2, (%rsp)  # увеличиваем переменную на 2 - она равна 7
    movq %rdi, %rax    # в RAX значение параметра из RDI
    addq (%rsp), %rax    # rax = rax + (%rsp) = rax + 5
    addq $8, %rsp      # особождаем стек
    ret

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

addb $2, (%rsp)

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

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

.globl _start 

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

    movq $60, %rax      # RDI = 16
    syscall

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

    movl $5, 4(%rsp)       # По адресу (rsp+4) первая локальная переменная, которая равна 5
    movl %edi, (%rsp)     # По адресу (rsp) вторая локальная переменная, которая равна ECX

    movl 4(%rsp), %eax     # в EAX значение первой переменной
    addl (%rsp), %eax     # EAX = EAX + вторая переменная

    addq $8, %rsp           # особождаем стек
    ret

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

subq $8, %rsp

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

movl $5, 4(%rsp)
movl %edi, (%rsp) 

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

movl 4(%rsp), %eax
addl (%rsp), %eax

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

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

.globl _start 

.equ _a, 4
.equ _b, 0
.text
_start:
    movq $11, %rdi      # в RDI параметр для функции sum
    call sum            # после вызова в RAX - результат сложения
    movq %rax, %rdi     # помещаем результат в RDI

    movq $60, %rax      # RDI = 16
    syscall

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

    movl $5, _a(%rsp)       # По адресу (rsp+4) первая локальная переменная, которая равна 5
    movl %edi, _b(%rsp)     # По адресу (rsp) вторая локальная переменная, которая равна ECX

    movl _a(%rsp), %eax     # в EAX значение первой переменной
    addl _b(%rsp), %eax     # EAX = EAX + вторая переменная

    addq $8, %rsp           # особождаем стек
    ret

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

.equ _a, 4
.equ _b, 0

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

movl $5, _a(%rsp)       # По адресу (rsp+4) первая локальная переменная, которая равна 5
.......................
movl _a(%rsp), %eax     # в EAX значение первой переменной

Регистр RBP

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

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

pushq %rbp        # сохраняем старое значение регистра RBP
movq %rsp, %rbp   # помещаем указатель стека RSP в регистр RBP
subq пространство_для_локальных переменных, %rsp

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

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

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

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

.globl _start 

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

    movq $60, %rax      # RDI = 16
    syscall

sum:
    pushq %rbp              # сохраняем старое значение RBP в стек
    movq %rsp, %rbp         # копируем текущий адрес из RSP в RBP
    subq $16, %rsp          # выделяем место для двух переменных по 8 байт

    movq $7, -8(%rbp)       # По адресу -8(%rbp) первая локальная переменная, равная 7
    movq %rdi, -16(%rbp)    # По адресу -16(%rbp) вторая локальная переменная, равная RDI

    movq -8(%rbp), %rax     # в RAX значение из -8(%rbp)  - первая локальная переменная
    addq -16(%rbp), %rax    # RAX = RAX + -16(%rbp) - вторая локальная переменная

    movq %rbp, %rsp         # восстанавливаем ранее сохраненное значение RSP 
    popq %rbp               # восстанавливем RBP
    
    ret

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

pushq %rbp              # сохраняем старое значение RBP в стек
movq %rsp, %rbp         # копируем текущий адрес из RSP в RBP

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

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

subq $16, %rsp 

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

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

movq $7, -8(%rbp)       # По адресу -8(%rbp) первая локальная переменная, равная 7
movq %rdi, -16(%rbp)    # По адресу -16(%rbp) вторая локальная переменная, равная RDI

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

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

movq -8(%rbp), %rax     # в RAX значение из -8(%rbp)  - первая локальная переменная
addq -16(%rbp), %rax    # RAX = RAX + -16(%rbp) - вторая локальная переменная

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

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

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

.globl _start 

.equ _a, -8
.equ _b, -16
.text
_start:
    movq $11, %rdi      # в RDI параметр для функции sum
    call sum            # после вызова в RAX - результат сложения
    movq %rax, %rdi     # помещаем результат в RDI

    movq $60, %rax      # RDI = 16
    syscall

sum:
    pushq %rbp              # сохраняем старое значение RBP в стек
    movq %rsp, %rbp         # копируем текущий адрес из RSP в RBP
    subq $16, %rsp          # выделяем место для двух переменных по 8 байт

    movq $8, _a(%rbp)       # По адресу -8(%rbp) - локальная переменная _a
    movq %rdi, _b(%rbp)    # По адресу -16(%rbp) - локальная переменная _b

    movq _a(%rbp), %rax     # в RAX значение из _a  - первая локальная переменная
    addq _b(%rbp), %rax    # RAX = RAX + _b - вторая локальная переменная

    movq %rbp, %rsp         # восстанавливаем ранее сохраненное значение RSP 
    popq %rbp               # восстанавливем RBP
    ret

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

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

pushq %rbp
movq %rsp, %rbp
subq $N_байтов, %rsp 

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

enter $N_байтов, $0

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

А вместо кода

movq %rbp, %rsp
popq %rbp 

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

leave

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

.globl _start 

.equ _a, -8
.equ _b, -16
.text
_start:
    movq $11, %rdi      # в RDI параметр для функции sum
    call sum            # после вызова в RAX - результат сложения
    movq %rax, %rdi     # помещаем результат в RDI

    movq $60, %rax      # RDI = 16
    syscall

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

    movq $8, _a(%rbp)       # По адресу -8(%rbp) первая локальная переменная, равная 8
    movq %rdi, _b(%rbp)    # По адресу -16(%rbp) вторая локальная переменная, равная RDI

    movq _a(%rbp), %rax     # в RAX значение из -8(%rbp)  - первая локальная переменная
    addq _b(%rbp), %rax    # RAX = RAX + -16(%rbp) - вторая локальная переменная

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

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

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