Всем привет, всем крепких нервов, решительности, смелости, силы воли и упорства. Ощущение «что-то страшное грядёт» довлеет всем настолько, что любая креативность убивается на корню. Однако, наш рептильный мозг редко бывает прав. Давайте скажем кортизолу решительное «нет» и не будем самоубивать тот участок жизни, который у нас есть здесь и сейчас. Рептильный мозг не знает, что мы давно уже не в пустыне среди шушпанчиков и никакой потенциальной пользы «в случае чего» от тех решений, которые он навязывает, не будет — а будет один только вред.
Итак, встречайте: ядро микроконтроллера с шестибитными байтами. Глава первая: описание «на словах».
Это обычный «школьный процессор», на котором студентам показывают базовые принципы работы железа. Fetch, Sum, Jump… В принципе, это роднит его как с древнейшими процами, имевшими 8-16 команд, так и с современными, разной степени эзотеричности (вплоть до Single Instruction Set Computer, имеющий всего одну команду типа «инверсия указанного бита и затем безусловный переход на указанный адрес», ну или какую-то в том же духе). Но я решил вдруг, ХЗ с какого перепугу, придать ему практический смысл в нашем странном веке, когда даже в одноразовые вейпы лепят грошовые 32-битники, которые потом летят в помойку.
Дело в том, что проц, имеющий сложность уровня «за пригоршню КМОП-транзисторов», обладает одним свойством, которым эти девайсы обладать не могут ни с каким развитием технологий, потому что технологии уводят их всё дальше и дальше от обладания этим свойством: его можно реализовать зацело с устройством, которым он управляет. Да-да, на одном кристалле. Минус корпус, минус пайка, минус разводка и… минус питание.
Этот проц можно реализовать прямо в чипе силового драйвера, управляющего реле в стиральной машинке. И питать прямо от 12 вольт, а не от 3.3. Его мозги достаточно «кирпичные», чтобы их реализовать прямо на силовых ключах, дёргающих реле и тиристоры.
Этот проц можно реализовать прямо на TFT-ключах матрицы. Рынок «показометров» огромен, миллионы больших и маленьких экранов показывают исключительно номерки в электронной очереди, и в данном случае это могла бы сделать просто сама матрица. Минус мини-ПК, прикрученный сзади к монитору, и даже минус сам монитор вместе с его видеовходом, миландровскими и не очень миландровскими контроллерами, вход какого-нибудь I²C, проходящего через всё помещение — прямо на самой матрице. Возможно, правда, вместо флэша придётся сделать какое-нибудь подобие памяти на ферритовых кольцах (или стержнях) с соответствующего размера датчиками Холла (вместо разрушающего чтения, как в обычной ПФК). Ну, или ещё как-то выкрутиться. Я просто не в курсе, реально ли удержание зарядов флэшки в TFT-технологии, не обессудьте. Может, там проще всего вообще красным лазером писать прошивку, как сидюк, а потом стирать УФ-лазером, если нужно (поместить слой чего-нибудь эдакого, меняющего проводимость при нагреве навсегда, и разлагающегося обратно под УФ-ом).
Этот проц можно впихнуть даже в контроллер DC-DC преобразователя, чтобы он не только вовремя включал-выключал мосфет, шарашащий током по обмотке какой-нибудь «токовыжималки», но и отчитывался по системной шине о своей температуре, потреблённом среднем токе, погрешностях напряжений…
Этот проц можно реализовать в гараже на самодельном литографе «для поржать». Но это, правда, к практическому смыслу уже не относится.
Какие изменения я внёс в «учебно-эзотерический» проц, чтобы приблизить его к практическим нуждам?
1) Конечно, система команд должна быть больше заточена под работу в качестве ядра микроконтроллера. В первую очередь это касается команд для работы с портами.
2) Минимум транзисторов. Минимум и ещё меньше, чем минимум. Реализация на TFT или силовых ключах, прямо в кристалле драйвера реле (а то и драйвера шаговика!) — это вам не…
3) Возвращение к старым добрым истокам. Реальные машинные коды лежат только в масочном ПЗУ, на них реализован какой-нибудь токенизированный Бейсик (или любая другая ВМ, дело вкуса) — а на ней уже крутится пользовательский код из флэшки. Можно даже сказать, что у нас CISC с микрокодом в масочном ПЗУ! И эта CISC обладает очень компактным кодом, позволяя пользовательский код запихнуть хоть на ферритовые стержни с кирпич размером. А можно сказать, что у нас RISC, на которой реализована виртуальная машина. Я буду говорить то так, то так, в зависимости от того, на каких свойствах акцентируюсь.
4) Система команд должна быть такой, чтобы не разбухал ни микрокод (он же код ВМ), ни… см. п. 2. Поэтому я остановился на шестибитном байте: четыре — уже мало, приходится городить городушки в микрокоде (а он, хоть и требует всего один транзистор на два бита, всё-таки не бесплатный). Восемь — много, получаются или «дыры» в системе команд, а это, опять же, не только лишние транзисторы в железе, но и лишние транзисторы в матричном ПЗУ. Ну, и главное — основная задача показометров, стиральных машин и микроволновок обычно отсчитывать время. А 6 бит с их диапазоном значений 0..63 для подсчёта секунд и минут подходят даже лучше, чем 4 бита в своё время подходили для двоично-десятичной арифметики микрокалькуляторов. В итоге я впихнул в 6 бит ровное, аккуратное и осмысленное использование каждого возможного значения, да ещё и так, что не потребовалась огромная система декодирования команд.
Если кто-то вспомнил моё обещание «когда-нибудь» спроектировать 6-битный аналог 6502-го, то это вы правильно вспомнили. Но это не он. Этот процессор ещё проще. Если там речь шла о слегка модифицированной системе команд 6502-го, то здесь всё настолько «высушено», что в 6 бит я впихнул не только код операции (как в гипотетическом «6502-для-часов»), но и все возможные варианты операндов. И если там неизбежно должна существовать мета-команда fetch, да и вообще хранение состояния процессора между отдельными доступами к коду, то здесь я всё свёл к «прямоточной трубе»: после того, как на входах масочного ПЗУ устоялись биты адресов, через некоторое время на выходе устаканиваются корректные коды команды и операнда (три бита на команду, три — на операнд, как вы наверняка уже и сами догадались). Не менее комбинаторная логика этого «прямоточного проца» через некоторое время устаканивает на входах регистров-защёлок то, как ему на эту команду реагировать. По переднему фронту тактирования состояния защёлкиваются, в результате чего на выходах адресных линий начинают устаканиваться новые адреса, а соответствующие разновидности памяти начинают формировать нужные данные.
Чем это хорошо? Это реально самый-самый минимум транзисторов. Чем это плохо? Это снижает педагогическую ценность этого проца, потому что студентам надо показать работу команды Fetch (извлёк из памяти команду, распарсил, выполнил…) и хотя бы «игрушечный» конвейер (для одной, самой долгой, команды мы в одной половине проца делаем первую часть следующей команды, а в другой — доделываем вторую часть предыдущей команды). Иначе понимания работы «взрослых» процов добиться сложнее. Ну, и ещё это намного медленнее: все команды исполняются со скоростью (в моём случае) сложения с переносом. Она же задаёт тактовую частоту. Остальные команды сто лет ждут свой ненаглядный такт, когда всё уже давно устоялось на входах защёлок… Студентом я пытался разработать самотактирующийся проц, где прошедший через все перипетии (по длине соответствующие команде) фронт импульса защёлкивает регистры именно тогда, когда команда реально выполнилась.
Теперь давайте разберём архитектуру. Своему нано-камню я выдал:
RAM — 64 шестибитных байта. Статическая, сами понимаете. Спроектировать камень, который не борется с заморочками DRAM (precharge, подключение ОУ, сваливание крошечным зарядом их бистабильности в ту или иную сторону, работа сразу с целой строкой), а опирается на них, максимально использует это вот «доступ медленный, но зато сразу с огромной шириной машинного слова» — на это у меня до сих пор чешутся руки. Это будет, несомненно, application processor, потому что он прямо-таки создан складывать какие-нибудь полупрозрачные окошки в смартфонных интерфейсах по килобайту за один RAS (а машинный код вообще пишется с учётом расположения команды в строке, чтобы заложиться в нужных местах на RAS2CAS и спокойно делать CAS из какого-нибудь из большой кучи Row-регистров, они же — SRAM-кэши, не беспокоясь, там данные или ещё не там). Но это уже совсем другая история…
ROM — 64×64=4096 шестибитных байт. Масочное, полтранзистора на бит. С учётом того, что мы тут не лыком шиты и даже нативный код (ну или микрокод, вопрос философский) получили довольно компактный — в эту крошку можно затолкать очень вкусную ВМ!
PROM (флэшка, «магнитка», да хоть антифьюзы, прожигаемые пользователем навсегда, в стиральной машинке вообще редко надо прошивку менять) — то же самое. Но там уже 4096 команд ВМ, за каждой из которых может стоять целая «войнаимир»! Если флэшка, то, естественно, NOR, ибо нам нужна «прямоточная труба» (хотя, в принципе, и NAND как-то вроде можно присобачить, но я так глубоко не копал; может потребоваться выделить отдельный регистр под страницу, а дальше долбать каждую страницу как NOR, но я не уверен). Экономить на спичках тут уже не нужно — всё уже сэкономлено масочной ПЗУшкой, обеспечивающей три транзистора на команду микрокода.
PDIR, INP, OUTP. По сути, ещё две копии SRAM, но замапленные на порты, микроконтроллер же. Беззастенчиво сдул их с нашей любимой «атмеги»: биты, задающие направление, биты, читаемые со входа порта, биты, подаваемые на выход порта (если направление у нас «чтение», то задают они «подтяжку»). Внимательный читатель уже понял, что я, по сути, сделал просто «срам» размером 192 байта, часть которой замаплена в порты ввода-вывода (или, скажем, какого-нибудь встроенного АЦП или ЦАП). Причём в том случае, когда мы не используем все 64 порта (что составляет как бы не большинство реальных ситуаций), там действительно можно просто реализовать дополнительную память. Или использовать в этом качестве регистры портов, если они никуда не припаяны. Таким образом, разделение на память и порты весьма условное — у нас есть адресное пространство в 192 шестибитных байта, а мы в нём мапим на I/O тот участок, который левая задняя пятка захочет.
Регистры: A, B, L, H, P (да-да, один двухбайтный указатель и один однобайтный), и два напрямую недоступных: вшитый в A двухбитный регистр флагов и трёхбитный регистр полусегмента Seg. Ну, и регистр-счётчик адреса команды PC (program counter), но он больше счётчик, чем регистр.
Теперь, наконец-то, перейдём к сладкому, то есть к системе команд. Начнём с самой многострадальной: запись из ROM константы в регистр.
Сначала я честно пытался сделать команду, которая следующий байт приказывает интерпретировать как эту самую константу. Но для этого надо или делать «сшивалку шины данных», которая предоставляет ядру инфу о текущем байте и о следующем, причём работающую корректно независимо от того, чётный это адрес или нечётный (да-да, это не просто двухбайтная шина), или, что немногим проще, вгонять проц в «особое состояние», когда он ждёт следующего такта и интерпретирует соответственно следующий байт. Оценив потребное количество «мухов» (мультиплексоров) для того и для другого, я отказался от этой идеи и сделал команду совершенно другого типа:
000: SCN (Set Constant Nibble). Её операнд — не номер регистра, в который надо положить следующий байт, а как раз половинка этого байта. А вот куда именно их класть — решает отдельная парочка Т-триггеров, которые бдят за тем, в который раз вызывается эта команда.
Первые два вызова SCN — это задание адреса в P. Регистр P смещается на три бита вправо (да, это можно легально использовать для деления на 8, вызвав SCN 0 один раз), а старшие три бита заполняются аргументом из SCN. Два раза вызвали — прямой адрес записали (учитывая, что P нужен в первую очередь для работы со SRAM, это самый штатный способ задать адрес переменной в оперативке). И, что характерно, реализация нормальной команды с кучей мультиплексоров потребовала бы ровно те же два байта… первый был бы, конечно, команда «записать константу в P», а второй — константа. Ничего, собственно, и не потеряли (кроме возможности писать константу не в P).
Третий вызов перенаправляет константу в Seg. Поэтому он, собственно, и сделан трёхбитным. Как нетрудно догадаться, четвёртый вызов отправит P в младший байт PC, а Seg и новый аргумент — в старший (ну, а триггеры естественным образом обнулятся и дальше снова будет P). Таким образом, мы имеем на один безусловный переход по фиксированному адресу те же 4 байта, которые имели бы в случае реализации «в лоб» команд «записать константу в P», «записать константу в PC». И да, в этот момент становится понятно, что я исповедую Intel Byte Order (ибо «запятая под запятой»), поскольку сначала задаётся младший байт от младшего ниббла к старшему, а потом — старший в том же порядке (Seg считаем за три младших бита, а старшие берём напрямую из четвёртого вызова SCN). Лучшее, чего бы мы могли добиться путём зверского усложнения и ещё большего снижения тактовой частоты — это сделать для «записать константу в PC» отдельную цепь, читающую не один, а два байта подряд. Ну, и для «записать константу в HL» её использовать. Это свело бы четырёхбайтный jump (и ХЗ-сколько-но-явно-много-байтное задание адреса в HL) к трём байтам каждое. Но у нас масочное ПЗУ, нам не нужно так гробить тактовую и усложнять само ядро :)
Что же до остальных команд, то операнды у них такие: 0 — это A или P, зависит от команды. Некоторые не имеют смысла с аргументом A, некоторые — с P. 1, 2, 3 — это B, L и H соответственно. 4, 5, 6 — это SRAM, PDIR и INP/OUTP, то есть, по сути, наше едино-слитное адресное пространство в 192 байта оперативки, часть из которого замаплена на I/O. И, наконец, 7 — это наша read-only (для кода) PROM, в которой лежит пользовательский код (если у нас операция чтения) или старший байт PC, если у нас операция записи (младший при этом немедленно берётся из P). То есть у нас, по сути, запись с операндом 7 представляет собой переход на вычисленный адрес.
Промку мы в общем случае писать не можем, но ведь при помощи портов можно что угодно сделать — если так уж прямо нужно сделать бутлоадер, просто делаем вторую страницу масочного ПЗУ, кладём бутлоадер туда и цепляем как EPROM (не обязательно именно EEPROM или УФ-EPROM, прошу заметить! Флэшка, магнитные сердечники, любой EPROM в широком смысле термина), так и старший бит ПЗУ, к нужным портам. Стартуем, проверяем пин «полный ресет», если не прижат к земле — ставим старший бит ПЗУ и улетаем в ВМ до снятия питания. А если прижат — остаёмся в бутлоадере, который дёрганьем портов трёт EPROM и грузит туда новую прошивку, ну а потом ставим старший бит ПЗУ и опять-таки улетаем в ВМ. У нас ведь таки два бита на транзистор!
001: MOV ?, A. Аргументы (от 0 до 7): P, B, L, H, SRAM[P], PDIR[B], OUTP[B], PC (то есть jump A:P). Обратите внимание, что порты я адресую через B, а не P, хотя у меня есть сомнения в том, что это имеет смысл: адреса портов вычислять приходится крайне редко, и такое «распределение нагрузки» между регистрами может принести только вред. Очень вероятно, что я откажусь от этой затеи и оставлю всё единообразно через P, который удобно задаётся через SCN.
Мерзкое свойство SRAM в контексте этой команды состоит в том, что мы не можем каждый бит научить защёлкиваться по фронту сигнала. Мы должны подать адрес, данные, потом подать команду на запись, подождать, пока перещёлкнутся ячейки, снять команду на запись и только после этого можно позволить себе козявить уровни на линиях адреса и данных. Мы вернёмся к этому во второй статье «шестибитный процессор из одних картинок», где будет подробнее разобрана схемотехника. Не уверен, что опишу с должной подробностью, но снова упомянуть это свойство придётся.
010: Jcc A/B:P. Тут я некоторое время перетасовывал команды, чтобы они как можно проще декодировались (один бит опкода задавал некое фундаментальное свойство, общее для всех его команд). В результате условный переход скитался, скитался и стал в итоге 010-м опкодом. С операндами у него тоже всё просто — один бит (пусть будет старший) задаёт то, куда делаем переход (на A:P или на B:P, опять же, чтобы не заниматься постоянным копированием аккумулятора туда-сюда — у него и так обязанностей выше крыши), следующий — какой регистр является условием (zero или carry, которые изменяются только в случае, если старший бит опкода равен 1), ну и последний — делать переход по значению этого флага 0 или 1.
Следующие 5 команд сводятся к тому, что на вход АЛУ подаётся аккумулятор и выход мультиплексора, а из какой-то его точки другой мультиплексор берёт выходное значение и кладёт в аккумулятор же. Поэтому они начинаются с элементарного декодера команды, который выдаёт 1, если у опкода два младших бита и/или один старший равны единице. То есть 011, 100, 101, 110 и 111 через этот декодер включают вход аккумулятора на запись. Сами же сигналы «старший бит равен единице» и «оба младших равны единице» нам тоже очень пригодятся! Для простоты предположим, что там AND и OR, хотя на реальной схеме, конечно, будут NOR и NAND, а активным уровнем будет низкий — это по сути не меняет ничего, просто в реальности взаимно сократятся лишние инверсии на входе и выходе.
011: MOV A, ?. Аргументы: P, B, L, H, SRAM[P], PDIR[?], INP[?], NOR[H:L]. Специально пишу в этот раз «?», потому что насчёт регистра, адресующего номер порта, я пока так и не решился. Легко видеть следующее: эта группа команд позволяет брать второй аргумент для АЛУ прямо из флэшки с пользовательским кодом (по адресу в регистрах H:L), это рас. Старший бит опкода нулевой, поэтому флаги не модифицируются, это двас. Операнд 0 означает не аккумулятор (действительно, зачем перекладывать аккумулятор в аккумулятор?), а указатель, это трис. Тут мы просто берём результат прямо со входа второго аргумента АЛУ. Его можно загнать в любой режим, на результат это не влияет.
100: XOR A, ?. Аргументы: A, B, L, H, SRAM[P], PDIR[?], INP[?], NOR[H:L]. Да-да, наш любимый XOR A, A как средство быстрого обнуления аккумулятора — работает! :) Заодно обнуляет и оба флага.
АЛУ для выполнения этой команды находится в режиме запрета внутреннего переноса. Как мы увидим ниже, команды сгруппированы так, что для разрешения внутреннего переноса надо подать результат OR от двух младших бит опкода. Он должен быть строго заблокирован только у этой команды, строго включен только у команд ADD и ADC, и для MOV и AND не влияет на результат.
101: ADD A, ?. Аргумен��ы те же. У нас есть быстрое деление P на 8, а теперь есть A=A+A как быстрое удвоение A.
110: ADC A, ?. Аргументы те же, но на вход переноса АЛУ ещё и подаётся флаг переноса от прошлой операции.
111: AND A, ?. Вот уж многострадальная операция! Мало того, что она кончается аж на две единицы, поэтому разрешает в АЛУ внутренний перенос. Это не страшно — мы игнорируем результат и берём промежуточное значение, на которое это не влияет. А вот то, что у неё есть бессмысленный аргумент А…
Я перебрал разные варианты. Больше всего мне нравился вариант «NEG A» — сделать отдельную цепь, которая в случае этого аргумента инвертирует A и добавляет единичку, то есть меняет знак A в дополнительном коде, чрезвычайно удобно для вычитания, знаете ли. Но по мере ковыряния в глубинах АЛУ у меня получалось, что для этого потребуется отдельное маленькое второе АЛУ. Я пробовал переключить АЛУ принудительно в режим суммирования и подать на вход 000001, чтобы сделать команду инкремента A, но это тоже не выглядело очень легковесным решением (причём замедляло декодирование команд в самом «бутылочном горлышке» — в суммировании с переносом). Но тут я заметил, что она кончается на две единицы, как и команда MOV — а значит, мы можем использовать готовый мультиплексор, который переключает нулевой вход с A на P (который всё равно вставили туда ради MOV). Просто делать это не только для 011, но и для 111 (так будет даже ещё проще). Учитывая то, что P задаётся через SCN приятно и легко — мы получаем хороший быстрый способ проверки однобитных флагов, двумя командами задав значение P, одной командой сделав AND A, P и дальше выполнив обычный условный переход (ещё всего пять шестибитных байт)…
Но чу! Погружение в глубины оптимизации АЛУ показывает, что, возможно, команду AND не удастся реализовать с должным минимализмом! И тогда она станет командой NAND! И тогда там всё-таки будет NAND A, A, дающая простую и быструю инверсию, без необходимости куда-то грузить 111111 и делать об них XOR! Короче, продолжение следует (ЕБЖ), а пока давайте-ка подумаем насчёт защиты от копирастов. Как насчёт LGPL? Или она не покрывает HDL-решения?
