Программирование в П-коде.

Основные особенности П-кода заключаются в том, что это код для двух-стековой машины, и исполняется этот П-код не процессором непосредственно, а эмулятором П-кода. П-процедура начинается с байта, содержащего одно из значений $E4..$E7 (в зависимости от числа параметров, принимаемых через регистры EAX, EDX, ECX). Процессором i808x данный код рассматривается как привилегированную инструкцию in/out, после чего срабатывает заранее установленный обработчик исключения и передает управление эмулятору П-кода.

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

Двухстековость. Внутри П-процедур, а так же при вызове П-процедур из П-процедур, основной машинный стек превращается в вычислительный стек. На нем выполняется вычисление выражений, на него загружаются параметры функций, на нем могут формироваться даже небольшие динамические массивы. Но этот стек не используется для хранения адресов возврата. Поэтому практически любой линейный кусок кода (на внутренние команды которого нет переходов из других частей П-кода), в любой момент может быть вынесен в отдельную подпрограмму. Это так называемая свертка кода, и именно благодаря ей Форт-программы становятся такими компактными. В П-коде для системы COLLAPSE свертка пока не используется, кроме тех случаев, когда вручную формируется процедура на основе нескольких часто встречающихся подряд последовательностей команд. Частично функцию свертки может брать на себя упаковщик, т.к. последовательности одинаковых команд в П-коде чаще всего будут преобразованы в одинаковую последовательность байтов, и особой необходимости в свертке не возникает (при использовании упаковщиков). В любом случае, П-код компактнее обычного машинного кода в 2-3 и более раз.

Обращение выражений. Таким образом, с точки зрения вычислений, П-машина - это стековый процессор. Все выражения вычисляются в стиле польской обратной записи, когда (A+B) превращается в (A, B, +). Аналогично, вызов процедуры с параметрами вида fun(X,Y) превращается в последовательность (Y, X, fun). Здесь в качестве X может быть развернуто вычисление некоторого выражения, в результате которого на вершине стека оказывается X, а fun - это обращение к процедуре-функции fun в состоянии, когда на вершине стека находится Y (а под ним лежит X). Не думаю, что стоит на этом останавливаться еще подробнее. Но особо обращаю внимание: если у вас возникают ошибки при П-программировании, то это скорее всего не потому, что П-компилятор виноват. В первую очередь ищите в своем коде, где вы забыли изменить направление вычислений, или неверно рассчитали, сколько на стеке находится данных и в каком порядке - после очередной операции. Малейшая неаккуратность при построении П-кода чревата большими неприятностями, и значительно большими, чем обычно.

Синтаксис исходного П-кода. Вызов процедур. П-код ориентирован на вызов процедур (функций, методов, в общем - подпрограмм). Отсюда и основное правило синтаксиса: если что-то встретилось, и оно не распознано как непосредственная числовая или строчная константа, или директива, или макрос, то это - имя подпрограммы, которую надо вызвать. Для упрощения вызова Паскаль-процедур, которые часть параметров получают через регистры EAX, EDX, ECX, используется особая форма вызова [имя_процедуры]<n>, где n - число передаваемых через регистры параметров.
   Вызов процедур в П-коде выполняется по таблице. Сама таблица физически формируется П-компилятором как файл CollapseProcTable.inc в директории проекта, подключается к файлу Collapse.pas и компилируется в массив 4-байтовых адресов в памяти, где размещаются эти процедуры. Таким образом, для вызова первых 64 процедур по таблице П-программа использует 1-байтовую команду, для всех прочих - до 16384 подпрограмм - 2х-байтовую. В случае вызова Паскалевской подпрограммы используется непрямой вызов, когда на вершину стека загружается сначала номер подпрограммы, а затем вызывается одна из процедур PASCAL1, PASCAL2 или PASCAL3 (соответственно, для одного, двух или трех параметров, передаваемых через регистры). В этом случае для первых 16 подпрограмм вызов занимает 2 байта, для первых 256 - 3 байта, и для прочих - 4 байта. Это все равно короче, чем в исходном машинном коде, и в любом случае лучше сжимается паковщиками, т.к. не используются относительные смещения. (Разумеется, при формировании таблицы процедур она сортируется, так что в начале таблицы находятся более часто вызываемые процедуры).
   Для вызова из П-кода "внешней" подпрограммы, независимо от используемого ей соглашения о передаче параметров, параметры должны быть подготовлены на вычислительном стеке в обратном порядке. При вызове паскаль-процедуры с n=1..3 параметрами, указанное в угловых скобках число параметров будет перед вызовом снято со стека и помещено на регистры EAX, EDX, ECX, так что вызванная процедура не заметит никакой подмены, и отработает как обычно. Единственная нерешенная проблема: в случае, если вызываемая подпрограмма имеет кроме нескольких параметров, передаваемых через регистры, еще и некоторое количество параметров на стеке, то при переводе ее в П-код и вызове из П-кода возникает "потеря ориентации", по той причине, что при вызове П-кода из П-кода адрес возврата не сохраняется в стеке. Временное "решение" этой проблемы: отказ от перевода в П-код паскаль-процедур, имеющих более 3х параметров, или перевод их к соглашению stdcall, когда все параметры передаются через стек.

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

