Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Флоренсов А.Н. УП Системное программное обеспечение.docx
Скачиваний:
46
Добавлен:
28.06.2021
Размер:
148.95 Кб
Скачать

5.2. Организация стекового кадра подпрограммы

Рассмотренный и использованный нами способ передачи аргументов в процедуру через регистры имеет ту принципиальную особенность, что им нельзя передать большое число аргументов. Причина этого очевидна: регистров программиста в архитектуре процессора достаточно немного, в частности, в архитектуре Intel всего шесть регистров общего назначения.

Принципиальным решением по передаче аргументов стало использование для этого стека. Пусть некоторая подпрограмма с именем funa описана на некотором языке высоко уровня, например Паскаль, использует при вызове фактические аргументы arg1, arg2, arg3, и пусть все они для простоты картины имеют целочисленный тип. Вызов этой функции, который на языке высокого уровня запишется в виде funa(arg1, arg2, arg3), реализуется с помощью стека следующей последовательностью команд

push значение arg1

push значение arg2

push значение arg3

call funa

В результате все значения фактических аргументов окажутся в стеке, и вызванной подпрограмме останется только извлекать их оттуда. С этим извлечением возникают, казалось бы, некоторые проблемы. Извлекать их непосредственно командами POP нельзя, так как над значениями этих аргументов в стеке оказывается значение адреса возврата, записываемое туда командой CALL. Извлекая «по дороге» с вершины стек адрес возврата, подпрограмма лишила бы себя возможности вернуть управление в вызвавшую ее программу (команде RET должно быть что снимать с вершины стека). Какие-то манипуляции со стеком, отличные от классических операций PUSH, POP, теоретически возможны, но такое решение неудобно.

С учетом динамического характера заполнения стека (в свое время) было принято красивое решение связывать участок заполнения стека для процедуры со специальным регистром – указателем. Этот регистр называют указателем фрейма или указателем кадра, а в архитектуре Intel его назвали просто базовым указателем, подразумевая базовый указатель фрейма в стеке. Символическое обозначение этого регистра в архитектуре IA32 записывается как EBP (от сочетания слов – Base Pointer). Обратим внимание, что этот регистр имеет специализированное назначение, хотя временно его можно использовать и как регистр общего назначения (но делать это не рекомендуется, так как при этом нужно учитывать его определенные особенности).

Специализация регистра EBP заключается прежде всего в том, что подпрограммы стандартной структуры должны в качестве первых двух своих командах выполнить следующие

PUSH EPB

MOV EBP, ESP

Первая из этих команд просто сохраняет предыдущее значение регистра EBP в стеке, чтобы при выходе из процедуры восстановить исходное значение, которое этот регистр имел в вызывающей процедуре. Вторая команда запоминает в регистре EBP адрес текущей верхушки стека – напомним, что специализированный регистр ESP всегда используется как место хранения вершины стека. Далее в ходе выполнения программы процедуры содержимое регистра EBP не должно меняться. Поэтому, какие бы осмысленные манипуляции со стеком не делались в процедуре, регистр EBP своим содержимым постоянно указывает на фиксированное место в стеке. А именно, указывает место запомненного старого содержимого регистра EBP, которое, в свою очередь, записано сразу за адресом возврата. Адрес же возврата был записан командой CALL сразу после записи значений аргументов процедуры в стек.

Посмотрим с помощью рис. 5.2.1, к какому строению участка стека привело выполнение указанных выше двух первых команд процедуры.

Рис. 5.2.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

popebp

ret ; end procedure

Таким образом, как мы видим, в кадре процедуры хранятся аргументы ее вызова, адрес возврата и сохраняемые значения регистров. В действительности часть кадра процедуры еще систематически выделяется для хранения локальных переменных. Если переменные, определенные на языках высокого уровня, вне подпрограмм размещаются в сегменте данных, статические данные (данные, описанные со служебным словом static) в языке Си хранятся также в сегменте данных, то локальные переменные (определенные внутри подпрограммы) размещаются обязательно внутри кадра процедуры.

С этой целью непосредственно после команды MOV EBP, ESP в начале машинного кода процедуры выполняется команда

SUB ESP, размер_области_локальных_переменных

Например, если в подпрограмме определены две локальные переменные i, j целочисленного типа для 32-битной архитектуры (т. е. по 4 байта каждая), то под них необходимо зарезервировать 8 байтов, задав размер области равным восьми. В результате изменения значения регистра ESP для указателя вершины стека новая вершина будет располагаться на 8 байтов ближе к началу сегмента стека. Причем зарезервированные таким образом четырехбайтовые поля можно обозначить в операндах команд как [EBP-4] и [EBP-8]. На рис. 5.3.2 изображено строение кадра процедуры для данного частного примера.

Рис. 5.2.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 соответственно. Особенности организации стека для архитектуры AMD64 кратко будут рассмотрены позже.