本教材的使用方式:本講義整合了第九週四份投影片 (9-1 Virtual machines and intermediate representations、 9-2 The Hack VM I: Structure, arithmetic and logic、 9-3 The Hack VM II: Branching and memory、 9-4 Implementing the Hack VM translator)的全部內容, 並補充了堆疊機 vs 暫存器機、JVM/WebAssembly、 VM 指令翻譯成組語的具體範例等延伸知識。 每章結尾附有「本章重點」整理;第 5 章為綜合練習題,附完整詳解。 上週我們寫了組譯器(組語 \(\to\) 機器碼); 本週往抽象階梯再爬一層:學習 Hack 的中介表示法 Hack VM, 並理解如何寫一個 VM 翻譯器把 VM 程式碼編譯成組語。

下一個目標

回想多數高階語言會先編譯到中介表示法(intermediate representation, IR),再進一步編譯到組語。 接下來兩週我們要學 Hack 的 IR——Hack VM—— 並寫一個 VM 翻譯器(VM translator)把 Hack VM 程式碼 編譯成組語。

為什麼要用 IR?

IR 讓編譯工作乾淨地切成三段:

階段 工作 相依性
前端(front end) 高階語言 \(\to\) IR(含最佳化) 不依賴架構、依賴語言
中端(middle end) IR 內部的最佳化 不依賴架構、不依賴語言
後端(back end) IR \(\to\) 組語(含最佳化) 依賴架構、不依賴語言

許多語言的編譯器可以(也確實)共用同一個 IR: 基於既有 IR 做新編譯器時只需要寫前端; ISA/微架構更新時只需要改後端—— 這些工作(大致上)只要做一次,而不是每個編譯器各做一次。

真實世界的 IR

  • LLVM:從零設計的通用開源 IR,力求適用於盡可能多的語言。 多數新編譯器都做成 LLVM 的前端。

  • GIMPLE:GCC(最重要)的 IR。GCC 在 C 與 C++ 效能上與 LLVM 系編譯器不相上下,且對老舊架構的支援明顯更強。

  • JVM:Java 的 IR。Java 完全不用編譯器後端, 而是跑一個 VM 直接執行 IR——保證了可攜性,但犧牲效能。

虛擬機(Virtual Machines)

回想 Hack 中,組語、ISA 與微架構的設計緊緊綁在一起: 決定支援哪些指令時,得在三者間妥協—— 寫組語時什麼有用、什麼塞得進 16 位元的字、什麼在微架構容易實作。

在 IR 中,我們切斷這些連結:把 IR 程式碼想成跑在一台 虛擬機上——一台不以實體硬體存在、 而是由實際存在的電腦模擬出來的電腦。 惱人的是,「VM」一詞既指這台「電腦」在實體硬體上跑的 具體實例,也指這台「電腦」的設計—— 你可能同時說「在 Windows 上同時跑兩個 Hack VM」 和「Hack VM 的指令集」。

另一種虛擬機

本課程關心的是 IR 的 VM(多半永遠不會做成真硬體)。 但真實架構的 VM 也很常用,用途例如: 執行不受信任的軟體(隔離真正的作業系統與硬體); 多台實體機器跑同步的虛擬伺服器副本以便故障轉移; 一台實體超級電腦跑多個 VM 做分散式高效能運算(AWS、BC4); 從外部分析運行中的作業系統(找安全漏洞); 跑其他架構的原生軟體(如行動裝置開發測試)。 (投影片還挖苦了某遊戲引擎的按安裝收費政策—— VM 可以自動大量安裝來灌爆帳單,這正是該政策荒謬之處。) 在編譯器情境以外聽到「虛擬機」,多半指的是這種!

Hack VM 的目標與非目標

  • 實作函式呼叫!(下週)

  • 正式的編譯期記憶體配置!(下週)

  • 多檔案編譯以支援函式庫。(下週)

  • 算術/邏輯運算式的乾淨程式碼,如 \((x \mathbin{\&} y) \mathbin{|} (x \mathbin{\&} z) + 5 > y + y - 42\)。

  • 比只有 \(D\) 暫存器更多的工作空間。

  • 比組語更精簡的語法。

  • 記憶體位址與實體記憶體脫鉤。

其中許多目標來自同一個源頭:把 Hack VM 建立在堆疊之上。

非目標(要到課程最後用高階語言 Jack 才實現): 人類友善的程式碼(變數名、for/while 迴圈——VM 程式碼 本來就該由編譯器產生);運算式的直接寫法; 具型別的變數(字串、陣列);抽象資料型別(C 的 struct); 執行期記憶體配置(C 的 malloc)。

本章重點

  • IR 把編譯切成前端(語言相依)/中端(皆不相依)/ 後端(架構相依);新語言只寫前端、新架構只改後端。

  • LLVM(通用)、GIMPLE(GCC)、JVM(直接執行 IR, 可攜但慢)。

  • VM = 被模擬的電腦;一詞兩義(設計 vs 執行實例)。

  • Hack VM 是堆疊機:函式呼叫、記憶體配置、 運算式編譯等目標都源自堆疊。

