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