Размещение переменных, передача параметров в процедуры

03.12.2017

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

Глобальные и статические переменные

Глобальные и статические переменные, в данном случае, самые малоинтересные. Они существуют в памяти все время работы программы, поэтому их размещение выполняется компилятором в сегменте данных (для настольных ПК) или просто в оперативной памяти (для микроконтроллеров). Каждая переменная получает постоянный адрес выделенного ей блока памяти соответствующего размера. Статусы "глобальная", "внешняя", "статическая" не имеют значения для процессора. Они нужны компилятору, для разграничения доступа к переменным, и программисту, так как отражают семантику видимости данных.

Локальные переменные

А вот с локальными переменными все уже интереснее. Во первых, такие переменные существуют не всегда, а значит, должны как то создаваться и уничтожаться в ходе выполнения программы. Во вторых, если мы создаем вложенный блок в программе, то переменные этого блока имеющие такие же имена, как блоке более высокого уровня, скрывают ранее определенные переменные, которые при этом продолжают существовать. То есть, локальные переменные не только должны создаваться и уничтожаться во время работы программы, но должна поддерживаться и их иерархия. Локальные переменные в языке С, если иное не указано, получают модификатор auto, но сейчас это для нас не важно. Стандартным местом размещения локальных переменных является стек.

Как же быть, если архитектура процессора, например, микроконтроллера, не имеет стека для размещения данных? Так микроконтроллеры Microchip PIC семейства midrange имеют аппаратный стек всего из 8 ячеек, причем программно он не доступен и автоматически используется только в командах вызова процедуры, возврата из процедуры, и для прерываний. В таком случае компилятор будет использовать под стек обычную область памяти, так называемый программно управляемый стек.

Давайте рассмотрим небольшой пример. При этом пока не будем обращать внимания на то, что main это процедура.

            void main(void) {
                int     var1;
                char    var2;
                struct  {
                    int    id;
                    int    val;
                }       var3;
                    .  .  .
            }

Вспомним, что стек размещается в направлении уменьшения адресов. Это не очень принципиально, но обычно бывает именно так. Сначала в стеке размещается переменная var1, потом var2, затем структура var3. Вот как это будет выглядеть.

Пример простейшего стекового фрейма

Предположим, что тип int занимает два байта. Тогда адрес переменной var3 будет равен содержимому регистра SP (указатель стека). Переменная var2 расположится по адресу SP+4, а переменная var1 по адресу SP+6. Пока ничего сложного. Обратите внимание на "начало фрейма". Совокупность сохраненных в стеке переменных процедуры (или блока, как станет видно дальше) называется стековым фреймом. Это не просто термин, это удобная абстракция. Для чего она применяется мы еще увидим. Указатель на начало фрейма на рисунке показан не совсем верно, но об этом разговор впереди.

Теперь немного усложним наш пример добавив вложенные блоки.

            void main(void) {
                int     var1;
                char    var2;
                struct  {
                    int    id;
                    int    val;
                }       var3;
                
                for(int i=0; i<10; i++) {
                    int  tmp;
                    tmp=var2+var3.id;
                    var3.val=tmp;
                }
                    .  .  .
            }

Теперь наш стек будет выглядеть так

Формат стекового фрейма для вложенных блоков

Обратите внимание, у нас появился еще один стековый фрейм. Но появится он не сразу при начале выполнения программы, а только когда выполнение дойдет до оператора for в котором объявлены еще две переменные. Оператор for создает новый блок, вложенный в блок main, который будет для блока for блоком верхнего уровня. Когда выполнение дойдет до закрывающей фигурной скобки оператора for фрейм2 будет удален.