Hack VM I:結構、算術與邏輯

堆疊機與堆疊複習

Hack VM 是堆疊機的例子:由一個堆疊取代暫存器 進行算術/邏輯運算,記憶體只用於儲存。 堆疊支援的操作(COMSM1201): create() 建立新堆疊; push(x) 把 \(x\) 加到堆疊頂; pop() 移除最近加入的資料並回傳。 堆疊是 LIFO:「後進先出(Last In, First Out)」。

追蹤 push(2)、push(5)、pop()、push(4)、push(42)、pop():

(JVM 也是堆疊機,所以這樣做有充分的現實理由!)

延伸補充:堆疊機 vs 暫存器機。 堆疊機的優點:程式碼更小(運算元隱含在順序中,不用編碼 暫存器編號)、編譯器容易產生程式碼(見 2.6 的 RPN)、 驗證簡單。缺點:同一計算需要更多條指令 (值要先搬上堆疊),直譯執行時較慢——研究顯示暫存器式 VM 平均少執行約 47% 的指令。 現實中兩者並存:JVM 與 WebAssembly 是堆疊機 (Wasm 另配有類似暫存器的 locals)、Lua 與 Android 曾用的 Dalvik 是暫存器機;高效能執行環境常在內部把堆疊碼 轉成暫存器形式再跑。

虛擬記憶體

在組語中我們用實體記憶體——每個位址就是送往 實體晶片上實體閂鎖的邏輯訊號(ROM 或 RAM)。 IR 應該可攜,所以改用虛擬記憶體: 行為像實體記憶體(每個位址存一個字), 但不管每個位址實際存放在哪裡。 (ISA 相依的)VM 翻譯器會在編譯時把虛擬位址映射到實體位址。

延伸(投影片註腳):現代電腦中虛擬記憶體還有第二個 更重要的角色:每個行程的組語程式碼都假設自己是唯一在跑的行程、 能存取任何位址——這其實就是虛擬記憶體。作業系統用專用機器碼指令 維護分頁表(page table)把每個行程的虛擬位址空間映射回 實體記憶體;甚至可能不映射到 RAM—— 少用的資料會被換出到分頁檔(page file)。

Hack VM 有 8 個獨立的虛擬記憶體庫, VM 翻譯器會把它們映射到底層 RAM 的不同段(segment, 連續區塊)。本章先看兩個:

  • local:區域變數的通用儲存空間;

  • constant:在每個 15 位元位址 \(i\) 存放常數 \(i\)。 這塊「記憶體」唯讀,不對應任何實體 ROM 或 RAM ——它是把常數帶上堆疊的語法手段。

語法:push 與 pop

堆疊只有一個,所以不必 create()。

  • push [memory] [address]: 把該虛擬記憶體位址的值推上堆疊;

  • pop [memory] [address]: 彈出堆疊頂的值並存入該位址。 (單獨的 pop 不是合法語法。)

push 與 pop 是唯一的記憶體管理手段。 例如把 local 4 複製到 local 250: push local 4 然後 pop local 250。

投影片的完整追蹤範例(local 33..37 初值 451、1138、0、0、13):

指令 堆疊(左=底) local 的變化
push local 34 1138 —
push local 36 1138, 0 —
push local 34 1138, 0, 1138 —
push constant 255 1138, 0, 1138, 255 —
pop local 35 1138, 0, 1138 local 35 \(\leftarrow\) 255
pop local 33 1138, 0 local 33 \(\leftarrow\) 1138

堆疊運算

所有算術運算都經由堆疊進行。例如 add (無引數):彈出堆疊頂兩個值、相加、把結果推回堆疊。 其他運算的做法與語法完全相同。 不能直接對 local 裡的兩個值相加—— 必須先推上堆疊。

邏輯比較同理:eq 彈出兩個值、判斷是否相等、把結果推回。

指令 彈出 計算 說明
add 2 個值 \(x + y\) 整數加法
sub 2 個值 \(x - y\) 整數減法
neg 1 個值 \(-y\) 算術取負
and 2 個值 \(x \mathbin{\&} y\) 逐位元 AND
or 2 個值 \(x \mathbin{|} y\) 逐位元 OR
not 1 個值 \(\lnot y\) 逐位元 NOT
eq 2 個值 \(x == y\) 測試相等
gt 2 個值 \(x > y\) 測試大於
lt 2 個值 \(x < y\) 測試小於
  • 彈兩個值的運算中,\(y\) 是先彈出的值、\(x\) 是第二個。 例:push constant 3、push constant 1、 sub 結束時堆疊頂是 \(2\) 而不是 \(-2\)。

  • 所有算術用 2 的補數,例如 \(-x = \lnot x + 1\)。

  • 所有邏輯把 true 寫成 0xFFFF(65535)、 false 寫成 0x0000—— 這使得逐位元運算同時可當邏輯運算用!

完整範例:複合邏輯測試

