Содержание
Подходы к симуляции
Для упрощения представления примем, что реальные логические (цифровые) схемы работают как потоки жидкости.
Жидкость "стекает" от высокого потенциала (depletion-mode транзисторы, питание) к низкому (земля). Вы часто можете видеть в моих стримах и на схемах то, что я обычно называю английским словом "flow". Просто таким образом я провожу анализ схем, проводя аналогию с течением жидкости
Транзисторы представляют собой "вентили", которые управляются путём подачи контрольного сигнала на затвор. При подаче 1 вентиль открывается, при подаче 0 - вентиль закрыт.
Кроме логических 0 и 1 в схеме могут также присутствовать такие значения как "x" и "z".
x - означает что мы не можем сказать какое состояние у выбранного элемента. Например после включения питания - мы не можем сказать какой заряд хранится на затворах у защелок. А вдруг питание было включено, а потом сразу резко выключено и снова включено? Тогда на затворе защелки может остаться слабый заряд, который будет "дотягивать" до логической 1.
z - означает обрыв. Представьте себе транзистор, слева у которого имеется какая-то управляющая схема, а справа он подсоединен к другому устройству, которым он управляет. Так вот если транзистор будет закрыт, то никакого тока в правой цепи не будет, то есть фактически схема будет как-будто разорвана.
Если на затворе транзистора x (то есть "не пойми чего"), то то же самое будет и на выходе. А если на затворе "z" (то есть затвор "оторван" от схемы), то на выходе тоже будет "z" (через такой транзистор ток не проходит).
Статическая симуляция
По условию задачи мы имеем процессор, а также 2 вспомогательных устройства - APU и PPU. При этом тактовый сигнал для этих устройств общий - CLK.
Если мы напишем на Си отдельно симуляцию всей логики 6502, отдельно для APU и PPU, то возникает вопрос: что выполнять в первую очередь? Хотя 6502 является black-box, однако он соединен с внешним миром шинами.
Простой пример: запись в регистр PPU. Звучит просто, но рассмотрим что происходит при этом:
- процессор (а точнее ядро процессора 6502 встроенного в APU) начинает выполнение инструкции Store. Инструкция декодируется и в итоге значение регистра подается на шину данных APU, которая соединена с шиной данных PPU. При этом на адресную шину выставляется адрес регистра PPU.
- Дополнительная логика на материнской плате NES определяет что выставленный адрес соответствует регистрам PPU ($2000 например). Логика включает PPU CS (chip select) (а точнее делает контакт #DBE = 0), также она подает сигнал R/W (WR=1) и выставляет контакты PPU RS0-RS2 (выбор регистра)
- В это время логика Register Select внутри PPU запускает процесс обновления затребованного регистра. При этом все внутренние схемы PPU мгновенно реагируют на изменение регистра и соответственно изменяется логика работы всего PPU.
Какие могут возникнуть тут проблемы? Главная проблема - это конечно же propagation delay. Или что было раньше - курица или яйцо. А ведь ситуации могут быть и похлеще, например спрайтовая DMA: когда внутренняя схема APU отключает ядро от шины и подвешивает процессор, при этом в PPU пересылается поток байт, для обновления спрайтовой памяти.
В общем случае проблема выглядит так (упрощенно):
- Схема A имеет выходы aaa и входы bbb.
- Схема B имеет выходы bbb и входы aaa. (то есть схемы выдают друг на друга управляющие сигналы)
- Что выполняется вначале - схема A или B?
Ведь при изменении aaa - схема B сразу изменится, соответственно сигналы bbb тоже поменяются и повлияют на работу A. Возникает долбаный круговорот.
То есть мы имеем более общий вариант, частным вариантом которого являются "транзисторные гонки" (конфликты шин). Только теперь в качестве устройств у нас не отдельные компоненты схемы, а сразу микросхемы целиком (в качестве конфликтующих девайсов).
Выход из этой ситуации заключается в следующем: девайсы симулируются по-очереди, в зависимости от их приоритета. Например при записи в регистр PPU вначале выполняется ядро 6502, а потом весь PPU. Вообще в APU главным является не ядро, а управляющая логика APU, которая решает что главнее в данный момент - внутренние устройства APU (звуковые каналы) или ядро 6502. Поэтому статический подход к симуляции полутакта CLK заключается в следующем:
- симулировать все внутренние схемы APU (делитель частоты, frame counter, выбор регистра и выдача его на шину данных, звуковые схемы)
- симулировать ядро 6502 (выполнить инструкцию, получить или выдать значение на шину данных)
- симулировать PPU
Но опять же остается проблема: что делать если мы уже симулировали всю схему (например 6502), но последующая симуляцию другой схемы поменяла входные данные. Например, чтение регистра PPU:
- мы уже выполнили инструкцию Load (при этом получили с шины данных какую-то фигню)
- настала очередь симуляции PPU, мы выдаем на шину данных верное значение регистра, но ведь симуляция 6502 уже прошла!
- жопа
Реактивное программирование
Все вы запомнили чувака http://www.youtube.com/watch?v=SyWFvn0I6m8 который выкупал про реактивное программирование
Я залез в вики и почитал немного про парадигмы программирования. Оказывается всё что я до этого делал (статический подход) - называется императивным программированием:
b = 10 a = b; b = 20;
То есть когда b меняется на 20, a остается таким же, каким после операции a = b;
А теперь взглянем на реактивный подход :
b = 10; a <=> b; b = 20;
В этом случае a получает реактивную связь с b, и при "реакции" b = 20, также изменяется и становится равным 20. Я пометил реактивное приравнивание как ⇔
А теперь вспомним наш пример с чтением регистра PPU и потоками жидкости. В момент чтения регистра на шине данных ничего не было (z), поэтому 6502 соединил свой внутренний регистр с "ничем". Однако после того как PPU выдал на шину данных значение регистра PPU - схемы 6502 мгновенно отреагировали и загрузили этот регистр. То есть схемы цифровой логики работают по принципам реактивного программирования. В данном случае наш пример в реактивной связке будет выглядеть так (пример инструкции LDA $2000) :
A <=> DATA_BUS; DATA_BUS = PPUREG[0];
Таким образом проблема "что выполнить раньше" снимается, причём глобально, вплоть до уровня симуляции отдельных транзисторов.
Стабилизация схемы
С помощью реактивного подхода можно поделить схему на компоненты, при этом обозначив их входы, выходы и двунаправленные соединения (шины).
После изменения тактового сигнала - схема запускается, в зависимости от входных сигналов изменяет своё внутреннее состояние и выдаёт наружу выходы. Далее эти выходы используются как входы для другой схемы по цепочке.
А теперь рассмотрим пример когда исходная система передает своё состояние на выход другой, а выход другой системы подается на вход первой. При этом обе системы формируют выходы на основе своих внутренних состояний. В качестве примера будет выступать обыкновенный регистр:
Логика работы следующая:
- Во время CLK = 0 вход регистра отсоединен и входная защелка рефрешится старым значением
- Во время CLK = 1 выход отрезается (транзистором /CLK), чтобы не конфликтовать, а новое значение подается на вход in, но только в том случае, если открыт транзистор ENABLE (разрешить запись)
- Выход будет инвертирован относительно входа (так как входная защелка организована в виде инвертора с плавающим затвором)
- Если CLK = 1, а ENABLE = 0, то на выход подается остаточный заряд с затвора защёлки (то есть старое значение)
Псевдокод в этом случае будет такой:
block reg : inputs : regnew, enable outputs : #out inouts : - regs : regvalue at (CLK == 1) { if (enable == 1) regvalue = regnew; #out = not (regvalue); } at (CLK == 0) { #out = not (regvalue); regvalue = not (#out); } end block some_circuit : outputs : regnew, enable bla bla bla do something. end
В этом случае все входы и выходы всех компонентов схемы являются реактивными связями (почему это не пишут в обучалках по Verilog - я хз, ведь именно с этого надо вести речь). Как будет синтезироваться netlist схемы нам не интересно (это вообще отдельная тема), но вот как теперь будет проходить симуляция: вполне понятно!
При симуляции происходит следующее:
- Выбираются все блоки at. Эти блоки являются реактивными по отношению к какому-либо сигналу. В данном случае реактивным сигналом будет являться сигнал CLK. Ядро симулятора постоянно следит за CLK, и как только он изменяет своё значение (с 0 на 1, или наоборот) - запускается соответствующий блок at. Блоки которые не имеют реактивной связи нас не интересуют (они как будто "заморожены").
- Последовательность симуляции выбранных at-блоков не имеет значения, поскольку все входы и выходы также реактивно связаны
- В нашем примере мы имеем непосредственно наш регистр (reg) и какую-то схему, которая выдает новое значение regnew и сигнал enable.
- Порядок расположения схем выбран не случайно (вначале reg, потом some_circuit), чтобы показать особенность реактивного "выполнения" блоков.
Начинаем исполнение:
- Первым на очереди стоит блок reg, его входы regnew и enable пока не определены (то есть равны "x")
- В этом случае выход блока #out будет просто инверсией текущего значения регистра not(regvalue).
- Это был первый прогон блока reg
- Теперь мы выполняем блок some_circuit и выдаем наружу сигналы regnew и enable. Ядро симулятора видит что эти сигналы реактивно связаны с входами блока reg, и (это ключевой момент) они изменились. А раз они изменились надо заново прогнать схему reg, чтобы "стабилизировать" её.
- Блок reg вызывается во второй раз, но уже на этот раз входы regnew и enable имеют определенные значения.
- Схема стабилизировалась, значит все блоки выполнились и мы ждём изменения сигнала CLK, чтобы начать всё заново, но логика работы блоков уже поменяется (так как будут загружены блоки at для CLK = 1).
А что будет если схема не сможет стабилизироваться? Что если regnew или enable будет всегда меняться и нам придётся выполнять блок reg снова и снова? Да ничего особенного Такая ситуация называется "race condition" и обычно не возникает. В частности в процессоре 6502 для стабилизации всех схем достаточно не более 10 итераций. Для того, чтобы наша схема не начала бесконечный цикл мы просто ставим таймаут на количество итераций и если их количество стало ну скажем - больше 100, то просто выводим ошибку "Race condition!" и до свидания
Ну короче вы поняли к чему я клоню Для симуляции всех микропроцессоров и выполнения нашей задачи проще будет не изобретать велосипед, а взять и написать реализацию на Verilog. Только не простую, а интерактивную.
Verilog
Из имеющихся открытых реализаций Verilog самый адекватный - это Icarus Verilog (http://iverilog.icarus.com). Работает он как консольное приложение и содержит в своём составе компилятор Verilog и симулятор.
Такой вариант нам не подходит. Всё таки у нас speed-application, причём интерактивное. То есть помимо собственно симулятора у нас есть псевдо-устройства (джойстики, телевизор, колонки), которые соединяются к контактам симулируемых микросхем. Джойстики соединены с IO-портами APU, а звук и видео мы генерируем на основании контактов APU SND1/SND2 и PPU VOUT (пока мы не хрена не генерируем).
То есть нам нужно сделать так, чтобы Verilog стал скриптовым языком, а псевдо-устройства стали встроенными функциями по типу $display. При этом не каким-то тормозным интерпретатором нетлиста, а самым настоящим рекомпилятором нетлиста в X86-код, чтобы максимально эффективно "выполнять" симулируемые блоки.
Такая реализация обеспечит нам серьезный задел на будущее:
- Симулировать можно любые системы и процессоры, просто перегнав их в Verilog
- Система встраиваемых функций позволяет реализовать самые любые псевдо-устройства
- Реализацию на Verilog можно будет воплотить в железном варианте
- HardWareMan будет тоже очень рад, потому что он получит готовую реализацию PPU на Verilog
Задача ясна и понятна:
- Написать лексический анализатор Verlog-синтаксиса
- Написать парсер синтаксиса в синтаксическое дерево
- Оптимизировать и преобразовать синтаксическое дерево в netlist (надо изучить какие бывают популярные форматы для netlist-ов)
- Написать ядро симулятора netlist, которое статически рекомпилирует его в X86-код и выполняет (тут надо продумать эффективную реактивную модель)
- Написать псевдо-устройства для NES (джойпад и телек)