Но теперь стала заметной одна проблема, внутри оператора for нам нужен доступ к переменным var2 и var3 внешнего блока, а указатель стека у нас изменился и теперь SP указывает на переменную tmp, а не на var3. Вот тут и становится понятной еще одна из функций стекового фрейма. Переменные в стековых фремах адресуются не относительно SP, а относительно начала фрейма. Если же нужен доступ к переменной одного из внешних блоков, то к нужно еще добавить смещение до начала соответствующего фрейма. Это напоминает получение доступа к элементам массива, что мы рассматривали ранее в другой статье. Для нашего рисунка адрес переменной var3 будет равен "начало фрейма 1" минус 6 независимо от того, появились в стеке новые фреймы, или нет. В процессорах архитектуры x86 есть регистр BP (EBP), который называется "указатель базы" и который, в отличии от регистров SI, DI и BX (при косвенной адресации), как раз формирует адрес относительно сегмента SS, то есть стека. Вот этот регистр и используется для хранения адреса начала стекового фрейма, а регистр SP, как обычно, используется для указания вершины стека. В процессорах другой архитектуры регистра аналогичного BP может не быть, или его функции может исполнять другой регистр, суть от этого не меняется.

Примечание. Локальные переменные могут быть не только явно объявленные программистом. Компилятор может создавать скрытые переменные для хранения возвращаемых функциями значений. Так же, скрытые временные переменные может создавать оптимизатор кода компилятора в процессе обработки сложным математических выражений.

Регистровые переменные

Регистровые переменные есть только в языке С (модификатор register). По крайней мере, из распространенных языков. В обычных программах, обычно, не используются. Более того, компилятор имеет полное право игнорировать модификатор register. Объявление переменной как регистровой советует компилятору разместить переменную не в оперативной памяти, а в одном из регистров процессора. Обращение к регистрам происходит гораздо быстрее, обычно, чем к оперативной памяти, что может оказаться важным для критичных по времени участков программ. Регистровые переменные не имеют адреса, следовательно, не могут быть доступны по ссылке. При этом, компилятор может разместить переменную в памяти (скорее всего, стеке), но при частом к ней обращении в каком то фрагменте программы размещать ее в регистре с последующим копированием обратно в память. Регистровая переменная не может быть глобальной. Более того, регистровой может быть только переменная естественной для процессора разрядности. То есть, структура, массив, объединение, не могут быть регистровыми.

Динамические переменные

Точнее, переменные с динамическим выделением памяти. Если для размещения переменной требуется много памяти, например, большой массив, то размещать ее в стеке становится проблематичным. Ведь стек часто имеет ограниченный размер. В таком случае объявляют переменную-указатель (другое название ссылочная переменная), которая содержит адрес выделяемого операционной системой (или средой времени выполнения компилятора) блока памяти. Естественно, запрос выделения блока памяти должен выполняться программистом, так как размещение переменной-указателя не подразумевает автоматического выделения памяти под собственно данные. Точно так же, выделенный системой блок памяти должен быть программистом возвращен системе перед уничтожением переменной-указателя. В противном случае будут возникать ошибки обращения к памяти или утечка памяти. В языке C++ есть понятия конструктора и деструктора. Операции запроса блока динамической памяти и возврата памяти системе можно разместить в этих процедурах в классе, экземпляром которого является переменная.

Передача параметров в процедуру и возврат значений из процедуры

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

В общем и целом, схема передачи параметров и возврата значений зависят от компилятора. Был компилятор Watcom C++, который использовал комплексную схему, когда первые два параметра передавались в регистрах AX и DX, а остальные, при их наличии, через стек. Но такие схемы используются редко. Обычно, параметры передаются через стек и размещаются в нем в том порядке, в котором они указаны в списке вызова (хотя могут быть и исключения).При этом компилятор знает, сколько байт в стеке займут параметры вызова. Это нужно для быстрой очистки стека от параметров.

Возврат значений может выполняться так же через стек или через регистр процессора, например через AX для процессоров x86, или WREG для микроконтроллеров PIC. Я буду рассматривать возврат значения через стек. Фактически, результат выполнения функции формируется в стеке как обычная локальная переменная, которая размещается до списка параметров вызова.

