При вызове функции доступен весь стек, выделенный в программе. Но каждая функция может иметь свою особенную информацию, например, локальные переменные, параметры и прочие. И для хранения этой информации для функции определяется фрейм стека (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 значение первой переменной
Для управления доступом к различным частям фрейма стека 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
Поскольку данная схема работа с регистром %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 работает быстрее, чем заменяемый ею код, поэтому она достаточно распространена.