- •Аппаратно-ориентированное программирование
- •Ббк 32.973.73
- •Удк 681.3 ббк 32.973.73ф 73
- •1. Основы программирования на ассемблере
- •1.1. Принципы построения ассемблерных программ
- •1.2. Понятие архитектуры компьютера
- •1.3. Регистры программиста в ia32
- •1.4. Описание сегментной структуры программы
- •2. Простейшие средства ассемблера
- •2.1. Средства описания данных
- •2.2. Обращения к функциям ос посредством прерываний
- •2.3. Средства преобразования в исполняемый файл
- •2.4. Управление строками при выводе и ввод данных
- •2.5. Простейшие способы адресации
- •3. Архитектурные элементы для построения программ
- •3.1. Организация условных переходов
- •3.2. Средства организации циклов
- •3.3. Особенности команд умножения и деления
- •3.4. Организация процедур
- •3.5. Неарифметические операции над кодами
- •4. Использование неэлементарных способов адресации
- •4.1. Косвенно-регистровая адресация
- •4.2. Использование индексной адресации данных
- •4.3. Базовая и индексно базовая адресации
- •4.4. Адресация с масштабированием
- •5. Взаимосвязи программных единиц
- •5.1. Многомодульная разработка программ
- •5.2. Использование библиотек объектных модулей
- •5.3. Организация стекового кадра подпрограммы
- •5.4. Программный доступ к системным функциям Win32
- •5.5. Особенности использования объектных файлов формата coff
- •5.6. Стандартный доступ к системным функциям Unix
- •6. Вспомогательные средства базовой архитектуры
- •6.1. Использование строковых команд пересылки
- •6.2. Применение строковых команд сравнения
- •7. Использование ассемблерных отладчиков
- •7.1. Особенности отладчика gdb для программ в Linux
- •7.2. Отладчики текстового режима для Windows
- •Библиографический список
- •Оглавление
5.3. Организация стекового кадра подпрограммы
Рассмотренный и использованный нами способ передачи аргументов в процедуру через регистры имеет ту принципиальную особенность, что им нельзя передать большое число аргументов. Причина этого очевидна: в архитектуре процессора регистров программиста достаточно немного, в частности, в архитектуре Intel всего шесть регистров общего назначения.
Принципиальным решением по передаче аргументов стало использование для этого стека. Пусть некоторая подпрограмма с именем funa описана на некотором языке высоко уровня, например Паскаль, использует при вызове фактические аргументы arg1, arg2, arg3 и пусть все они для простоты картины имеют целочисленный тип. Вызов этой функции, который на языке высоко уровня запишется в виде funa(arg1, arg2, arg3), реализуется с помощью стека следующей последовательностью команд:
push значение arg1
push значение arg2
push значение arg3
call funa
В результате все значения фактических аргументов окажутся в стеке, и вызванной подпрограмме останется только извлекать их оттуда. С этим извлечением возникают, казалось бы, некоторые проблемы. Извлекать их непосредственно командами POP нельзя, так как над значениями этих аргументов в стеке оказывается значение адреса возврата, записываемое туда командой CALL. Снимая с вершины стека этот адрес возврата, подпрограмма лишила бы себя возможности вернуть управление в вызвавшую ее программу (команде RET нужен адрес возврата на вершине стека). Какие-то манипуляции с стеком, отличные от классических операций PUSH, POP теоретически возможны, но такое решение не удобно.
С учетом динамического характера заполнения стека (в свое время) было принято дальновидное решение связывать участок заполнения стека для процедуры со специальным регистром - указателем. Этот регистр называют указателем фрейма или указателем кадра, а в архитектуре Intel его назвали просто базовым указателем, подразумевая базовый указатель фрейма в стеке. Символическое обозначение этого регистра здесь - EBP (от сочетания слов - Base Pointer). Обратим внимание, что использование этого регистра специализированно назначением, хотя временно его можно использовать и как регистр общего назначения (но не рекомендуется, так как при этом нужно учитывать его определенные особенности).
Специализация регистра EBP заключается прежде всего в том, что подпрограммы стандартной структуры должны в качестве первых двух своих командах выполнить следующие:
PUSH EPB
MOV EBP, ESP
Первая из этих команд просто сохраняет предыдущее значение регистра EBP в стеке, чтобы при выходе из процедуры восстановить исходное значение, которое этот регистр имел в вызывающей процедуре. Вторая команда запоминает в регистре EBP адрес текущей верхушки стека - напомним, что специализированный регистр ESP всегда используется как место хранения вершины стека. Далее в ходе выполнения программы процедуры содержимое регистра EBP не должно меняться. Поэтому, какие бы осмысленные манипуляции со стеком не делались в процедуре, регистр EBP своим содержимым постоянно указывается в ней на фиксированное место в стеке. А именно, указывает место запомненного старого содержимого регистра EBP, которое, в свою очередь, записано сразу за адресом возврата. Адрес же возврата был записан командой CALL сразу после записи значений аргументов процедуры в стек.
Посмотрим с помощью рис. 5.3.1, к какому строению участка стека привело выполнение указанных выше двух первых команд процедуры.
Рис. 5.3.1. Строение кадра стека после стандартного пролога в подпрограмме
Из этого рисунка, как и из внимательного рассмотрения действий заполнения стека, следует, что содержимое четырехбайтового поля стека, где размещено значение аргумента arg3, может быть обозначено с помощью базового способа адресации в виде [EBP+8]. (Старое значение регистра EBP обозначается как [EBP+0], значение адреса возврата как [EBP+4], а далее следует как раз значение аргумента arg3.) Содержимое аналогичного поля стека, где содержится значение аргумента arg2, обозначится как [EBP+12], а содержимое поля стека, где находится значение аргумента arg1, - как [EBP+16].
Следует особенно подчеркнуть, что такое обозначение позволяет через содержимое служебного регистра всегда "дотягиваться" до значений аргументов процедур вне зависимости, на какое конкретное место от начала стека они помещаются при вызове. В частности, когда процедура вызывается многократно, не завершив действий предыдущих вызовов, - в так называемых рекурсивных вызовах.
Заметим, что в общем случае могут использоваться более экзотические вызовы процедур, так называемые дальние (FAR), при которых адрес возврата занимает не одно четырехбайтовое поле в стеке, а в два раза больше. В таких случаях указанные рассуждения приводят к обозначению [EBP+12] уже для самого верхнего аргумента в стеке.
Используя рассмотренные содержательные связи, вычисление суммы первых двух аргументов процедуры можно выполнить с помощью двух команд:
MOV eax, [ebp+16] ; arg1
ADD eax, [ebp+12] ; arg2
Для дополнительных объяснений, не связанных непосредственно с проблемой организации кадра процедуры, но использующих приведенную информацию и полезных для понимания действительных операций с аргументами в процедуре, рассмотрим следующее уточнение примера. Пусть третий аргумент данной процедуры представляет собой не просто числовое значение, а является адресом значения третьего аргумента, например, будучи описан в исходной программе на Паскале в составе определения заголовка
PROCEDURE funa(arg1: INTEGER; arg2: INTEGER; VAR arg3 : INTEGER);
Для данной процедуры аргументы arg1 и arg2 передаются по значению, а аргумент arg3 - по ссылке (иначе говоря, по адресу). Поэтому обращение к процедуре funa записывается машинными командами, записанными на ассемблере NASM, как
PUSH DWORD [arg1]
PUSH DWORD [arg2]
PUSH DWORD arg3
Запись того же фрагмента на ассемблере TASM/MASM будет иметь несколько иной вид, а именно
PUSH arg1
PUSH arg2
PUSH OFFSET arg3
(Здесь используется возможность неявного задания атрибутов данных, определенных при их описании в области данных, что позволило отказаться от уточнителя-модификатора DWORD и использования вспомогательного служебного слова OFFSET для указания адреса именованного объекта вместо его самого.)
Сложение оператора Паскаля
arg3:=arg1+arg2;
выполнится в этом примере командами
MOV eax, [ebp+16] ; arg1
ADD eax, [ebp+12] ; arg2
MOV ebx, [ebp+10] ; адрес собственно arg3
MOV [ebx], eax
причем в реальной подпрограмме необходимо было предварительно сохранить (а перед выходом из подпрограммы восстановить) значения регистров eax и ebx.
Перед выходом из процедуры, если она использует стандартное строение кадра процедуры, следует дополнительно выполнять команду POP EBP - для восстановления старого значения регистра EBP, которое нужно будет после возврата в вызывавшую программу. С учетом необходимого сохранения рабочих регистров для расширенного примера собственно содержимое запишется фрагментом
funa: ;; процедура funa
push ebp
mov ebp, esp
push eax
push ebx
mov eax, [ebp+16] ; arg1
add eax, [ebp+12] ; arg2
mov ebx, [ebp+10] ; адрес собственно arg3
mov [ebx], eax
pop ebx
pop eax
pop ebp
ret ; end procedure
Таким образом, как мы видим, в кадре процедуры хранятся аргументы ее вызова, адрес возврата и сохраняемые значения регистров. В действительности часть кадра процедуры еще систематически выделяется для хранения локальных переменных. Если переменные, определенные на языках высокого уровня вне подпрограмм, размещаются в сегменте данных, статические данные (данные, описанные со служебным словом static) в языке Си хранятся также в сегменте данных, то локальные переменные (определенные внутри подпрограммы) размещаются обязательно внутри кадра процедуры.
С этой целью непосредственно после команды MOV EBP,ESP в начале машинного кода процедуры выполняется команда
SUB ESP, размер_области_локальных_переменных
Например, если в подпрограмме определены две локальные переменные i,j целочисленного типа для 32-битной архитектуры (т.е. по 4 байта каждая), то под них необходимо зарезервировать 8 байтов, задав размер области, равным восьми. В результате изменения значения регистра ESP для указателя вершины стека новая вершина будет располагаться на 8 байтов ближе к началу сегмента стека. Причем зарезервированные таким образом четырехбайтовые поля можно обозначить в операндах команд как [EBP-4] и [EBP-8]. На рис. 5.3.2 изображено строение кадра процедуры для данного частного примера.
Рис. 5.3.2. Строение кадра стека после выделения области
для локальных переменных
Резервирование области для локальных переменных в стеке требует определенных обратных действий перед выходом их подпрограммы (иначе эти поля, оставшись на вершине стека, не позволят автоматически сделать вершиной стека то место в нем, где хранится адрес возврата (и запомненное "старое" значение EBP). Такие обратные восстановительные действия могут быть выполнены одним из двух следующих вариантов. Следует либо выполнить команду
ADD ESP, размер_области_локальных_переменных
в том месте завершающих действий, где область локальных переменных окажется на вершине стека, либо выполнить команду
MOV ESP, EBP
после которой регистр ESP будет указывать на то место в стеке, на которое указывал до тех пор регистр EBP (после этого можно выполнять команду POP EBP и команду RET).
Если задумываться о правильном поддержании структуры стека не только в процессе выполнения процедуры, но и после возврата из нее, то возникает вопрос, какая часть программы и как должна позаботиться об извлечении из стека аргументов процедуры, положенных в нее. В примере эта задача сводится к удалению из стека полей arg1, arg2, arg3. Оставить все как есть нельзя, во-первых, потому что остающиеся аргументы рано или поздно переполнят стек, во-вторых, вызывающая программа, как правило, также является подпрограммой и она не сможет автоматически снять адрес возврата для перехода в программу, вызвавшую ее (если в стеке над ней остаются неснятыми данные).
Теоретически для последней проблемы возможны два решения: очисткой кадра процедуры от аргументов занимаются команды процедуры и очисткой кадра процедуры от аргументов занимаются команды вызвавшей программы. Для реализации первого варианта нужно было освобождать стек после снятия адреса возврата, но до возвращения в вызвавшую процедуру. Но так как снятие адреса возврата выполняет команда RET, а к концу выполнения этой команды последовательность управления выборкой следующей команды должна вернуться в вызывающую программу (что называется просто возвратом управлением ), то такая реализация может быть вложена только внутрь выполнения самой команды RET. Именно так и поступили разработчики архитектуры и дополнительно к обычной команде RET ввели в набор команд еще и команду, записываемую в мнемонике как
RET число
где операнд число задает, сколько байтов нужно удалить из стека в ходе выполнения команды RET после снятия из стека адреса возврата. С учетом того, что из стека может быть извлечено только четное число байтов (слово или двойное слово), то непосредственный операнд число должен обязательно задаваться четным числовым значением. В нашем демонстрационном примере следовало выполнить команду RET 12. (Размер области аргументов процедуры funa есть 12 байтов.)
Второе из упомянутых выше решений заключается в выполнении команды
ADD ESP, размер_области_аргументов_процедуры
после команды CALL. В частности, по этому варианту в приведенном примере следует выполнить команду ADD ESP, 12.
Для разработчика программного обеспечения встает естественный вопрос, какой вариант из рассмотренных использовать в конкретной ситуации. Для очистки стека от аргументов процедуры предпочтительным является первый вариант, так как вызовы подпрограммы используются многократно и по второму варианту за каждой командой вызова CALL должна помещаться дополнительная команда ADD ESP, размер, а команда RET число будет присутствовать в процедуре в единственном экземпляре.
В то же время существуют ситуации, когда приходится использовать второй вариант очистки стека от аргументов. Он используется, когда предполагается применение подпрограммы с переменным числом аргументов. Напомним, что в языке С++ допускаются подпрограммы, которые можно использовать, задав только часть аргументов, а именно опустив сколько-то последних из них. Более того, даже в обычном языке Си неявно допускаются такие подпрограммы с переменным числом аргументов, среди которых можно назвать обычные системные функции типа printf.
Рассмотрим, что получится, если предложенную выше подпрограмму примера funa вызвать не с тремя, а только с двумя аргументами. Обращаясь к последовательности укладки аргументов в стек и модифицированной этим допущением схеме, аналогичной рис. 5.3.1, получим, что arg1 должен теперь внутри команд процедуры обозначаться как [EBP+12], а arg2 - как [EBP+8]. Но разработчик программы, а следовательно и машинный код никак не могут знать, с каким числом аргументов в действительности вызвана подпрограмма, и поэтому выбрать правильное обозначение для доступа к аргументом нет никакой возможности.
(Теоретически есть одно возможное решение, не применяемое в настоящее время. Оно заключается в соглашении, по которому в качестве дополнительного аргумента передается число аргументов подпрограммы. Это число как обязательный аргумент, помещаемый сверху всех других аргументов подпрограммы, анализирует машинный код процедуры и, в зависимости от его значения, принимает то или иное решение. Отказ от использования этого решения, несмотря на его универсальность, обусловлен дополнительными расходами памяти на лишний аргумент, его анализ и использование конкретного варианта, а также дополнительными затратами времени при его реализации.)
Выход был найден с помощью простого и достаточно оригинального решения - помещать аргументы в стек не с начала их перечня в списке заголовка подпрограммы на языке высокого уровня, а с конца такого списка. Для нового варианта, называемого вариантом языка Си, вызов подпрограммы funa в машинных командах запишется как
push значение arg3
push значение arg2
push значение arg1
call funa
В результате такой укладки аргументов на верху области аргументов в стеке окажется как раз аргумент arg1, а последний из списка аргументов - в низу области внутри стека. Поэтому теперь аргументу arg1 будет соответствовать обозначение операнда [EBP+8], аргументу arg2 - обозначение [EBP+12], а аргументу arg1 - обозначение [EBP+16]. В результате неукладки последнего аргумента последовательность этих обозначений нисколько не изменится, за исключением того, что неуложенный аргумент ни обозначать, ни использовать будет нельзя (но это и не является неожиданностью или неудобством).
Поэтому, соглашение по укладке аргументов в стек, по которому аргументы укладываются в порядке, обратном их размещению внутри заголовка подпрограммы на языке высокого уровня, называют соглашением типа языка Си. Противоположное же соглашение по укладке аргументов, когда они укладываются в естественном порядке относительно описания, называют соглашением языка Паскаль. Соответственно и соглашение по организации на уровне команд выхода из подпрограммы с коррекцией кадра процедуры, чтобы освободить его от аргументов, называют соглашением языка Си (при использовании добавочной команды сложения для ESP после команды CALL) и соглашением языка Паскаль (при использовании команды RET число).
Последнее, менее значимое соглашение касается возвращаемой информации из подпрограммы. В тех случаях, когда заголовок подпрограммы на языке высокого уровня предполагает возврат в качестве собственного значения ее (как подпрограммы-функции) простого значения, размещаемого в одном, двух, четырех байтах или восьми байтах, то для передачи этого значения используются регистры AL, AX, EAX или пара регистров EAX, EDX, соответственно.