Сама процедура, обычно, сохраняет используемые регистры процессора, но может не ограничиваться этим. Может сохраняться даже полное состояние задачи, если шлюз вызова указывает на задачу. Это я рассказывал в другой статье и тут упомянул только для полноты картины. Обычно, сохранение регистров, отличных от общих, например, регистров периферийного устройства, должно выполняться программистом. Но в обычных процедурах такая необходимость отсутствует. Процедура может иметь свои локальные переменные, причем больше одного уровня вложенности блоков. Кроме того, в стеке же размещается адрес возврата в точку вызова.

Давайте рассмотрим пример. Только теперь уже учтем, что main тоже является процедурой. Пример чисто учебный, не ищите в нем логику. Я указал в комментариях контрольные точки. Для этих точек я буду приводить снимки стековых фреймов и давать комментарии к ним. Так же, будем считать, что используется процессор архитектуры x86, причем 16 разрядный, для простоты.

            int main(int argc, char* argv[]) {        // *** контрольная точка 1
                int      var1, var2=5;                // *** контрольная точка 2
                struct {
                    int   id;
                    int   val;
                } var3;
                
                for(int i=0; i<10; i++) {             // *** контрольная точка 3
                    int lc;
                    var1=var2+var3.id;
                    var1=func(5, 10, var3.val);       // *** контрольная точка 4
                    while(var3!=5) {
                        int tmp=var3.val/var1;        // *** контрольная точка 5
                        lc=tmp+var1;
                    }
                }
                
                return var2;
            }
            
            int func(int a1, int a2, int a3) {
                int   lc;
                lc=a1-a2;
                return a1*lc+a3;                      // *** контрольная точка 6
            }
Пример стекового фрейма при входе в main

Это состояние стека сразу после того, как процедура main получила управление. Посмотрим на него повнимательнее. Многое нам уже знакомо, но видны и отличия. Помните, я говорил, что на предыдущих рисунках начало фреймов было показано не совсем верно? Дело в том, что адрес начала фрейма используется, в том числе, для очистки стека, то есть, удаления из него локальных данных. Для упрощения этого, началом фрейма процедур считается адрес возврата в вызвавшую процедуру. При входе в процедуру SP указывает на адрес возврата. И простое копирование SP → BP позволяет быстро установить указатель базы на начало фрейма, не требуется вычитание для перемещения указателя. Перед выходом из процедуры обратное копирование BP → SP разом перемещает указатель стека на адрес возврата, уничтожая, логически, все локальные данные.

Положительные смещения относительно начала фрейма позволяют адресовать переданные процедуре параметры. В данном случае это количество аргументов argc указанное оператором при запуске программы (причем не только из командной строки), и адрес списка самих параметров argv[]. argc имеет смещение +4 относительно начала фрейма, а argv +2. Так же видно, что перед списком параметров разместилась переменная, через которую main вернет результат своей работы.

Отрицательные смещения относительно начала фрейма позволяют адресовать локальные переменные. В данном случае var1 имеет смещение -2 относительно начала фрейма, var2 смешение -4, а var3 смещение -8. Почему у var смещение -8, а не -6? Почитайте еще раз статью про неоднородные данные и структуры.

Примечание. Обратите внимание, что я не оформил блок с параметрами как фрейм. Дело в том, что функция main не получает управление сразу после запуска программы. Первым получает управление, так называемый, start-up код, который является частью среды времени выполнения (run-time) формируемой компилятором. Этот код выполняет начальную инициализацию среды и только потом вызывает main, написанную программистом. При этом, так как main, фактически, является обычной процедурой, стартовый код формирует список параметров вызова, как для любой процедуры. Но сам стартовый код не является процедурой языка С и ему не нужно следовать всем соглашениям. В частности, ему не нужны фреймы.

Поскольку пока в стеке больше ничего нет, даже сохраненных регистров процессора, размер стекового фрейма процедуры main составляет 10 байт. Фактически, размещение локальных переменных в стеке выполняется просто вычитанием из SP размера фрейма, то есть, SP - 10 → SP, в нашем случае.