Синтаксис исходного П-кода. Комментарии. Комментарий, как и в Delphi, это двойной слэш // - определяет комментарий до конца строки. В отличие от Delphi/Pascal, содержимое фигурных скобок не рассматривается как "истинный" комментарий: в фигурные скобки заключается {код, который генерируется при установленной опции отладки DEBUG}. Единственное исключение - директива вида {$R ...} - она переносится в откомпилированный код без изменений, обеспечивая подключение ресурса как обычно.

Синтаксис исходного П-кода. Непосредственные числовые значения. Байт-код, в который компилируется исходный П-код, может представлять из себя не только последовательность команд, но и перемежающих их данных, непосредственных параметров, или операндов команд. Опуская из виду способ доступа к непосредственным операндам, заметим, что для того, чтобы в конечный байт-код вставился 1-байтный операнд, в исходном П-коде надо записать #число (само число после знака # - это любое целое число со знаком, разрешенное синтаксисом Паскаля, в том числе шестнадцатеричное значение с префиксным символом '$'). Или, можно записать #(константное_выражение). Важно, что константное выражение в скобках - это должна быть константа, известная компилятору Delphi на момент компиляции (но допустимая правилами встроенноего ассемблера). В остальном никаких ограничений на содержимое скобок П-компилятор не накладывает. Вложенные скобки так же разрешаются.
   Для того, чтобы задать двухбайтный непосредственный числовой операнд, пишут уже два знака
##число или ##(выражение). Аналогично, разрешаются #### для представления четырехбайтного (DWORD), ######## (8-байтное - QWORD). (Есть еще макросы B( ), W( ), D( ), Q( ), можно использовать их).