設 local 3 \(= 64\)、local 5 \(= 256\)。 要測試 \((\texttt{local 3} > 42)\) and \((\texttt{local 3} < \texttt{local 5} - 100)\): 分別測試兩個條件、再 and 起來 (記住 and/or/not 既是逐位元也是邏輯運算!)。

push local 3      // 64
push constant 42
gt                // 64>42: true
push local 3      // 64
push local 5      // 256
push constant 100
sub               // 256-100=156
lt                // 64<156: true
and               // true

逐步的堆疊內容(左=底):

指令後 堆疊
push local 3 64
push constant 42 64, 42
gt 65535
push local 3 65535, 64
push local 5 65535, 64, 256
push constant 100 65535, 64, 256, 100
sub 65535, 64, 156
lt 65535, 65535
and 65535

注意「先算 \(\texttt{local 5} - 100\) 留在堆疊上、再 lt」 的模式:子運算式的結果自然地留在堆疊頂, 等著被外層運算取用——這正是堆疊機讓運算式編譯變乾淨的原因。

延伸補充:逆波蘭記法(RPN)。 本週 workshop 會示範如何用樹與文法把一般算術運算式轉成 「堆疊順序」,即逆波蘭記法(Reverse Polish Notation) (非考試範圍)。核心想法:對運算式的語法樹做後序走訪 (先左子樹、再右子樹、最後根節點),輸出的指令序列就是 VM 碼。 例如 \((3 + 4) \times 5\) 的後序是 \(3\ 4\ +\ 5\ \times\), 對應 push 3、push 4、add、push 5、mult。 第 8 章的解析器輸出 CST,本章的堆疊機吃 RPN—— 編譯器前端與後端就此接上。

本章重點

  • Hack VM 是堆疊機:運算全走堆疊、記憶體只做儲存;LIFO。

  • 8 個虛擬記憶體段;constant 唯讀、不佔實體記憶體。

  • push/pop [memory] [address] 是唯一的 記憶體操作;單獨 pop 不合法。

  • 二元運算:\(y\) = 先彈出、\(x\) = 後彈出,算 \(x \circ y\)。

  • true = 0xFFFF、false = 0x0000 \(\Rightarrow\) 位元運算兼作邏輯運算。

  • 運算式編譯 = 語法樹的後序走訪(RPN)。

Hack VM II:分支與記憶體

標籤與 goto

Hack VM 怎麼處理迴圈與條件?跟組語一樣——用跳躍。 現在改叫 goto,因為它們不再只對應單一一條機器碼指令。

  • label LABEL_NAME:在程式碼該處宣告標籤;

  • goto LABEL_NAME:從程式任何地方跳到該標籤 (嚴格說 goto 與 label 必須在同一個函式內——見下週);

  • if-goto LABEL_NAME:彈出堆疊頂, 若結果非零(即不是 false)則執行 goto。

if-goto 的用法對應組語的 D;JNE,差別是: 比較對象是堆疊頂而不是 \(D\); 且語言內建了真正的邏輯運算子 gt、eq、lt、and、or、 not 來取代各種跳躍條件—— 不再需要七種 jump,一種 if-goto 搭配比較運算就夠。

pointer 與 this:執行期定址

用 local 只能存取執行前就知道的位址, 這會造成問題:假設記憶體裡存了一個陣列,想取它的第 local 0 個元素——但 push local (local 0) 不是合法 VM 碼!

兩個特殊記憶體段。this 到實體 RAM 的映射 不在編譯期固定,而是執行期決定。保證: \[\texttt{this } i \;\text{映射到}\; RAM[(\texttt{pointer 0}) + i] \quad\text{對所有 } i.\] 改寫 pointer 0,就整段搬動了 this 的落點——這是 VM 層的「指標解參考」。

仍需事先決定陣列放在哪段實體記憶體(跟組語一樣)。 假設決定放在 \(RAM[\texttt{0x0800}]\)–\(RAM[\texttt{0x08FF}]\), 要取第 local 0 個元素(從 0 數起):

push constant 2048  // 0x0800
push local 0
add                 // 堆疊:(local 0) + 0x0800
pop pointer 0       // this i 現在映射到 (local 0)+0x0800+i
push this 0         // 陣列第 (local 0) 個元素上堆疊

當然,若 local 0 \(\geq 256\) 就出界了! 高階語言將自動處理這種記憶體配置,但現在得手動來。 (投影片原話:Life is suffering.)

I/O 與 that

I/O 可以用同一招存取螢幕 \(RAM[\texttt{0x4000}\)–\(\texttt{0x5FFF}]\) 與鍵盤 \(RAM[\texttt{0x6000}]\)……對吧? 可以,但惱人的是不能用 this。 之後會看到 this 在編譯抽象資料型別(如 C 的 struct)時 有特殊角色,所以不能映射到任意記憶體段(第 11 週就懂了)。 目前只需知道:

  • this 只能存取 \(RAM[\texttt{0x0800}\)–\(\texttt{0x3FFF}]\);

  • 這範圍之外(例如螢幕、鍵盤)必須用 that。 that 的行為幾乎與 this 相同,僅有兩點不同: 映射基準是 pointer 1(而非 pointer 0); 可存取任何實體 RAM 位址。