Контрольная точка 2 показана не очень наглядно, поэтому нужно пояснить, что в этой точке процедура не только подготовлена к выполнению, но и начала выполняться. И первым шагом, обычно, бывает сохранение общих регистров процессора. Надо заметить, что не обязательно сохраняются все регистры. Компилятор может определить, какие регистры он задействует в процедуре и сохранит только их. Предположим, что в нашем случае компилятор решил сохранить три регистра - ax, bx и cx.

Пример состояния стека при начала выполнения main

По сравнению с предыдущим состоянием стека изменений не много. В стеке появились сохраненные регистры. Сместился и указатель стека. Но обратите внимание, что смещения переменных и параметров относительно начала фрейма не изменились! А для нас это очень важно.

пример состояния стека при входе во вложенный блок

В контрольной точке 3 у нас начал выполняться оператор цикла for, поэтому в стеке появился новый фрейм с двумя локальными для него переменными. Обратите внимание, что BP у нас не изменился. Это один из вариантов организации вложенных фреймов. Дело в том, что регистр BP у нас один и если его значение изменится, то изменятся и смещения относительно него для уже размещенных в стеке локальных переменных и параметров, а это недопустимо. Поэтому BP всегда устанавливается на начало фреймов процедур, но не изменяет значение для фреймов вложенных блоков. Так же, обратите внимание, что я нарисовал "total main frame", который охватил оба сформированных в стеке фрейма. И это именно так, поскольку фрейм блока оператора for является вложенным в блок main.

В нашем случае i (переменная цикла) получает смещение -16 (десятичное) относительно начала фрейма main, а lc смещение -18 (десятичное). Как и ранее, выделение памяти под эти переменные осуществляется простым вычитанием длинны фрейма (в данном случае 4) из SP. А удаление фрейма оператора for будет выполнено прибавлением длинны фрейма (4) к SP. В этом различие между фреймами процедур и вложенными в них фреймами.

Теперь посмотрим, как выполняется адресация структуры var3. Я уже писал, что смещение var3 относительно начала фрейма (BP), равно -8. Смещение до поля id этой переменной равно 0 относительно адреса ее размещения. Теперь посчитаем

addr(var3.id)=BP+offset(var3,BP)+offset(id,var3)=0xFA+(-8)+0=0xF2

Как видно, это соответствует истине. Здесь offset(var3,BP) это смещение адреса начала переменной относительно фрейма (BP, если точнее), а offset(id,var3) это смещение поля относительно адреса начала переменной.

Пример состояния стека подготовленного к вызову процедуры с параметрами

В контрольной точке 4 мы подготовились к вызову процедуры (функции) func. Как видно, у нас опять увеличился размер общего фрейма процедуры main. В стеке разместилась скрытая переменная для возврата значения из func, я уже писал об этом выше. Так же, в стек помещены параметры вызова процедуры в том порядке, в каком они размещены в списке вызова. Но тут есть и существенное отличие, у нас перед списком параметров идет сохраненное значение регистра BP. Это важный момент! Вызываемая процедура создаст свой собственный фрейм в стеке, который будет адресовать через  BP. То есть, она изменит его значение, а нам нельзя этого допустить. Но почему он сохранен именно в этом месте? Дело в том, что возвращенное из func значение мы должны адресовать относительно начала фрейма main и в этом месте нам BP еще нужен. А вот список параметров после возврата из func нам уже не потребуется и его можно удалить из стека. Удаление списка параметров из стека будет осуществлено прибавлением к SP длинны списка, в нашем случае, 6, как это описывалось ранее. После чего мы можем восстановить BP. В результате мы получим полностью восстановленное состояние фрейма main. Кстати, скрытая переменная для возврата значения из func будет иметь смещение -20 (десятичное) относительно начала фрейма main.

Пример состояния стека после возврата из процедуры с еще одним уровнем вложенности