Синтаксис исходного П-кода. Непосредственные строковые значения. Для вставки строки непосредственно в П-код она просто записывается в кавычках. Например 'ASDFG''' вставляет в код 6 байтов (последний - с кодом апострофа ' ). Прошу заметить, что финальный нулевой байт не добавляется - об этом надо попросить отдельно (добавив #0).

Синтаксис исходного П-кода. Макросы. В П-коде возможно по несколько сложным правилам определить макросы. Опуская сам способ, отмечу лишь наличие в базовой библиотеке baselib.pas некоторых макросов, в том числе наиболее интересен макрос L( число ). Он позволяет записать команду загрузки константы на вершину стека безотносительно загружаемого значения. Важное требование здесь - значение в скобках должно быть действительно константой, и не использовать идентификаторов (даже именованных констант), с тем, чтобы П-компилятор смог в зависимости от значения сформировать нужную команду загрузки (Загрузка значений в диапазоне -16..+15 выполняется 1-байтовой командой П-машины, для загрузки значений от -128 до +255 используется одна из двух команд, и в итоге это будет - скорее всего - 2 байта на загрузку, для загрузки 2х-байтного значения так же будет вызвана отдельная команда, и скорее всего, на такую загрузку будет затрачено 3 байта, и так далее).
   Ряд макросов без параметров служит для простого переопределения специальных символов. Например, в коде можно писать A B +, и это будет эквивалентно записи A B xyAdd, т.к. макрос + превращается в обращение к процедуре xyAdd из базовой библиотеки.

Специальные команды. RETURN, EXIT, RESULT. Любые команды П-машины - это подпрограммы, написанные на ассемблере (а иногда - и в П-коде), и вызываемые из байт-кода точно так же, как и обычные подпрограммы. Более того, П-машина вообще не делает между ними различий, и просто передает им управление как обычным подпрограммам. На самом деле, это мы выделяем такие подпрограммы из общего ряда, полагая их особыми командами.
   Команда RETURN выполняет возврат из П-процедуры-функции, предварительно снимая с вершины вычислительного стека и отправляя его в регистр результата (EBX, если возврат происходит в П-процедуру, или EAX - если возврат происходит в машинную процедуру). Если возврат произошел в П-процедуру, то этот результат может быть опять выложен на вершину стека командой RESULT (но только если он действительно нужен и только непосредственно вслед за вызовом, немедленно после возврата, пока регистр EBX не успел испортиться. Примечание: ряд команд не портят регистр результата EBX, и могут безопасно размещаться между вызовом функции и командой RESULT, а именно: DEL, DUP, xySwap, C1, ... , C6).
   Команда EXIT выполняет возврат в вызвавшую П-процедуру или в машинный код совершенно аналогично команде RETURN, но вычислительный стек не изменяет, и никакого результата не возвращает.

Команда Стек до Стек после
DUP A A A
DEL A  
DELN A B C D 3 A
C3 A B C D A B C D A
COPY A B C D 2 A B C D B
>< A B B A

Специальные команды. DUP, DEL, DELN, Cn, COPY, xySwap. Команда DUP дублирует вершину стека, DEL удаляет значение с вершины стека, DELN снимает с вершины стека число (N) и затем удаляет с вершины стека еще N двойных слов (полезная особенность команды DEL: ее можно вызывать немедленно после возврата из функции, и только после этого вызывать процедуру RESULT, т.е. DEL не портит текущий результат в регистре результата). Команда Cn (где n = число 1..6) копирует на вершину стека число, отстоящее от текущей вершины на n двойных слов. Так, C1 копирует на вершину значение, лежащее непосредственно "под" текущей вершиной. Команда COPY снимает с вершины число (N), и затем копирует на вершину стека двойное слово со стека, отстоящее от новой текущей вершины стека на N двойных слов (если N=0, то команда эквивалентна DUP). Команда xySwap (синоним ><)обменивает два значения на вершине стека.

Специальные команды. C2R, R2C, R2CN. Команда C2R снимает число с вершины вычислительного стека и добавляет его в стек возвратов. Команда R2C выполняет противоположное действие: число снимается со стека возвратов и загружается на вершину вычислительного стека. Команда R2CN снимает с вершины вычислительного стека счетчик, и затем перебрасывает указанное счетчиком количество чисел со стека возвратов на вычислительный стек, при этом меняя порядок чисел по сравнению с последовательностью N команд R2C. Команды переброски данных с вычислительного стека на стек возвратов и обратно могут использоваться только для кратковременных манипуляций с числами на стеке, и по завершении этих действий стек возвратов обязательно должен быть возвращен в исходное состояние.

Специальные команды. SetSELF, LoadSELF. Команда SetSELF копирует значение на вершине стека (не удаляя его из стека) в специальный "регистр" SELF П-машины (регистр EBP). Единственное назначение этого "регистра" - хранить это значение и выкладывать его на вершину стека по команде LoadSELF (последняя действует как любая команда загрузки числа на вершину стека, т.е. глубина стека увеличивается на двойное слово, как обычно для команд загрузки). Значение этого регистра сохраняется до конца процедуры, и в случае, если вызывается другая П-процедура, оно сохраняется в стеке П-возвратов. (При вызове машинных процедур никогда не портятся регистры EBP, EBX, ESI и EDI - это соглашения операционной системы и Delphi).

Специальные команды. LoadStack, LoadByte, LoadSignedByte, LoadWord, LoadSmallInt, Load3, Load4, Load8. Команда LoadStack загружает на вершину стека указатель на текущую вершину стека (т.е. [ESP] теперь становится равным ESP+4). Эта команда полезна, когда непосредственно в стеке размещена некая структура данных или массив, и необходимо загрузить адрес этой структуры, например, для передачи в качестве параметра другой процедуре. Команда LoadByte берет непосредственный 1-байтовый операнд, расположенный непосредственно вслед за командой LoadByte, расширяет его нулями до двойного слова, и помещает результат на вершину стека. Для правильного использования команды LoadByte следует записать: LoadByte #(выражение). Аналогично, команда LoadSignedByte так же берет непосредственный 1-байтовый операнд, но расширяет его битом знака до двойного слова перед тем, как положить на вершину стека. Прочие команды этой группы отличаются только размером операнда. Обратите внимание, что Load3 использует 3х-байтовый операнд, лобавляя к нему старший нулевой байт, и записать обращение можно так же: Load3 ###(выражение). От вычисленного выражения в коде будет оставлено ровно 3 байта, старший байт отбрасывается.

Специальные команды. Store, StoreB, StoreW, StoreQ, StoreVar. Команды сохранения значения сначала снимают с вершины стека адрес ячейки памяти, в которую будет помещено следующее снимаемое с вершины стека значения (кроме StoreQ, когда с вершины стека снимается еще одно значение). Store записывает 4х-байтное значение в память, StoreB - 1-байтное, StoreW - 2х-байтное, StoreQ - 8-байтное (сначала старшее, потом младшее двойное слово).  Несколько особняком стоит команда StoreVar, которая берет адрес места назначения из непосредственного 4х-байтного операнда вслед за командой, и сохраняет 4х-байтное значение  вершины стека по адресу, указанному этим непосредственным операндом (фактически, это то же самое, что Load4 с операндом, вслед за которым сразу выполняется Store).

Специальные команды. LoadStr, LoadPCharArray, LoadAnsiStr, DelAnsiStr. Команда LoadStr загружает на вершину стека адрес следующего за командой байта, устанавливая указатель следующей инструкции П-машины на байт, следующий за первым обнаруженным нулевым байтом. Фактически, это наиболее экономный способ поместить на вершину стека указатель PChar-строки, размещенной вслед за командой в качестве непосредственного операнда. Следует заметить, что в отличие от обычных констант, дублирование строки в памяти гарантируется в случае необходимости обратиться к ней более одного раза, поскольку сама строка размещается в коде непосредственно. Это может быть минусом, если сжатие исполнимого файла не используется, и одна и та же строка используется подобным образом многократно.
   Команда LoadPCharArray снимает с вершины число (N, разрешается 0), и далее выполняет команду LoadStr указанное N число раз (если 0, ни разу не выполняет), загружая сразу несколько непосредственных строк, завершающихся нулевым байтом.
   В отличие от предыдущих двух команд, команда LoadAnsiStr не просто загружает на вершину стека PChar -указатель на строку (так же - непосредственный операнд, следующий за командой), но формирует AnsiString-переменную. Кроме того, для полученной строки указатель сразу дублируется (как если бы была выполнена команда DUP). Важно, что когда строка становится не нужна, ее необходимо удалить, вызывая команду DelAnsiStr - только в этом случае будет правильно освобождена занятая строкой динамическая память (или уменьшен счетчик использования, как обычно для Ansi-строк в Delphi - если он был увеличен в процессе присваивания строки какой-либо переменной).

Специальные команды. ParamByte, ParamWord, Param3, Param4. Эти команды предназначены для загрузки параметра (непосредственного операнда), следующего за командой вызова текущей П-процедуры. В результате выполнения, на вершину стека загружается операнд, а адрес возврата сдвигается на соответствующее число (1, 2, 3 или 4), таким образом, возврат происходит в ту точку, которую и хотелось - при написании вызова данной процедуры.

Специальные команды. LoadRef, LoadRefByte, LoadRefWord. Команды этой группы заменяют значение на вершине стека значением из ячейки памяти, адресуемой прежним (т.е. заменяемым) значением на вершине стека. LoadRef загружает двойное слово, LoadRefByte загружает байт, расширяя его нулями, LoadRefWord загружает 2х-байтное слово (так же расширяя нулями).

Команда Стек до Стек после
AddByte_LoadRef #B A PByte(A+B)^
AddWord_LoadRef #W A PByte(A+W)^

Специальные команды. AddByte_LoadRef, AddWord_LoadRef. Команды данной группы полезны для более короткого доступа к полям структуры или объекта. Вслед за командой размещается непосредственный операнд (смещение) - для первой команды смещение 1-байтовое, для второй - 2х-байтовое. Далее непосредственный операнд прибавляется к значению на вершине стека (адресу структуры или объекта), после чего полученный адрес поля используется для замены вершины стека 4х-байтовым значением по вычисленному адресу.

Команда Стек до Результат
AddByte_Store #B V A PByte(A+B)^:=V
AddWord_Store #W V A PByte(A+W)^:=V

Специальные команды. AddByte_Store, AddWord_Store. Команда этой группы полезны для более короткого изменения полей структуры или объекта. Непосредственный операнд (байт или слово) прибавляется к вершине (полученная сумма со стека удаляется), после чего с вершины стека снимается еще одно значение, и отправляется в память по вычисленному таким образом адресу.

Команды Результат
A B + A+B
A B - A-B
A B xyMulMul A*B
(A*B)shr32
A B * A*B
A B / A/B
A B /: A/B
(A mod B)
A B << A<<B
A B >> A>>B
A xNeg -A

Специальные команды. Арифметика и логика. xyAdd (синоним +) выполняет сложение двух двойных слов на вершине, и заменяет их обоих результатом сложения, xySub (синоним -) вычитает число на вершине (снимая его со стека) из числа, оставшегося на новой вершине, заменяя его результатом вычитания. xyMulMul перемножает два числа на вершине стека, заменяя их двумя двойными словами результата (на вершине - старшее двойное слово, если оно не требуется, достаточно выполнить DEL, чтобы осталось только младшее двойное слово. Но проще в таком случае использовать xyMul или его синоним *). xyIntDiv (синоним /) снимает с вершины стека двойное слово делителя, затем двойное слово делимого, выполняет знаковое расширение делимого до четверного слова, и делит его на делитель, помещая результат деления на вершину стека. xyDivMod (синоним /:) делает примерно то же самое, что и xyDiv, но на вершину стека в качестве результата помещается сначала результат, а потом еще и остаток от деления (т.е. остаток от деления оказывается на вершине). xyShiftL (синоним <<) снимает с вершины фактор сдвига, и оставшееся на вершине значение сдвигает влево на этот фактор, замещая выдвинутые биты нулями. Аналогично, xyShiftR (синоним >>) выполняет сдвиг вправо. xNeg умножает число на вершине стека на -1, инвертируя его арифметически.

Все, что было написано выше, вполне достаточно для того, чтобы писать линейный П-код, не требующий ветвлений, циклов и переходов другого вида.

Специальные команды. GOTO, IF0/IF1 ELSE ENDIF. Оператор GOTO(метка) передает управление на указанную метку безусловно, оператор IF0(метка) снимает число с вершины, и если оно равно 0, то выполняет переход на метку, аналогично ему, оператор IF1(метка) выполняет переход, когда на вершине был не ноль. Составной оператор IF0 [если0] ELSE [иначе] ENDIF снимает с вершины значение, и если оно равно 0, то выполняет ветку [если0] (и переходит к ENDIF), иначе выполняет переход к ветке [иначе] при ее наличии (или к ENDIF при отсутствии).  Аналогично, оператор IF1 [если_не_0] ELSE [иначе] ENDIF работает точно так же, но первая ветка выполняется, если снятое с вершины значение не равно 0. В общем-то, для организации ветвлений представленных операторов более чем достаточно. Замечу, что в П-коде любой переход выполняется двухбайтовой командой перехода, в диапазоне -4096..+4095 байт кода, так что должно хватать практически всегда. (Если не хватит, добавим команды более далекого перехода, с этим нет никаких проблем).
  Чтобы задать метку, достаточно указать слово, завершающееся двоеточием, среди прочих. Не рекомендую использовать GOTO кроме необходимости организовать цикл типа while или for.

Специальные команды. Директивы IFDEF( ) ... ELSE ... ENDIF, /IF( ) ... ELSEIF( ) ... ... ELSE ... ENDIF. Это директивы условной компиляции. Фактически IFDEF(символ) превращается в {$IFDEF символ} и т.п., так что окончательную проверку выполняет Delphi. В случае же директивы IF(выражение) вычисление выражения и ветвление (в том числе по одной из нескольких веток ELSEIF) выполняет П-компилятор. В качестве констант могут использоваться только символы условной компиляции, включенные в опции всего проекта.


Владимир Кладов, 3.12.2005