Нет ветвлений? Нет проблем — Форт-ассемблер
Перевод
Оригинальный текст:
22 июня 2021 г. · 36 минут чтения
Эта статья является частью серии «Начальная загрузка» , в которой я начинаю с 512-байтного начального числа и пытаюсь загрузить реальную систему.
Предыдущий пост:
Разместить FORTH в 512 байтах
Следующий пост:
Ветвление: сборка не требуется
Набор слов, доступных после загрузки Miniforth, довольно скуден. Один читатель даже заявил , что, поскольку ветвей нет, он не является полным по Тьюрингу и, следовательно, не достоин называться Фортом! Сегодня тот день, когда мы докажем, что они ошибались.
В предыдущем посте я описал ядро Miniforth размером с загрузочный сектор со всеми хитроумными оптимизациями, которые мне пришлось использовать, чтобы вместить его. Подводя итог, доступны следующие слова:
+ - ! @ c! c@ dup drop swap emit u. >r r> [ ] : ; load s:
Большинство из них будут знакомы программистам на Forth, но load
и s:
могут нуждаться в некоторых комментариях. load ( u -- )
является стандартным словом из необязательного набора слов Block — оно загружает блок u
и выполняет его как исходный код Forth. 1 Это слово имеет решающее значение для практического использования такого маленького Форта, поскольку, как только вы загрузитесь достаточно далеко, вы можете сохранить свой код на диск, а после перезагрузки возобновить работу с помощью всего лишь 1 load
.
Однако, чтобы добраться до этой точки, вам нужно написать довольно много кода. Чтобы сделать исходный код доступным в памяти, как только вы сможете его сохранить, я включил s: ( buf -- buf+len )
, что, по сути, представляет собой ввод строки — остальная часть входного буфера копируется в buf
. Адрес конца буфера остается в стеке, чтобы его можно было использовать s:
на следующей строке, и результат будет конкатенирован (соеденён).
В этом посте мы начнем с состояния, в котором загружается Miniforth, и:
Нельзя сказать, что это единственный способ. У меня есть реализация ветвей на чистом Форте поверх Miniforth, и я намерен поговорить о ней подробнее примерно через неделю, а пока рекомендую вам попробовать разобраться с этим самостоятельно. Мне действительно любопытно, сколько существует различных подходов.
Тем временем давайте рассмотрим подход, который не отказывается от большей части производительности во имя чистоты. Для ознакомления и удобства экспериментов код из этого поста доступен на GitHub . При объяснении кода я иногда добавляю комментарии, но, поскольку мы еще не реализовали обработку комментариев, на самом деле их нет в коде.
s:- рабочий процесс
Я решил хранить свой исходный код по адресу 1000
, в пространстве между стеками параметра и возврата. Первое, что нам понадобится, — это способ запуска кода, который мы туда поместили. Определено InputPtr
, чтобы быть по адресу A02
, поэтому давайте определим run
, который подставляет значение по нашему выбору по этому адресу:
: >in A02 ; : run >in ! ;
>in
— это традиционное имя для указателя входного буфера, поэтому я выбрал его. 2 Чтобы убедиться, что он также доступен при последующих загрузках, я сохраняю этот фрагмент кода в памяти:
1000 s: : >in A02 ; : run >in ! ;
Это хорошее время для просмотра текущего указателя на исходный буфер с помощью dup u.
. Если вы не добавили некоторое пространство для записи, ответ будет 101A
, и это адрес, на который мы хотим перейти run
позже, чтобы избежать переопределения >in
и run
. 3
Написав достаточно кода, чтобы протестировать его, я печатал текущий адрес буфера с помощью u.
, а затем run
новый код из предыдущего напечатанного адреса буфера. Во-первых, важно, чтобы адрес буфера не оставался наверху стека, так как Miniforth загружается с адресами встроенных системных переменных в стеке , и мы хотим получить к ним доступ.
Системные переменные
На самом деле почти все, что мы хотим сделать, требуют этих переменных, поэтому давайте сначала позаботимся об этом — необходимость копаться в стеке каждый раз, когда вам нужна одна из этих переменных, не работает.
Стек начинается так:
latest st base dp disk#
Обычно мы просто делаем constant disk#
, constant here
и так далее. Однако у нас нет constant
, или какого-либо способа его определения (пока). literal
ближе, но нам нужно по крайней мере here
реализовать его и latest
пометить как немедленное. Мы можем обойти насущную проблему с помощью [
и ]
, что предполагает следующий порядок действий:
swap : dp 0 [ dup @ 2 - ! ] ;
Давайте рассмотрим это шаг за шагом, так как это работает довольно сложно. dp
означает указатель данных . Это переменная back here
, указатель компиляции, значение которого here
просто определяется как
: here dp @ ;
Когда выполняется код внутри квадратных скобок, наша память выглядит так:
То, что мы хотим сделать, это поставить dp
там, где 0
в настоящее время. Так как мы запустили swap
перед определением нашего слова, адрес dp
находится на вершине стека. После dup @ 2 -
у нас будет указатель на ячейку, содержащую 0
, и !
мы перезапишем ее. Как видите, the 0
не имеет особого значения, мы могли бы использовать любой литерал.
Далее определяем cell+
и cells
. Причина, по которой я делаю это так рано, заключается в том, что одна из вещей, которую я в конечном итоге хотел бы сделать, — это переключиться на 32-битный защищенный режим, чтобы как можно больше кода не зависело от ширины ячейки.
: cell+ 2 + ;
: cells dup + ;
Кроме того, поскольку у нас теперь есть dp
, давайте напишем allot
. Функциональность увеличения переменной может быть выделена в +!
:
: +! ( u addr -- ) dup >r @ + r> ! ;
: allot ( len -- ) dp +! ;
Это позволяет определить c,
и ,
, которые записывают байт или ячейку соответственно в область компиляции:
: c, here c! 1 allot ;
: , here ! 2 allot ;
Далее мы напишем lit,
, который добавляет литерал к текущему определению. Для этого нам нужен адрес LIT
ассемблерной процедуры, которая обрабатывает литерал. Мы сохраняем его в 'lit
«константе» с помощью трюка, аналогичного тому, что мы сделали для dp
:
: 'lit 0 [ here 4 - @ here 2 - ! ] ;
: lit, 'lit , , ;
Это позволяет нам легко обрабатывать остальные переменные в стеке:
: disk# [ lit, ] ;
: base [ lit, ] ;
: st [ lit, ] ;
: latest [ lit, ] ;
Я вызываю его st
вместо state
, потому что state
это должна быть переменная размером с ячейку, где true означает компиляцию, а st
переменная размером с байт, где true означает интерпретацию.
Пользовательские переменные
Если вы в настроении пошалить, вы можете создать переменные из воздуха, просто упомянув их. Отсутствие проверки ошибок превратит их в число, по сути, дав вам случайный адрес. Например, srcpos u.
выходы DA9C
. Конечно, вы рискуете тем, что эти адреса будут конфликтовать либо друг с другом, либо с чем-то еще, например со стеком или пространством словаря.
Я был не в настроении для шалостей, так что мы сделаем это как следует. Ядро любого определяющего слова будет основано на :
, поскольку оно уже анализирует слово и создает словарную статью. Нам просто нужно вернуться в режим интерпретации. [
делает это, но это непосредственное слово, и мы postpone
пока не можем его определить, поэтому давайте определим наш собственный вариант, который не является немедленным:
: [[ 1 st c! ;
Нам также понадобится непрямой вариант ;
. Единственное, что нужно сделать, это скомпилировать exit
. Мы не знаем адрес exit
, но можем прочитать его из последнего скомпилированного слова:
here 2 - @ : 'exit [ lit, ] ;
Например, вот как мы будем использовать его для constant
:
: constant \ example: 42 constant the-answer
: [[ lit, 'exit ,
;
create
определяет слово, которое помещает адрес сразу после него. Типичное использование
create some-array 10 cells allot
Чтобы вычислить адрес, который мы должны скомпилировать, нам нужно добавить 3 cells
— по одному для каждого из LIT
, фактического значения и EXIT
.
: create : [[ here 3 cells + lit, 'exit , ;
variable
, то просто allot
одна ячейка:
: variable create 1 cells allot ;
Улучшениеs:
До сих пор указатели передавались s:
и run
управлялись вручную. Однако это простой процесс, поэтому давайте его автоматизируем. srcpos
будет содержать текущий конец буфера и checkpoint
будет указывать на часть, которая еще не была запущена.
variable checkpoint
variable srcpos
Автоматический вариант s:
называется s+
:
: s+ ( -- ) srcpos @ s: dup u. srcpos ! ;
Мы также печатаем новый указатель. Это имеет два применения:
если вы допустили опечатку и хотите исправить, то можете просто прочитать примерный адрес, где нужно ковыряться;
нам нужно убедиться, что ни одно определение не выходит за пределы килобайтной границы, чтобы наш буфер можно было напрямую записать в блоки.
Отложенная часть буфера может быть выполнена с помощью doit
:
: move-checkpoint ( -- ) srcpos @ checkpoint ! ;
: doit ( -- ) checkpoint @ run move-checkpoint ;
Настройка этого сводится к чему-то вроде
1234 srcpos ! move-checkpoint
Эта строка не записывается на диск, так как после перезагрузки точный адрес не пригодится.
Ассемблер четвертого стиля
Обычный синтаксис сборки выглядит так:
mov ax, bx
Если бы мы хотели справиться с этим , нам пришлось бы написать модный синтаксический анализатор, а мы никак не сможем сделать это без ветвей. Вместо этого давайте настроим синтаксис для наших целей — если AT&T разрешено это делать, то и мы тоже. Чтобы быть конкретным, давайте сделаем каждую инструкцию Форт-словом, передав аргументы через стек:
bx ax movw-rr,
Я решил упорядочить аргументы как src dst instr,
, с потоком данных слева направо. Это согласуется с тем, как данные передаются в обычном коде Forth, и является точным отражением синтаксиса Intel. После дефиса я включаю типы аргументов в том же порядке — регистр ( r
), память ( m
) или непосредственный ( i
). Наконец, инструкции, которые могут быть размером как в байт, так и в слово, имеют суффикс b
или w
, как в синтаксисе AT&T.
Обратите внимание, что необходимость указывать типы операндов вручную не является фундаментальным ограничением ассемблеров Forth. Обычно ничто не мешает встроить в эти слова больше ума, чтобы автоматически подобрать правильный вариант на основе операндов. Однако в данном конкретном случае у нас нет ветвящихся слов (поскольку они и есть наша цель ?).
кодировка инструкций x86
Проще всего кодировать инструкции, которые не принимают никаких аргументов. Например,
: stosb, AA c, ; : stosw, AB c, ; : lodsb, AC c, ; : lodsw, AD c, ;
Следующими по простоте являются инструкции, включающие непосредственные аргументы — числовые аргументы, которые идут сразу после кода операции:
: int, CD c, c, ;
Некоторые инструкции используют битовое поле в байте кода операции. Например, немедленная загрузка, такая как mov cx, 0x1234`, кодирует регистр младшими 3 битами кода операции:
Регистры сопоставляются со следующими числовыми значениями:
: ax 0 ; : cx 1 ; : dx 2 ; : bx 3 ; : sp 4 ; : bp 5 ; : si 6 ; : di 7 ;
Вы правильно прочитали, это идет AX CX DX BX
. Насколько мне известно, это не потому, что кто-то в Intel забыл свои азбуки, а потому, что этимология этих имен регистров выглядит примерно так: «Аккумулятор , Счетчик , Данные , База » , и тот факт, что они являются первые четыре буквы просто совпадение. Или, во всяком случае, в этом суть. Этот пост на retrocomputing.SE содержит некоторые предположения о том, как это может быть полезно для конструкции оборудования, но не содержит твердых фактов.
Соответствующая нумерация для байтовых регистров выглядит так:
: al 0 ; : cl 1 ; : dl 2 ; : bl 3 ; : ah 4 ; : ch 5 ; : dh 6 ; : bh 7 ;
Таким образом, мы можем закодировать некоторые mov
s:
: movw-ir, B8 + c, , ;
: movb-ir, B0 + c, c, ;
Их можно использовать так:
ACAB bx movw-ir,
42 al movb-ir,
Обратите внимание, что ответственность за использование 8-битного регистра с movb
, и 16-битного регистра с movw
.
Некоторые другие инструкции, которые закодированы таким образом, incw/decw
и push/pop
:
: incw, 40 + c, ;
: decw, 48 + c, ;
: push, 50 + c, ;
: pop, 58 + c, ;
ModR/M
Самые сложные инструкции, с которыми нам придется иметь дело, используют байт ModR/M . Это механизм кодирования, отвечающий за такие инструкции, как add ax, [bx+si+16]
, но также и за такие простые, как mov ax, bx
.
Сам код операции определяет шаблон, например mov r16, r/m16
. В данном случае это означает, что адресатом является регистр, а источником является либо регистр, либо память. Байт ModR/M, который следует за кодом операции, определяет детали операндов:
Три бита в середине определяют r16
часть, а остальные определяют часть r/m16
в соответствии с этой таблицей:
поле reg/[regs] | mod: 00 | mod: 01 | mod: 10 | mod: 11 |
---|---|---|---|---|
0 | [BX+SI] | [BХ+SI+d8] | [BХ+SI+d16] | AL/AX |
1 | [BX+DI] | [BХ+DI+d8] | [BХ+DI+d16] | CL/CX |
2 | [BP+SI] | [BP+SI+d8] | [BP+SI+d16] | DL/DX |
3 | [BP+DI] | [BP+DI+d8] | [BP+DI+d16] | BL/BX |
4 | [SI] | [SI+d8] | [SI+d16] | АХ/SP |
5 | [DI] | [DI+d8] | [DI+d16] | CH/BP |
6 | [d16] | [BP+d8] | [BP+d16] | DH/SI |
7 | [BХ] | [BХ+d8] | [BХ+d16] | BH/DI |
Как видите, если в поле mod установлено значение 3
, то младшие 3 бита просто кодируют другой регистр в том же порядке, что и раньше. В противном случае мы выбираем одну из восьми возможностей для вычисления адреса с необязательным смещением, которое может быть либо 8, либо 16 бит. Упомянутое смещение идет сразу после байта ModR/M и при необходимости расширяется по знаку до 16 бит.
Есть одна неточность: если мы попытаемся закодировать a [BP]
без какого-либо смещения, вместо этого мы получим жестко закодированный адрес, такой как mov bx, [0x1234]
, который должен идти после самого байта ModR/M. 4 Если вы помните, именно из-за отсутствия было выгодно [BP]
переключение стека возврата на использование вместо него.DI
Специфическим аспектом этого кодирования является то, что операции между регистрами могут быть закодированы двумя разными способами. Возьмем xor cx, dx
, например:
В любом случае, давайте это реализуем. Во-первых, вариант от регистрации к регистрации. Я выбрал слово для компиляции такого байта ModR/M , означающее, что в поле rm-r,
есть регистр, который также может быть emory, за которым следует еще один регистр. У нас нет битовых смещений, но мы можем обойти это повторным добавлением:r``m``r
: 2* dup + ;
: 3shl 2* 2* 2* ;
: rm-r, ( reg-as-r/m reg -- ) 3shl + C0 + c, ;
При использовании rm-r,
нам нужно убедиться, что используемый код операции соответствует шаблону r/m16, r16
— в противном случае нам потребовались бы swap
два регистра:
: movw-rr, 8B c, rm-r, ;
: addw-rr, 03 c, rm-r, ;
: orw-rr, 0B c, rm-r, ;
: andw-rr, 23 c, rm-r, ;
: subw-rr, 2B c, rm-r, ;
: xorw-rr, 33 c, rm-r, ;
: cmpw-rr, 3B c, rm-r, ;
Варианты памяти для регистрации не намного сложнее. Мы определяем режимы адресации так же, как мы это делали для регистров.
: [bx+si] 0 ; ; [bx+di] 1 ; ; [bp+si] 2 ; ; [bp+di] 3 ;
: [si] 4 ; ; [di] 5 ; ; [#] 6 ; ; [bp] 6 ; ; [bx] 7 ;
[#]
режим абсолютного адреса, который следует использовать, собирая адрес вручную после инструкции, например
[#] ax movw-mr, some-addr ,
Я также включаю [bp]
, который конфликтует с [#]
, так как слова режима адреса могут использоваться совместно с режимами [??+d8]
и [??+d16]
.
Аналогично rm-r,
имеем m-r,
:
: m-r, ( mem reg -- ) 3shl + c, ;
r-m,
то же самое, просто поменяйте местами операнды:
: r-m, ( reg mem -- ) swap m-r, ;
Нет необходимости определять каждую инструкцию с операндами памяти, mov
достаточно просто s:
: movw-mr, 8B c, m-r, ;
: movw-rm, 89 c, r-m, ;
Существует также одно несколько иное использование байта ModR/M. А именно, если инструкции требуется только один операнд (например, not bx
или jmp ax
), r/m
фактически используется только он. В этом случае поле регистра вместо этого повторно используется как дополнительные биты для самого кода операции.
В руководстве Intel для этого используется обозначение opcode /regbits
. Например, непрямой переход — это FF /4
, а непрямой вызов — это FF /2
совместное использование основного байта кода операции. Мы можем кодировать такие инструкции, просто введя нужное значение перед вызовом rm-r,
.
: jmp-r, FF c, 4 rm-r, ;
: notw-r, F7 c, 2 rm-r, ;
Чтобы на самом деле собрать примитивное слово, нам также понадобится способ создания его заголовка. Самый простой способ сделать это — вызвать normal :
, а затем перемотать dp
на три байта назад, чтобы удалить вызов DOCOL
:
: :code : [[ here 3 - dp ! ;
Чтобы закончить такое определение, мы компилируем NEXT
:
: next, lodsw, ax jmp-r, ;
Обратите внимание, что next,
не определяется с помощью :code
— это эквивалент макроса ассемблера.
В качестве примера простого собранного примитива рассмотрим реализацию 1+
:
:code 1+ bx incw, next,
Дисковый ввод-вывод
На самом деле этого достаточно, чтобы записать нашу работу на диск. Как и в случае с load
, нам нужно создать пакет адресов дисков, а затем вызвать int 0x13
. Одно примитивное слово может служить как для чтения, так и для записи, поскольку единственная разница заключается в значении, которое AX
вам нужно. Крайне важно сохранить SI
— я имел неудовольствие узнать это эмпирически .
create packet 10 allot
:code int13
si push, \ push si
packet si movw-ir, \ mov si, packet
bx ax movw-rr, \ mov ax, bx
disk# dl movb-ir, \ mov dl, disk#
13 int, \ int 0x13
ax bx movw-rr, \ mov bx, ax
si pop, \ pop si
next,
Обратите внимание, что мы сохраняем возвращаемое значение AX
back в стеке. Это связано с тем, что ненулевое AH
значение сигнализирует об ошибке.
Чтобы заполнить packet
данные, я использую вариант, ,
который записывает в контролируемое место вместо here
:
variable pos
: pos, ( val -- ) pos @ ! 2 pos +! ;
: make-packet ( block buffer -- )
packet pos !
10 pos, \ size of packet
2 pos, \ sector count
pos, 0 pos, \ buffer address
2* pos, 0 pos, 0 pos, 0 pos, \ LBA
;
Для чтения мы используем AH = 0x42
, как и раньше. Запись использует AH = 0x43
, но в этом случае значение определяет, AL
хотим ли мы, чтобы BIOS проверял запись — мы делаем это, поэтому я установил для него значение 0x02
.
: read-block make-packet 4200 int13 ;
: write-block make-packet 4302 int13 ;
Меры предосторожности
Было бы неплохо убедиться, что наш новый код собран правильно, прежде чем запускать его. В идеале мы бы написали небольшую утилиту hexdump, но у нас пока нет никакого способа зациклиться. Однако есть способ обойти это — просто введите нужное слово несколько раз подряд:
: p ( buf -- buf+1 ) dup c@ u. 1 + ;
\ later...
here 10 - p p p p p p p p p p p p p p p p drop
Еще одна хорошая проверка работоспособности — убедиться, что ничего, чего мы не ожидали, не было помещено в стек — обычно это происходит из-за того, что неопределенные слова плохо преобразуются в числа. Это можно сделать следующим образом dup u.
: пустой стек приведет к ответу E0E
, происходящему из-за безобидного опустошения стека, которое мы только что вызвали. Одним из примеров обнаруженной ошибки является опечатка, когда я набрал movb-it,
вместо movb-ir,
.
Первый доступ к диску, который я попробовал, был 0 4000 read-block u. 41fe @ u.
. Это показывает AA55
магическое число в конце загрузочного сектора. Затем я записал свой исходный код в блоки 1 и 2 и прочитал их в отдельный буфер, чтобы убедиться, что он работает. Оглядываясь назад, возможно, было бы неплохо прочитать блок, отличный от 0, перед попыткой записи, чтобы убедиться, что предоставление LBA работает правильно. К счастью, эта конкретная ошибка была чисто гипотетической.
Я также записал те же данные в блоки 0x101 и 0x102. Таким образом, я могу восстановиться, если когда-нибудь сломаю загрузку с обычными номерами блоков.
Прыжки
Прежде чем мы займемся реализацией ветвей, нам понадобится еще одна инструкция — условный переход. В x86 смещения перехода кодируются как значение со знаком относительно текущего указателя инструкции. Существуют разные кодировки для разной разрядности этого значения, но нам понадобится только более короткая 8-битная.
Чтобы быть конкретным, значение относится к концу инструкции перехода, так что оно соответствует количеству пропущенных байтов в случае перехода вперед:
Чтобы составить дистанции прыжков, я использую две пары слов — одну для прыжков вперед, а другую для прыжков назад:
jnz, j> ... >j \ forward jump
j< ... jnz, <j \ backward jump
Правило состоит в том, что стрелки показывают направление прыжка, и стрелки должны быть «внутри» — другими словами, если вы получили два слова рядом друг с другом, эти стрелки должны подойти как влитые. Два слова просто общаются через стек. Например, j<
просто запомнит текущее местоположение:
: j< here ;
Затем это используется функцией <j
, которая вычитает текущую позицию и компилирует смещение:
: <j here 1 + - c, ;
Для прыжков вперед мы составляем фиктивное смещение, чтобы переписать его позже:
: j> here 0 c, ;
: >j dup >r 1 + here swap - r> c! ;
Наконец, сами инструкции по прыжкам. Некоторые прыжки имеют несколько названий. Например, поскольку флаг переноса устанавливается, когда при вычитании требуется заимствование, a jc
ведет себя точно так же, как беззнаковое сравнение j
ump, если b
elow. То же самое относится к je
и jz
, но это достаточно интуитивно для меня, поэтому я не чувствовал необходимости определять оба имени.
: jb, 72 c, ; ; jc, 72 c, ; ; jae, 73 c, ; ; jnc, 73 c, ;
: jz, 74 c, ; ; jnz, 75 c, ; ; jbe, 76 c, ; ; ja, 77 c, ;
: jl, 7C c, ; ; jge, 7D c, ; ; jle, 7E c, ; ; jg, 7F c, ;
Ветви
При компиляции в определение ветки выглядят так:
По соглашению имена слов, которые компилируются в определение, но не используются напрямую, заключены в круглые скобки, поэтому наши ветки называются (branch)
, что является безусловным, и (0branch)
, что выталкивает значение из стека и выполняет ветки, если оно равно нулю.
Мы можем прочитать цель перехода из последовательности указателей с помощью lodsw
:
:code (branch)
lodsw, \ lodsw
ax si movw-rr, \ mov si, ax
next,
В случае условного перехода важно всегда читать (или пропускать) цель перехода, независимо от того, верно ли условие перехода.
:code (0branch)
lodsw, \ lodsw
bx bx orw-rr, \ or bx, bx
jnz, j> \ jnz .skip
ax si movw-rr, \ mov si, ax
>j \ .skip:
bx pop, \ pop bx
next,
Для обработки вычисления смещения ветвления я использую набор слов, очень похожий на те, которые используются для переходов. Однако реализация проще, поскольку кодировка не относится к текущей позиции:
: br> here 0 , ;
: >br here swap ! ;
: br< here ;
: <br , ;
Поток управления
Чтобы логика, которая компилирует if
s, действительно выполнялась во время компиляции, нам нужно пометить эти слова как немедленные. Для этого мы используем immediate
, который устанавливает непосредственный флаг последнего скомпилированного слова:
: immediate ( -- )
latest @ \ получить указатель на слово
cell+ \ пропустить поле ссылки
dup >r c@ \ прочитать текущее значение из length+flags поля
80 + \ установить flag
r> c! \ запись обратно
;
Нам также понадобится compile
, который при вызове как compile x
добавляется x
к слову, которое компилируется в данный момент. На самом деле нам не нужно делать его непосредственным словом, которое само по себе анализирует следующее слово, достаточно просто прочитать адрес x
like lit
или do it:(branch)
: compile r> dup cell+ >r @ , ;
if
это просто условная прямая ветвь:
: if compile (0branch) br> ; immediate
: then >br ; immediate
else
немного сложнее. Нам нужно скомпилировать безусловную ветвь, переходящую к then
, а также разрешить if
переход к точке сразу после безусловного перехода. Мне нравится использовать для этого слова манипулирования стеком возврата, поскольку стрелки совпадают со стрелками, используемыми >br
и облегчают чтение кода:
: else >r compile (branch) br> r> >br ; immediate
Далее нам понадобятся циклы. Во-первых, простой begin ... again
бесконечный цикл:
: begin br< ; immediate
: again compile (branch) <br ; immediate
begin ... until
не намного сложнее — просто используйте условный переход в конце:
: until compile (0branch) <br ; immediate
Наконец, уникальный цикл Форта begin ... while ... repeat
, где условие цикла находится в середине цикла:
: while ( backjmp -- fwdjmp backjmp )
compile (0branch) br> swap
; immediate
: repeat ( fwdjmp backjmp -- )
compile (branch) <br >br
; immediate
Сравнения
Ветвление не так уж полезно без возможности сравнивать вещи. Одной из общих проблем среди всех слов сравнения является превращение флага процессора в логическое значение Forth. Вспомним, что в Форте все биты логических значений либо установлены, либо не установлены:
: false 0 ;
: true FFFF ;
Я решил сгенерировать эти логические значения, сначала установив ax
значение 0, а затем, возможно, уменьшив его на основе результата сравнения. Этот код можно разложить следующим образом:
: :cmp :code ax ax xorw-rr, ;
: cmp; j> ax decw, >j ax bx movw-rr, next, ;
Затем, чтобы определить сравнение, вам просто нужно скомпилировать переход, который будет выполнен, когда результат должен быть ложным:
:cmp 0= bx bx orw-rr, jnz, cmp;
:cmp 0<> bx bx orw-rr, jz, cmp;
:cmp 0< bx bx orw-rr, jge, cmp;
:cmp 0<= bx bx orw-rr, jg, cmp;
:cmp 0> bx bx orw-rr, jle, cmp;
:cmp 0>= bx bx orw-rr, jl, cmp;
:cmp = cx pop, bx cx cmpw-rr, jnz, cmp;
:cmp <> cx pop, bx cx cmpw-rr, jz, cmp;
:cmp u< cx pop, bx cx cmpw-rr, jae, cmp;
:cmp u<= cx pop, bx cx cmpw-rr, ja, cmp;
:cmp u> cx pop, bx cx cmpw-rr, jbe, cmp;
:cmp u>= cx pop, bx cx cmpw-rr, jb, cmp;
:cmp < cx pop, bx cx cmpw-rr, jge, cmp;
:cmp <= cx pop, bx cx cmpw-rr, jg, cmp;
:cmp > cx pop, bx cx cmpw-rr, jle, cmp;
:cmp >= cx pop, bx cx cmpw-rr, jl, cmp;
Для более сложных условий у нас есть типичные логические операторы. Пока мы используем правильно построенные булевы значения, нет необходимости выделять отдельный набор чисто логических операторов — побитовые работают нормально.
:code or ax pop, ax bx orw-rr, next,
:code and ax pop, ax bx andw-rr, next,
:code xor ax pop, ax bx xorw-rr, next,
:code invert bx notw-r, next,
Ура, циклы! Что теперь?
До сих пор многие слова, которые могли бы улучшить рабочий процесс редактирования кода в памяти, просто невозможно было определить, поскольку они по своей сути используют цикл. Это меняется сейчас. Во-первых, давайте определим type
, который печатает строку:
: type ( addr len -- )
begin dup while 1 - >r
dup c@ emit 1 +
r> repeat drop drop
;
В моем текущем рабочем процессе последний определенный блок завершается нулевым байтом. Поиск этого места необходим для добавления элементов в этот блок. Вот что seek
делает:
: seek ( addr -- end-addr ) begin dup c@ 0<> while 1 + repeat ;
Затем он используется для appending
установки srcpos
и checkpoint
соответствующим образом:
: appending ( addr -- ) seek dup u. srcpos ! move-checkpoint ;
Еще одна полезная вещь — возможность показать содержимое блока и быстро оценить адрес конкретной точки в коде — поскольку у нас нет настоящего редактора кода, это первый шаг любого патчинга. Я решил отображать блоки в обычном формате 64 на 16, хотя мои блоки никак не форматируются, а токены часто охватывают такие разрывы строк.
Вместо номеров строк я печатаю адрес, с которого начинается каждая строка. Это и проще реализовать, и полезнее. Во-первых, у нас есть show-line
, который показывает одну строку:
\ cr emits a linebreak
: cr 0D emit 0A emit ;
40 constant line-length
10 constant #lines
: show-line ( addr -- next-addr )
dup u. \ номер линии
dup line-length type cr \ содержимое линии
line-length +
;
Затем он вызывается 16 раз в цикле с помощью list
. Поскольку у нас еще нет do
- loop
, это довольно сложно.
: list ( addr -- )
#lines begin
>r show-line r>
1 - dup 0= until
drop drop
;
Иногда нам нужно переместить некоторый код. Для этого нам понадобится move
. Это похоже на C memmove
в том, что он копирует данные с правого конца, так что результат правильный, даже если источник и место назначения перекрываются. Мы реализуем это с помощью инструкции x86 rep movsb
, которая, по сути, состоит memcpy
из одной инструкции. Давайте пока обучим ассемблер остальным строковым инструкциям:
: rep, F2 c, ;
: movsb, A4 c, ; ; movsw, A5 c, ; ; cmpsb, A6 c, ; ; cmpsw, A7 c, ;
Затем он используется cmove
, который копирует данные вперед. 5 Эта оболочка несколько длинная, так как нам нужно сохранить значения si
и di
.
:code cmove ( src dest len -- )
bx cx movw-rr,
si ax movw-rr, di dx movw-rr,
di pop, si pop,
rep, movsb,
ax si movw-rr, dx di movw-rr,
bx pop,
next,
Далее нам понадобится cmove>
, который начинается с конца буферов и копируется в обратном направлении. Стрелка >
указывает, что это правильное слово для использования, если данные должны быть перемещены по более высокому адресу, т. е. вправо. Чтобы запустить такую копию, мы запускаем ее rep movsb
с включенным флагом направления x86. Это предполагает, что регистры будут содержать адреса концов буферов , поэтому мы запускаем саму копию в (cmove>)
, а потом фактическое cmove>
отвечает за вычисление правильного адреса.
: cld, FC c, ; ; std, FD c, ;
:code (cmove>)
bx cx movw-rr,
si ax movw-rr, di dx movw-rr,
di pop, si pop,
std, rep, movsb, cld,
ax si movw-rr, dx di movw-rr,
bx pop,
next,
: cmove> ( src dest len -- )
dup >r \ сохранить длину на стеке возвратов
1 - \ нам нужно добавить len-1, чтобы получить конечный указатель
dup >r + swap r> + swap \ настроить указатели
r> (cmove>)
;
Наконец, move
сравнивает два адреса и выбирает подходящее направление копирования:
: over ( a b -- a b a ) >r dup r> swap ;
: move ( src dest len -- )
>r
over over u< if
r> cmove>
else
r> cmove
then
;
Реализация fill
похожа, и она полезна для стирания любых остатков после move
s.
:code fill ( addr len byte -- )
bx ax movw-rr,
cx pop,
di dx movw-rr, di pop,
rep, stosb,
dx di movw-rr,
bx pop,
next,
Еще одна проблема при редактировании кода — определить, было ли уже написано конкретное слово. Для этого я написал words
, который выводит список слов, известных системе. Напомним структуру словаря:
Заголовки слов содержат имя в виде строки с подсчетом, что означает, что первый байт хранит длину. count
берет указатель на такую подсчитанную строку и превращает ее в типичное addr len
представление:
: count ( addr -- addr+1 len )
dup 1+ swap c@
;
Учитывая указатель на заголовок словаря, >name
извлечет из него имя:
1F constant lenmask
: >name ( header-ptr -- addr len )
cell+ \ skip link pointer
count lenmask and
;
Нам также нужно проверить скрытый флаг, так как в противном случае мы столкнемся с именами мусора, введенными нашими трюками со сжатием .
: visible? ( header-ptr -- t | f )
cell+ c@ 40 and 0=
;
Нашими последними вспомогательными словами являются space
, которое просто печатает пробел, и #bl
(обозначает пробел ), представляющее собой константу, в которой хранится ASCII-значение пробела. 6
: #bl 20 ;
: space #bl emit ;
Все это затем используется функцией words-in
, которая принимает указатель на первое слово в списке. Это облегчит адаптацию, как только наш Форт получит поддержку словарей. 7
: words-in ( dictionary-ptr -- )
begin dup while \ loop until NULL
dup visible? if
dup >name type space
then
@
repeat
drop ;
: words latest @ words-in ;
Разбор
Со временем мне нужно будет заменить внешний интерпретатор codegolf на другой, написанный на Forth. Это позволит мне добавить такие вещи, как правильная обработка неопределенных слов, знакомая ok
подсказка, а также словари и обработка исключений. 8 Первым шагом к этому является parse ( delim -- addr len )
слово, которое будет анализировать ввод до тех пор, пока не встретится указанный символ-разделитель. Для обычного синтаксического анализа это будет пробел, но если мы установим его равным )
, у нас, наконец, появятся комментарии.
parse
хранит разделитель в переменной, чтобы его могли использовать вспомогательные слова.
variable sep
Разбор может завершиться из-за того, что мы нашли разделитель или из-за того, что у нас закончились входные данные, что обозначается байтом NULL.
: sep? ( ch -- t | f ) dup 0= swap sep @ = or ;
После завершения цикла разбора у нас будет указатель на разделитель. Если это настоящий разделитель, мы хотим удалить его из ввода — в конце концов, он )
не существует как слово. Однако, если мы остановились из-за того, что ввод закончился, то мы не должны продвигаться дальше нулевого терминатора. +sep
обрабатывает это и продвигает указатель ввода только в том случае, если он не указывает на нулевой байт.
: +sep ( ptr -- ptr' ) dup c@ 0<> if 1+ then ;
Цикл parse
удерживает два указателя в стеке. Один не двигается и отмечает начало жетона. Другой продвигается в каждой итерации. В конце мы сохраняем перемещенный указатель в >in
, а затем вычисляем длину, вычитая два указателя.
: parse ( sep -- addr len )
sep ! >in @ dup begin ( begin-ptr end-ptr )
dup c@ sep? invert
while 1+ repeat
dup +sep >in ! \ обновить >in
over - \ вычислить length
;
Это работает, когда мы хотим проанализировать комментарий, но для анализа слова нам действительно нужно сначала пропустить начальные пробелы. Это обрабатывается skip
, который также принимает разделитель в качестве аргумента и продвигается вперед >in
, чтобы больше не указывать на разделитель.
: skip ( sep -- sep ) begin dup >in @ c@ = while 1 >in +! repeat ;
token
сочетает в себе два.
: token ( sep -- addr len ) skip parse ;
Затем он используется функцией char
, которая возвращает первый символ следующего токена — мы можем использовать его как символьные литералы.
: char #bl token drop c@ ;
Однако, чтобы включить такой литерал в скомпилированное слово, нам нужно [char]
:
: [char] char lit, ; immediate
Наконец, (
это непосредственное слово, которое запускает )
синтаксический анализ с разделителями и отбрасывает результат.
: ( [char] ) parse 2drop ; immediate
Голое железо
Как только загрузочный сектор Miniforth был готов, я решил выполнить всю свою разработку и тестирование на компьютере с «голым железом». В общем, я не жалею об этом решении, но мне потребовалось несколько попыток, чтобы сохранить код на диске. Однако я сфотографировал код, поэтому повторная попытка потребовала повторного ввода около килобайта исходного кода. Несколько попыток были испорчены опечатками, сделанными во время этой транскрипции, но помимо этого у меня было две ошибки, о которых стоит упомянуть.
В первый раз я попытался реализовать ветки перед записью на сам диск. Сам код ветвления был в порядке, но мой тест на зацикливание - нет. Попробуйте найти ошибку:
: foo begin dup u. 1 - 0= until ; 5 foo
Показать подсказку
Код вылетел вот так:
Показать решение
Потребляет 0=
счетчик циклов в стеке, что означает, что каждая итерация опускается дальше в стек. Поскольку защиты от этого нет, код будет выталкивать что-то до тех пор, пока не начнет перезаписывать код, достаточно важный, чтобы вызвать сбой.
Другая ошибка была в int13
самом слове. Как я упоминал ранее, я забыл сохранить SI, который является указателем выполнения для многопоточного кода. Он рухнул, как только запустился next
. По иронии судьбы, если бы я не был так осторожен и не выполнил write-block
свою первую операцию, большая часть кода была бы сохранена ?
Помимо ошибок, есть еще одна проблема с запуском на «голом железе»: публикация кода на GitHub. Я решил эту проблему, написав скрипт, splitdisk.py
который извлекает код из образа диска. Поскольку я загружаюсь с USB-накопителя, получение кода не займет много времени.
Поскольку там нет разрывов строк, я написал эвристику, чтобы разбить его на строку для каждого определения. Благодаря Python difflib
он даже сохраняет любые корректировки форматирования, сделанные вручную при извлечении обновленной версии блока.
Что дальше?
Теперь, когда у нас есть ветвления, многие вещи становятся доступными в качестве потенциального следующего шага. Одной из важных целей является написание текстового редактора, но некоторые другие улучшения нашего Форта, вероятно, должны быть сделаны первыми.
Между тем, я рекомендую вам попробовать выполнить начальную загрузку поверх Miniforth вплоть до ветвления _без_использования каких-либо дополнительных собранных примитивов. Не то чтобы в таком ограничении себя есть смысл, но это интересная проблема. Я объясню свое решение через неделю, а также любые существенно отличающиеся решения, найденные такими читателями, как вы. Я создал отдельную ветку для обсуждения этой проблемы, поэтому, пожалуйста, избегайте спойлеров в комментариях под статьей ?
Понравилась эта статья?
Возможно, вам понравятся и другие мои посты . Если вы хотите получать уведомления о новых, вы можете подписаться на меня в Твиттере или подписаться на RSS-канал .
Я хотел бы поблагодарить моих спонсоров GitHub за их поддержку: Michalina Sidor и Tijn Kersjes.
1
Есть небольшое отличие от стандартного поведения в том, что my load
просто перенаправляет входной указатель, и блок будет выполняться только один раз, непосредственно перед тем, как выполнение достигнет REPL верхнего уровня.↩
2
Теперь до меня дошло, что причина, по которой его обычно называют, >in
заключается в том, что обычно это смещение ( >
), которое добавляется к отдельному базовому адресу входного буфера. Ну что ж.↩
3
Впрочем, это не было бы катастрофой. Повторный запуск такого кода является нормальным явлением после исправления ошибки.↩
4
Если бы это было что-то вроде mov [0x1234], 0x5678
, то использовался бы точный порядок: код операции, ModR/M, адрес, немедленный.↩
5
Это не лучшее название, так как это не то, что программист на C назвал бы "движением", но это то, что использует стандарт Forth, так что я буду придерживаться его.↩
6
Теперь до меня доходит, что я мог бы определить это constant
вместо этого. Я, вероятно, изменю это, когда загрузлю подходящий текстовый редактор.↩
7
Словари Forth, также известные как списки слов, представляют собой способ разделения слов на несколько отдельных «словарей», которые можно динамически добавлять и удалять из порядка поиска.↩
8
Точный аспект, который меня интересует, - это установка обработчика верхнего уровня для исключений, которые не перехвачены. Вероятно, есть способ заставить этот конкретный аспект работать без нового внешнего интерпретатора, но другие преимущества все еще существуют, поэтому нет особого смысла пытаться понять это.↩