八個虛擬記憶體段

已見過 local、constant、this、 that、pointer。剩下三個:

  • argument:函式呼叫開始時持有該次呼叫的引數, 不可寫入。(詳見下週。)

  • static:內容跨函式呼叫持續存在 (之後用於高階語言的 static 與全域變數)。

  • temp:行為與 local 完全相同但小得多, 作為高階語言編譯器編譯單一指令時的「工作空間」, 不用打擾 local 的內容。

(對照:local 在函式呼叫開始時是空的, 函式返回時內容被丟棄。)

投影片同時把 fill.asm(按鍵時全螢幕變白、 否則全黑)改寫成 fill.vm 做對照—— 用 that 掃描螢幕記憶體、用 if-goto 做迴圈, 行數遠少於組語版。

本章重點

  • label/goto/if-goto(彈堆疊、 非零則跳)取代組語的七種 jump。

  • this \(i \mapsto RAM[\texttt{pointer 0} + i]\): 執行期定址,陣列的基礎;pop pointer 0 = 搬動整段。

  • this 限 0x0800–0x3FFF; 螢幕鍵盤等其他位址用 that(基準 pointer 1)。

  • 八段:local(呼叫時清空)、argument (持引數、唯讀)、static(跨呼叫持續)、 temp(小型工作區)、constant(唯讀常數)、 this/that/pointer。

實作 Hack VM 翻譯器

Hack VM 的權杖

類別 內容
關鍵字 push、pop; add、sub、neg、and、or、 not、eq、gt、lt; local、constant、this、that、 pointer、argument、static、temp; label、goto、if-goto; function、call、return(下週!)
整數字面值 十進位 \(0 \ldots 32767\)
識別字 無空白、非關鍵字、字母開頭
換行

沒有新東西——你已經知道詞法器怎麼運作, 所以這部分作業已經幫你寫好了。 組譯器在詞法階段建了標籤符號表;這裡不需要 (除非想做好錯誤處理,而我們不想)。為什麼? 因為 VM 的 label X 可以直接翻譯成組語的 (X)、goto X 翻成 @X + 0;JMP ——把「解析標籤位址」的工作下放給組譯器就好, VM 翻譯器根本不必知道標籤在哪。

Hack VM 的文法

Hack VM 的文法有很多種寫法,以下是其中一種:

\[\begin{align*} \ensuremath{\textcolor{ntred}{\langle\text{\itshape instruction}\rangle}} \;::=\;{} & (\ensuremath{\text{\textcolor{tokblue}{`\texttt{push}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape data}\rangle}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{pop}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape data}\rangle}}\\ &\;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{add}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{sub}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{neg}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{and}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{or}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{not}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{eq}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{gt}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{lt}'}}}\\ &\;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{label}'}}},\ \text{identifier} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{goto}'}}},\ \text{identifier} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{if-goto}'}}},\ \text{identifier}\\ &\;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{function}'}}},\ \text{identifier},\ \text{integerLiteral}\\ &\;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{call}'}}},\ \text{identifier},\ \text{integerLiteral} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{return}'}}}),\ \text{newline}\\[4pt] \ensuremath{\textcolor{ntred}{\langle\text{\itshape data}\rangle}} \;::=\;{} & (\ensuremath{\text{\textcolor{tokblue}{`\texttt{local}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{constant}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{this}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{that}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{pointer}'}}}\\ &\;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{argument}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{static}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{temp}'}}}),\ \text{integerLiteral} \end{align*}\]

這其實比 Hack 組語簡單得多——它是 LL(1) 文法: 每個 \(\textcolor{ntred}{\langle\text{\itshape instruction}\rangle}\) 怎麼解析,看第一個權杖就知道。 (對照:組語因為 DM=M+D 需要 LL(2)。) 難的部分其實是把 VM 指令翻譯成組語!

配置記憶體:基底位址與位移

把虛擬記憶體映射到實體記憶體(配置記憶體) 的一般做法與 this/that 相同。 假設要把 local 映射到實體記憶體: 選一個 RAM 位址(例如 300),然後 local 0 \(\mapsto RAM[300]\)、 local 1 \(\mapsto RAM[301]\)、……、 local \(k \mapsto RAM[300 + k]\)。

300 稱為基底位址(base address)、 local 後面的數字稱為位移(offset): 每個虛擬位址映射到基底位址+位移。 例如 pointer 0 就是 this 的基底位址、 pointer 1 是 that 的基底位址。

硬體限制與段長度

Hack VM 的八個段各有 64KB 記憶體(32,768 個 16 位元字), 堆疊還可以無限高;但 Hack CPU 總共只支援 64KB—— 總得有取捨。(這不是 Hack VM 特有的: 多數計算模型都假設無限儲存——圖靈機有無限長的紙帶、 C 程式對 malloc 總量沒有硬上限。)

解法:給每個段一個長度,禁止存取超過長度的區域。 例如給 local 長度 5,就只允許 local 0 到 local 4; 「local 5 的位置」就能拿去當(例如) argument 的基底位址。