В контрольной точке 5 мы закончили вызов func, обработали результат и начали еще один цикл while. В нем определена одна локальная переменная, а значит появился еще одни фрейм. Тут нет ничего нового, за одним исключением. Куда делась переменная для возврата значения из func? Она стала нам не нужна после оператора, в котором был вызов func и была удалена из стека, как временная скрытая переменная.

После завершения цикла while будет удален его фрейм. А после завершения цикла for и его фрейм. Оператор return помещает возвращаемое процедурой main значение на его место в стеке, но возврата управления из main пока не происходит. Теперь мы возвращаемся к состоянию стека в контрольной точке 2. Восстанавливаем сохраненные ранее регистры и мы возвращаемся к состоянию стека в контрольной точке 1. Остается полностью удалить фрейм main копированием регистра BP в регистр SP. Теперь оператор return может продолжить свое выполнение и вернуть управления стартовому коду, который выполнит завершающую очистку среды и вернет управление операционной системе, передав ей возвращенной main значение в качестве кода завершения.

Но мы не рассмотрели, что же будет происходить внутри вызванной нами func. Сейчас это и сделаем. Итак, контрольная точка 6.

Пример состояния стека во время выполнения вызванной процедуры с параметрами

Тут нам уже все знакомо. Только в данном случае я, для примера, показал, что компилятор решил сохранить только два регистра, а не три, как в случае main. Остановлюсь только на двух моментах. Обратите внимание, что у нас изменилось значение регистра BP, он теперь указывает на фрейм процедуры func, как и должно быть во время ее выполнения. Изменение нам не страшно, так как вызывающая процедура main сохранила нужное ей значение. Второй момент, это появление в стеке двух переменных lc. Внутри func будет доступна только переменная по адресу 0xDA. 

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

Заключение

Остается ответить на последний вопрос, зачем все так сложно? Неужели нельзя просто передавать параметры через переменные в памяти? Да, так и было когда то. Например, так передавались параметры в Fortran IV. Но такой подход имеет два минуса. Первый, не самый важный, при таком способе будет больше расход памяти под данные. Ведь в стеке переменные и параметры процедур могут занимать одно и то же место, если они не нужны одновременно. Второй, и уже существенный минус, будет невозможно реализовать повторно-входимые (реентерабельные, reentrant) процедуры. То есть, процедуры которые можно вызывать снова до того, как процедура отработала предыдущий вызов. Это нужно при разработке многопоточных программ и процедур, которые могут быть вызваны и из основной программы, и из обработчика прерывания. Ну и, следовательно, нельзя реализовывать рекурсивные процедуры.

А вот с микроконтроллерами все сложнее. Их аппаратные ресурсы меньше, часто значительно, чем доступные микропроцессорам настольных систем и серверов. Да и архитектура бывает отличной от фон Неймановской, например, микроконтроллеры Microchip PIC имеют Гарвардскую архитектуру, а их оперативная память является, фактически, набором регистров общего назначения. Как я уже упоминал, в таких случаях используется программная имитация аппаратного стека, но полноценные фреймы не формируются. И повторная входимость для процедур недоступна, как и рекурсия. Однако, иногда остается необходимость в процедурах, которые могут вызываться и из основной программы, и из обработчика прерывания. Некоторые компиляторы, например линейки XC, в таком случае скрыто дублируют код таких процедур, что бы одну копию использовать из основной программы, а вторую из прерывания. Естественно, дублируется не только код, но и локальные переменные. Однако обсуждение тонкостей передачи процедурам управления, написания переносимого, позиционно-независимого и повторно-входимого кода выходит далеко за рамки данной статьи и будет обсуждаться отдельно.

Вот собственно и все. Мы рассмотрели как размещаются в памяти переменные в процессе выполнения программ. В данном случае, написанных на С. Но примерно так же будет и для программ на Pascal, и других языках. Будут отличаться детали, но сам принцип останется таким же.