Об этом блоге

Об этом блоге

Добра тебе, читатель.
Это запасной аэродром Великого Позитроника.
А основная писанина — в тележеньке.

среда, 13 марта 2013 г.

Пилим восьмибитный процессор. Часть шестая: улучшение архитектуры и снова ассемблер.

После вынужденного перерыва снова возвращаюсь к своей архиувлекательной разработке. Оставил я её в каком-то промежуточном состоянии: например, минимальный набор команд реализован, возможность менять значения в памяти есть - а перехода на нужный адрес нет (хотя всё необходимое для реализации такой команды есть, саму команду я не запилил).
Непорядок.
В общем, я достаточно долго думал и прикидывал, что делать дальше, и как лучше это что делать. Пришёл к следующему выводу: нужно менять структуру команд, пилить уже условные переходы и циклы. После этого - уже всё, настоящий хардкор.
Что плохо в текущих командах? Они были взяты от балды, и шли не в логическом порядке: скажем за логическим XOR шло арифметическое SUM, потом опять логический SHL. Добавь я сейчас команду SUB (вычитание) - её пришлось бы помещать вновь за логической командой, а не рядом с SUM, где ей самое место. Нет, если пользоваться ассемблером - то пофиг, в каком порядке идут опкоды, но при трейсе обработки команд с упорядоченными командами удобнее.
Дальше: команда NOP. Много ли можно придумать программ, где ничего не делающий операнд реально нужен? В крайнем случае для пропуска такта можно использовать MOV A,A.
Итого я пересмотрел набор команд, и выглядит теперь он так:

ОпкодКомандаОписание
0x0 MEM Переходы по памяти
0x1 MOV Пересылка значения
0x2 XOR Логическое ИЛИ-НЕ
0x3 OR Логическое ИЛИ
0x4 AND Логическое И
0x5 SHL Левое смещение
0x6 SHR Правое смещение
0x7 SUM Арифметическое сложение
0x8 SUB Арифметическое вычитание
0x9 MUL Арифметическое умножение
0xA DIV Арифметическое деление
0xB STACK Работа со стеком
Описание команд переходов и работы со стеком чуть ниже.
Как видите - изменился и набор и порядок. При этом пока четыре опкода остаются свободными - я даже не знаю, какие команды можно ими задать. Возможно, при расширении архитектуры появятся какие-то команды (например опкод, выводящий "Hello, world!", таким образом, делающий возможным написание такой программы размером в один байт).
Думаю, с опкодами 0x1 - 0xA вопросов не возникло. Другое дело - 0x0, обозначающий различные переходы по памяти. Как одной командой можно закодировать их все?
Тут стоит вспомнить, как переходы реализованы в x86. Самый простой, безусловный переход вызывается так:
JMP @ADDRESS
где ADDRESS указывает на адрес в памяти, на который нужно совершить переход.
Соответственно, команда в памяти представлена двумя последовательностями бит: одна кодирует команду JMP, другая шифрует адрес. Просто и логично.