現代系統嚴格執行這件事:行程存取配置段以外的位址時, 硬體產生中斷——通常導致 segmentation fault 崩潰 (若該段是堆疊,錯誤就叫 stack overflow)。 這需要 Hack 沒有的硬體支援,軟體模擬又太慢, 所以我們只是手動追蹤段長度。

C 的 long long 是 64 位元整數,在 64 位元電腦上是一個字。

  • long long myArray[128]; 配置 128 個字的段, 把基底位址存進 long long * 變數 myArray。

  • myArray[50] 回傳位址 myArray \(+ 50\) 的值 ——第 51 個元素。陣列索引就是基底+位移!

  • myArray[128] 試圖存取段外(超出一個字) \(\Rightarrow\) segmentation fault。

  • 其他型別按資料大小折算: short *myArray = malloc(64*sizeof(short)); 配置 16 個字、每字塞四個 16 位元 short; myArray[50] 取位址 myArray\(+12\) 處的第三個 short。現代 ISA 能高效操作這種佈局。 (CLion 等 IDE 有記憶體與反組譯視圖,可以自己寫測試碼觀察!)

Hack 的記憶體配置慣例

這些細節是我們這個 Hack VM 實作特有的 (不是 Hack VM 本身的一部分,但 Nand2tetris 的 VM 模擬器假設它們)。 組語那些還沒解釋的神祕關鍵字(除了 SP) 全都是存放段基底位址的變數:

  • local 的基底位址存在 \(RAM[1]\) = LCL;

  • argument 的基底位址存在 \(RAM[2]\) = ARG;

  • pointer 固定配置在基底 3、長度 2: pointer 0(= this 的基底)就是 \(RAM[3]\) = THIS; pointer 1(= that 的基底)就是 \(RAM[4]\) = THAT;

  • temp 固定配置在基底 5、長度 8;

  • static 固定配置在基底 16、長度 240。 編譯 Foo.vm 時,static 5 應映射到 組語變數 Foo.5(解釋見下週!);

  • constant 不出現在實體記憶體。

目前假設 \(RAM[1\text{--}4]\) 在執行開始時已被初始化成合理的值 (提供的測試腳本會做這件事!)。

實作堆疊

堆疊也配置固定段:基底 256、長度 1792。 堆疊頂再往上一格的位址存在 \(RAM[0]\) = SP (Stack Pointer)。

  • push \(x\):把 \(x\) 寫入 \(RAM[SP]\),然後 SP++;

  • pop 到 \(RAM[i]\):先 SP--, 再把 \(RAM[SP]\) 複製到 \(RAM[i]\)。

注意 pop 時不必清零 \(RAM[SP]\)—— 整段都保留給堆疊,下次用到時自然會被覆寫。

嚴格說 local 與 argument 也會存成堆疊的 子段——但這週先不用擔心!

延伸補充:VM 指令的組語翻譯範例

投影片說「難的是翻譯本身」但把細節留給作業。 以下是三個代表性翻譯(標準 Nand2tetris 做法), 展示每條 VM 指令如何展開成一段組語:

(1) push constant 7——常數上堆疊:

@7
D=A      // D = 7
@SP
A=M      // A = SP(堆疊頂上一格)
M=D      // RAM[SP] = 7
@SP
M=M+1    // SP++

(2) pop local 2——彈出存入 local 2 (用 R13 暫存目標位址):

@LCL
D=M      // D = local 的基底位址
@2
D=D+A    // D = 基底 + 2
@R13
M=D      // R13 = 目標位址
@SP
M=M-1    // SP--
A=M
D=M      // D = 彈出的值
@R13
A=M
M=D      // RAM[基底+2] = 值

(3) add——彈兩個、加、推回 (實作上直接原地改寫,少一次 push):

@SP
M=M-1    // SP--
A=M
D=M      // D = y(先彈出)
@SP
M=M-1    // SP--
A=M
M=D+M    // RAM[SP] = x + y(結果原地放回)
@SP
M=M+1    // SP++

一行 VM 碼展開成 7–13 行組語——這就是 IR 「更精簡的語法」目標的具體體現,也解釋了為什麼 goto 不再對應單一機器碼指令。

Hack VM 記憶體總覽

關鍵字 位址 用途
SP 0 [堆疊頂值的位址] \(+ 1\)
LCL 1 存 local 段的基底位址
ARG 2 存 argument 段的基底位址
THIS 3 pointer 0(this 段的基底位址)
THAT 4 pointer 1(that 段的基底位址)
R5–R12 5–12 temp 段(最大 8)
R13–R15 13–15 VM 翻譯器的暫時變數(需要時用)
— 16–255 static 段(最大 240)
— 256–2047 保留給堆疊,含 local 與 argument 段(合計最大 1792)
— 2048–16383 「堆積(heap)」記憶體,可配置給 this 或 that 段
SCREEN 16384–24575 記憶體映射的螢幕輸出
KBD 24576 記憶體映射的鍵盤輸入

