Програмирование на fasm под Win64 часть 5 “стек, процедуры, соглашения о вызове”

В данной статье мы рассмотрим, что такое стек, процедуры, макросы для объявления процедур и как их вызывать.

Стек

Стек — область памяти, в него можно добавлять данные и извлекать их, добавление и извлечение организовано по правилу “последним вошёл, первым вышел”.
Приведу пример: если добавить в стек подряд числа 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-битном режиме же принято одно соглашение о вызове и заключается
оно в следующем:

    1. Первые 4 параметра передаются через регистры, если это — указатели или целые числа то передаются они в следующем порядке:
      1-rcx, 2-rdx, 3-r8, 4-r9. Если это — числа с плавающей точкой, то они передаются через регистры xmm0-xmm3 (о них я расскажу в последующих статьях).
      При этом в стеке вызывающая функция выделяет место для хранения этих параметров.
    2. Следующие параметры передаются через стек.
    3. После выполнения процедуры стек очищает от переметров вызывающая функция.
    4. Возвращаемое значение функция записывает в rax.
    5. После вызова функции должны остаться неизменными регистры: 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 тогда можно использовать в своих целях. ОБРАТИТЕ ВНИМАНИЕ:

    1. ИСПОЛЬЗОВАТЬ ТЛЬКО RSP БУДУТ ПРОЦЕДУРЫ СТОЯЩИЕ ПОСЛЕ ЭТИХ СТРОК
    2. В ЭТОМ РЕЖИМЕ НЕ НУЖНО ИСПОЛЬЗОВАТЬ frame и endf

Чтобы Вернуть обратно rbp нужно вставить строки:

  prologue@proc equ prologuedef
  epilogue@proc equ epiloguedef
  close@proc equ

На этом — всё.

Комментарии

l_inc05.04.2016 02:46

Продолжаем традицию… 🙂

push <op>, в качестве операнда может выступать регистр общего назначения или память

Или числовая константа.

Сразу скажу, что обращаться к стеку можно только по адресам большим текущего значения rsp, так что не рекомендуется обращаться по адресам [rsp-xx]

Вообще, “нельзя” и “не рекомендуется” — немного разные вещи. По адресам меньше верхушки стека вполне можно обращаться, но не стоит к этому привыкать. Хотя мне неизвестно ни одной веской причины, по которой в пользовательском режиме это делать плохо.

ret означает возврат из процедуры, т.е. там, где в процедуре вы поставите ret fasm поставит что-то вроде mov rsp,rbp / pop rbp / retn .

Зачем гадать, что поставит fasm, если это элементарно проверить. Макрос ret по умолчанию (если не заменять epilogue@proc ) раскрывается в leave + retn .

в начале процедуры вызывается макрос locals, после него объявляются переменные по тем же правилам, что и глобальные переменные

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

Соглашение о вызове это — способ передачи параметров в функцию и возврата из неё знпчений.

Не только. Всё, что касается вызова функций, относится к соглашению. В частности, выравнивание стэка (очень важно, но упомянуто не было), выделение какой-либо дополнительной памяти, список регистров, которые необходимо сохранять.

После вызова функции должны остаться неизменными регистры: r12-r15, rdi, rsi, rbx, rbp, остальные регистры общего назначения могут после вызова измениться.

rsp ещё не меняется.

для вызова процедур постоянно выделяется и освобождается память в стеке (выделено красным), чтобы это оптимизировать нужно вызывать макросы frame и endf в начале и в конце процедуры соответственно

И полностью упущено из виду, почему именно существуют frame и endf , т.е. почему нельзя было без них оптимизировать, по умолчанию выделяя память в начале и в конце процедуры, а не для каждого вызова. Из этого же следуют и важные ограничения на код, их использующий.

P.S. Такая туча опечаток… Понравилось: “Дулим rdx на 2”.

qwe8013 l_inc05.04.2016 17:59

>>В частности, выравнивание стэка (очень важно, но упомянуто не было)
Вот дебил, забыл написать.

>Продолжаем традицию… 🙂
Действительно, хорошая традиция, стоило бы мне допускать меньше неточностей, всё ещё надеюсь, что мои статьи кому-нибудь помогут.

Программы для развития.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *