После вынужденного перерыва снова возвращаюсь к своей архиувлекательной разработке. Оставил я её в каком-то промежуточном состоянии: например, минимальный набор команд реализован, возможность менять значения в памяти есть - а перехода на нужный адрес нет (хотя всё необходимое для реализации такой команды есть, саму команду я не запилил).
Непорядок.
В общем, я достаточно долго думал и прикидывал, что делать дальше, и как лучше это что делать. Пришёл к следующему выводу: нужно менять структуру команд, пилить уже условные переходы и циклы. После этого - уже всё, настоящий хардкор.
Что плохо в текущих командах? Они были взяты от балды, и шли не в логическом порядке: скажем за логическим XOR шло арифметическое SUM, потом опять логический SHL. Добавь я сейчас команду SUB (вычитание) - её пришлось бы помещать вновь за логической командой, а не рядом с SUM, где ей самое место. Нет, если пользоваться ассемблером - то пофиг, в каком порядке идут опкоды, но при трейсе обработки команд с упорядоченными командами удобнее.
Дальше: команда NOP. Много ли можно придумать программ, где ничего не делающий операнд реально нужен? В крайнем случае для пропуска такта можно использовать MOV A,A.
Итого я пересмотрел набор команд, и выглядит теперь он так:
Описание команд переходов и работы со стеком чуть ниже.
Как видите - изменился и набор и порядок. При этом пока четыре опкода остаются свободными - я даже не знаю, какие команды можно ими задать. Возможно, при расширении архитектуры появятся какие-то команды (например опкод, выводящий "Hello, world!", таким образом, делающий возможным написание такой программы размером в один байт).
Думаю, с опкодами 0x1 - 0xA вопросов не возникло. Другое дело - 0x0, обозначающий различные переходы по памяти. Как одной командой можно закодировать их все?
Тут стоит вспомнить, как переходы реализованы в x86. Самый простой, безусловный переход вызывается так:
Соответственно, команда в памяти представлена двумя последовательностями бит: одна кодирует команду JMP, другая шифрует адрес. Просто и логично.
Условные переходы реализованы сложнее, и состоят из двух команд. Первая: CMP (compare) - она сравнивает два операнда (вычитая один из другого), в зависимости от результата сравнивания изменяя специальные флаги во флаговом регистре. А команда условного перехода (их больше десятка, и включены даже такие, как "перейти, если чётно") уже проверяет эти флаги и переходит (или не переходит) по указанному адресу.
Сложно? Не особенно, но, в общем, сложнее, чем могло быть. И хотя у такого решения наверняка есть свои плюсы, это совершенно не значит, что его необходимо копировать.
Тем более, что у нас не большой и жирный x86, у нас слабый и виртуальный meighty, и раздавать по опкоду на каждую команду перехода просто нельзя (да ещё опкод на команду сравнения). Выход достаточно очевиден: сравнивать значения, которые всегда находятся в одном и том же месте - и, применительно к процессору, такими "местами" могут стать только регистры. Также в определённом регистре можно сохранять адрес, на который необходим переход.
Допустим, такие регистры уже есть. Тогда любая команда условного или безусловного перехода не нуждается в параметрах, а это значит, что четыре младших бита из командного байта, которые использовались под обозначение типа операнда, можно отдать под команды! Так что в один опкод 0x0 может уместиться 16 команд перехода по памяти - чего более, чем достаточно, для реализации любых условий. Я реализовал следующие условия:
Само собой, перед сравнением и переходом нужно задать значения для сравнения и адрес для вероятного перехода, для чего нужно использовать команду MOV.
Аналогичный подход реализован и со стековыми командами. Их запланировано четыре: PUSH (поместить в стек), POP (извлечь из стека), PEEK (изменить значение на верхушке), POKE (удалить значение с верхушки). Параметр у всех стековых команд может быть только один, поэтому ничего не мешает отдать два бита на кодирование команд:
Но к стеку я вернусь ещё не скоро (его единственное предназначение - оптимизация рекурсивных вызовов, для полноценного вычислителя он не нужен), а для переходов нужны эти самые регистры.
Можно поступить так, как поступили разработчики Intel: дать каждому пользовательскому регистру своё дополнительное назначение (я без шпаргалки помню только AX - аккумулятор и CX - счётчик). Меня всегда это смущало: вот использую я регистр (который выступает в роли сверхбыстрой надоперативной памяти) для своих целей, и тут какая-нибудь команда ждёт, что я через этот регистр буду передавать ей параметр! В общем, мне всегда хотелось разделить регистры на чисто пользовательские (с которыми программист или компилятор ЯВУ может работать, как ему вздумается, и которые процессором не используются более никак), на служебные (с которыми можно работать, но которые могут выступать параметрами команд) и внутренние (которые используются процессором под свои нужды, и изменять значения которых не рекомендуется или вовсе нельзя, например, стековый указатель).
Собственно, ничего не мешает мне сделать именно так. На адресацию регистров выделен целых восемь бит, сейчас регистров восемь, и для их кодирования достаточно трёх бит. Добавлю ещё восемь служебных регистров S0-S7, которые будут кодироваться числами от 0x8 до 0xF - и никаких проблем. Три первых регистра щедро выделяю под операнды команд условных переходов: S0 пусть содержит первое сравниваемое значение, S2 - второе, S3 - адрес перехода.
Небольшое отступление:
Да, я прекрасно понимаю, что методика передачи параметров через регистр увеличивает размер программ, поскольку один условный переход может занимать 10 байт в худшем случае! Но на такие жертвы приходится идти по причинам, описанным выше. К тому же, 10 байт - это действительно худший случай; если какой-то регистр уже содержит нужное значение, размер команды уменьшится.
И вроде бы всё хорошо, но есть один маленький нюанс: все операции с памятью (чтение, запись, переход) сейчас возможны только по абсолютным адресам, заданным константой. О том, чтобы ссылаться на адреса переменными я попросту не подумал; к счастью не поздно исправить эту ошибку, убрав тип операнда "порт", и добавив тип операнда "адрес, указанный в регистре". Тип операнда "порт", если помните, был всунут для того, чтобы не пропадало значение, освободившееся после отказа от командной работы со стеком. Портов у нас нет, и отказаться от этого типа можно безболезненно (реализовать ввод-вывод в порты можно будет и отдельными командами). Так что теперь типы операндов выглядят так:
Как видите, я также передвинул тип операнда "адрес, заданный константой" - так попросту логичнее. Это изменение потребует переделки схем Operand selector, Data selector, и, возможно, других - но об этом позже.
До того, как воплощать новые команды и новые регистры в виртуальном кремнии, я доработал ассемблер - программы получаются совсем уж сложные, писать их в опкодах долго и муторно. Изменения и добавления следующие:
- Чуточку поменялся синтаксис: портов больше нет, и символ @ отдан под обозначение меток.
- Для команды SUM добавлен алиас ADD (т.е. можно писать и так и эдак).
- Добавились операнды новых регистров (S0-S7) и все перечисленные выше команды.
- Также, как понятно из вышенаписанного, добавились метки. Метки позволяют не считать размер команд и адресов перехода "на пальцах" - ассемблер делает это сам (для чего пришлось реализовать подобие линковщика). Метки регистронезависимы, т.е. LABEL, label, Label и LaBeL - это одна и та же метка. Как работают метки понятно из нижеприведённого исходника.
- Появилась поддержка комментариев (всё, что после ; считается комментарием).
- Ну и заодно ассемблер выводит портянку команд вместе с адресами и метками, а вывод бинарного представления команд я убрал (он нафиг не нужен).
Вот как будет выглядеть программа, реализующая перезапись самой себя степенями двойки от 2^0 до 2^7:
Клик для увеличения Я специально написал избыточный код, для того, чтобы было легче продемонстрировать следующую возможность ассемблера.
Заметно, что задавать условия переходов несколькими командами неудобно, к тому же такая запись ухудшает читаемость программ. Поэтому для условных и безусловных переходов сделан краткий вариант записи.
Для безусловного перехода, вместо
Ассемблер самостоятельно развернёт сокращённую команду, и конечный результат ассемблирования ничем не будет отличаться от варианта с полной записью. Таким образом, вышеприведённую программу можно сократить:
Клик для увеличения
Этот код тоже не является оптимальным - например, нет нужды каждый раз делать присвоение MOV S1,80h, да и цикл можно организовать удобнее. Но пока и этого достаточно.
Побаловаться ассемблером и поискать в нём ошибки можно всё там же. Следующую часть постараюсь сделать "железячной", рассказав о реализации логики, способной такие программы выполнять.
Продолжение следует.
Непорядок.
В общем, я достаточно долго думал и прикидывал, что делать дальше, и как лучше это что делать. Пришёл к следующему выводу: нужно менять структуру команд, пилить уже условные переходы и циклы. После этого - уже всё, настоящий хардкор.
Что плохо в текущих командах? Они были взяты от балды, и шли не в логическом порядке: скажем за логическим 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 | Переход, если первый больше или равен |
Аналогичный подход реализован и со стековыми командами. Их запланировано четыре: 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 байт - это действительно худший случай; если какой-то регистр уже содержит нужное значение, размер команды уменьшится.
И вроде бы всё хорошо, но есть один маленький нюанс: все операции с памятью (чтение, запись, переход) сейчас возможны только по абсолютным адресам, заданным константой. О том, чтобы ссылаться на адреса переменными я попросту не подумал; к счастью не поздно исправить эту ошибку, убрав тип операнда "порт", и добавив тип операнда "адрес, указанный в регистре". Тип операнда "порт", если помните, был всунут для того, чтобы не пропадало значение, освободившееся после отказа от командной работы со стеком. Портов у нас нет, и отказаться от этого типа можно безболезненно (реализовать ввод-вывод в порты можно будет и отдельными командами). Так что теперь типы операндов выглядят так:
bin | hex | type |
---|---|---|
00 | 0x0 | Константа |
01 | 0x1 | Регистр |
10 | 0x2 | Адрес в памяти, заданный константой |
11 | 0x3 | Адрес в памяти, заданный в регистре |
До того, как воплощать новые команды и новые регистры в виртуальном кремнии, я доработал ассемблер - программы получаются совсем уж сложные, писать их в опкодах долго и муторно. Изменения и добавления следующие:
- Чуточку поменялся синтаксис: портов больше нет, и символ @ отдан под обозначение меток.
- Для команды 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, да и цикл можно организовать удобнее. Но пока и этого достаточно.
Побаловаться ассемблером и поискать в нём ошибки можно всё там же. Следующую часть постараюсь сделать "железячной", рассказав о реализации логики, способной такие программы выполнять.
Продолжение следует.
Комментариев нет:
Отправить комментарий