第五週的「神祕關鍵字」至此全部解密: SP、LCL、ARG、THIS、 THAT 是 VM 實作的基礎設施, R0–R15 中 5–12 給 temp、 13–15 給翻譯器自用。

本章重點

  • VM 翻譯器不需要標籤符號表:標籤直接翻成組語標籤, 位址解析交給組譯器。

  • Hack VM 文法是 LL(1)(第一個權杖決定一切); 難點在翻譯不在解析。

  • 虛擬位址 = 基底位址 + 位移;段有長度、越界即 segfault(堆疊段則是 stack overflow)。

  • 慣例:SP=0、LCL=1、ARG=2、THIS=3、THAT=4、 temp=5–12、翻譯器暫存=13–15、static=16–255、 堆疊=256–2047、堆積=2048–16383。

  • push:寫 \(RAM[SP]\) 再 SP++;pop:SP-- 再讀 \(RAM[SP]\);不必清零。

綜合練習題(附詳解)

練習 1:堆疊追蹤

初始堆疊為空,local 0 \(= 10\)、local 1 \(= 20\)。 逐步寫出每條指令後的堆疊與 local 內容:

push local 0
push local 1
push local 0
pop local 1
add
pop local 0

(堆疊左=底)

指令後 堆疊 local
push local 0 10 \(0{:}10,\ 1{:}20\)
push local 1 10, 20 \(0{:}10,\ 1{:}20\)
push local 0 10, 20, 10 \(0{:}10,\ 1{:}20\)
pop local 1 10, 20 \(0{:}10,\ 1{:}\textbf{10}\)
add 30 \(0{:}10,\ 1{:}10\)
pop local 0 (空) \(0{:}\textbf{30},\ 1{:}10\)

最終:local 0 \(= 30\)、local 1 \(= 10\)。

練習 2:運算元順序

(a) push constant 3、push constant 1、sub 之後堆疊頂是什麼? (b) 要計算 \(5 - \texttt{local 2}\),指令順序怎麼寫? (c) neg 與 not 對值 \(3\) 各得到什麼(16 位元)?

(a) \(y = 1\)(先彈出)、\(x = 3\),sub 算 \(x - y = \textbf{2}\) (不是 \(-2\))。口訣:堆疊順序 = 運算式由左至右的順序。

(b) 先推被減數、再推減數: push constant 5、push local 2、sub。

(c) neg:\(-3\) 的 2 的補數 \(= \texttt{0xFFFD} = 65533\)。 not:\(\lnot 3 = \lnot\texttt{0x0003} = \texttt{0xFFFC} = 65532\)。驗證關係 \(-x = \lnot x + 1\):\(65532 + 1 = 65533\)。✓

練習 3:運算式編譯

寫出計算 \((\texttt{local 0} + \texttt{local 1}) \mathbin{\&} (\texttt{local 0} - 7)\) 並把結果存入 local 2 的 VM 碼。

後序走訪:先算左子式、再算右子式、最後套運算子。

push local 0
push local 1
add             // local 0 + local 1
push local 0
push constant 7
sub             // local 0 - 7
and             // 兩者逐位元 AND
pop local 2

注意兩個子運算式的結果依序疊在堆疊上, and 恰好取到它們。

練習 4:true 與 false 的表示

(a) 為什麼 Hack VM 用 0xFFFF 表示 true 而不是 1? (b) 設堆疊頂是 gt 的結果,接著執行 not。 這在邏輯上等價於什麼比較? (c) 若某語言用 1 當 true,and 還能兼作邏輯運算嗎?

(a) 因為 0xFFFF 是全 1:這使逐位元運算 同時就是邏輯運算—— \(\texttt{0xFFFF} \mathbin{\&} \texttt{0xFFFF} = \texttt{0xFFFF}\) (true and true = true)、 \(\texttt{0xFFFF} \mathbin{\&} \texttt{0x0000} = \texttt{0x0000}\), 不需要另設一套邏輯指令。 (另一個觀點:0xFFFF 是 2 的補數的 \(-1 = \lnot 0\), 所以 not false = true 也在位元層面自動成立。)

(b) \(\lnot(x > y) \equiv x \leq y\)—— 這就是 VM 只需要 gt/eq/lt 三種比較的原因:其餘三種(\(\geq\)、\(\neq\)、\(\leq\)) 各補一個 not 就有了。

(c) 不行。位元運算上 \(1 \mathbin{\&} 2 = 0\), 但「true and true」應為 true—— 用 1 當 true 時位元 AND 與邏輯 AND 不一致 (C 因此需要 & 與 && 兩種運算子!)。

練習 5:RPN 轉換

把運算式 \(((a + b) - c) > (d \mathbin{|} e)\) 轉成 VM 碼, 其中 \(a\)–\(e\) 分別是 local 0–local 4。

語法樹後序走訪:\(a\ b\ +\ c\ -\ d\ e\ |\ >\)。

push local 0    // a
push local 1    // b
add
push local 2    // c
sub             // (a+b)-c
push local 3    // d
push local 4    // e
or              // d|e
gt              // ((a+b)-c) > (d|e)

檢查運算元順序:gt 彈出 \(y = d|e\)(後推的)、 \(x = (a+b)-c\)(先推的),計算 \(x > y\)。✓

練習 6:迴圈翻譯

把下列 C 程式翻成 Hack VM(i 用 local 0、 sum 用 local 1):

sum = 0;
for (i = 10; i > 0; i--) { sum += i; }
push constant 0
pop local 1        // sum = 0
push constant 10
pop local 0        // i = 10
label LOOP
push local 0
push constant 0
gt                 // i > 0 ?
not                // 取反:i <= 0 ?
if-goto END        // 條件不成立就離開
push local 1
push local 0
add
pop local 1        // sum += i
push local 0
push constant 1
sub
pop local 0        // i--
goto LOOP
label END

另一種寫法是把測試放在迴圈尾(if-goto LOOP 直接用 i > 0 的結果,不需 not),少一次跳躍: 兩種都正確。注意 if-goto 彈出比較結果—— 堆疊在每輪迴圈開始時保持乾淨。

練習 7:陣列存取

陣列存放在 \(RAM[\texttt{0x0900}]\) 起。寫 VM 碼完成 arr[local 2] = arr[local 2] + 1(假設不出界)。

push constant 2304   // 0x0900
push local 2
add
pop pointer 0        // this 0 -> arr[local 2]
push this 0
push constant 1
add
pop this 0           // 寫回同一位置

關鍵:pop pointer 0 之後 this 0 就指著目標元素,讀(push this 0) 與寫(pop this 0)都不必重算位址。 \(\texttt{0x0900} = 2304\) 在 this 允許的 0x0800–0x3FFF 範圍內。✓

練習 8:this vs that

(a) 為什麼把螢幕塗黑的程式必須用 that 而不能用 this? (b) 寫 VM 碼把螢幕的第一個字(\(RAM[16384]\))設為全 1(\(-1\))。

(a) 螢幕在 \(RAM[\texttt{0x4000}\)–\(\texttt{0x5FFF}]\) (16384–24575),超出 this 允許的 0x0800–0x3FFF。 this 被保留給之後編譯抽象資料型別(struct)用, 映射範圍受限;that(基準 pointer 1) 可指向任何 RAM 位址。

(b)

push constant 16384
pop pointer 1        // that 0 -> RAM[16384]
push constant 0
not                  // 0xFFFF(-1,全 1)
pop that 0           // 螢幕第一個字 = 全黑

(push constant 只能推 \(0\)–\(32767\), 所以「\(-1\)」用 push constant 0 + not 或 push constant 1 + neg 製造。)

練習 9:基底與位移

某次執行中 \(RAM[1] = 317\)、\(RAM[2] = 310\)、\(RAM[3] = 3000\)、 \(RAM[4] = 16384\)。求下列虛擬位址對應的實體位址: (a) local 4;(b) argument 1; (c) this 12;(d) that 100; (e) temp 3;(f) pointer 1。

(a) \(\texttt{LCL} + 4 = 317 + 4 = \textbf{321}\)。 (b) \(\texttt{ARG} + 1 = 310 + 1 = \textbf{311}\)。 (c) \(\texttt{THIS} + 12 = 3000 + 12 = \textbf{3012}\)。 (d) \(\texttt{THAT} + 100 = 16384 + 100 = \textbf{16484}\) (螢幕記憶體內——正在畫圖!)。 (e) temp 基底固定為 5:\(5 + 3 = \textbf{8}\) (不查 RAM——這是編譯期常數)。 (f) pointer 基底固定為 3:\(3 + 1 = \textbf{4}\) (也就是 THAT 本身)。 注意 (e)(f) 與 (a)–(d) 的本質差異: 固定段的位址在編譯期就知道, local 等段要在執行期讀基底變數。

練習 10:記憶體映射

(a) 編譯 Foo.vm 時 static 5 映射到哪裡? 為什麼用組語變數而不是固定位址? (b) R13–R15 的用途是什麼? 在練習哪一題的翻譯範例中出現過? (c) 堆疊段最多能長到哪?再長會發生什麼事?

(a) 映射到組語變數 Foo.5——由組譯器 從 16 起自動配置 RAM(恰好落在 static 段 16–255 內)。 用變數而非固定位址的好處: 多檔案編譯時 Foo.vm 的 static 5 與 Bar.vm 的 static 5 自動得到 不同位址(Foo.5 vs Bar.5), 不會互相踩踏(詳見下週)。

(b) VM 翻譯器的暫時變數。 pop local 2 的翻譯範例用了 R13 暫存目標位址——因為計算「基底+位移」和「彈出值」 都需要經過 \(D\),得有地方寄放其中一個。

(c) 堆疊段是 256–2047(長度 1792, 還要容納 local 與 argument 子段)。 超過就是 stack overflow—— 現代系統靠硬體中斷抓到它,Hack 沒有這種硬體支援, 所以只能靠手動追蹤(或默默覆寫 heap 資料,產生詭異 bug)。

練習 11:SP 機制

