Show HN: Programmierbarer Computer aus NAND-Gattern
(github.com/ArhanChaudhary)- NAND ist ein im Web emulierter turingäquivalenter 16-bit-Computer, der auf der didaktischen Prämisse basiert, nur aus clock und NAND gate zu bestehen, und vom CPU bis hin zu IDE und UI alles umfasst
- Der eigene Stack basiert auf der Jack-VM-Hack-Plattform und bietet zusammen CPU, machine code, assembly, assembler, VM-Sprache, VM translator, Jack-Sprache und compiler
- Zu den Beispielprogrammen gehören nicht nur Average, Pong und 2048, sondern auch eine VM-escape-Demo mit stack overflow und stack smashing sowie GeneticAlgorithm mit einfachem maschinellem Lernen
- Jack ist eine schwach typisierte objektorientierte Sprache mit einer Java-ähnlichen Syntax, stützt sich intern jedoch nur auf einen signed 16-bit integer und hat große Einschränkungen wie keine operator precedence, manuelle Speicherverwaltung und undefiniertes Verhalten
- Das Projekt bedeutet nicht, dass tatsächlich jede Berechnung auf physischen NAND gate ausgeführt wird, sondern ist eine Implementierung für Lehre und Theorie, die einen TypeScript compiler und VM translator, einen NAND-gate-Logiksimulator auf Rust-Basis sowie WebAssembly verwendet
Der vollständige Computer-Stack von NAND
- NAND wird als webbasierter 16-bit-Computer vorgestellt, dessen Name für Not A Nand-powered Device steht
- Er emuliert einen turingäquivalenten Computer aus clock und NAND gate und enthält die folgenden Bestandteile direkt selbst
- CPU
- machine code language
- assembly language
- assembler
- virtual machine language
- virtual machine translator
- programming language
- compiler
- IDE
- user interface
- Grundlage ist die Jack-VM-Hack-Plattform aus dem Nand to Tetris course und den dazugehörigen Büchern
- Ein NAND-Demovideo ist verfügbar
Beispielprogramme und Demos
-
Average
- Ein einfaches Programm zur Eingabe von Zahlen und zur Berechnung des Durchschnitts
- Zeigt control flow, arithmetische Operationen, I/O und dynamische Speicherzuweisung
- Ein Programm aus der Nand to Tetris software suite
-
Pong
- Ein Pong-Spiel, das ein objektorientiertes Modell demonstriert
- Mit den Pfeiltasten bewegt man das paddle nach links und rechts und hält den Ball im Spiel
- Bei jedem Abprallen des Balls wird das paddle kleiner; berührt der Ball den unteren Bildschirmrand, ist das Spiel vorbei
- Ein Programm aus der Nand to Tetris software suite
-
2048
- Ein 2048-Spiel, das recursion und komplexe application logic demonstriert
- Auf einem 4x4 grid werden Zahlen mit den Pfeiltasten bewegt; gleiche Zahlen werden zusammengeführt
- Beim Erreichen des 2048 tile gewinnt man, kann aber bis zur Niederlage weiterspielen
-
Overflow
- Führt absichtlich einen stack overflow durch unendliche Rekursion aus, um einen virtual machine escape zu erreichen
- Nutzt aus, dass die runtime keine Prüfung zur Verhinderung von stack overflow enthält
- Wenn der Wert des stack pointer 2048 überschreitet, läuft der stack aus dem vorgesehenen Speicherbereich in den heap memory space hinein
- Bei Ausführung auf leerem RAM kann das Programm zwischendurch zurückgesetzt werden, weil eine instruction den program counter auf 0 setzt
- Wird es direkt nach der Ausführung von GeneticAlgorithm gestartet, kann es vorherige, nicht überschriebene RAM-Inhalte auslesen
-
SecretPassword
- Nutzt aus, dass die runtime stack smashing nicht verhindert, um eine ursprünglich unzugängliche Funktion aufzurufen
- Der Benutzer kann einen memory address im RAM mit einem gewünschten Wert überschreiben
- Wenn die return address eines stack frame mit der Adresse einer anderen Funktion überschrieben wird, lässt sich beliebiger Code im Programm ausführen
- Beispielwerte sind memory location
267und overwrite value1743 - Die gleiche Art von Schwachstelle gibt es auch bei buffer overflow in C
-
GeneticAlgorithm
- Eine creature simulation mit einfachem maschinellem Lernen
- Jeder dot hat ein eigenes „brain“ aus acceleration vectors und entwickelt sich durch natürliche Selektion so weiter, dass er das goal erreicht
- Dots, die näher am goal sterben, werden mit höherer Wahrscheinlichkeit als parent der nächsten Generation ausgewählt
- Während der reproduction werden Teile des brain mutiert, um natürliche Evolution zu simulieren
- Ein Genetic-Algorithm-Demovideo ist verfügbar
Hardware-Beschränkungen, auf die GeneticAlgorithm stieß
- GeneticAlgorithm ist unter den verschiedenen Bestandteilen von NAND das einzelne Programm, dessen Entwicklung am längsten dauerte
- Aus Performance-Gründen ist der einzige Faktor, den die dots für ihre Evolution nutzen, wie nah sie beim Sterben am goal waren; der Entropiegehalt des Natural-Selection-Algorithmus ist niedrig
- Wegen des Speicherverbrauchs gibt es unbefriedigende Einschränkungen bei der Anzahl der dots und der Größe ihrer brain
- Aufgrund der technischen Komplexität ist auch dann nicht garantiert, dass die brain eines dot groß genug sind, um das goal zu erreichen, wenn Hindernisse während der simulation neu platziert werden
- Die brain size wird nur beim Start des Programms festgelegt
- Die Implementierung umgeht die Einschränkungen von NAND mit verschiedenen Optimierungen
- Der ROM instruction memory space ist begrenzt; bei zu viel Code lässt sich das Programm nicht compilieren
- Der finale GeneticAlgorithm nutzt 99,2 % des instruction memory space
- Der RAM memory space ist begrenzt, daher muss die heap memory usage optimiert werden
- Dass der Bildschirm zwischen den Generationen statisch gefüllt erscheint, liegt daran, dass der screen memory space als temporärer swap memory für die nächste Generation verwendet wird
- NAND hat keinen floating point type, und der darstellbare integer-Bereich reicht von -32768 bis 32767
- Die Präzision der fitness-Berechnung sinkt, und auch integer overflow muss berücksichtigt werden
- Zugehörige Optimierungen und weitere Einblicke sind in der GeneticAlgorithm codebase dokumentiert
Mit Jack NAND-Programme schreiben
- Als wichtigste Ursache dafür, dass Programme in Jack nicht funktionieren, wird das Fehlen einer Operatorpräzedenz hervorgehoben
4 * 2 + 3muss als(4 * 2) + 3geschrieben werdenif (~x & y)muss alsif ((~x) & y)geschrieben werden- Der Auswertungswert eines mehrdeutigen Ausdrucks ohne Klammern ist undefiniert
- Jack ist die weakly typed objektorientierte Programmiersprache von NAND
- Der Beschreibung nach kommt es „C mit Java-Syntax“ nahe
- Da NAND einen eigenen vollständigen Tech-Stack besitzt, kann ausschließlich in Jack dafür programmiert werden
- Das grundlegende Jack OS wird beim Kompilieren zusammen mit dem Programm gebündelt
- Es stellt Schnittstellen zu Strings, Speicher und Hardware bereit
- Es enthält Funktionen wie
Keyboard.readLine,Keyboard.readInt,Output.printStringundOutput.println
- Jack unterstützt die drei primitiven Typen
int,charundboolean- Über Klassen lassen sich abstrakte Datentypen definieren
field-Variablen deklarieren Attribute pro Instanzfield-Variablen haben privaten Scope, für externen Zugriff sind also Methoden nötig
functionundmethodunterscheiden sich in der Aufrufweise- Eine Methode des aktuellen Objekts kann innerhalb derselben Klasse als
do g();aufgerufen werden - Bei einem Function-Call muss der Klassenname vorangestellt werden
- Eine Objektmethode wird über das Objekt aufgerufen, etwa als
do b.q();
- Eine Methode des aktuellen Objekts kann innerhalb derselben Klasse als
Schwache Typisierung und Speicherverwaltung in Jack
- Anders als Java unterstützt Jack weder strong typing noch Downcasting, Polymorphismus oder Vererbung
- Intern gibt es tatsächlich nur einen einzigen Typ: signed 16-bit integer
- Dem Compiler ist es bei Zuweisungen und Operationen egal, ob Typen gemischt werden
- Weist man
charden Wert65zu, kann er äquivalent zu'A'behandelt werden - Wenn man einer
Array-Variablen5000zuweist unda[100] = 77ausführt, wird darausRAM[5100] = 77 - Array-Einträge können unterschiedliche Datentypen enthalten
- Wenn das Speicherlayout passt, kann ein
Arraywie eine Instanz einer anderen Klasse verwendet werden
- Jack ist eine Sprache mit manueller Speicherverwaltung
- Wird nicht mehr benötigter Speicher nicht freigegeben, entsteht ein Memory Leak
- Ein Heap Overflow erscheint als
ERR6 - Das Jack OS speichert Arrays und Strings nicht auf dem Stack, sondern auf dem Heap
- Für Klassen, die Objekte darstellen, gilt eine
dispose-Methode als Best Practice- Zuerst wird
disposederfield-Variablen aufgerufen - Zum Schluss wird mit
do Memory.deAlloc(this);die Objektinstanz selbst freigegeben
- Zuerst wird
- Eine Schleife, die wiederholt String-Literale erzeugt und ausgibt, kann einen Heap Overflow verursachen
- Entweder wird der String in jeder Iteration freigegeben
- Oder der String wird nur einmal alloziert und wiederverwendet, sodass er dauerhaft ausgegeben werden kann
Undefiniertes Verhalten und Vorsichtspunkte
-
Vergleichsoperatoren
a > bunda < bsind nicht immer mathematisch korrekt- Die VM-Implementierung wandelt
a > bina - b > 0um a - bkann überlaufen, sodass20000 > -20000zufalsewird- Wenn der absolute Abstand zwischen
aundbgrößer als 32767 ist, können>und<falsch sein - Dieses Verhalten wird aus Kompatibilitätsgründen mit Nand to Tetris nicht geändert
-
-32768
-32768ist der einzige Wert, für den-(-32768) = -32768gilt- Da es kein positives Gegenstück gibt, kann das zu Unsoundness und Logikfehlern führen
Output.printInterwartet intern, dassMath.abseine positive Zahl zurückgibt, was bei-32768nicht der Fall ist, sodass das Jack OS fehlerhaft arbeitet
-
Funktionsaufrufe mit zu wenigen Argumenten
- Wird eine Funktion mit Parametern ohne Argumente aufgerufen, kann undefiniertes Verhalten auftreten
- Umgekehrt sind Funktionsaufrufe mit zu vielen Argumenten gültig; zusätzliche Argumente können über das Keyword
argumentsper Index angesprochen werden - Es gibt keinen Indikator für die Anzahl der Argumente
-
Ungeeignetes Type-Casting
- Mit
Arraylassen sich Variablen in einen anderen Typ casten - Wird auf einer gecasteten Variablen eine nicht vorhandene Instanzmethode aufgerufen, ist das undefiniertes Verhalten
- Der Compiler ist nicht intelligent genug, um das zu erkennen
- Mit
-
Änderung von Stack-Frames und internen Registern
- Werden Stack-Frames im Speicherbereich
256~2047oder interne Register1~15verändert, kann undefiniertes Verhalten auftreten - Im Allgemeinen soll das außer durch Missbrauch von
Memory.pokeoder negatives Array-Indexing schwer möglich sein
- Werden Stack-Frames im Speicherbereich
-
Laden benutzerdefinierter VM-Dateien
- NAND bietet Programmvalidierung für
.jack-Dateien, aber nicht für.vm-Dateien - In
.vm-Dateien lassen sich nicht existierende Funktionen aufrufen, nicht zugewiesene Variablen referenzieren und logisch ungültige Speicheroperationen ausführen - In den meisten Fällen kommt es zu einem Escape aus der Virtual Machine, und auf dem Bildschirm wird möglicherweise nichts angezeigt
- NAND bietet Programmvalidierung für
Hardware-Spezifikationen und Speicherbelegung
- Das RAM von NAND besteht aus 32.768 Wörtern, wobei jedes Wort eine 16-bit binary number enthält
- Die Hardware reserviert 8.192 Speicheradressen für den Bildschirm
- Jedes Bit jeder Adresse wird linear auf das entsprechende Pixel des 512x256-Bildschirms abgebildet
- Die Bit-Nummerierung erfolgt nach dem LSb-0-Schema
- Die Tastatur ist auf die Speicheradresse
24576gemappt- Die aktuell gedrückte Taste wird an dieser Position abgebildet
- Es wird empfohlen, Benutzereingaben nicht direkt über diese Adresse zu verarbeiten, sondern die
Keyboard-Klasse des Jack OS zu verwenden
- Die Tastatur erkennt ASCII-Zeichen und Sondertasten
- neue Zeile =
128 - Rücktaste =
129 - Pfeil links/oben/rechts/unten =
130~133 - Home/Ende/Bild auf/Bild ab/Einfg/Entf/ESC =
134~140 - F1~F12 =
141~152
- neue Zeile =
- Die Hardware reserviert 240 Speicheradressen für statische Klassenvariablen und 1.792 Speicheradressen für den globalen Stack
- Es heißt, dass diese Begrenzung in der Regel kein Problem darstellt, solange keine tiefe Rekursion ausgeführt wird
Eigene OS-Implementierung über Jack OS hinaus
- Standardmäßig wird Jack OS beim Kompilieren zusammen mit dem Programm gebündelt und stellt Strings, Speicher und Hardware-Interfaces bereit.
- Man kann eine eigene OS-Implementierung mit einem dedizierten Hardware-Interface bereitstellen.
- Die IDE behandelt Jack-OS-Dateien genauso wie normale Programmdateien.
- OS-Dateien können ebenfalls gelöscht oder überschrieben werden.
- Auch bei Verwendung eines eigenen OS gibt es Kernfunktionen, die für das Kompilieren zwingend implementiert werden müssen.
Sys.init: der tatsächliche Entry Point, der in der VM-Implementierung fest verdrahtet ist, nichtMain.mainMemory.alloc: der Heap-Speicher-Allocator, den Klassenkonstruktoren intern beim Erzeugen von Objekten verwendenString.newWithStr: der interne Konstruktor für String-LiteraleMath.multiply: wird intern anstelle des Jack-Ausdrucksx * yaufgerufenMath.divide: wird intern anstelle des Jack-Ausdrucksx / yaufgerufen
Sys.initdes mitgelieferten Jack OS initialisiert Speicher, Mathematik, Bildschirm und Ausgabe, ruft dannMain.main()auf und anschließendSys.halt().
Interne Funktionsweise von NAND
- Der NAND-Computer folgt einer Harvard-Architektur.
- ROM als Instruction Memory und RAM als Data Memory sind getrennt.
- Die CPU sorgt dafür, dass beide zusammenarbeiten.
- Die CPU ist eine Akkumulator-Maschine.
- Im Kontrollfluss stützt sie sich stark auf eingebaute Register.
- Der Akkumulator ist hier das Register
D.
- Der Befehlssatz der CPU hat nur zwei Opcodes.
- Der Befehlssatz ist relativ einfach, bietet aber einen großen Funktionsumfang.
- Die ALU ist über die Ausdrücke definiert, die in einem einzelnen Befehl berechnet werden können.
- Compiler und virtuelle Maschine sind keine NAND-spezifischen Konzepte und werden daher nur kurz behandelt.
- Einige ungewöhnliche Syntax-Features sind das Ergebnis davon, die Compiler-Implementierung zu vereinfachen.
- Der Compiler ist ein Recursive-Descent-Parser auf Basis einer LL(1)-Grammatik.
- Der Compiler erzeugt VM-Code, und die VM wird als einfache Stack-Maschine verwendet.
- Jede VM-Instruktion wird auf Assembler und Maschinencode abgebildet.
- Der Implementierungscode ist unter core und compiler implementation zu finden.
Kernpunkte der Jack-Sprache und der OS-Referenz
- Ein Jack-Programm besteht aus einer Sammlung von Klassen.
- Klassen werden in separaten Dateien definiert.
- Mindestens eine Klasse ist erforderlich, und eine davon muss
Mainsein. - Der Entry Point nach Maßgabe des Jack OS ist die Funktion
mainder KlasseMain.
- Eine Klasse kann Deklarationen für
field,static,constructor,methodundfunctionenthalten.- Die Reihenfolge von
field- undstatic-Deklarationen ist beliebig. - Auch die Reihenfolge der Subroutine-Deklarationen ist beliebig.
- Der Typ ist einer von
void,int,boolean,charoder ein Klassenname.
- Die Reihenfolge von
- Syntax-Eigenschaften
- Whitespace und Kommentare werden ignoriert.
&und|sind bitweise Operatoren und verwenden kein Short-Circuiting.true,falseundnullentsprechen jeweils-1,0und0.- String-Konstanten dürfen weder Zeilenumbrüche noch Anführungszeichen direkt enthalten, Escaping ist ebenfalls nicht möglich.
- Anführungszeichen und Zeilenumbrüche werden vom OS über
String.doubleQuote()undString.newLine()bereitgestellt. - Bezeichner sind case-sensitive.
- Wichtige Klassen des Jack OS
Array: Erzeugen und Freigeben von ArraysKeyboard: Tastenanschläge, Zeichen-, Zeilen- und Integer-EingabeMath: abs, multiply, divide, sqrt, max, minMemory:peek,poke,alloc,deAllocOutput: Ausgabe auf dem Textbildschirm und Cursor-BewegungScreen: Zeichnen von Pixeln, Linien, Rechtecken und KreisenString: Erzeugen und Freigeben von Strings, Zeichenzugriff, Anhängen und Integer-KonvertierungSys: halt, error, wait
- Ungültige Zustände zeigen einen Fehlercode im Format
"ERR[N]"an und beenden die Programmausführung.ERR3: Division durch nullERR6: Heap-ÜberlaufERR15,ERR16: String-Index außerhalb des gültigen BereichsERR17: String ist vollERR20: ungültige Cursor-Position
Kein Projekt, das tatsächlich nur aus NAND-Gattern besteht
- In den FAQ wird eingeräumt, dass die Beschreibung und der Titel „everything made from NAND gates“ irreführend sind, aber in guter Absicht formuliert wurden.
- Compiler und Übersetzer der virtuellen Maschine sind in TypeScript geschrieben.
- Der emulierte Kernel und die emulierte Hardware bilden die Funktionsweise eines echten Computers nicht eins zu eins ab.
- Der eigentliche NAND-Gatter-Logiksimulator ist in Rust geschrieben und macht nur einen kleinen Teil der gesamten Codebasis aus.
- Der Rust-Code wird zu WebAssembly kompiliert, damit er im Browser läuft.
- Dadurch entfällt faktisch die Annahme, dass alle Berechnungen auf NAND-Gattern ausgeführt werden.
- NAND dient als Bildungs- und Theorieprojekt.
- Theoretisch könnte dieselbe CPU-Logik auch in einer Real-World-Ausprägung der emulierten Hardware funktionieren.
- Als Beispiel für ein nand2tetris-basiertes FPGA-Hardwareprojekt wird https://gitlab.com/x653/nand2tetris-fpga/ genannt.
Implementierungsumfang und Grenzen der IDE
- NAND folgt der Spezifikation des Kurses Nand to Tetris und des dazugehörigen Buchs.
- Der Implementierer hat die Spezifikationen für CPU, Assembler, Übersetzer der virtuellen Maschine und Compiler selbst umgesetzt und beim Portieren der Plattform ins Web eine eigene IDE und UI ergänzt.
- Dass Jack Typen explizit angeben muss, liegt daran, dass der Compiler erkennen muss, zu welcher Klasse eine Instanzmethode gehört.
s.appendChar(33)für ein als TypStringdeklariertesswird beim Kompilieren inString.appendChar(s, 33)umgewandelt.
- Die IDE opfert zugunsten einer einfachen Implementierung die User Experience.
- Für Syntax Highlighting werden
contenteditableund Logik zur Cursor-Positionierung verwendet. - Dadurch ist sie langsam, auffällig fehlerhaft und gängige Tastenkombinationen funktionieren nicht.
- Für Syntax Highlighting werden
- Um Code zu kompilieren und auszuführen, muss man auf „Start“ klicken.
- Das OS benötigt normalerweise etwas weniger als 1 Sekunde für Speicherinitialisierung und Service-Setup.
1 Kommentare
Hacker-News-Kommentare
Ein großartiges Nebenprojekt, und auch das README ist wirklich gut. Nachdem ich ein wenig mit Ben Eaters 6502 Computer (https://eater.net/) herumgespielt hatte, dachte ich darüber nach, Nand to Tetris nachzuvollziehen.
Allein aus diesem Material könnte man mehrere Uni-Kurse machen. Sehr gut aufbereitet.
Wirklich gut gemacht. Damit hat man die Abstraktionsebenen, die die meisten Programmierer in ihrer gesamten Laufbahn nie zu sehen bekommen, direkt durchlaufen.
Großartige Arbeit. NAND to Tetris hat mir geholfen, nach dem Uni-Abschluss meinen ersten Job zu bekommen.
In den frühen 1990er-Jahren war ein ähnlicher Entwurf an der UC Berkeley eine zentrale Aufgabe in der Qualifikationsprüfung für Computerhardware.
Konkret ging es darum, von Grund auf nur mit NAND-Gates einen mikrocodebasierten, pipelined RISC-Prozessor zu entwerfen; man musste ihn nicht tatsächlich bauen, aber einen detaillierten Entwurf auf Papier einreichen.
Wirklich beeindruckende Arbeit. Als ich den Nand2Tetris-Kurs gemacht habe, wollte ich selbst eine ähnliche virtuelle Implementierung bauen.
Beeindruckend, dass das tatsächlich jemand umgesetzt hat; ich vermute, die Person versteht jetzt wirklich sehr gut, wie Computer funktionieren.
Umso erstaunlicher, dass jemand schon etwas umgesetzt hat, das um eine Größenordnung beeindruckender ist, als ich es mir vorgestellt hatte.
Hervorragende Arbeit. Ich habe kürzlich ebenfalls mit Nand2Tetris angefangen und möchte in den nächsten Monaten Teil 1, also den Hardware-Teil des Kurses, abschließen.
Meinen Fortschritt habe ich hier im Blog festgehalten: https://gurudas.dev/blog/2024/04/13/nand-to-tetris-2024-proj...
Gelogen. Es wurden doch NAND-Gates und ein Takt verwendet.
Tolle Arbeit. Ich habe es mir gebookmarkt, um später tiefer einzusteigen.
Ich mag NAND-to-Tetris, habe es aber nie bis zum Ende durchgezogen, daher freue ich mich darauf, mir dieses Projekt anzusehen.
Mich würde interessieren: Wie viele NAND-Gates sind es insgesamt?