Условные переходы реализованы сложнее, и состоят из двух команд. Первая: CMP (compare) - она сравнивает два операнда (вычитая один из другого), в зависимости от результата сравнивания изменяя специальные флаги во флаговом регистре. А команда условного перехода (их больше десятка, и включены даже такие, как "перейти, если чётно") уже проверяет эти флаги и переходит (или не переходит) по указанному адресу.
Сложно? Не особенно, но, в общем, сложнее, чем могло быть. И хотя у такого решения наверняка есть свои плюсы, это совершенно не значит, что его необходимо копировать.
Тем более, что у нас не большой и жирный x86, у нас слабый и виртуальный meighty, и раздавать по опкоду на каждую команду перехода просто нельзя (да ещё опкод на команду сравнения). Выход достаточно очевиден: сравнивать значения, которые всегда находятся в одном и том же месте - и, применительно к процессору, такими "местами" могут стать только регистры. Также в определённом регистре можно сохранять адрес, на который необходим переход.
Допустим, такие регистры уже есть. Тогда любая команда условного или безусловного перехода не нуждается в параметрах, а это значит, что четыре младших бита из командного байта, которые использовались под обозначение типа операнда, можно отдать под команды! Так что в один опкод 0x0 может уместиться 16 команд перехода по памяти - чего более, чем достаточно, для реализации любых условий. Я реализовал следующие условия:
[7-4][3-0]КомандаОписание
0x0 0x0 JMP Безусловный переход
0x0 0x1 JE Переход при равенстве операндов
0x0 0x2 JNE Переход при неравенстве операндов
0x0 0x3 JL Переход, если первый меньше
0x0 0x4 JG Переход, если первый больше
0x0 0x5 JLE Переход, если первый меньше или равен
0x0 0x6 JGE Переход, если первый больше или равен
Само собой, перед сравнением и переходом нужно задать значения для сравнения и адрес для вероятного перехода, для чего нужно использовать команду MOV.
Аналогичный подход реализован и со стековыми командами. Их запланировано четыре: PUSH (поместить в стек), POP (извлечь из стека), PEEK (изменить значение на верхушке), POKE (удалить значение с верхушки). Параметр у всех стековых команд может быть только один, поэтому ничего не мешает отдать два бита на кодирование команд:
[7-4][3-2][1-0]КомандаОписание
0x0 0x0 {TYPE} PUSH Поместить на вершину стека
0x0 0x1 {TYPE} POP Взять с вершины стека
0x0 0x2 {TYPE} PEEK Изменить вершину стека
0x0 0x3 {TYPE} POKE Удалить вершину стека
Но к стеку я вернусь ещё не скоро (его единственное предназначение - оптимизация рекурсивных вызовов, для полноценного вычислителя он не нужен), а для переходов нужны эти самые регистры.
Можно поступить так, как поступили разработчики Intel: дать каждому пользовательскому регистру своё дополнительное назначение (я без шпаргалки помню только AX - аккумулятор и CX - счётчик). Меня всегда это смущало: вот использую я регистр (который выступает в роли сверхбыстрой надоперативной памяти) для своих целей, и тут какая-нибудь команда ждёт, что я через этот регистр буду передавать ей параметр! В общем, мне всегда хотелось разделить регистры на чисто пользовательские (с которыми программист или компилятор ЯВУ может работать, как ему вздумается, и которые процессором не используются более никак), на служебные (с которыми можно работать, но которые могут выступать параметрами команд) и внутренние (которые используются процессором под свои нужды, и изменять значения которых не рекомендуется или вовсе нельзя, например, стековый указатель).
Собственно, ничего не мешает мне сделать именно так. На адресацию регистров выделен целых восемь бит, сейчас регистров восемь, и для их кодирования достаточно трёх бит. Добавлю ещё восемь служебных регистров S0-S7, которые будут кодироваться числами от 0x8 до 0xF - и никаких проблем. Три первых регистра щедро выделяю под операнды команд условных переходов: S0 пусть содержит первое сравниваемое значение, S2 - второе, S3 - адрес перехода.

Небольшое отступление:
Да, я прекрасно понимаю, что методика передачи параметров через регистр увеличивает размер программ, поскольку один условный переход может занимать 10 байт в худшем случае! Но на такие жертвы приходится идти по причинам, описанным выше. К тому же, 10 байт - это действительно худший случай; если какой-то регистр уже содержит нужное значение, размер команды уменьшится.

И вроде бы всё хорошо, но есть один маленький нюанс: все операции с памятью (чтение, запись, переход) сейчас возможны только по абсолютным адресам, заданным константой. О том, чтобы ссылаться на адреса переменными я попросту не подумал; к счастью не поздно исправить эту ошибку, убрав тип операнда "порт", и добавив тип операнда "адрес, указанный в регистре". Тип операнда "порт", если помните, был всунут для того, чтобы не пропадало значение, освободившееся после отказа от командной работы со стеком. Портов у нас нет, и отказаться от этого типа можно безболезненно (реализовать ввод-вывод в порты можно будет и отдельными командами). Так что теперь типы операндов выглядят так:
binhextype
00 0x0 Константа
01 0x1 Регистр
10 0x2 Адрес в памяти, заданный константой
11 0x3 Адрес в памяти, заданный в регистре
Как видите, я также передвинул тип операнда "адрес, заданный константой" - так попросту логичнее. Это изменение потребует переделки схем Operand selector, Data selector, и, возможно, других - но об этом позже.

