Оригинал текста Июнь 10, 2021 - 38 минут чтения
Программное обеспечение полно своих зависимостей, если смотреть достаточно глубоко. Компиляторы, написанные на языке, на котором они компилируются, - самый очевидный, но не единственный пример. Чтобы скомпилировать ядро, нам нужно работающее ядро. Линкеры, системы сборки, оболочки. Даже текстовые редакторы, если вы хотите писать код, а не просто загружать его. Как разорвать этот цикл?1 С тех пор как проблема начальной загрузки впервые привлекла мое внимание, я стал интересоваться этой уникальной областью программной инженерии. Не из страха, что кто-то попытается реализовать атаку на доверие, а просто как интересный вызов.
11 лет назад vanjos72 описал на Reddit то, что он называет мысленным экспериментом: что если бы вас заперли в комнате с IBM PC, на котором нет операционной системы? Какое минимальное количество программного обеспечения вам понадобилось бы для начала, чтобы вернуться к комфортной работе?
Так получилось, что в последнее время у меня появилось много свободного времени, поэтому я решил сделать это больше, чем просто мысленный эксперимент. Увы, мой компьютер не был оснащен переключателями на передней панели, поэтому некоторые программы должны уже присутствовать на компьютере ...
Самым минимальным вариантом может быть простая программа, которая принимает ввод с клавиатуры, а затем переходит на нее. Поскольку подпрограммы ввода с клавиатуры в BIOS реализуют escape-коды alt+numpad, вам даже не нужно писать код преобразования базы.2Более того, циклу даже не нужно условие завершения а просто пишите в буфер обратно, пока не столкнетесь с существующим кодом и не перезапишете точку перехода. Такой подход занимает всего 14 байт:
6a00 push word 0
07 pop es
fd std
bf1e7c mov di, buffer+16 ; Adjust to taste. Beware of fenceposting.
input_loop:
b400 mov ah, 0
cd16 int 0x16
aa stosb
ebf9 jmp short input_loop
buffer:
Однако перспектива вводить код таким образом не кажется мне привлекательной. Я решил, что, поскольку BIOS в любом случае загружает целый сектор, любая загрузочная программа, которая помещается в бутсектор, является честной игрой.3Очевидно, что хочется максимизировать полезность выбранной программы. Какую самую мощную вещь мы можем уместить в 510 байт?
Оскар Толедо написал много интересных программ размером с сектор. Среди них много игр, таких как DooM-подобная игра или шахматный ИИ, а также базовый интерпретатор BASIC, но самой, пожалуй, актуальной для нашего случая является bootOS:
bootOS - это монолитная операционная система, которая помещается в одном загрузочном секторе. Она способна загружать, выполнять и сохранять программы. Также имеет файловую систему.
Она открывает свои подпрограммы файловой системы с помощью интерфейса прерываний и включает встроенную команду, которая позволяет создать файл, набрав его шестнадцатиричное представление. Очень аккуратно, но, очевидно, в основном предназначена для переключения между другими программами, имеющими размер сектора.
Я бы искал решение, которое минимизирует набор текста в машинном коде ручной сборки. В идеале это должен быть язык программирования, но такой, который, в отличие от BASIC, может быть расширен во время выполнения. Если вы прочитали заголовок этого поста, то уже знаете, на чем я остановился. Оказалось, что в бутсекторе можно разместить примитивный FORTH. Код можно посмотреть врепозитории Miniforth на GitHub, но я приведу большую его часть здесь.
Весь FORTH занимает на данный момент 504 байта. Как и следовало ожидать, процесс разработки включал в себя постоянный поиск возможностей экономии байтов. Однако, когда я опубликовал, как мне казалось, достаточно плотно оптимизированный код, появилсяИлья Курдюков и нашел 24 байта, которые можно сэкономить! Я быстро реинвестировал это сэкономленное место в новые возможности.
Вводная экскурсия в FORTH
Если вы когда-либо писали что-либо на FORTH, вы можете смело пропустить этот раздел.
FORTH - это язык, основанный на стеках. Например, число вталкивает свое значение в стек, а слово + выталкивает два числа и их сумму. Обычная утилита отладки, но не включенная в Miniforth, - это слово .s, которое печатает содержимое стека.
?1 2 3 + .s <enter>
<2> 1 5 ok
Примечание: ok - готовность системы к приёму слов языка
Пользователь может определять свои собственные слова с помощью : и ;. Например:
: double dup + ; <enter>
ok 3 double . <enter>
6 ок
Это определяет слово double, которое делает то же самое, что и dup +. dup, кстати, является одним из слов FORTH для работы со стеком. Оно дублирует верхний элемент в стеке:
42 dup .s <enter>
<2> 42 42 ok
Это практически весь язык. Есть некоторые стандартные средства для условий и циклов, но нам пока не нужно ими заниматься, так как они могут быть построены поверх Miniforth позже.
Чтобы описать влияние слова на состояние стека, мы используем следующую нотацию:
dup ( a -- a a )
swap ( a b -- b a )
Список до символа -- - это входы , причем вершина стека перечислена последней. После -- перечислены выходы, которые начинаются с той же глубины стека. Это позволяет нам кратко описать общие аспекты слова.
Шитый код
Хотя некоторые системы FORTH включают в себя полноценные оптимизирующие компиляторы, подобные тем, которые можно увидеть в обычном языке программирования, существует гораздо более простая стратегия. В конце концов, все, что может сделать слово в FORTH, это выполнить другие слова, так что последовательность инструкций вызова очень близка к этому:
DOUBLE:
call DUP
call PLUS
ret
Однако это связывает аппаратный стек x86 для стека возврата, заставляя нас вручную обрабатывать отдельный стек для фактического стека пользовательского уровня (известного как стек параметров). Поскольку доступ к стеку параметров встречается гораздо чаще, мы хотели бы использовать для этого инструкции push и pop, а вместо них можно применить механизм, аналогичный call. Во-первых, давайте просто сохраним список указателей на слова:
DOUBLE:
dw DUP
dw PLUS
Это реализуется следующим образом: каждое примитивное слово извлекает из памяти адрес следующего слова и переходит к нему. Указатель на эту последовательность указателей хранится в SI, так что инструкция lodsw позволяет легко обрабатывать этот список:
DUP:
pop ax
push ax
push ax
lodsw
jmp ax
PLUS:
pop ax
pop bx
add ax, bx
push ax
lodsw
jmp ax
Этот общий код можно абстрагировать в макрос, который традиционно называется NEXT:
%macro NEXT 0
lodsw
jmp ax
%endmacro
Этот механизм, кстати, известен как шитый код. Никакой связи с примитивом параллелизма.
Однако что произойдет, если одно скомпилированное слово вызовет другое? Здесь в дело вступает стек возвратов. Может показаться естественным использовать регистр BP для указателя стека. Однако в 16-битных x86 не существует режима адресации [bp]. Самый близкий к нему - [bp+imm8], что означает, что при обращении к памяти по адресу bp тратится байт, чтобы указать, что вам не нужно смещение. Вот почему я использую регистр di для стека возврата вместо этого. В целом, этот выбор экономит 4 байта.
В любом случае, вот как стек возврата используется для обработки компилированных слов, вызывающих друг друга. Проталкивание в стек возврата особенно удобно, поскольку это просто инструкция stosw.
DOUBLE:
call DOCOL
dw DUP
dw PLUS
dw EXIT
DOCOL: ; сокращение от "do colon word".
xchg ax, si ; используется здесь как mov ax, si ,
; но меняет местами.
; ax - только один байт, а mov - два байта.
stosw
pop si ; захватить указатель, вытолкнутый call .
NEXT
EXIT:
dec di
dec di
mov si, [di]
NEXT
Это практически та же стратегия выполнения, что и в Miniforth, с одним простым, но существенным улучшением - значение на вершине стека хранится в регистре BX. Это позволяет пропустить push и pop во многих примитивах:
PLUS:
pop ax
add bx, ax
NEXT
DROP:
pop bx
NEXT
DUP:
push bx
NEXT
Однако один случай все еще остается нерешенным. Что произойдет, если слово содержит число, например : DOUBLE 2 \ ;? С этим справляется LIT, который извлекает литерал, следующий за ним, из потока указателей:
DOUBLE:
call DOCOL
dw LIT, 2
dw MULT
dw EXIT
LIT:
push bx
lodsw
xchg bx , ax
NEXT
Словарь
FORTH нужен способ найти реализацию слов, которые набирает пользователь. Это роль словаря. Я использую структуру, подобную многим другим небольшим FORTH - односвязный список заголовков слов, непосредственно предваряющий код каждого слова. По традиции, глава списка хранится в переменной LATEST.
В старших битах поля длины имени также хранятся некоторые флаги:
F_IMMEDIATE equ 0x80
F_HIDDEN equ 0x40
F_LENMASK equ 0x1f
Если слово помечено как IMMEDIATE, оно будет выполнено немедленно, даже если в данный момент мы составляем определение. Например, это используется для реализации ;. Если слово помечено как HIDDEN, оно игнорируется при поиске по словарю. Помимо использования в качестве элементарного механизма инкапсуляции, это может быть использовано для реализации традиционной семантики FORTH, когда определение слова может ссылаться на предыдущее слово с тем же именем (а RECURSE используется, когда вам нужно определение, которое компилируется в данный момент). Однако, ближе к концу разработки я удалил код, который действительно делает это, из стандартной реализации : и ;.
Компрессия
Обычно не стоит использовать сжатие, когда и расжатие , и его полезная нагрузка должны уместиться всего в 512 байт. Однако в реализации FORTH есть одна вещь, которая повторяется очень часто, - это реализация NEXT.
?
ad lodsw
ffe0 jmp ax
Мы можем попытаться сэкономить несколько байт, заменив их переходами к общей копии. Однако короткий переход все равно занимает два байта, что не является существенной экономией. Как выясняется, специальная схема сжатия, которая может обрабатывать только этот один повторяющийся паттерн, стоит того, если совместить ее со следующим наблюдением: За NEXT почти всегда следует словарная статья следующего примитива, поле ссылки которого предсказуемо.
Я выбрал схему сжатия, в которой каждый байт 0xff заменяется на NEXT, за которым следует поле ссылки, которое вычисляется на основе предыдущего появления байта 0xff. Эта стратегия сэкономила 19 байт, когда я ее внедрил.4
Сначала я использовал для этого байт 0x90, в конце концов, это опкод nop, который я точно не буду использовать. Однако этот байт все еще может встречаться в непосредственном байте инструкции. Сначала это не было проблемой, но когда код перемещался в памяти, различные адреса и смещения становились 0x90 достаточно часто, чтобы стать неприятностью. У 0xff, похоже, такой проблемы нет.
Чтобы создать ссылку, мы копируем значение LATEST на выход декомпрессора и обновляем LATEST, чтобы оно указывало на слово, которое мы только что написали. Это может быть сделано очень компактной последовательностью инструкций, но все равно занимает достаточно байт, чтобы стоило оформить это в виде подпрограммы. Она также используется в реализации :, которая создает словарные статьи во время выполнения.
; Создает ссылку словарного связного списка в DI.
MakeLink:
mov ax , di
xchg [LATEST], ax ; AX теперь указывает на старую запись,
; а LATEST и DI указывают на новую.
stosw
ret
В декомпрессоре использовался интересный прием, когда вместо короткого перехода вперед опкод размещается так, что непосредственный аргумент, который он требует, съедает инструкцию, через которую мы хотим перейти. То есть, вместо
jmp short .after
.write:
stosb
.after:
пишим
3c db 0x3c ; пропустить stosb ниже, сравнив его опкод с AL
.write:
aa stosb
Таким образом, если какой-то другой код переходит к .write, выполняется stosb, но этот кодовый путь просто выполняет cmp al, 0xaa. Сначала я не подумал об инструкции cmp al, и вместо нее использовал mov в отбрасываемый регистр. Это привело кэффектному отказуиз-за моей неспособности выбрать регистр, который можно безопасно перезаписать.
Илья Курдюков затем продемонстрировал , что тот же самый байткоунт может быть достигнут без подобной "магии". Аналогичная модификация позволила мне устранить и другое проявление этого трюка. Суть в том, что вместо того, чтобы пытаться пропустить stosb, мы выполняем его безоговорочно перед ветвлением кодовых путей, а затем, по сути, отменяем его с помощью dec di, если это необходимо:
SPECIAL_BYTE equ 0xff
mov si , CompressedData
mov di , CompressedBegin
mov cx , COMPRESSED_SIZE
.decompress:
lodsb
stosb
cmp al , SPECIAL_BYTE
jnz short .not_special
dec di
mov ax , 0xffad ; lodsw / jmp ax
stosw
mov al , 0xe0
stosb
call MakeLink
.not_special:
loop .decompress
На самом деле генерация сжатого потока более сложна. Поскольку я хочу, чтобы переходы между сжатой и несжатой частями работали, ассемблеру нужно верить, что он пишет код в том месте, где он будет выполняться на самом деле. Сначала я попытался сделать это, корректируя org после каждого SPECIAL_BYTE, но, к сожалению, yasm это не понравилось.
boot.s:137: error: program origin redefined
Очевидно, что необходим отдельный этап постобработки. Я написал макрос для подгонки байтов, которые будет вставлять декомпрессор:
%macro compression_sentinel 0
db SPECIAL_BYTE
dd 0xdeadbeef
%endmacro
Это имеет дополнительное преимущество, предoставляя простой автоматизированный способ проверки того, что никакие SPECIAL_BYTE не проскочили случайно.
Мне все еще нужно было выделить место для сжатых данных. Я выбрал следующую схему:
\1. Несжатый код начинается с 7C00 - инициализация, декомпрессия и внешний интерпретатор.
\2. Сжатые данные немедленно следуют за ним, заполняя бутсектор вплоть до момента перед 7E00.
\3. Сразу после этого выделяется буфер декомпрессии, в который yasm выводит содержимое цели.
Чтобы добиться этого, мне нужно было точно знать, сколько места нужно выделить для сжатых данных. Сначала я подсчитал точное количество сэкономленных байт, увеличив счетчик в макросе compression_sentinel:
%assign savings 0
%macro compression_sentinel 0
%assign savings savings+4
db SPECIAL_BYTE
dd 0xdeadbeef
%endmacro
Затем я просто вычитаю это значение из размера несжатого сегмента:
CompressedData:
times COMPRESSED_SIZE db 0xcc
CompressedBegin:
; ...
CompressedEnd:
COMPRESSED_SIZE equ CompressedEnd - CompressedBegin - savings
Постобработка выполняется простым скриптом Python:
SPECIAL_BYTE = b'\xff'
SENTINEL = SPECIAL_BYTE + b '\xef\xbe\xad\xde'
with open('raw.bin', 'rb') as f:
data = f.read()
output_offset = data.index( b '\xcc' \ 20)
chunks = data[output_offset:].lstrip( b '\xcc').split(SENTINEL)
assert SPECIAL_BYTE not in chunks[0]
compressed = bytearray (chunks[0])
for chunk in chunks[1:]:
assert SPECIAL_BYTE not in chunk
compressed.extend(SPECIAL_BYTE)
compressed.extend(chunk)
\# Убедитесь, что для сжатых данных выделено именно то место, которое нужно.
\# для сжатых данных.
assert b '\xcc' \ len(compressed) in data
assert b '\xcc' \ (len(compressed) + 1) not in data
output = data[:output_offset] + compressed
print(len(output), 'bytes used')
output += b '\x00' \ (510 - len(output))
output += b '\x55\xaa'
with open('boot.bin', 'wb') as f:
f.write(output)
Этот же сценарий также генерирует расширенный образ диска, который содержит некоторый код для тестирования в блоке 1:
output += b '\x00' \ 512
output += open('test.fth', 'rb').read().replace( b '\n', b ' ')
output += b ' ' \ (2048 - len(output))
with open('test.img', 'wb') as f:
f.write(output)
compression_sentinel чаще всего используется макросом defcode, который создает словарную статью для примитивного слова. Он принимает метку (которая затем может быть использована для перехода к реализации некоторого слова), имя слова в виде строки и, по желанию, некоторые флаги, которые должны быть включены в поле длины:
; defcode PLUS, "+"
; defcode SEMI, ";", F_IMMEDIATE
%macro defcode 2-3 0
compression_sentinel
%strlen namelength %2
db %3 | namelength, %2
%1:
%endmacro
Затем это используется для определения примитивов. Код, по сути, переходит в defcode:
defcode PLUS, "+"
pop ax
add bx , ax
defcode MINUS, "-"
pop ax
sub ax , bx
xchg bx , ax
defcode PEEK, "@"
; ...
Однако DOCOL, EXIT и LIT также используют механизм сжатия для своих NEXT. Поскольку поле ссылки все еще записывается, это создает фиктивные словарные статьи. К счастью, первый опкод EXIT и LIT имеет установленный бит F_HIDDEN, так что это не проблема:
CompressedBegin:
DOCOL:
xchg ax , si
stosw
pop si ; grab the pointer pushed by call
compression_sentinel
LIT:
push bx
lodsw
xchg bx , ax
compression_sentinel
EXIT:
dec di
dec di
mov si , [ di ]
defcode PLUS, "+"
; ...
Переменные?
Инструкции немедленной загрузки имеют тенденцию иметь более короткие кодировки, чем загрузки из памяти:
be3412 mov si , 0x1234
8b363412 mov si , [0x1234]
Именно поэтому Miniforth хранит большинство своих переменных в непосредственных полях инструкций. Конечно, это означает, что адрес этих переменных будет меняться при каждом редактировании кода, что проблематично, поскольку мы захотим получить доступ к этим переменным в коде FORTH. Типичный способ доступа к переменной - это создание слова, которое передает ее адрес. Однако это слишком дорого при наших ограничениях. То, на чем я остановился, это заталкивание адресов в стек при запуске. Это можно сделать, используя всего 2 байта для каждого адреса, просто определив начальное содержимое стека как данные:
org 0x7c00
jmp 0:start
stack:
dw HERE
dw BASE
dw STATE
dw LATEST
start:
; ...
mov sp , stack
Даже когда адрес переменной должен быть помещен в стек, эта самомодифицирующаяся стратегия кода экономит байты, если переменная должна быть инициализирована. Лучший способ инициализировать переменную - просто выделить ее в бутсекторе и поместить туда начальное значение, что точно выравнивает данные стека и сохраняет преимущество более короткой кодировки инструкций.
Код инициализации
Первое, что делается после загрузки, это настройкасегментных регистрови стека. Флаг направления также очищается, чтобы строковые инструкции работали в правильном направлении.
jmp 0:start
; ...
start:
push cs
push cs
push cs
pop ds
pop es
pop ss
mov sp , stack
cld
В этом коде есть два примечательных момента. Во-первых, сегментные регистры устанавливаются через стек. Это трюк экономии байтов, который я перенял из bootBASIC a он позволяет инициализировать регистр общего назначения нулем:
?
31c0 xor ax , ax ; through AX - 8 bytes
8ed8 mov ds , ax
8ec0 mov es , ax
8ed0 mov ss , ax
0e push cs ; through the stack - 6 bytes
0e push cs
0e push cs
1f pop ds
07 pop es
17 pop ss
Во-вторых, можно подумать, что во время перенаправления стека возникает небольшое окно состояния гонки и если прерывание произошло между pop ss и mov sp, то может возникнуть хаос, если предыдущее значение SP окажется в неудачном месте памяти. Конечно, я мог бы просто скрестить пальцы и надеяться, что этого не произойдет, если бы 2 байта, необходимые для обертывания этого в пару cli/sti, были слишком большими. Однако оказалось, что этот компромисс не нужен благодаря одному неясному уголку архитектуры x86. Процитируем том 2B Руководства разработчика программного обеспечения x86:
Загрузка регистра SS инструкцией POP5 подавляет или блокирует некоторые отладочные исключения и блокирует прерывания на границе следующей инструкции. (Запрет заканчивается после доставки исключения или выполнения следующей инструкции). Такое поведение позволяет загрузить указатель стека в регистр ESP со следующей инструкцией (POP ESP)6 ,прежде чем может быть доставлено событие.
После установки сегментов, стека и флага направления запускается декомпрессор. Очень важно, что он не использует регистр DL, который содержит номер диска BIOS, с которого мы загрузились. Затем он пихается в реализацию load (которая находится в сжатом сегменте) и заталкивается в стек для последующего использования пользовательским кодом:
mov [DRIVE_NUMBER], dl
push dx ; for FORTH code
Внешний интерпретатор
На этом этапе мы достигаем внешнего интерпретатора - части системы FORTH, которая обрабатывает пользовательский ввод. Название "внешний интерпретатор" отличает его от внутреннего интерпретатора, который является компонентом, координирующим выполнение в пределах определенного слова, и состоит из NEXT, DOCOL, EXIT и LIT.
Обычно FORTH представляет строительные блоки своего внешнего интерпретатора в виде слов в словаре, таких как
REFILL (считывание строки ввода из текущего выполняющегося источника),
WORD (разбор слова из входного потока),
FIND (искать слово в словаре),
NUMBER (преобразование строки в число).
В Miniforth этой практике вообще не уделяется никакого внимания. Заголовки словарей стоят байты, как и общение только через стек. Фактически, WORD и >NUMBER объединяются в одну процедуру, которая выполняет работу обеих. Таким образом, цикл может быть общим, что экономит байты.
Эта монолитная архитектура также позволяет нам решить, что BX и DI не резервируются для вершины стека и указателя стека возврата, соответственно, во время выполнения внешнего интерпретатора. Это значительно помогает справиться с регистровым голоданием в этих сравнительно сложных частях системы. Эти регистры устанавливаются непосредственно перед переходом к слову и сохраняются после возврата.
Ввод с клавиатуры
После завершения инициализации код переходит к ReadLine, процедуре для чтения строки ввода с клавиатуры. Мы также вернемся сюда позже, когда текущая строка ввода будет исчерпана. Буфер ввода находится по адресу 0x500, сразу послеBDA. Хотя идиоматический формат строк для FORTH использует отдельное поле длины, этот буфер NULL-терминирован, так как это легче обрабатывать при разборе. Указатель на неразобранный фрагмент ввода хранится в InputPtr, которая является единственной переменной, не использующей технику самомодификации, так как ее не нужно явно инициализировать и она естественным образом записывается до чтения.
InputBuf equ 0x500 InputPtr equ 0xa02 ; dw
ReadLine:
mov di , InputBuf
mov [InputPtr], di
.loop:
mov ah , 0
int 0x16
cmp al , 0x0d
je short .enter
stosb
cmp al , 0x08
jne short .write
dec di
cmp di , InputBuf ; underflow check
je short .loop
dec di
.write:
call PutChar
jmp short .loop
.enter:
call PutChar
mov al , 0x0a
int 0x10
xchg ax , bx ; write the null terminator by using the BX = 0 from PutChar
stosb
InterpreterLoop:
call ParseWord ; returns length in CX. Zero implies no more input.
jcxz short ReadLine
Прерывание BIOS для получения символа с клавиатуры не печатает клавишу и мы должны сделать это сами. Это делается с помощью функции "TELETYPE OUTPUT", которая уже обрабатывает специальные символы, такие как backspace или newline.
?
PutChar:
xor bx , bx
mov ah , 0x0e
int 0x10
ret
У этой функции есть свои недостатки. Например, необходимы "грязные" символы окончания строки CRLF (CR для перемещения курсора в начало строки и LF для перемещения на следующую строку). Кроме того, символ backspace только перемещает курсор на один символ назад, но не стирает его. Чтобы получить поведение, которого мы ожидаем, необходимо напечатать \b \b (справедливости ради, это также происходит на современных терминалах). Я решил это пропустить.
Наконец, в "Списке прерываний" Ральфа Браунаупоминается, что некоторые BIOS сбрасывают BP, когда печатаемый символ вызывает прокрутку экрана. Это нас не касается, так как мы вообще не используем этот регистр.
Парсинг
После того как мы прочитали строку, нам нужно разобрать ее на слова. Это делается по требованию и каждое слово выполняется (или компилируется, в зависимости от состояния), как только оно разобрано. Помимо цикла интерпретатора, ParseWord также вызывается реализацией : (для получения имени определяемого слова).
Как уже упоминалось, эта процедура также вычисляет числовое значение слова, предполагая, что оно достоверно. В этом отношении нет никакой проверки ошибок и если слово не найдено в словаре, его числовое значение выталкивается, что, вероятно, является бессмыслицей, если это не было задумано.
Для начала мы пропускаем все пробельные символы во входном буфере:
; returns
; DX = pointer to string
; CX = string length
; BX = numeric value
; clobbers SI and BP
ParseWord:
mov si , [InputPtr]
; repe scasb вероятно, сохранит некоторые байты здесь, если бы реестры были разработаны - SCASB ; использует DI вместо SI :( - scasb
; uses DI instead of SI :(
.skiploop:
mov dx , si ; Если мы выйдем на петлю в этой итерации, DX укажет первую букву слова
lodsb
cmp al , " "
je short .skiploop
Обратите внимание на то, как сохраняется указатель на начало строки. Цикл проходит на один байт дальше пробела, поэтому сохранение указателя после цикла потребовало бы отдельного декремента. Вместо этого мы обновляем регистр при каждой итерации цикла, но до того, как указатель увеличивается на lodsb.
В этот момент регистр AL загружен первым символом слова. Таким образом, наш следующий цикл должен будет выполнить lodsb в своем конце.
xor cx , cx
xor bx , bx
.takeloop:
and al , ~0x20
jz short Return ; jump to a borrowed ret from some other routine
Эта инструкция интересна тем, что делает три вещи одновременно. Она определяет пробелы и нулевые байты одним махом, а также отключает бит, различающий заглавные и строчные буквы, что позволяет работать с шестнадцатеричными числами в обоих случаях без дополнительных затрат.
Если мы не обнаружили конец слова, мы увеличиваем счетчик длины и преобразуем цифру в ее числовое значение:
inc cx
sub al , "0" &~0x20
cmp al , 9
jbe .digit_ok
sub al , "A" - ("0" &~0x20) - 10
.digit_ok
cbw
cbw - это малоизвестная инструкция, которая преобразует знаковое число из байта в слово, но для нас это просто более короткое mov ah, 0. Возможно, аналогичным образом мы используем знаковое умножение imul, потому что у него больше возможностей для использования регистров, чем у беззнакового mul. Используемая здесь форма позволяет умножать на непосредственное значение и не перезаписывать в DX верхнюю половину произведения.7
Эта конкретная инструкция должна быть закодирована вручную, чтобы ширина литерала составляла 2 байта.8
; imul bx, bx, <BASE> но yasm настаивает на кодировании непосредственного в один байт...
db 0x69, 0xdb
BASE equ $
dw 16
add bx , ax ; add the new digit
Наконец, мы настраиваемся на следующую итерацию цикла. Мы используем аналогичный трюк, как и раньше, где результат указателя обновляется в каждой итерации, чтобы избежать отдельного декремента в конце, поскольку нам нужно убедиться, что входной указатель не указывает после терминатора.
? mov [InputPtr], si
lodsb
jmp short .takeloop
Поиск по словарю
После того как слово разобрано, мы пытаемся найти его в словаре. Для каждой записи нам нужно сравнить длину имени, а если она совпадает, то и само имя. Включая F_HIDDEN в маску, мы автоматически обрабатываем и скрытые записи. То, как мы сравниваем длину, может выглядеть немного странно. Цель состоит в том, чтобы сохранить бит F_IMMEDIATE в AL, чтобы нам не пришлось держать рядом указатель на заголовок этого слова. Это одна из умных оптимизаций Ильи Курдюкова.
?
InterpreterLoop:
call ParseWord
jcxz short ReadLine
; Пытаемся найти слово в словаре..
; SI = указатель словаря
; DX = указатель строки
; CX = длина строки
; Следим за сохранением BX, в котором хранится числовое значение
LATEST equ $+1
mov si , 0
.find:
lodsw
push ax ; сохранить указатель на следующую запись
lodsb
xor al , cl ; если длина совпадает, то AL содержит только флаги
test al , F_HIDDEN | F_LENMASK
jnz short .next
mov di , dx
push cx
repe cmpsb
pop cx
je short .found
.next:
pop si
or si , si
jnz short .find
; Если мы дойдем до этой точки, то это будет число.
; ...
.found:
pop bx ; отбрасываем указатель на следующую запись
; Когда мы дойдем до этого места, SI указывает на код слова, а AL содержит
; флаг F_IMMEDIATE
Эта часть показывает еще одно преимущество отсутствия разбиения интерпретатора на многократно используемые фрагменты: мы можем легко выйти на два разных кодовых пути, основываясь на результате поиска.
Должны ли мы его выполнить?
Система может находиться в двух возможных состояниях:
интерпретация - все слова должны быть выполнены
компиляция - непосредственные слова должны быть выполнены
Другими словами, слово должно быть выполнено, если оно немедленное, или мы его интерпретируем. Мы храним этот флаг в поле immediate инструкции or, так как при компиляции он будет установлен в 0:
?
; Когда мы сюда попадаем, SI указывает на код слова, а AL содержит
; флаг F_IMMEDIATE STATE
STATE equ $+1
or al , 1
xchg ax , si ; оба кодовых пути должны иметь указатель в AX
jz short .compile
; Выполняем слово
; ...
Наиболее важными словами, которые должны изменить состояние, являются : и ;, но они просто переходят в [ и ] слова, которые позволяют временно вернуться в интерпретируемый режим при компиляции слова. Типичный случай использования - вычисление значения постоянного выражения:
: third-foo [ foos 3 cells + ] literal @ ;
Поскольку два значения STATE отличаются только на 1, мы можем переключаться между ними с помощью inc и dec. Это имеет тот недостаток, что они больше не являются идемпотентными, но это не должно иметь значения для хорошо написанного кода:
defcode LBRACK, "[", F_IMMEDIATE
inc byte[STATE]
defcode RBRACK, "]"
dec byte[STATE]
Выполнение слова
Если мы решили выполнить слово, мы извлекаем BX и DI, и настраиваем SI так, чтобы NEXT перешел обратно к .executed:
; Выполнение слова RetSP ?
RetSP equ $+1
mov di , RS0
pop bx
mov si , .return
jmp ax
.return:
dw .executed
.executed:
mov [RetSP], di
push bx
jmp short InterpreterLoop
Обработка чисел
Для чисел нет флага F_IMMEDIATE, поэтому для принятия решения нам нужно просто проверить состояние. Это простое сравнение, но если мы будем достаточно умны, здесь можно сэкономить байт. Давайте снова посмотрим на код, который выполняет поиск в словаре. Какое значение будет иметь AH, когда мы дойдем до регистра чисел?
.find:
lodsw
push ax ; сохранить указатель на следующую запись
lodsb
xor al , cl ; если длина совпадает, то AL содержит только флаги
test al , F_HIDDEN | F_LENMASK
jnz short .next
mov di , dx
push cx
repe cmpsb
pop cx
je short .found
.next:
pop si
or si , si
jnz short .find
; AH = ?
Видите ли вы это? В этот момент AH равен нулю, так как содержит старшую половину указателя на следующее слово, которое, как мы знаем, является NULL, поскольку мы только что добрались до конца списка. Это позволяет нам проверить значение STATE, не загружая его в регистр или какие-либо непосредственные байты:
; Это число. Вставьте его значение - мы выкинем его позже, если окажется, что нужно скомпилировать
; его вместо этого.
push bx
cmp byte[STATE], ah
jnz short InterpreterLoop
; Иначе скомпилируйте литерал. ; ...
Компиляция вещей
Указатель точки процесса компиляции называется HERE. Он начинается сразу после распакованных данных. Функция, которая выписывает слово в эту область, называется COMMA, так как слово FORTH, которое это делает, - ,.
COMMA:
HERE equ $+1
mov [CompressedEnd], ax
add word[HERE], 2
ret
Он используется в прямом смысле для составления как чисел, так и слов. Мы можем разделить хвост, хотя a составлении слова он будет перескакивать на середину составления числа:
; Иначе, компилируем литерал.
mov ax , LIT
call COMMA
pop ax
.compile:
call COMMA
jmp short InterpreterLoop
Последним кусочком головоломки являются : и ;. Давайте сначала рассмотрим :. Поскольку ParseWord использует BX и SI, нам нужно сохранить эти регистры. Более того, поскольку мы пишем множество частей заголовка словаря, мы загрузим HERE в DI, чтобы упростить работу. Это большое количество регистров, которые нам нужно переместить. Однако на самом деле нам не нужно изменять ни один регистр, поэтому мы можем просто сохранить все регистры с помощью pusha.
defcode COLON, ":"
pusha
mov di , [HERE]
call MakeLink ; link field
call ParseWord
mov ax , cx
stosb ; length field
mov si , dx
rep movsb ; name field
mov al , 0xe8 ; call
stosb
; поле длины mov si, dx rep movsb ; поле имени mov al, 0xe8
; вызов stosb ; Смещение определяется как (цель вызова) - (ip после инструкции вызова)
; Получается DOCOL - (di + 2) = DOCOL - 2 - di
?
mov ax , DOCOL - 2
sub ax , di
stosw
mov [HERE], di
popa
jmp short RBRACK ; enter compilation mode
; войти в режим компиляции
; гораздо короче. Нам просто нужно скомпилировать EXIT и вернуться в режим интерпретации:
defcode SEMI, ";", F_IMMEDIATE
mov ax , EXIT
call COMMA
jmp short LBRACK
То, как эти слова переходят к другому слову в конце, весьма удобно. Помните, как NEXT записываются как часть кода следующего слова? Одно из слов должно быть последним в памяти, и тогда после него не будет никакого "следующего слова". : и ; - идеальные кандидаты для этого, поскольку им вообще не нужен NEXT.
Загрузка кода с диска
Поскольку мы не хотим вводить дисковые подпрограммы при каждой загрузке, нам нужно предусмотреть способ запуска исходного кода, загруженного с диска. Файловая система была бы отдельным зверем, но в традициях FORTH есть минималистичное решение: диск просто делится на блоки по 1 КБ, в которых хранится исходный код, отформатированный как 16 строк по 64 символа. Затем load ( blknum -- ) выполнит блок с указанным номером.
Мы размещаем блок 0 в LBA 0 и 1, блок 1 в LBA 2 и 3 и так далее. Это означает, что блок 0 частично занят MBR, а LBA 1 используется впустую, но меня это не особенно беспокоит.
Поскольку оригинальная служба BIOS по адресу int 0x13 / ah = 0x02 требует адресации CHS, я решил использовать вариант расширения EDD (ah = 0x42). Это означает, что дискеты не поддерживаются, но я все равно не планировал их использовать.
Чтобы использовать интерфейс EDD, нам нужно создать пакет адреса диска, который выглядит следующим образом:
db 0x10 ; размер пакета
db 0 ; зарезервировано
dw sector_count
dw buffer_offset, buffer_segment
dq LBA
Мы используем гибридную стратегию для создания этого пакета. Первая часть сохраняется как данные в бутсекторе, а остальное записывается во время выполнения, даже если оно не меняется. Шаблон" должен находиться в таком месте, чтобы мы могли писать после него, поэтому идеальное место - прямо перед сжатыми данными:
>
>
> DiskPacket: db 0x10, 0 .count: dw 2 .buffer:
; остальное заполняется во время выполнения, перезаписывая сжатые данные,
; которые больше не нужны
CompressedData: times COMPRESSED_SIZE db 0xcc
Первые четыре байта пакета достаточно случайны, чтобы их можно было жестко закодировать. Однако, когда дело доходит до адреса буфера, мы можем сделать лучше. В любом случае нам нужно будет записать этот адрес в InputPtr. Самый прямой способ сделать это занимает шесть байт:
c706020a0006 mov word[InputPtr], BlockBuf
Однако мы можем получить эту переменную в AX без дополнительных затрат:
?b80006 mov ax , BlockBuf
a3020a mov [InputPtr], ax
Таким образом, мы можем записать эти два байта дискового пакета с помощью всего 1 байта кода:
defcode LOAD, "load"
pusha
mov di , DiskPacket.buffer
mov ax , BlockBuf
mov word[InputPtr], ax
stosw
Далее нам нужно записать сегмент (0000) и LBA (который заканчивается шестью байтами 00). Мне нравится думать о соответствующих инструкциях следующим образом:
31c0 xor ax , ax ; LBA zeroes
ab stosw ; segment
d1e3 shl bx , 1 ; LBA data
93 xchg ax , bx ; LBA data
ab stosw ; LBA data
93 xchg ax , bx ; segment
ab stosw ; LBA zeroes
ab stosw ; LBA zeroes
ab stosw ; LBA zeroes
То есть, мы записываем шесть нулей LBA за 5 байт кода. Запись сегмента потребовала только перемещения xor ax, ax ранее, и дополнительных stosw и xchg ax, bx. Таким образом, он занимает нейтральные 2 байта (но нам нужно выписать его в коде, чтобы указатель был правильным для остальной части пакета). Наконец, конечно, у нас есть фактические данные LBA, которые меняются.
Пока AX равен нулю, воспользуемся случаем и поставим нулевой терминатор после буфера:
mov [BlockBuf.end], al
Теперь мы готовы вызвать функцию BIOS. Если она выдаст ошибку, мы просто зациклимся, так как восстановление сложное и самое неприятное осложнение заключается в том, что счетчик секторов в пакете перезаписывается количеством успешно прочитанных секторов, что ломает наш шаблон.
DRIVE_NUMBER equ $+1
mov dl , 0
mov ah , 0x42
mov si , DiskPacket
int 0x13
jc short $
popa
pop bx
Числа для печати
u. печатает беззнаковое число, за которым следует пробел. Поскольку при делении числа на разряды с помощью деления первой получается наименее значащая цифра, мы помещаем цифры в стек, а затем выводим и печатаем их в отдельном цикле. Пробел печатается путем выталкивания фальшивой "цифры", которая будет преобразована в пробел. Это также позволяет нам определить, когда мы вытолкнули все цифры и цикл печати останавливается, когда он только что напечатал пробел.
defcode UDOT, "u."
xchg ax , bx
push " " - "0"
.split:
xor dx , dx
div word[BASE]
push dx
or ax , ax
jnz .split
.print:
pop ax
add al , "0"
cmp al , "9"
jbe .got_digit
add al , "A" - "0" - 10
.got_digit:
call PutChar
cmp al , " "
jne short .print
pop bx
s: Поместить в строку
s: - это функция, которая, как мне кажется, имеет уникальное значение для бутстраппинга. Это слово принимает адрес буфера и копирует туда остаток текущей строки ввода. Без этого значительный объем кода пришлось бы набирать дважды: сначала для того, чтобы запустить его и загрузить редактор дисковых блоков, а затем еще раз, чтобы сохранить его на диске.
Реализация представляет собой простой цикл, но установка вокруг него заслуживает внимания и мы хотим загрузить входной указатель в SI, но нам также нужно сохранить SI, чтобы мы могли правильно вернуться. Используя xchg, мы можем сохранить его в [InputPtr] на время копирования без дополнительных затрат:
;; Копирует остаток строки в buf.
defcode LINE, "s:" ; ( buf -- buf+len )
xchg si , [InputPtr]
.copy:
lodsb
mov [ bx ], al
inc bx
or al , al
jnz short .copy
.done:
dec bx
dec si
xchg si , [InputPtr]
Указатель назначения хранится в BX. Хотя запись в DI займет всего один стосб, получение указателя в DI и обратно перевешивает это преимущество. В конце мы оставляем в стеке указатель на нулевой терминатор. Таким образом, вы можете продолжить строку, просто используя s: в следующей строке. Поскольку мы не пропускаем пробельные символы, это даже гарантирует правильную расстановку символов.
Другие примитивы
Выбор примитивов для включения в Miniforth - это, пожалуй, самый большой компромисс, который необходимо сделать. Я полностью ожидаю, что некоторые более примитивные слова нужно будет определять во время выполнения, вставляя их в опкоды. В конце концов, здесь нет ни одного слова с ветвлением. Однако я уверен, что эти опкоды можно будет генерировать простым ассемблером FORTH, а не просто жестко закодировать.
Такая базовая арифметика, как +, незаменима. Я определяю и +, и -, хотя, если бы я хотел вписать что-то более важное, я мог бы оставить только - и позже определить ?: negate 0 swap - ; и : + negate - ;.
Как и в любом низкоуровневом языке программирования, нам нужен способ подглядывать и пихать значения в память. Реализация ! особенно хороша, поскольку мы можем просто заглянуть прямо в [bx]:
defcode PEEK, "@" ; ( addr -- val )
mov bx, [bx]
defcode POKE, "!" ; ( val addr -- )
pop word [bx]
pop bx
Существуют также варианты, которые читают и записывают только один байт:
?defcode CPEEK, "c@" ; ( addr -- ch )
movzx bx , byte[ bx ]
defcode CPOKE, "c!" ; ( ch addr -- )
pop ax
mov [bx], al
pop bx
Нам определенно нужны слова для работы со стеком. dup
и drop
имеют очень простые реализации, а swap
определенно слишком полезен, чтобы его пропустить.
defcode DUP, "dup" ; ( a -- a a )
push bx
defcode DROP, "drop" ; ( a -- )
pop bx
defcode SWAP, "swap" ; ( a b -- b a )
pop ax
push bx
xchg ax , bx
Я решил также включить >r
и r>
, которые позволяют использовать стек возвратов в качестве второго стека для значений (но, очевидно, только в пределах одного слова). Это довольно мощный инструмент. Фактически, в сочетании с dup
, drop
и swap
они позволяют реализовать любое слово для манипуляции стеком, которое вы только можете себе представить.9
defcode TO_R, ">r"
xchg ax , bx
stosw
pop bx
defcode FROM_R, "r>"
dec di
dec di
push bx
mov bx , [ di ]
Наконец, emit
печатает символ. Это достаточно далеко от критического пути bootstrap, чтобы я мог спокойно удалить его в случае необходимости.
defcode EMIT, "emit"
xchg bx , ax
call PutChar
pop bx
Заключение
Я доволен тем, что получилось. Для системы, ограниченной загрузочным сектором, я могу назвать ее вполне законченной по функциям - я не могу придумать ничего, что могло бы значительно упростить бутстрап, занимая при этом достаточно мало байт, чтобы это казалось удаленно досягаемым для кодового гольфа. Это во многом благодаря помощи Ильи Курдюкова - без него я бы не смог вписать s:
.
Я нашел старый ПК, который могу использовать для своих экспериментов. На нем Miniforth загружается просто отлично:
Я буду документировать свое путешествие по созданию Miniforth в будущих записях этого блога. Если это похоже на вашу чашку чая (а если вы дочитали до этого места, то, вероятно, так оно и есть), подпишитесь на RSS-канал или следите за мной в Twitter, чтобы получать уведомления о новых статьях.
1
У теоретика графов было бы много сильных слов, чтобы описать это, а не только цикл.
2
И даже если бы это было не так, есть много, многопримеров кода x86, написанного с использованием печатаемого подмножества ASCII. Я даже сам однажды сделал этонесколько лет назад.
3 Если вы, дорогой читатель, считаете это неудовлетворительным, я хотел бы пригласить вас в собственное путешествие по бутстрапингу. Это действительно очень весело!
4
Точную экономию на данный момент трудно подсчитать, поскольку некоторые слова не используют добавляемый NEXT
.
5
Хотя в этом отрывке справки говорится только о pop ss, аналогичное утверждение содержится в документации по mov
.
6
Похоже, это одна из многих ошибок в SDM и использование pop esp
для этого не работает. Раздел 6.8.3 ("Маскировка исключений и прерываний при переключении стека") в томе 3A разъясняет, что все одноинструкционные способы загрузки SP
работают для этого. Я бы процитировал этот раздел, если бы не тот факт, что, хотя в нем перечислены многие другие типы событий, которые подавляются, в нем не упоминаются реальные прерывания как один из них. Однако в этом разделе упоминаются некоторые интересные крайние случаи. Например, если вы похожи на меня, вам может быть интересно, что произойдет, если много инструкций подряд записываются в SS
. Ответ заключается в том, что только первая из них гарантированно подавляет прерывания.
7
Полагаю, имеет смысл, что эта опция доступна только для imul
, так как по модульной арифметике единственная разница между знаковым и беззнаковым умножением заключается в верхней половине произведения, которую мы здесь отбрасываем. Иммедиаты могли бы быть полезны и для mul
, хотя...
8
Вы можете спросить, почему бы просто не объявить, что BASE
- это переменная размером в байт? Ответ заключается в том, что u.
, которое является словом, печатающим число, использует div word[BASE]
, так что результат все еще 16-битный.
9
Сюда не входят слова типа PICK
, для них нужны циклы. Однако все, что можно определить как ( <список имен> -- <список имен>)
, является честной игрой. Доказательство этого факта мы оставляем на усмотрение читателя. 10
10
Если серьезно, попробуйте разработать безошибочную стратегию превращения эффекта стека в последовательность слов, которые его реализуют. Это хорошая проблема.
Celsius
Красота! Но ничего революционного и экономия на спичках это скучно.
Я давно думаю, как сделать современную фортоподобную систему, которая будет лучше легаси-Форта, в то же время проще и лаконичней. Еще бы переносимость на любую платформу к этому делу присобачить и было б замечательно.
Один умелец реализовал эту идею в виде простой виртуальной машины, но его Форт огромен и за 20 лет разработки там скопилось порядочно мусора.
Beetle_ru
Думаю, что в случае форта нужно исходить из практической задачи, а не пытаться изобрести самый лаконичный, компактный или быстрый компилирующий интерпретатор. Форт предельно прост, гибок, компактен форт это стековая vm + командный интерфейс взаимодействия с пользователем, функциональность определяется набором слов из коробки. При разработке своего форта есть место распутья, можно предоставить набор слов для решения узкого круга задач, некий DSL или постараться сделать что-то универсальное и попытаться реализовать максимум ansi стандартных слов, чтобы затянуть готовые алгоритмы из других реализаций. А можно постараться придумать полностью свой универсальный набор слов как это сделал автор retro, но зачем не совсем понятно, этот путь тернист, нужно многое изобрести и понаступать на грабли. Все тоже самое можно реализовать и на стандартном форте, но для этого его хорошо бы изучить прежде чем начать делать свое решение.)
Кажется, что благодаря своей простоте форт мог бы стать отличным встраиваем скриптовым языком для конфигурации или написания логики в стиле DSL. Для популяризации такого использования не хватает методички по написанию своего форта на ЯП высокого уровня с нуля, чтобы каждый желающий мог бы за n шагов без долгого погружения получить язык готовый для использования, причем с нужным только ему функционалом. Мне для того, чтобы получить свой форт, который я мог бы использовать подходящим мне способом пришлось поковырять не мало литературы и исходников готовых проектов. В итоге имею решение, которое прикручиваю к проектам для конфигурации или для dsl. Но вот чтобы это решение двинуть в сторону стандарта приходится опять копать и переделывать, ломая совместимость со своими предыдущими решениями.
Так что я тоже вынашиваю идею, но не конкретной реализации форта, а пошаговой инструкции по написанию форта, желательно близкого к стандарту.