Итак, процедуры и функции.
Всё это есть в книжках, но сильно размазано и может "взгляд с другой стороны" (ну или на меня напала мания графоманства, тоже повод).
Сначала чуть истории. Когда компы были большими, а программы маленькими и в кодах (просто массив чисел), то поначалу всё было хорошо и без функций, пиши себе чиселки и пиши. И даже когда надоело запоминать числа и придумали язык ассемблера (поначалу просто как синонимы числам) всё было неплохо. Но программы росли, функционал ширился, и быстро оказалось что практически одинаковый код приходилось писать много раз в разных частях программы (например преобразование числа в строку или строки в число), а код был уже немаленький (в отличие от памяти которой были считанные килобайты) и много раз дублировать десятки/сотни строк/команд неудобно (и непрактично, вдруг где ошибка или что подправить/улучшить надо - ищи меняй по всему тексту). Решение разумеется все знали ещё со школы - функции в математике, сколько надо раз столько и используй, где и как угодно. Оставалось его реализовать. Сначала сделали чисто программно, на том что было. Это можно и сейчас сделать, на любом процессоре, но что-то лень выдумывать пример. Штука оказалась мегаудобной, памяти кода экономила просто жуть как, а времена были доисторические (до микропроцессоров), каждая ЭВМ имела свою систему команд и добавить в следующую пару-тройку нужных команд вообще ни разу не проблема. И вообще эпоха была ... интересной, каждое удачное/полезное решение могли и делали в виде новой команды. И разумеется работу с функциями. И сделали. Потом пришли микропроцессоры и унаследовали и этот механизм тоже.
Пару слов об отличии функции от процедуры. В математике понятия процедуры нет (точнее там оно совсем из другой оперы, если не ошибаюсь, математику я знаю хуже программирования). В ЯВУ (языках высокого уровня если кто так и не догадался) разделение очень чёткое и формальное: функция возвращает единственный результат (может быть сложным типом данных), процедура ничего не возвращает. Точка. Будет результат потом использоваться, не будет - дело десятое (вызывающей программы и ошибок программиста). В ассемблере и для процессора понятия функции нет - для них функция это та же самая процедура, ничего никуда не возвращающая. Тут можно возмутиться "как же так, вон GetTickCount возвращает же в EAX число - значит функция!". Возвращает. Однако и WriteFile тоже что-то там возвращает (подробности как обычно у MS)! А вызов идентичен (ну параметров больше, не в том дело). И если не смотреть ниже по коду, то отличий нет никаких. Ни в записи ассемблерного текста, ни, как покажу ниже, в машинном коде для процессора. Потому дальше (да и раньше) могу путать функции с процедурами и наоборот ... Отличия лишь в голове программиста, пользуется он изменениями состояния компа (регистров в процессоре, ячеек памяти, файлов на диске, пикселей на экране) или не пользуется.
Хорошо, процедуры. Это выделенный кусок кода, который можно вызывать из разных мест и после его выполнения возвращаться обратно в точку откуда вызвали. Нужно это страшно часто, потому в процессоре давно предусмотрены команды для этого. Вызвать процедуру (вообще говоря произвольный кусок кода, за исключением последней команды) можно командой
call с указанием метки начала процедуры. Когда процедура сделает всё что надо
и половину чего не надо она должна вернуться откуда вызвали - для этого команда ret (в ЯВУ часто return). Но для этого надо помнить откуда её собственно вызвали, ведь это не одно единственное место куда можно сделать jmp, они могут быть каждый раз разными. Для запоминания места возврата придумано несколько механизмов, но в x86 (и наследнице x64) используется один (и не самый лучших): запоминается в памяти. Где в памяти, она же большая? А вот есть специальный регистр SP/ESP/RSP, и указывающий где в памяти лежит адрес куда надо вернуться.
Т.е. происходит следующее. Команда call уменьшает регистр ESP на 4 (мы же помним что все адреса=указатели равны 32 битам), пишет по новому адресу из ESP адрес
следующей команды (куда надо будет вернуться), и делает jmp на указанную метку (в процедуру). Процедура приходит на команду ret, которая читает адрес куда надо сделать jmp из памяти по адресу ESP, увеличивает ESP на 4 (адрес возврата больше не нужен) и делает jmp на прочитанный адрес, тем самым возвращаясь ровно на следующую команду после call. Вуаля.
Именно поэтому регистр ESP хоть и называется регистром общего назначения, но у него очень специальная функция! И использовать его по другому нельзя. (Иногда, очень и очень редко, можно, но это такие дебри, что лучше не полезу, нельзя и всё.)
Замечу, я пока ни слова не сказал как процессор видит параметры процедур и как он возвращает результат функций. Почему? Да потому что процессор об этом вообще ничего не знает! Вот не знает! Ему хватает команд call и ret, всё остальное забота программиста! Рады? То ли ещё будет.
Ещё чуть
альтернативной истории. Поначалу всё так и было, каждый программист писал код передачи параметров и возврата результата как ему бог на душу положит, да ещё и в каждой программе по разному (если не ленился), да и в одной программе для разных процедур по разному. (Я кстати иногда и до сих пор так пишу ... Потому что красивее! И короче и понятнее. Иногда. Ну да ладно.) Потом программисты естественно обленились и стали копировать придуманный удобный способ из программы в программу. А потом и между собой, такая стихийная стандартизация.
А потом пришёл MS и всё порушил. Пришёл, но не порушил. Просто стали пользоваться одинаковым методом для всех функций и процедур в винде (в дос кстати сильно по другому). И этот способ разумеется поддержали в компиляторах ЯВУ (им же тоже надо обращаться к ОС) и назвали стандартным. И чтобы указать на него стали указывать термин
stdcall, т.е. стандартный call. Я могу сильно привирать в последовательности и причинности событий, но результат именно такой.
Итак, как же передаются параметры в процедуры при вызове функций WinAPI (и соответственно любых других, для единообразия). Параметры у каждой функции свои, потому их нельзя (точнее очень неудобно, можно-то всё) хранить в фиксированной памяти. И нельзя хранить в
куче (heap) - слишком долго получать оттуда кусок памяти и возвращать при выходе. В регистрах тоже неудобно - они для другого обычно нужны, а их и так мало. Решение лежало на поверхности: есть же стек (место адресуемое ESP и хранящее адреса возвратов из функций), давайте вместе с адресом положим и параметры, они больше никому мешать не будут, и это достаточно быстро. Ура. Тем более что уже есть специальная команда push положить в стек регистр или ячейку памяти или константу, как раз параметр.
Итого вызов процедуры из одной строки на языке ассемблера превращается в такую последовательность команд:
;Было в тексте:
invoke wsprintf, buf, .fmt3, ECX, EDX
;Станет в коде и увидит процессор:
push EDX
push ECX
push .fmt3 ;Адрес метки .fmt3
push buf ;Адрес метки buf
call dword [wsprintf] ;Адрес для перехода берётся из ячейки с меткой wsprintf
Что здесь интересного: параметры в стек заталкиваются в обратном порядке, начиная с конца. Не уверен с чем это связано, просто вот так.
Ещё почему-то адрес не указан прямо в call, а лежит где-то в памяти ... Это потому что мы не знаем где реально лежит код функции WinAPI и узнаем это только когда наша программа из файла .exe загрузится виндой в память и тогда винда положит в ячейку wsprintf правильный адрес этой функции WinAPI в какой-то
левой DLL из дистрибутива винды. Это частности.
Главное теперь видно куда и как засовываются указанные нами параметры и видим правильную команду процессора call вместо неизвестной ему invoke. Это преобразование делает FASM. Для удобства программиста, ведь согласитесь первая одна строка понятнее 5-ти последних, да ещё про порядок не забыть ...
Но в коде есть и stdcall (не как ключевое слово, а как команда), что с ним. С ним всё хорошо, он отличается от invoke только лишь тем в команде call будет не dword [wsprintf], а прямо адрес скажем isprime, без скобочек [], ведь его мы (в смысле компилятора асма) точно знаем и можем сразу подставить правильный, не дожидаясь загрузки файла виндой в память и не читая из памяти. Весь остальной код ровно такой же. Надеюсь с вызовом процедур понятно.
А с функциями как и говорил, процессору без разницы. Просто в винде принято возвращать результат в EAX (целочисленный, какой бы смысл у него ни был). Ну вот принято. Хотите - в своих делайте как угодно, а MS для WinAPI уже сделала так. И чтобы не путаться через месяц/год/десятилетие лучше делайте так же, единообразно. Я опять же бывает нарушая это правило, но всегда в первых строках функции в комментариях указываю что где принимает и что где возвращает. Это вообще хорошая практика, в примерах выше поленился и размер экономил.
Хорошо, вызывать процедуры научились. А что с самой процедурой, там код и её текст для FASM как-то отличается от просто куска кода? Да, отличается, и это важно.
Формально отличие только одно: в процессе выполнения управление должно попасть на одну из команд ret (да, теоретически их может быть много в одной функции, но так делать очень не стоит, дальше покажу почему). Всё, со стороны процессора больше никаких требований нет. И можно так и писать. Но разумеется для блага
всего прогрессивного человечества программиста компилятор доработали и несколько формализовали: процедуры/функции, вызываемые командами invoke/stdcall, начинаются с команды proc (от procedure) и заканчиваются командой endp (end procedure). Это команды компилятору, не процессору. Команда proc имеет параметры, первым идёт имя процедуры (метка, которую указывать в командах call), без этого никуда, иначе как же эту процедуру вызывать. Дальше через запятую нужно указать параметры (если они есть) - тогда FASM сможет сам проверить что при всех миллионах вызовах по всему тексту вы нигде не ошиблись и поставили правильное количество параметров (аргументов). Борьба с опечатками и ошибками.
Можно аргументы и не указывать (ни при объявлении функции командой proc, ни при вызове командой stdcall) и самому их передавать как сможете. И тогда даже вместо stdcall писать обычный call. Можно. Вопрос нужно ли. (Иногда нужно, но это исключение.) Проще, удобнее и надёжнее (я ещё не надоел этими мантрами? но это суровая правда, политая в том числе и кровью) пользоваться уже реализованным механизмом.
Итак, с вызовом разобрались, с передачей параметров тоже. Теперь бы ещё их прочитать в самой процедуре ...
Разумеется FASM спешит на помощь, пишем просто чтение аргумента из памяти (а мы ещё помним что он лежит где-то в стеке вместе с ненужным нам сейчас адресом возврата) например в ECX командой mov ECX,[num] в isprime. FASM сам найдёт в списке аргументов имя num, подсчитает как высоко в стеке от ESP (т-с-с-с, см. ниже) он лежит (мы же помним по call что при записи в стек ESP уменьшается), и подставит вместо num внутри скобок [] правильный адрес. Ничего сложного. И работает.
Разумеется такое можно проделывать не только в самом начале процедуры, но и в любом её месте. Т.е. вовсе не обязательно читать аргументы в регистры и сохнуть над ними, можно оставить где лежат в памяти и каждый раз обращаться туда. Медленнее, зато экономит регистры. И как будет выгоднее в конкретном коде ещё большой вопрос! С современными кэшами и шириной шин в процессорах лишний свободный регистр-два могут заметно ускорить другие вычисления, с лихвой перекрывающее тормоза чтения памяти (реально кэша). Так что этот, описанный во всех книгах по оптимизации, метод (все совать в регистры) весьма спорен в современности.
А теперь важные тонкости про аргументы.
В момент попадания управления в процедуру по адресу ESP лежит адрес возврата, он нам пока не нужен, а выше лежат аргументы, всегда по 4 байта каждый. Значит первый аргумент мы можем прочитать по адресу [ESP+4] (сразу выше адреса возврата), второй аргумент по адресу [ESP+8] и так далее. Можем. Если
не пользовались подарком FASM proc и endp! (Про исключение снова промолчу, не уверен что оно документировано и не изменится в следующей версии FASM.) А если пользовались, как всех призываю, то в начале функции (и в конце, но там не так страшно) появится дополнительный код, которого вы не писали, и который изменит ESP! Давайте уже посмотрим на реальный код, сколько можно болтать:
;Было в тексте (чуть сократил)
proc isprime stdcall, num
mov ECX,[num]
;Стало в реальном коде для процессора:
push EBP
mov EBP,ESP
mov ECX,[EBP+8]
Чуете? Зачем-то сохранили регистр EBP и записали в него
новое значение ESP. А потом ещё и лазить к аргументам стали не по ESP, а по EBP! Чушь какая-то. Что делать и кто виноват?
Ничего не делать, а виноват конечно FASM. Потому что вы его попросили! Командой proc. Писали? Писали. Получите.
Реально конечно это не баг, а фича, и не в шутку, а правда. Что бы дальше в функции мы не делали с ESP (а команды push и обратную к ней pop нам никто не запрещал) и как бы он не дрыгался в процессе наших запутанейших вычислений, регистр EBP так и будет сохранять адрес самого себя в стеке. И можно адресоваться в аргументам относительно него, не заморачиваясь насколько в каждом конкретном месте изменился ESP командами push без парной ей pop. А смещение +8 вместо +4 потому что кроме аргументов и адреса возврата мы туда положили ещё и регистр EBP и только потом уже уменьшенное значение ESP запомнили в EBP.
Это одна причина. Вторая в том что функциям часто нужны локальные параметры (уникальные для каждого вызова функции, не для куска кода, а даже когда код, а вызвали много раз (например сама себя рекурсивно)). И кроме стека их хранить нигде неудобно. А в стеке - удобно, каждый вызов двигает ESP ниже и ниже и ничего выше не портит. Потому делаем так: после показаных двух команд уменьшаем ESP на величину необходимой нам памяти в стеке. И вуаля: все следующие записи в стек пойдут ещё ниже и ничего нам в зарезервированной памяти (от [ESP] и до [EBP]) не попортят, а адресоваться к этой памяти можно удобно через [EBP-xxx], знаковые константы никто не запрещал.
Поэтому важный момент: регистр EBP обычно уже использован FASM-ом под служебные цели и недоступен для вычислений! Остаётся лишь 6 регистров, вот такая беда. Зато удобно пользоваться процедурами/функциями.
Остался неосвещённым вопрос о выходе из процедур, ведь туда уже EBP положили, ESP поменяли, как теперь до адреса возврата добраться и что делать с тучей аргументов выше него. Ну, рвать волосы рано, за нас подумали и Intel и авторы FASM - команда endp меняет нашу любовно написанную команду ret на что-то левое:
;Было в тексте
ret
endp
;Стало в реальном коде для процессора:
mov ESP,EBP
pop EBP
ret 4
Или правое? Mov возвращает ESP чтобы он снова указывал на сохранённую копию EBP в стеке и адрес возврата выше и аргументы (сколько бы ни было зарезервировано памяти для локальных переменных), команда pop вынимает из стека значение регистра EBP на момент запуска процедуры, команда ret с числом возвращает управление откуда вызвали и дополнительно увеличивает ESP на указанное число - что "удаляет" из стека и единственный аргумент функции isprime (мы сами про него сказали в команде proc). Выходит что после возврата на команду после invoke/stdcall всё вернётся в исходное состояние, и указатель стека ESP, и адрес выполнения, и регистр EBP. Значит не левое, а очень даже правое и нужное. И ничего самим помнить и писать не надо, endp и привет.
И вот тут появляется обещанная засада: это прекрасно срабатывает если ret стоит в самом конце (про исключения молчу, пальцы болят
) функции и всего один на всю функцию, где его и видит FASM и правильно меняет на вон те команды. А все другие ret он не видит (недостаточно умный) и не меняет! И если туда попадёт управление, то будет беда. Та или другая, но точно будет. Потому при использовании proc/endp надо чтобы ret был всегда один на всю функцию/процедуру и в конце, прямо перед endp. Если нужен где-то ещё - ставьте там команду jmp на вот этот последний ret.
Зачем я в объявлении isprime командой proc указал ещё и uses? Ну, мне в isprime понадобятся какие-то регистры для работы, в показанном коде нужны оказались EBX ECX EDX ESI - но ведь они же могли использоваться и там откуда вызывали isprime! И что делать ... Неужели помнить какая функция какие регистры использует и не хранить в них ничего на момент вызова каждой функции?! Ну, можно и так. Только регистры быстро закончатся, да и голова опухнет. Сделаем проще (но не значит лучше! тут мнение у каждого своё): при входе в функцию сохраним куда-нибудь все регистры которая она использует, а при выходе восстановим оттуда. Решение? Решение. Можно сохранять в память, но если эту же функцию вызовут дважды или рекурсивно (сама себя) будет беда. Выход нам уже известен - стек наше всё. Можно всё делать самому руками, уменьшать ESP, писать туда ([ESP+xxx]) регистры, потом при выходе их читать (кстати это будет быстрее стандартного способа! под x64 примерно к этому и пришли), можно пользоваться командами push для сохранения и pop для восстановления (в обратном порядке! очень легко забыть), а можно перепоручить эту муть FASM-у указав какие регистры нам нужны в процедуре и пусть сам парится как их сохранить и восстановить. Словом uses с перечислением регистров через пробел. Что при этом получается покажу чуть ниже.
А пока ещё момент. Программистам быстро надоело писать каждый раз кучи push и pop подряд (FASM тогда ещё не было) и Intel пошла им навстречу сделав команды pushad и popad - запихать сразу все регистры в стек или достать их оттуда. Я использовал их выше в "заклинании" для краткости. Хорошие команды. Три НО: медленные (8-9 тактов на 8 регистров); отсутствуют в x64; popad восстановит
все регистры, и EAX тоже, а там ведь должен лежать результат вычислений функции ... Все три можно обойти, но тогда и выигрыш от всего одной команды станет не столь заметным.
Ну и наконец давайте же посмотрим во что превращается код процедуры при использовании и аргументов, и рабочих регистров, и локальных переменных:
;Было в тексте:
proc f1 stdcall uses EAX ECX, num
local x:DWORD, s[18]:BYTE
mov ECX,[num]
movzx EAX,[s+0]
add EAX,ECX
add EAX,[x]
ret
endp
;Станет в коде и увидит процессор:
push EBP ;Сохраним старое значение EBP
mov EBP,ESP ;Теперь он будет указывать и на локальную память, и на адрес возврата, и на аргументы
sub ESP,24 ;Зарезервировали 24 байта локальной памяти, 20 на s[] (кратно 4) и 4 на x
push EAX ;Сохранение рабочих регистров
push ECX
mov ECX,[EBP+8] ;Это как ни странно первый аргумент, он именно там лежит
movzx EAX,byte [EBP-20];Массив s[] выровняли на 4 байта (до 20) и положили в тек первым (выше)
add EAX,ECX
add EAX,[EBP-24] ;Переменная x оказалась ниже s[]
pop ECX ;Восстановление рабочих регистров
pop EAX
mov ESP,EBP ;Сняли резерв локальной памяти если был
pop EBP ;Восстановили EBP как был на входе
ret 4 ;Возврат с удалением одного аргумента (они все ровно по 4 байта)
Было 5 явных команды, стало 14, 9 команд добавил FASM (
по нашему же указанию в виде proc, local, endp).
Если посмотреть на реальный скомпилированный код, то там вместо последних mov и pop будет непонятная
leave. Делает ровно то же самое, а код всего один байт. Ну и что что 6 тактов вместо 1 (да, две команды могут выполниться за 2 такт), зато какая экономия байтов! Это один из пережитков эпохи 8086/80286/80386, когда памяти было мало и экономили длину команд как могли. Вопросы к авторам FASM.
PS. Всё, 20К текста, больше низя.