До того, как воплощать новые команды и новые регистры в виртуальном кремнии, я доработал ассемблер - программы получаются совсем уж сложные, писать их в опкодах долго и муторно. Изменения и добавления следующие:
- Чуточку поменялся синтаксис: портов больше нет, и символ @ отдан под обозначение меток.
- Для команды SUM добавлен алиас ADD (т.е. можно писать и так и эдак).
- Добавились операнды новых регистров (S0-S7) и все перечисленные выше команды.
- Также, как понятно из вышенаписанного, добавились метки. Метки позволяют не считать размер команд и адресов перехода "на пальцах" - ассемблер делает это сам (для чего пришлось реализовать подобие линковщика). Метки регистронезависимы, т.е. LABEL, label, Label и LaBeL - это одна и та же метка. Как работают метки понятно из нижеприведённого исходника.
- Появилась поддержка комментариев (всё, что после ; считается комментарием).
- Ну и заодно ассемблер выводит портянку команд вместе с адресами и метками, а вывод бинарного представления команд я убрал (он нафиг не нужен).
Вот как будет выглядеть программа, реализующая перезапись самой себя степенями двойки от 2^0 до 2^7:
START:   ;Метка начала выполнения программы
 MOV A,1h ;Нулевая степень двойки (и любого другого числа) - это 1.
 MOV B,0h ;В регистре B будет адрес ячейки памяти, в который нужно записать вычисленное значение
FOR:   ;Метка цикла
 MOV S1,80h ;Ограничение выполнения: седьмая степень двойки, больше целых степеней в восемь бит уже не уместится.
 MOV S2,A ;Записываем текущее значение в служебный регистр для сравнения
 MOV S3,@END ;Записываем в служебный регистр метку, на который нужно будет перейти по условию
 JE  ;Если S1=S2 то переход на метку END
 MOV [B],A ;Записать по адресу, содержащемуся в регистре B значение регистра A
 SHL A,1h ;Левое смещение двоичного числа на одну позицию - это умножение его на 2.
 SUM B,1h ;Увеличиваем значение регистра B
 MOV S3,@FOR ;Адрес метки
 JMP  ;На которую и совершается переход при очередной итерации
END:   ;Выход из цикла
 MOV [B],A ;Последняя запись в память, и конец программы.


Клик для увеличения
Я специально написал избыточный код, для того, чтобы было легче продемонстрировать следующую возможность ассемблера.
Заметно, что задавать условия переходов несколькими командами неудобно, к тому же такая запись ухудшает читаемость программ. Поэтому для условных и безусловных переходов сделан краткий вариант записи.
Для безусловного перехода, вместо
MOV S3, ADDR
JMP
можно писать
JMP ADDR
Для условных переходов, вместо
MOV S1,VALUE1
MOV S2,VALUE2
MOV S3,ADDR
CMD
можно писать
CMD VALUE1,VALUE2,ADDR

Ассемблер самостоятельно развернёт сокращённую команду, и конечный результат ассемблирования ничем не будет отличаться от варианта с полной записью. Таким образом, вышеприведённую программу можно сократить:

START:
 MOV A,1h
 MOV B,0h
FOR:
 JE 80h,A,@END
 MOV [B],A
 SHL A,1h
 SUM B,1h
 JMP @FOR
END:
 MOV [B],A


Клик для увеличения

Этот код тоже не является оптимальным - например, нет нужды каждый раз делать присвоение MOV S1,80h, да и цикл можно организовать удобнее. Но пока и этого достаточно.
Побаловаться ассемблером и поискать в нём ошибки можно всё там же. Следующую часть постараюсь сделать "железячной", рассказав о реализации логики, способной такие программы выполнять.
Продолжение следует.

Комментариев нет: