不是
一個
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 的虛擬機器編譯器讓您可以自由地呼叫不存在的函數、引用未分配的變數或執行任何其他邏輯上無效的記憶體操作。在大多數此類未定義行為的情況下,虛擬機器將逃脫並且螢幕根本不會顯示任何內容。您有責任自行調試程式。
自 1970 年代興起以來,16 位元計算在現代時代失寵是有充分理由的。與 32 位元或 64 位元運算相比,16 位元運算提供的處理能力和記憶體容量有限,根本無法滿足當代軟體和應用程式的需求。
NAND 也不例外。
取自 Nand 到 Tetris 講座幻燈片。
與 16 GiB MacBook 相比,NAND 僅佔用 4 KiB 的 RAM,比率為0.00002% !儘管如此,它還是足以讓我們登上月球,所以誰說 NAND 也不能呢。
Jack OS 保留 14,336 個記憶體位址