Суперскалярный стековый процессор: продолжаем скрещивать ужа и ежа
Из общих соображений у описываемой архитектуры ожидаются проблемы с вызовом функций. В самом деле, после возврата из функции мы ожидаем и возврата состояния регистров в контексте текущей функции. В современных регистровых архитектурах для этого регистры делятся на две категории — за сохранность одних отвечает вызывающая сторона, других — вызываемая.
Но в нашей архитектуре фронтенд стековый, следовательно, компилятор может и не знать о существовании каких бы то ни было регистров. А процессор сам должен позаботиться о сохранении/восстановлении контекста, что кажется нетривиальной задачей.
Но прежде сделаем лирическое отступление на тему стека как такового. Само понятие стек способно вводить в заблуждение.
Вот в IBM/360 нет аппаратного стека. Но функции (в том числе и рекурсивно) вызывать можно, для этого параметры сохраняются в области памяти, которую перед вызовом необходимо выпрашивать у ОС.
В x86 есть аппаратный стек, но никто не относит эту архитектуру к стековым. Этот стек является отличным механизмом для хранения локальных переменных и параметров функций.
AMD29K, SPARC и Itanium относятся к так называемому Berkeley Risc семейству архитектур, у них стек выполняет еще одну важную функцию: пул регистров является верхушкой стека (register windows), что, как предполагается, ускоряет передачу параметров при вызове функций. SPARC V7 появился на пару лет раньше AMD29K, но он видится (автору) менее архитектурно стройным. RSE блок Itanium’а в целом аналогичен таковому от AMD29K, но появился существенно позже.
AMD29K заслуживает отдельных добрых слов- memory stack — используется для хранения больших локальных переменных (структуры и массивы) также как и хвоста параметров, если их больше 16. Регистр gr125(msp) является указателем на вершину этого стека.
- register stack — в наличии 128 локальных регистров, которые образуют вершину стека
- регистровый стек служит для быстрого доступа к вершине стека в памяти (отличного от вышеописанного memory stack, конечно)
- глобальные регистры gr126(rab) и gr127(rfb) определяют верх и низ стека, gr1(rsp) хранит указатель на его вершину
- умеет делать в одном цикле два чтения и одну запись
- в нём нет явных стековых операций таких как push&pop, вместо них при вызове функции для нее освобождается определенное компилятором количество регистров (activation record, так здесь называется call frame)
- доступ к данным из activation record идет через регистры, которые для каждой функции нумеруются от lr0
- lr0 и lr1 зарезервированы, в первом адрес возврата, во втором — activation record вызывающей функции
- регистровые окна вызывающей и вызываемой функций пересекаются параметрами аналогично SPARC
- если для вызова новой функции не хватает свободных регистров, происходит trap SPILL, обработчик которого выталкивает часть значений регистров в память, освобождая их
- наоборот, когда свободных регистров становится слишком много, срабатывает FILL
- чтобы это происходило, компилятор вставляет инструкции
- Нумерация регистров для каждой функции своя, это особенность Berkeley RISC
- А вот расщепление стека — особенность именно этой архитектуры. В SPARC’е регистровые окна сохраняются в тот же самый стек, где лежат и обычные (не быстрые) переменные. И fill/spill делаются с разрывами — каждое окно из своего фрейма.
Идея стека как хранилища локальных переменных (и параметров) прекрасна в своей логичности и завершенности. Слабое место — производительность системы упирается в задержки и производительность памяти. Во времена PDP-11 с этим ничего нельзя было поделать, но с тех пор ситуация изменилась.
Во-первых, доступ к регистрам стал существенно быстрее доступа к памяти, что вызвало потребность в кэшировании данных. Во вторых, появилась возможность иметь гораздо большее к-во регистров.
Обладание большим количеством регистров порождает соблазн воспользоваться ими для ускорения передачи аргументов при вызове функций. В самом деле, параметров обычно немного (меньше, чем локальных данных), их значения почти всегда кому-то нужны. А что из локальных данных заслуживает попадания в регистры пусть решит оптимизация. Это, конечно, очень грубое упрощение, призванное лишь продемонстрировать общую мотивацию.
- Закрепление за определенными регистрами специальной роли. Например, в MSVC(x86-64) принято первые четыре целочисленных аргумента передавать через регистры RCX, RDX, R8, R9. Из этого следует единая нумерация регистров для всех функций. К архитектурам, использующим эту технику, можно отнести также MIPS, PPC, ARM, DEC Alpha … Понятно, что в цепочке вызовов сохранять параметры всё равно больше негде, кроме как на стеке. Тут вся надежда на кэш. Или на оптимизатор, который может решить что конкретный параметр в данной функции больше не используется и сохранять его вовсе не нужно.
- Техника регистровых окон. Эта ветка архитектур растет из проекта Berkeley Risc. Сюда относится уже разобранный нами AMD29K, а также i960, Itanium, SPARC. Суть в том, что ограниченные количества параметров и локальных данных вызванной функции располагаются в регистровом окне, при вызове следующей функции окно сдвигается, таким образом эти данные образуют стек. В каждой функции своя нумерация регистров. Всё что не влезло в окно, попадает в обычный стек, глобальные регистры под временные данные также могут быть использованы. Так вот в случае i960 и SPARC, регистровый стек вкраплен в обычный, а для AMD29K & Itanium — это разные стеки. Фактически, AMD29K & Itanium предлагают компилятору выбрать, какие данные он считает достойными оказаться в “быстром стеке”, всё остальное произойдет само собой. Это напоминает устаревшее ныне ключеве слове “register” в С, только решение принимает компилятор, язык высокого уровня всё же.
Но мы несколько увлеклись, пора вернуться к сохранению контекста текущей функции в проектируемой архитектуре.
Сохранение контекста функцииА что входит в этот контекст? Занятые на момент вызова дочерней процедуры регистры. При этом регистры недовычисленных выражений связаны между собой через топологическую сортировку, но для вызываемой функции это не имеет значения. На момент захвата моп’ами выходных регистров уже неважно в каком порядке они были захвачены.
Есть нюанс, который стоит отметить — до начала вызова функции должны отработать все мопы, с помощью которых вычислялись её аргументы. Поэтому с точки зрения процессора, вызов функции — обобщенная инструкция с произвольным количеством аргументов.