不是
一个
N和供电
设备
是一台图灵等效 16 位计算机,完全由网络上模拟的时钟和 NAND 门组成。 NAND具有自己的CPU、机器代码语言、汇编语言、汇编程序、虚拟机语言、虚拟机翻译器、编程语言、编译器、IDE和用户界面。 NAND 基于 Nand to Tetris 课程及其相关书籍中指定的 Jack-VM-Hack 平台。
一个简单的程序,输入一些数字并计算它们的平均值,展示控制流、算术运算、I/O 和动态内存分配。
程序输出:
How many numbers? 4
Enter a number: 100
Enter a number: 42
Enter a number: 400
Enter a number: 300
The average is 210
该程序由 Nand to Tetris 软件套件提供。
Pong 游戏,展示了该语言的面向对象模型。使用箭头键左右移动球拍来弹球。每次弹跳,球拍都会变小,当球击中屏幕底部时游戏结束。
该程序由 Nand to Tetris 软件套件提供。
2048 的游戏,展示了递归和复杂的应用逻辑。使用箭头键在 4x4 网格中移动数字。当相同的数字相互移动时,它们就会合并成它们的总和。一旦达到 2048 块,您就赢得了游戏,但您可以继续玩直到输掉。当棋盘已满并且您无法再进行任何操作时,您就输了游戏。
通过无限递归故意导致堆栈溢出以执行虚拟机逃逸的程序。它利用了没有运行时检查的事实来防止堆栈溢出。没有其他现代平台可以让你这样做:-)
运行时,程序会不断地将堆栈指针打印到屏幕上。一旦显示的值超过 2048,堆栈将到达其预期内存空间的末尾并溢出到堆内存空间,导致 print 语句以爆炸性方式发生故障:
有两件事值得指出。
如果您在充满零的空 RAM 上运行此程序(您可以通过用户界面清除 RAM),您会注意到程序在执行过程中自行重置,尽管没有按下“重置”按钮。发生这种情况的原因很简单:越狱的运行时执行一条指令,将程序计数器的值设置为 0,有效地告诉程序跳转到第一条指令并重新开始。
如果您运行 GeneticAlgorithm 示例程序,然后立即运行该程序,则该程序会在其狂暴状态下读取从未被覆盖的旧 RAM 内存。
该程序利用运行时无法阻止堆栈粉碎的事实来调用原本无法访问的函数。为了了解其工作原理,让我们看一下 NAND 堆栈帧布局的图示。
取自《Nand to Tetris》一书。
如果您不熟悉堆栈布局,这里是该漏洞利用背后的主要思想。每当函数返回时,它需要知道应该去哪里(哪个机器代码指令内存地址)来继续执行流程。因此,当第一次调用该函数时,该内存地址以及其他一些不重要的数据会临时存储在堆栈中称为堆栈帧的内存区域中,作为返回位置的引用。该图描述了该返回地址相对于函数调用的确切位置,该位置可以进行逆向工程。
该程序使用户能够将 RAM 中的单个内存地址覆盖为任何值。将两个和两个放在一起,如果用户用另一个函数的地址覆盖堆栈帧的返回地址,他们本质上就获得了执行程序中包含的任意代码的能力。
事实上,如果您输入 267 作为内存位置,输入 1715 作为要覆盖的值,通过手动检查堆栈内存空间和汇编器对两个数字进行逆向工程,您将在工作中看到这个想法。
这并不是 NAND 独有的漏洞。它也存在于 C 中!多酷啊!
不管你相信与否,在 NAND 的众多不同组件中,这个单枪匹马的开发时间是最长的!
该程序是一个利用简单机器学习的生物模拟。它遵循《Code Bullet》中的人工智能编码系列(第一部分和第二部分)。请务必查看他的频道,他制作了一些非常酷的东西!
简单解释一下:
每个点都有自己的加速度矢量“大脑”,它们通过自然选择进化以达到目标。每一代人,“死亡”更接近目标的点更有可能被选为下一代的父母。繁殖本质上会导致大脑的某些部分发生突变,完全有效地模拟自然进化。
尽管如此,仍有很多不足之处。由于性能原因,点用于进化的唯一因素是它们在死亡时与目标的接近程度,从而赋予自然选择算法低熵。由于记忆的使用,点的数量和大脑的大小都没有令人满意的限制。最后,由于技术的复杂性,在模拟过程中重新放置障碍物并不能保证这些点有足够大的大脑来达到目标。大脑的大小仅在程序开始时确定。
我利用了无数的优化技术来绕过以下硬件限制并使之成为可能:
为了避免拐弯抹角,我一直坚持在该程序的代码库中为感兴趣的人记录这些技术和其他见解。
在我们开始之前,在 Jack 中编写程序时要记住的最重要的细节是没有运算符优先级;这可能就是您的程序无法运行的原因。
例如,您应该更改:
4 * 2 + 3
至(4 * 2) + 3
if (~x & y)
到if ((~x) & y)
但您可以保持if (y & ~x)
相同,因为没有运算符歧义。
如果没有括号,不明确的表达式的评估值是undefined 。
NAND 拥有自己完整的技术堆栈。因此,NAND 只能用 Jack 进行编程,Jack 是它的弱类型面向对象编程语言。通俗地说,Jack就是C加上Java的语法。
让我们采用基于示例的学习方法并深入探讨。
/**
* This program prompts the user to enter a phrase
* and an energy level. Program output:
*
* Whats on your mind? Superman
* Whats your energy level? 3
* Superman!
* Superman!
* Superman!
*/
class Main {
function void main ( ) {
var String s ;
var int energy , i ;
let s = Keyboard . readLine ( "Whats on your mind? " ) ;
let energy = Keyboard . readInt ( "Whats your energy level? " ) ;
let i = 0 ;
let s = s . appendChar ( 33 ) ; // Appends the character '!'
while ( i < energy ) {
do Output . printString ( s ) ;
do Output . println ( ) ;
let i = i + 1 ;
}
}
}
取自 Nand 到 Tetris 讲座幻灯片。
如果您已经有一些编程经验,那么这看起来应该非常熟悉;很明显,Jack 深受 Java 的启发。 Main.main
是程序的入口点,演示了变量的基本用法以及控制流的 while 循环。
此外,它还使用Keyboard.readLine
和Keyboard.readInt
读取用户的输入,并使用Output.printString
和Output.println
将输出打印到屏幕上 - 所有这些都在 Jack OS Reference 中详细定义。默认情况下,Jack OS 在编译期间与您的程序捆绑在一起,以实现与字符串、内存、硬件等的接口。
每种编程语言都有一组固定的原始数据类型。 Jack 支持三种: int
、 char
和boolean
。您可以根据需要使用自己的抽象数据类型扩展此基本指令。有关面向对象编程的先验知识直接延续到本节。
/** Represents a point in 2D plane. */
class Point {
// The coordinates of the current point instance:
field int x , y ;
// The number of point objects constructed so far:
static int pointCount ;
/** Constructs a point and initializes
it with the given coordinates */
constructor Point new ( int ax , int ay ) {
let x = ax ;
let y = ay ;
let pointCount = pointCount + 1 ;
return this ;
}
/** Returns the x coordinate of the current point instance */
method int getx ( ) { return x ; }
/** Returns the y coordinate of the current point instance */
method int gety ( ) { return y ; }
/** Returns the number of Points constructed so far */
function int getPointCount ( ) {
return pointCount ;
}
/** Returns a point which is this
point plus the other point */
method Point plus ( Point other ) {
return Point . new ( x + other . getx ( ) ,
y + other . gety ( ) ) ;
}
/** Returns the Euclidean distance between the
current point instance and the other point */
method int distance ( Point other ) {
var int dx , dy ;
let dx = x - other . getx ( ) ;
let dy = y - other . gety ( ) ;
return Math . sqrt ( ( dx * dx ) + ( dy * dy ) ) ;
}
/** Prints the current point instance, as "(x, y)" */
method void print ( ) {
var String tmp ;
let tmp = "(" ;
do Output . printString ( tmp ) ;
do tmp . dispose ( ) ;
do Output . printInt ( x ) ;
let tmp = ", " ;
do Output . printString ( tmp ) ;
do tmp . dispose ( ) ;
do Output . printInt ( y ) ;
let tmp = ")" ;
do Output . printString ( tmp ) ;
do tmp . dispose ( ) ;
}
}
var Point p1 , p2 , p3 ;
let p1 = Point . new ( 1 , 2 ) ;
let p2 = Point . new ( 3 , 4 ) ;
let p3 = p1 . plus ( p2 ) ;
do p3 . print ( ) ; // prints (4, 6)
do Output . println ( ) ;
do Output . printInt ( p1 . distance ( p2 ) ) ; // prints 5
do Output . println ( ) ;
do Output . printInt ( getPointCount ( ) ) ; // prints 3
取自 Nand 到 Tetris 讲座幻灯片。
我们定义一个Point
类来表示空间中的抽象点。它使用field
变量来声明数据类型的每个实例属性。它公开了我们可以用来与点交互的公共method
函数,为调用者提供了将两个点相加并计算两点之间的距离的功能。
所有field
变量的作用域都是私有的。如果您希望从类声明之外获取或设置这些变量,这些变量必须具有相应的method
函数来提供此功能。
为了切中主题,从代码示例中省略了,数据类通常会在不再需要对象时定义dispose
方法以进行释放。请参阅手动内存管理。
如果需要,这里有function
和method
调用语法的参考。
class Foo {
...
method void f ( ) {
var Bar b ; // Declares a local variable of class type Bar
var int i ; // Declares a local variable of primitive type int
do g ( ) ; // Calls method g of the current class on the current object instance
// Note: Cannot be called from within a function (static method)
do Foo . p ( 3 ) ; // Calls function p of the current class;
// Note: A function call must be preceded by the class name
do Bar . h ( ) ; // Calls function h of class Bar
let b = Bar . r ( ) ; // Calls function or constructor r of class Bar
do b . q ( ) ; // Calls method q of class Bar on the b object
}
}
取自 Nand 到 Tetris 讲座幻灯片。
还记得我们说过 Jack 与 Java 类似吗?那只是一种表面现象,或者充其量是一种误导。虽然 Java 是强类型的,因此支持复杂的类型功能,例如向下转换、多态性和继承,但 Jack 不支持这些功能,并且只有一种类型:带符号的 16 位整数。这是 Jack 如此弱类型的主要原因。实际上,编译器不会关心您是否在赋值和操作中混合和匹配不同的类型。
var char c ;
var String s ;
let c = 65 ; // 'A'
// Equivalently
let s = "A" ;
let c = s . charAt ( 0 ) ;
var Array a ;
let a = 5000 ;
let a [ 100 ] = 77 ; // RAM[5100] = 77
var Array arr ;
var String helloWorld ;
let helloWorld = "Hello World!"
let arr = Array . new ( 4 ) ; // Arrays are not strictly typed
let arr [ 0 ] = 12 ;
let arr [ 1 ] = false ;
let arr [ 2 ] = Point . new ( 5 , 6 ) ;
let arr [ 3 ] = helloWorld ;
class Complex {
field int real ;
field int imaginary ;
...
}
. . .
var Complex c ;
var Array a ;
let a = Array . new ( 2 ) ;
let a [ 0 ] = 7 ;
let a [ 1 ] = 8 ;
let c = a ; // c == Complex(7, 8)
// Works because it matches the memory layout
// of the Complex type
所有代码段均取自 Nand 到 Tetris 讲座幻灯片。
不要误解这一点——Jack 仍然提供了一个强大且实用的面向对象模型。此见解旨在帮助您了解应根据需要何时以及如何执行类型转换。
假设您是一个疯狂的猫爱好者,就像我一样!您想编写这个程序来展示您对猫的喜爱程度。
class Main {
function void main ( ) {
while ( true ) {
do Output . printString ( "Kittens are so adorable! " ) ;
}
}
}
您可能会惊讶地发现,几秒钟后,程序将因“ERR6”或堆溢出而崩溃!
Jack 是一种手动内存管理的编程语言。这意味着您必须保持警惕,正确地释放不再需要的内存,否则 Jack 操作系统会另有想法并导致内存泄漏。最佳实践建议是为每个类提供一个dispose
方法,该方法代表一个正确封装此释放的对象。因此,当不再需要对象时,您可以调用它们的dispose
方法以确保最终不会耗尽堆内存。
如果您使用其他手动内存管理语言(例如 C)进行编程,这应该看起来非常熟悉。一个关键的区别是 Jack OS 将数组和字符串存储在堆上而不是堆栈上,这暗示了程序因堆溢出而崩溃的原因。
让我们为我们的猫科动物狂热分子修复这个程序。
class Main {
function void main ( ) {
var String s ;
while ( true ) {
let s = "Kittens are so adorable! " ;
do Output . printString ( s ) ;
do s . dispose ( ) ;
}
}
}
或者,您可以只为字符串分配一次内存。
class Main {
function void main ( ) {
var String s ;
let s = "Kittens are so adorable! " ;
while ( true ) {
do Output . printString ( s ) ;
}
}
}
您会注意到,这些替代版本不仅打印字符串的速度更快,而且这次它们实际上会永远打印!万岁!
让我们快速了解一下String.dispose
以便您可以更好地了解如何编写自己的dispose
方法。
method void dispose ( ) {
do stringArray . dispose ( ) ;
do Memory . deAlloc ( this ) ;
}
stringArray
调用Array.dispose
method void dispose ( ) {
do Memory . deAlloc ( this ) ;
}
正确的dispose
方法必须首先对其字段变量适当地调用dispose
,然后以do Memory.deAlloc(this);
结束。释放对象实例本身。
鉴于 Jack 和 NAND 的原始性,语言中的枪炮是不可避免的。我整理了以下您应该注意的未定义行为的实例,按照(在我看来)最重要到最不重要的顺序排列。
我发现这个警告非常重要,因此我将其移到了本节的开头。
杰克的表情
a > b
a < b
看似简单。它们在数学上并不总是正确的,并且分别相当于 Java 表达式
( ( a - b ) & ( 1 << 15 ) ) == 0 && a != b
( ( a - b ) & ( 1 << 15 ) ) != 0
细微差别是怎么回事?虚拟机实现将a > b
转换为a - b > 0
。问题是: a - b
可能会溢出:(
20000 > -20000
计算结果是什么?虚拟机将其转换为20000 - (-20000) > 0
,其计算结果为-25336 > 0
。不幸的是,答案是false
。
但是, 20000 > -10000
计算结果为30000 > 0
或true
。
正如您可能已经想到的,如果a
和b
之间的绝对距离大于 32767,则a > b
和a < b
是错误的。否则,你就没事了。
这不是一个实现错误,而是 Nand 到 Tetris 本身的不一致。更多相关信息请点击这里。出于兼容性原因,此行为不会得到修复。
-32768 就是其中之一。它是唯一一个具有 -(-32768) = -32768 属性的数字,这是一个没有正对应*的单例。这可能会导致不合理和逻辑错误。
/**
* Program output:
* --.)*(
*/
class Main {
function void main ( ) {
// Note that -32768 must instead be written as ~32767
// because the CPU can't load a number that large
do Output . printInt ( ~ 32767 ) ;
}
}
Output.printInt
内部期望Math.abs
返回正数。 -32768 的情况并非如此,因此 Jack OS 出现故障。
您主要关心的应该是使用负运算符处理逻辑错误。作为程序员,如果您想保证负数的负数是正数,您有责任检查 -32768 的情况并采取适当的操作。
*这是正确的,因为 NAND 的 ALU 在内部将 Jack 表达式-x
处理为~(x - 1)
。让我们将x
设置为-32768
并逐步评估它。以下是计算的相应 16 位二进制补码二进制表示形式:
x
= 1000 0000 0000 0000
x - 1
= 0111 1111 1111 1111
~(x - 1)
= 1000 0000 0000 0000
= x
这是同一件事!这里发生了什么?因为 NAND 是 16 位机器,所以 -32768 是唯一一个数字,如果从中减一,就会得到翻转的位。换句话说, -32768 满足x - 1 = ~x
,将表达式简化为~(~x)
或只是x
。
这是不言自明的,所以这里有一个简短的演示。
/**
* Program output:
* I have 818 cookies.
*/
class Main {
function void main ( ) {
do Main . cookies ( ) ;
}
function void cookies ( int a ) {
do Output . printString ( "I have " ) ;
do Output . printInt ( a ) ;
do Output . printString ( " cookies." ) ;
}
}
另一方面,调用带有太多参数的函数是完全有效的。您可以使用arguments
关键字来索引额外的函数参数。请注意,没有参数计数指示符。
您可以利用Array
将变量转换为任何其他类型。调用类型转换变量上不存在的实例方法是未定义的行为;编译器不够智能,无法意识到您何时执行此操作。
/**
* Program output:
* 4
*/
class Main {
constructor Main new ( ) {
return this ;
}
function void main ( ) {
var Array a ;
var Main b ;
var String c ;
let a = Array . new ( 1 ) ;
let b = Main . new ( ) ;
let a [ 0 ] = b ;
let c = a [ 0 ] ;
// Invalidly calling `String.length` on an instance of `Main`.
do Output . printInt ( c . length ( ) ) ;
}
}
有关深入示例,请参阅溢出程序。
修改分别位于内存地址256
至2047
和1
至15
堆栈帧或内部寄存器可能会导致未定义的行为。如果不滥用Memory.poke
或负数组索引,这通常是不可能的。有关深入示例,请参阅 SecretPassword 程序。
NAND 为.jack
文件提供程序验证,但不为.vm
文件提供程序验证。这意味着 NAND 的虚拟机编译器让您可以自由地调用不存在的函数、引用未分配的变量或执行任何其他逻辑上无效的内存操作。在大多数此类未定义行为的情况下,虚拟机将逃脱并且屏幕根本不会显示任何内容。您有责任自行调试程序。
自 20 世纪 70 年代兴起以来,16 位计算在现代时代失宠是有充分理由的。与 32 位或 64 位计算相比,16 位计算提供的处理能力和内存容量有限,根本无法满足当代软件和应用程序的需求。
NAND 也不例外。
取自 Nand 到 Tetris 讲座幻灯片。
与 16 GiB MacBook 相比,NAND 仅占用 4 KiB 的 RAM,比率为0.00002% !尽管如此,它仍然足以让我们登上月球,所以谁说 NAND 也不能呢。
Jack OS 保留 14,336 个内存地址