В предыдущей статье "Микроконтроллеры для начинающих. Часть 28. Мост к Си. Размещение в памяти. Секции, сегменты, особенности" мы, упрощенно, познакомились с тем, как наши программы на С размещаются в памяти микроконтроллеров. Давайте теперь рассмотрим некоторые из этих областей памяти подробнее. Сегодня речь пойдет о стеке.
Программисты пишущие на С (да и на любом другом языке высокого уровня) прикладные программы для универсальных ЭВМ о стеке задумываются редко. Пишущие на ассемблере программисты используют стек так, как посчитают нужным. Наш случай находится где то посередине между этими двумя крайностями.
Стек, как это устроено
Что бы разобраться с использованием в С нам нужно сначала вспомнить, хотя бы в общих чертах, как стек устроен и работает.
Стек часто сравнивают с монетницей. И это хорошая иллюстрация его работы. Но аппаратные реализации стека могут несколько различаться по деталям работы. Давайте посмотрим на два наиболее часто встречающихся варианта.
Помещают значение в стек (аппаратный), обычно, командой push, а извлекают командой pop. Эти команды не только выполняют пересылку данных, но и корректируют указатель стека - регистр SP. Поскольку стек размещается от старших адресов памяти к младшим, команда push уменьшает значение в SP, а команда pop увеличивает.
Вот здесь и возникает два варианта
- Команда push уменьшает SP и потом сохраняет заданное значение в памяти по адресу указываемому SP. Команда pop извлекает значение из памяти по адресу указываемому SP и потом увеличивает SP.
- Команда push сохраняет заданное значение в памяти по адресу указываемому SP и потом уменьшает SP. Команда pop увеличивает SP и потом извлекает из памяти значение по адресу указываемому SP.
Увидели разницу? Давайте я покажу ее на иллюстрации для команды
PUSH 25h
Хорошо видно, что в первом варианте у нас SP указывает на помещенное в стек значение, а во втором варианте значение располагается по предыдущему месту в стеке. Старое значение SP (до выполнения команды) показано серым цветом. А ход выполнения команды показан красным цветом. Этапы, шаги, выполнения команды показаны числами в кружочках.
Другими словами можно сказать, что в первом варианте SP указывает на сохраненное значение, а во втором на место сохранения следующего значения.
Какой вариант лучше? Оба имеют право на жизнь, оба используются в различных процессорах. Однако, в рассматриваемых нами микроконтроллерах (кроме PIC) реализован второй вариант. Условно можно показать это так:
PUSH
(SP--) <- dst
POP
dst <- (++SP)
Самодельный стек
На самом деле мы можем использовать не только аппаратный стек, но и создать свой собственный. Для этого понадобится любой свободный индексный регистр, который будет использоваться вместо SP. В STM8 есть два индексных регистра - X и Y, а в AVR три - X, Y и Z.
Однако, мы говорим о программах на С, а не на ассемблере. Поэтому нам необходимо учитывать использование регистров компилятором. А для этого придется прочитать документацию на компилятор.
Разумеется, при использовании такого самодельного стека мы можем реализовать любой вариант его работы. Даже отличающийся от имеющегося аппаратного стека. И даже располагающийся от младших адресов к старшим.
В конце концов, такое направление (от старших адресов к младшим) аппаратного стека происходит от стремления максимально использовать имеющуюся память. Когда в начале памяти располагаются статически размещаемые переменные а за ними следует область динамически распределяемой памяти. Разместив стек в самом конце памяти и определив его направление снизу вверх мы получаем возможность использовать одну и туже область памяти для двух динамических сущностей - стека и динамической памяти.
Типичное использование стека компиляторами
Как мы уже знаем, стек используется командами вызова подпрограмм и возврата из них. А так же, при обработке прерываний. Это аппаратное использование, для аппаратных же целей.
Компиляторы используют стек для хранения локальных переменных, как мы видели в предыдущей статье. Кроме этого, стек используется компиляторами для передачи параметров в процедуры и функции. Сохраняется в стеке и состояние процессора при прерываниях. Написанная программистом на С функция обработки прерывания часто получает управление не сразу, а вызывается после специального кода, который генерируется компилятором.
Давайте кратко рассмотрим, как работает стек на следующем простом примере программы на С. При этом будем считать, что никакой передачи параметров и результатов через регистры не происходит, что бы не загромождать суть использования стека. Кроме того, это все зависит от микроконтроллера и компилятора. Так сказать, рассмотрим абстрактный чистый случай.
До начала выполнения main стек пуст. Это его исходное состояние
Функция main создает две локальные переменные, которые и разместятся в стеке. Обратите внимание, что переменные у нас не инициализируемые. Будем считать, что автоматическое обнуления таких переменных не выполняется. Стек стал выглядеть так
Как раньше, серым цветом я показал предыдущее значение SP. Обратите внимание, что иллюстрация отражает второй вариант работы стека (что это за варианты говорилось ранее). Предполагаем, что локальные переменные располагаются в стеке в том порядке, в котором определяются в программе.
Переменная var1 у нас целого типа и занимает два байта. Но микроконтроллер у нас 8-битный, а значит, переменная превышает его разрядность. Поэтому компилятор сгенерирует несколько команд для работы с ней. В STM8 есть команда PUSHW, которая позволяет сохранять в стеке сразу 16-битное значение. Эта команда помещает в стек сначала младший байт, а затем старший. Именно такой случай я показал на иллюстрации. low(var1) означает младший байт переменной, а high(var1) старший. Переменная var2 занимает один байт, с ней все просто.
С правой стороны я показал смещения относительно SP адресов переменных в стеке. Почему для var1 смещение равно 2, а не 3? Все просто. Адрес всегда указывает на первый байт переменной, который расположен по меньшему адресу. Это касается и сложных структурированных переменных определяемых программистом типов.
Обратите внимание, что если мы сейчас изменим значение SP, например, поместив в стек какую то переменную, то адреса наших локальных переменных изменятся. А вот компилятор этого не ожидает. Поэтому ручные манипуляции со стеком, которые изменяют SP, являются плохой идеей при разработке программ на С. Да и на любом языке высокого уровня.
Поскольку у нас локальные переменные не инициализируются и не обнуляются, нет необходимости использовать команды PUSH. Можно просто уменьшить SP на суммарный размер локальных переменных. В нашем случае это можно записать так
SP=SP-3, или короче, в стиле С, SP-=3
Внимание! Любые прямые операции с указателем стека должны обязательно проводится с запрещенными прерываниями! Я сейчас не буду останавливаться подробнее на этом вопросе, тема отдельной статьи.
Теперь посмотрим, что происходит со стеком при подготовке к вызову func. В стек помещаются параметры для func. Будем считать, что параметры помещаются в порядке описания.
Обратите внимание, произошло то, о чем я говорил ранее. У нас стали недействительными адреса локальных переменных main. Однако, это не важно, в данном случае. У нас оба параметра целые, поэтому они займут в стеке 4 байта.
Однако, это еще не все. Наша функция возвращает значение, значит нужно предусмотреть для него место. В общем случае, возвращаемое значение тоже размещается в стеке.
Возвращаемое значение занимает у нас 1 байт. Теперь все готово для передачи управления func. Нужно заметить, что вся эта подготовка происходит незаметно для программиста на С.
При вызове func в стеке сохраняется адрес возврата. Это делается автоматически командой call.
Будем считать, что регистр PC у нас 16-битный. Порядок байт при его автоматическом сохранении не имеет для нас значения. Обратите внимание, что адреса всех размещенных в стеке переменных и параметров снова изменились! Что с этим делать я скоро расскажу.
Теперь в стеке размещаются две локальные переменные func. Как это происходит мы уже видели на примере main
Справа показаны смещения до локальных переменных и параметров относительно SP. Оператор return помещает младший байт суммы двух параметров, как и написал программист, в стек на место возвращаемого значения. В данном случае это адрес SP+5.
Теперь осталось удались из стека локальные переменные func. Компилятор использует тот же самый трюк с арифметикой для SP
SP+=2
Теперь у нас стек выглядит точно так же, как при входе в func. Я не буду рисовать еще одну иллюстрацию, просто посмотрите уже размещенную ранее. Осталось выполнить возврат по адресу return addr.
Мы снова оказались в функции main. Теперь возвращенное из func значение копируется в var2.
Здесь может показаться, что лучше не использовать место в стеке для возвращаемого значения, лучше поместить туда адрес переменной, которой это значение присваивается. На самом деле, так делать нельзя. Дело в том, что возвращаемое значение может вообще не использоваться вызывающей функций, а значит нет и адреса переменной, которой оно присваивается. Кроме того, возвращаемое значение может использоваться в цепочке вычислений, без присваивания его какой либо переменной. Поэтому вызываемая функция просто помещает результат своей работы на предназначенное для этого место в стеке, а остальное отдается на усмотрение вызывающей функции. И компилятора.
Теперь нам надо очистить стек от помещенных туда параметров и возвращаемого значения. В нашем случае они занимают 5 байт, поэтому компилятор просто выполняет
SP+=5
и стек возвращается к тому состоянию, которое было после размещения локальных переменных, но до вызова func. Эта иллюстрация у нас тоже уже есть.
В программах для микроконтроллеров функция main обычно никогда не заканчивается. Но если это все таки произойдет, то стек будет обработан до своего исходного состояния точно так же, как это делалось для func.
Фиксируем переменные адреса переменных. Или, как поймать неуловимое
Рассматривая работу стека мы заметили одну неприятную особенность. У нас любое помещение данных в стек, или извлечение, приводило к тому, что адреса размещенных там переменных изменялись. А ведь в func, например, могут быть и вложенные блоки, со своими локальными переменными. Конечно, компилятор может отслеживать такие изменения и вносить соответствующие корректировки. Но это сложно, да и не всегда возможно (например, прерывания могут сильно осложнить ситуацию).
Что же делать? Все довольно просто, нужно оставить SP для работы со стеком на аппаратном уровне, а в качестве базы для отсчета адресов переменных в стеке использовать какой то иной индексный регистр. Например, вполне подойдет регистр X.
Я показал состояние стека во время выполнения func. Использование дополнительно регистра, а данном случае Х, требует некоторых дополнительных действий от компилятора. В частности, требуется сохранять его при входе в подпрограмму, что бы можно было вернуться к стековому кадру, который был до вызова процедуры. На иллюстрации сохраненный регистр X показан как saved X.
После сохранения хранящегося в нем значения, регистру Х можно присвоить значение регистра SP простой операцией
X=SP
Все теперь у нас адреса переменных и параметров в стеке можно отсчитывать относительно Х, а не SP. И они не будут изменяться внутри процедуры.
Обратите внимание, между адресом содержащимся в Х и параметрами (возвращаемым значением, если быть точным) располагаются сохраненное значение Х и адрес возврата. Однако, это происходит всегда и компилятор просто учитывает это. Один раз для каждой функции.
Параметры и возвращаемое значение будут иметь положительное смещение относительно Х, а все локальные переменные отрицательное. По той простой причине, что аппаратный стек располагается от старших адресов памяти к младшим.
Теперь мы можем размещать сколько угодно вложенных блоков кода, сколько угодно своих данных прямой работой со стеком (если нужно, но важно понимать, как это работает). Если мы не будем трогать регистр X, работа программы нарушена не будет, адреса переменных уже расположенных в стековом кадре не изменятся.
При выходе из подпрограммы нам тоже потребуется дополнительное действие - восстановление регистра Х. И потребуется учесть занимаемое им место при очистке стека. Но это не сложно.
Стек без стека. Возможно и такое
Все выше рассмотренное относится к AVR и STM8, в равной мере. Но, если вы помните, в PIC нет универсального аппаратного стека, есть только стек возвратов. Да, в старших моделях PIC и PIC18 есть возможность использовать его для хранения данных. Но размер этого стека очень маленький, а помещение в него данных сокращает доступную глубину вложенности вызовов. Я рассказывал об этом в "Микроконтроллеры для начинающих Часть 11. Процессор и память в PIC"
Как компилятор и программисты (даже на ассемблере) выкручиваются в такой ситуации я кратко рассказывал в "Микроконтроллеры для начинающих. Часть 13. Практический взгляд на архитектуру памяти". Особо повторяться не буду, но немного подробнее расскажу.
Для PIC возможны два варианта работы со "стеком". Для микроконтроллеров имеющих более одной пары регистров FSR:INDF возможна организация "самодельного" стека, которая не помешает, например, работе с массивами. И компилятор именно так и поступает. Такой стек называется программным.
Если же пара FSR:INDF всего одна, то компилятор не пользуется стеком в явном виде. Вместо этого строится дерево (граф) вызовов процедур, на основе их взаимосвязанности. Точно так же, как раньше делалось для построения программ "с перекрытиями" (если кто из вас еще помнит те времена). На основании такого графа можно присвоить переменным статические адреса, прямо на этапе компиляции. Если две процедуры никогда не вызывают одна другую (не располагаются по общему пути в графе), то их переменные можно разместить в одной и той же ячейке памяти. Такой стек называют компилированным.
Это усложняет работу компилятора, но дает и некоторые преимущества. В частности, это позволяет убрать промежуточный слой косвенной (индексной) адресации при доступе к переменным и использовать прямую адресацию. А это не редко бывает более быстрым.
Но есть и минусы. В частности, при этом теряется возможность повторной входимости в процедуру. А это не позволяет использовать процедуру для вызова из основной программы и из прерывания. Приходится делать копию кода (автоматически создается компилятором). И не позволяет делать рекурсивные вызовы. Я буду рассказывать об этом в одной из следующих статей.
Заключение
Пожалуй, на сегодня достаточно. К стеку мы вернемся еще не раз, но уже при рассмотрении отдельных специальных вопросов. Теперь вы знаете, как работает стек, пусть и в облегченной упрощенной форме. Более глубокое рассмотрение темы стека интересно, но для начинающих излишне.
Осталось добавить, что в отличии от универсальных ЭВМ обладающих большими ресурсами микроконтроллеры имеют малые объемы ОЗУ. А с учетом того, что в С параметры передаются не по адресу, а по значению, не стоит передавать в качестве параметров большие структуры. И строки символов. Что бы избежать лишнего копирования. Лучше передавать в качестве параметра ссылку на такую структуру.
Как всегда, если у вас появились или остались вопросы, можете задавать их в комментариях. Отвечу обязательно.
До новых встреч!