В данной статье мы рассмотрим, что такое стек, процедуры, макросы для объявления процедур и как их вызывать.
Стек
Стек — область памяти, в него можно добавлять данные и извлекать их, добавление и извлечение организовано по правилу “последним вошёл, первым вышел”.
Приведу пример: если добавить в стек подряд числа 1, 2, 3, то извлекаются они в порядке: 3, 2, 1. Для помещения в стек значения служит команда push <op>
,
в качестве операнда может выступать регистр общего назначения или память, причём размер должен быть либо 2 байта, либо 8 байт. Вытащить из стека значение
можно командой pop <op>
, в качестве операнда тут опять же может выступать либо регистр, либо память. Для примера напишем программу, которая перевернёт массив
задом на перёд, вообще-то для этого действия стек не нужен и более оптимально было-бы обойтись без стека, но в качестве демонстрации — подойдёт.
format PE64 console
entry start
include 'win64a.inc'
section '.code' readable executable code
proc start
int3
mov ecx,5;ecx - счётчик
lea rax,[Arr];rax указывает на исходный массив
.PushArr:;цикл из 5-и итераций
push qword [rax];помещаем очередной элемент массива в стек
add rax,8;передвигаем указатель на следующий элемент массива
dec ecx;вычитаем единицу из счётчика
jnz .PushArr;если не весь массив обработан, возвращаемся к следующей итерации
mov ecx,5;ecx - счётчик
lea rax,[ArrDst];rax указывает на конечный массив
.PopArr:;цикл из 5-и итераций
pop qword [rax];достаём из стека элемент и записываем его в конечный массив
add rax,8;передвигаем указатель на следующий элемент массива
dec ecx;вычитаем единицу из счётчика
jnz .PopArr;если не весь массив обработан, возвращаемся к следующей итерации
ret
endp
section '.data' readable writeable data
Arr dq 0x32167,0x456ab,0x76bd,0xa657f,0x213fe;исходный массив
ArrDst dq 5 dup (?);конечный массив
Как я говорил в одной из предидущих статей, можно писать и читать регистр флагов, делается это инструкциями pushf
и popf
, без явных операндов.
pushf
— добавляет значение регистра флагов в стек, popf
достаёт значение со стека и записывает в регистр флагов, соответственно, писать в регистр
флагов можно так:
push SomeValue
popf
Из написанного выше понятно, что можно класть значения в стек и вынимать их, а можно ли обратиться не к верхнему значению в стеке? Можно. На вершину стека
указывает регистр rsp, когда вы добавляете значение в стек, значение этого регистра уменьшается, и туда, куда указывает rsp записывается значение,
когда вы извлекаете значение из стека, извлекается значение, на которое указывает rsp, а потом значение rsp увеличивается. Соответственно, чтобы обратиться
к произвольному значению из стека надо написать что-то в духе: mov rax,[rsp+8]
— здесь в регистр rax записывается значение из стека.
Процедуры
Процедура (или функция) — это подпрограмма, к которой можно обращаться из разных мест вашей программы, она представляет собой такой же машинный код,
как и остальная программа, в её начале ставится метка. Для того, чтобы передать управление процедуре используется команда call <op>
, где в качестве операнда
выступает либо адрес (метка), либо регистр (указвыающий на метку), либо память (в которой распологается адрес метки).
Эта команда передаёт управление по указанному адресу как и jmp
, но кроме этого она ещё кладёт в стек адрес возврата,
т.е. адрес следующей за call
команды.
Заканчивается процедура инструкцией retn
или retn imm16
, где imm16 обозначает 2-х байтовое целое непосредственное значение
(т.е. не регистр и не память, а значение закодированное в инструкции). Эта инструкция достаёт из стека адрес возврата и передаёт на него управление,
imm16 — это количество байт, которое будет вытащено из стека помимо адреса возврата, т.е. просто увеличится значение регистра rsp.
Кроме инструкции retn
также есть инструкция retf
, но мы не будем её рассматривать.
Также, как возможно вам известно из языков высокого уровня, у каждой процедуры есть локальные переменные, к которым можно обращаться только из этой процедуры.
Локальные переменные создаются в стеке, т.е. значение регистра rsp уменьшается и в получившимся пространстве появляется свободное место для хранения
переменных, перед выходом из процедуры (перед командой retn
) регистр rsp должен быть возвращён в исходное состояние. Рассмотрим пример:
format PE64 console
entry start
include 'win64a.inc'
section '.code' readable executable code
Procedure:;начало процедуры
sub rsp,16;выделяем 16 байт для локальных переменных
mov [rsp],rax;записываем значение rax в локальную переменную
mov [rsp+8],rdx;rdx - в другую
mov rcx,[rsp];читаем значение одной из переменных в rcx
xor rcx,[rsp+8];ксорим rcx со значением другой локальной переменной
add rsp,16;возвращаем rsp на место
retn;выходим из процедуры
proc start
int3
call Procedure;вызываем процедуру
ret
endp
Сразу скажу, что обращаться к стеку можно только по адресам большим текущего значения rsp, так что не рекомендуется обращаться по адресам [rsp-xx]
Удобно для обращения к локальным переменным использоватьвторой другой регистр: rbp. Это будет выглядить примерно так:
format PE64 console
entry start
include 'win64a.inc'
section '.code' readable executable code
Procedure:;начало процедуры
push rbp;сохраняем значение rbp
mov rbp,rsp;rbp указывает на стек
sub rsp,16;выделяем 16 байт для локальных переменных
mov [rbp-16],rax;записываем значение rax в локальную переменную
mov [rbp-8],rdx;rdx - в другую
mov rcx,[rbp-16];читаем значение одной из переменных в rcx
xor rcx,[rbp-8];ксорим rcx со значением другой локальной переменной
mov rsp,rbp;возвращаем rsp на место
pop rbp;восстанавливаем значение rbp
retn;выходим из процедуры
proc start
int3
call Procedure;вызываем процедуру
ret
endp
Вместо связки команд mov rsp,rbp / pop rbp
можно использовать команду leave
, без явных операндов.
Обращаться к переменным по адресам [rbp-xx] не очень удобно, хотелось бы обращаться к ним по именам. Для этого нам нужны несколько макросов
для использования которых мы подключаем win64a.inc .
Макросы следующие:
proc
endp
ret
locals
endl
Макрос proc
объявляет начало процедуры, имя после этого макроса будет именем метки начала процедуры. Например если вы напишете proc SomeProc
,
то обращаться к ней следует так: call SomeProc
. Макрос endp
объявляет конец процедуры, для каждого объявления процедуры обязательно должен присутствовать
endp
. ret
означает возврат из процедуры, т.е. там, где в процедуре вы поставите ret
fasm поставит что-то вроде mov rsp,rbp / pop rbp / retn
.
Обратите внимание, что endp
и ret
— разные вещи: первый указывает конец процедуры, а второй — только возврат, их может быть несколько, рассмотрим
пример:
format PE64 console
entry start
include 'win64a.inc'
section '.code' readable executable code
proc SomeProc;Объявляем процедуру
cmp [SomeData],1000;сравниваем SomeData с 1000
ja .Lbl1;если SomeData больше 1000 - прыгаем на Lbl1
add [SomeData],200;если SomeData меньше либо равно 100, то прибавляем к ниму 200
ret;возврат из процедуры
.Lbl1:
sub [SomeData],200;если SomeData больше 1000, то вычитаем из него 200
ret;возврат из процедуры
endp;процедура закончена
proc start
int3
mov [SomeData],32167
call SomeProc;вызываем процедуру
nop
ret
endp
section '.data' readable writeable
SomeData dd ?
В процедуре из этого примера два возврата, и после каждого исполнение процедуры заканчивается и управление передаётся в процедуру “start” на команду nop
.
Что касается макросов locals
и endl
, то они нужны для объявления локальных переменных. Делается это следующим образом: в начале процедуры вызывается
макрос locals
, после него объявляются переменные по тем же правилам, что и глобальные переменные, и после них вызывается макрос endl
, приведу пример:
format PE64 console
entry start
include 'win64a.inc'
section '.code' readable executable code
proc Procedure;начало процедуры
locals
Var1 dq ?
Var2 dq ?
endl
mov [Var1],rax;записываем значение rax в локальную переменную
mov [Var2],rdx;rdx - в другую
mov rcx,[Var1];читаем значение одной из переменных в rcx
xor rcx,[Var2];ксорим rcx со значением другой локальной переменной
ret;возврат из процедуры
endp;процедура закончена
proc start
int3
call Procedure;вызываем процедуру
ret
endp
К локальным переменным данной процедуры можно обращаться только между макросами proc
и endp
, соответственно в разных процедурах могут находиться локальные
переменные с одинаковыми именами, и это не будет создавать проблем. Только помните: использовать указатели на локальные переменные можно только до возврата
из процедуры, после него нельзя считать осмысленными значения на которые ссылаются эти указатели.
Соглашения о вызове
К этому моменту мы научились объявлять процедуры и их локальные переменные с помощью макросов, но помимо этого процедуры могут принемать параметры и возвращать
значения. Для этого существуют соглашения о вызове. Соглашение о вызове это — способ передачи параметров в функцию и возврата из неё знпчений. В принципе,
на ассемблере, вы можете изобрести какие угодно соглашения о вызове, и их использовать, но для того, чтобы вызывать процедуры из чужого кода нужно заранее
договориться о способе передачи параметров и возврате значений, для этого существуют стандартные соглашения о вызове. Если погуглить, то можно выяснить,
что в 32-битном режиме есть ряд соглашений о вызове: stdcall, fastcall, cdecl, и т.д. В 64-битном режиме же принято одно соглашение о вызове и заключается
оно в следующем:
- Первые 4 параметра передаются через регистры, если это — указатели или целые числа то передаются они в следующем порядке:
1-rcx, 2-rdx, 3-r8, 4-r9. Если это — числа с плавающей точкой, то они передаются через регистры xmm0-xmm3 (о них я расскажу в последующих статьях).
При этом в стеке вызывающая функция выделяет место для хранения этих параметров. - Следующие параметры передаются через стек.
- После выполнения процедуры стек очищает от переметров вызывающая функция.
- Возвращаемое значение функция записывает в rax.
- После вызова функции должны остаться неизменными регистры: r12-r15, rdi, rsi, rbx, rbp, остальные регистры общего назначения могут после вызова измениться.
Приведу пример вызова функции SomeFunc с 6 параметрами:
...
sub rsp,6*8;выделяем место в стеке для 6-и параметров
mov rcx,Val1;первый параметр
mov rdx,Val2;второй параметр
mov r8,Val3;третий параметр
mov r9,Val4;четвёртый параметр
mov [rsp+4*8],Val5;пятый - уже через стек
mov [rsp+5*8],Val6;шестой - тоже
call SomeFunc;вызываем функцию
;в регистре rax - возвращённое значение
add rsp,6*8;освобождаем стек от параметров
...
В ринципе так мы больше писать не будем, потому что существуют макросы fastcall
и invoke
, отличаются они тем, что если в fastcall
стоит инструкция
call Func
, то в invoke
стоит call [Func]
. Только что приведённый код можно переписать в следующем виде:
...
fastcall SomeFunc,Val1,Val2,Val3,Val4,Val5,Val6
...
Для того, чтобы внутри процедуры обращаться к параметрам по именам нужно перечислить их в макросе proc
после имени процедуры, например:
...
proc SomeProc,Param1,Param2,Param3,Param4,Param5,Param6
...
К параметрам можно обращаться также, как к локальным переменным – по именам.ВАЖНО: В НАЧАЛЕ ИСПОЛНЕНИЯПРОЦЕДУРЫ В ПЕРВЫХ 4-Х ПАРАМЕТРАХ ЛЕЖИТ МУСОР,
ОБРАЩАТЬСЯ К НИМ СЛЕДУЕТ ЧЕРЕЗ РЕГИСТРЫ! Приведу пример объявления процедуры с 2-мя параметрами, в следующем коде процедура “Reverse” переворачивает
массив байтов заданной длины.
format PE64 console
entry start
include 'win64a.inc'
section '.code' readable executable code
proc Reverse,Arr,ArrLen
;rcx-Arr - указатель на массив
;rdx-ArrLen - длина массива
;обратите внимание, что к параметрам идёт обращение через регистры,
;т.к. по адресам Arr и ArrLen лежит мусор, но если нужно сохранить
;начения этих переменных можно записать их туда:
;mov [Arr],rcx
;mov [ArrLen],rdx
lea rax,[rcx+rdx-1];rax - указатель на массив
shr rdx,1;дулим rdx на 2
jz .exit; если в массиве меньше двух элементов - на выход
.loop:
mov r8b,[rcx];меняем местами значение по адресу [rcx]
xchg r8b,[rax]; и [rax]
mov [rcx],r8b
inc rcx;увеличеавем rcx
dec rax;уменьшаем rax
dec rdx;уменьшаем счётчик
jnz .loop
.exit:
ret
endp
proc start
locals
Arr1 db 3,2,1,6,7
Arr2 db 9,8,7,6,5,4,3,2,1,0
endl
int3
lea rcx,[Arr1]
fastcall Reverse,rcx,5;вызываем функцию для первого массива
lea rcx,[Arr2]
fastcall Reverse,rcx,10;вызываем функцию для второго массива
ret
endp
Скажу ещё пару слов о выделении памяти в стеке и передаче параметров.
Если вы взгланите на код процедуры start (см. Рис.1), то увидите, что для вызова процедур постоянно выделяется и освобождается память в стеке
(выделено красным), чтобы это оптимизировать нужно вызывать макросы frame
и endf
в начале и в конце процедуры соответственно, при их использовани
память выделяется в начале процедуры и освобождается в конце, пример:
proc start
locals
Arr1 db 3,2,1,6,7
Arr2 db 9,8,7,6,5,4,3,2,1,0
endl
frame
int3
lea rcx,[Arr1]
fastcall Reverse,rcx,5;вызываем функцию для первого массива
lea rcx,[Arr2]
fastcall Reverse,rcx,10;вызываем функцию для второго массива
endf
ret
endp
Из всего выше описанного очевидно, что использование регистра rbp не обязательно, и это можно исправить, достаточно в программе вставить следующие строки:
prologue@proc equ static_rsp_prologue
epilogue@proc equ static_rsp_epilogue
close@proc equ static_rsp_close
Они заставят использовать вместо rbp только rsp, и rbp тогда можно использовать в своих целях. ОБРАТИТЕ ВНИМАНИЕ:
- ИСПОЛЬЗОВАТЬ ТЛЬКО RSP БУДУТ ПРОЦЕДУРЫ СТОЯЩИЕ ПОСЛЕ ЭТИХ СТРОК
- В ЭТОМ РЕЖИМЕ НЕ НУЖНО ИСПОЛЬЗОВАТЬ
frame
иendf
Чтобы Вернуть обратно rbp нужно вставить строки:
prologue@proc equ prologuedef
epilogue@proc equ epiloguedef
close@proc equ
На этом — всё.
Комментарии
Продолжаем традицию… 🙂
Или числовая константа.
Вообще, “нельзя” и “не рекомендуется” — немного разные вещи. По адресам меньше верхушки стека вполне можно обращаться, но не стоит к этому привыкать. Хотя мне неизвестно ни одной веской причины, по которой в пользовательском режиме это делать плохо.
Зачем гадать, что поставит fasm, если это элементарно проверить. Макрос
ret
по умолчанию (если не заменятьepilogue@proc
) раскрывается вleave
+retn
.Не совсем по тем же. Нельзя объявить безымянную инициализированную переменную. Впрочем, это можно отнести скорее к проблемам макросов, чем к документированному поведению.
Не только. Всё, что касается вызова функций, относится к соглашению. В частности, выравнивание стэка (очень важно, но упомянуто не было), выделение какой-либо дополнительной памяти, список регистров, которые необходимо сохранять.
rsp
ещё не меняется.И полностью упущено из виду, почему именно существуют
frame
иendf
, т.е. почему нельзя было без них оптимизировать, по умолчанию выделяя память в начале и в конце процедуры, а не для каждого вызова. Из этого же следуют и важные ограничения на код, их использующий.P.S. Такая туча опечаток… Понравилось: “Дулим rdx на 2”.
>>В частности, выравнивание стэка (очень важно, но упомянуто не было)
Вот дебил, забыл написать.
Программы для развития.