初始 \(SP = 258\)、\(RAM[256] = 17\)、\(RAM[257] = 3\)。 逐步寫出執行 push constant 9、add、 pop local 0(設 \(\texttt{LCL} = 300\))後 \(SP\) 與相關 RAM 的值。

push constant 9:\(RAM[258] \leftarrow 9\)、\(SP = 259\)。 堆疊(256 起):17, 3, 9。

add:\(SP = 258\),\(D \leftarrow RAM[258] = 9\)(\(y\)); \(SP = 257\),\(RAM[257] \leftarrow 3 + 9 = 12\);\(SP = 258\)。 堆疊:17, 12。(\(RAM[258]\) 仍殘留 9,但已在堆疊外—— 不必清零,下次 push 會覆寫。)

pop local 0:\(SP = 257\), \(RAM[300] \leftarrow RAM[257] = 12\)。 堆疊:17。最終 \(SP = 257\)、\(RAM[300] = 12\)、 \(RAM[257]\) 殘留 12(無害)。

練習 12:端到端翻譯

把 VM 指令 push temp 3 翻譯成 Hack 組語。 提示:temp 是固定段。

temp 基底固定為 5,所以 temp 3 就是 \(RAM[8]\)——位址是編譯期常數, 不必像 local 那樣執行期讀 LCL:

@8
D=M      // D = RAM[8](temp 3 的值)
@SP
A=M
M=D      // RAM[SP] = D
@SP
M=M+1    // SP++

對照 push local 3 的版本開頭得是 @LCL / D=M / @3 / A=D+A / D=M(執行期算位址)—— 固定段少了兩條指令。 這種「編譯期能算就不留到執行期」正是後端最佳化的雛形。

附錄:速查表

Hack VM 指令總表

類別 指令 語意
記憶體 push [seg] [i] 把 seg i 的值推上堆疊
pop [seg] [i] 彈出堆疊頂、存入 seg i
算術 add/sub/neg \(x+y\)/\(x-y\)/\(-y\)
邏輯 and/or/not 逐位元 \(\&\)/\(|\)/\(\lnot\)(兼作邏輯運算)
比較 eq/gt/lt \(x==y\)/\(x>y\)/\(x<y\),結果 0xFFFF/0x0000
分支 label X 宣告標籤
goto X 無條件跳躍
if-goto X 彈出堆疊頂,非零則跳
函式 function/call/return 下週!

二元運算:\(y\) = 先彈出、\(x\) = 後彈出,計算 \(x \circ y\)。

八個虛擬記憶體段

段 基底 長度 性質
constant — — 唯讀;constant \(i\) 的值就是 \(i\)
local \(RAM[1]\)=LCL 動態 區域變數;呼叫時清空
argument \(RAM[2]\)=ARG 動態 函式引數;唯讀
this \(RAM[3]\)=THIS 動態 限 0x0800–0x3FFF;供 struct 用
that \(RAM[4]\)=THAT 動態 可指任何 RAM 位址
pointer 3(固定) 2 pointer 0=THIS、pointer 1=THAT
temp 5(固定) 8 編譯器工作空間
static 16(固定) 240 跨呼叫持續;Foo.vm 的 static \(i\) \(\to\) 組語變數 Foo.\(i\)

記憶體映射總表

位址 關鍵字 用途
0 SP 堆疊頂上一格的位址
1 LCL local 基底
2 ARG argument 基底
3 THIS this 基底(pointer 0)
4 THAT that 基底(pointer 1)
5–12 R5–R12 temp 段
13–15 R13–R15 VM 翻譯器暫存
16–255 — static 段
256–2047 — 堆疊(含 local/argument)
2048–16383 — 堆積(給 this/that)
16384–24575 SCREEN 螢幕
24576 KBD 鍵盤

名詞中英對照

英文 中文 英文 中文
intermediate representation 中介表示法 base address 基底位址
virtual machine 虛擬機 offset 位移
VM translator VM 翻譯器 segment 段
front / middle / back end 前端/中端/後端 segmentation fault 記憶體區段錯誤
stack machine 堆疊機 stack overflow 堆疊溢位
LIFO 後進先出 stack pointer (SP) 堆疊指標
virtual memory 虛擬記憶體 heap 堆積
page table / page file 分頁表/分頁檔 Reverse Polish Notation 逆波蘭記法
memory allocation 記憶體配置 postorder traversal 後序走訪

參考資料

  • John Lapinskas, Virtual machines and intermediate representations/The Hack VM I & II/ Implementing the Hack VM translator(9-1 至 9-4), COMSM1302, University of Bristol.

  • Nisan & Schocken, The Elements of Computing Systems (nand2tetris),Ch. 7–8(Virtual Machine I & II).

  • LLVM Project, LLVM Language Reference Manual (真實世界 IR 的規格範例).

  • Shi, Casey, Ertl & Gregg, Virtual Machine Showdown: Stack versus Registers, ACM TOPLAS, 2008(堆疊機 vs 暫存器機的實測研究).

  • WebAssembly Design Rationale (現代堆疊機 IR 的設計取捨).