本教材的使用方式:本講義整合了第九週四份投影片 (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 250push 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 3push constant 1sub 結束時堆疊頂是 \(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 起來 (記住 andornot 既是逐位元也是邏輯運算!)。

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\); 且語言內建了真正的邏輯運算子 gteqltandornot 來取代各種跳躍條件—— 不再需要七種 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}]\)

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

八個虛擬記憶體段

已見過 localconstantthisthatpointer。剩下三個:

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

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

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

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

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

本章重點

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

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

  • this0x08000x3FFF; 螢幕鍵盤等其他位址用 that(基準 pointer 1)。

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

實作 Hack VM 翻譯器

Hack VM 的權杖

類別 內容
關鍵字 pushpopaddsubnegandornoteqgtltlocalconstantthisthatpointerargumentstatictemplabelgotoif-gotofunctioncallreturn(下週!)
整數字面值 十進位 \(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 1that 的基底位址。

硬體限制與段長度

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

解法:給每個段一個長度,禁止存取超過長度的區域。 例如給 local 長度 5,就只允許 local 0local 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]\) = THISpointer 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]\)—— 整段都保留給堆疊,下次用到時自然會被覆寫。

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

延伸補充: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 0this 段的基底位址)
THAT 4 pointer 1that 段的基底位址)
R5R12 5–12 temp 段(最大 8)
R13R15 13–15 VM 翻譯器的暫時變數(需要時用)
16–255 static 段(最大 240)
256–2047 保留給堆疊,含 localargument 段(合計最大 1792)
2048–16383 「堆積(heap)」記憶體,可配置給 thisthat
SCREEN 16384–24575 記憶體映射的螢幕輸出
KBD 24576 記憶體映射的鍵盤輸入

第五週的「神祕關鍵字」至此全部解密: SPLCLARGTHISTHAT 是 VM 實作的基礎設施, R0R15 中 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 3push constant 1sub 之後堆疊頂是什麼? (b) 要計算 \(5 - \texttt{local 2}\),指令順序怎麼寫? (c) negnot 對值 \(3\) 各得到什麼(16 位元)?

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

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

(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 0local 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(ilocal 0sumlocal 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 允許的 0x08000x3FFF 範圍內。✓

練習 8:this vs that

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

(a) 螢幕在 \(RAM[\texttt{0x4000}\)\(\texttt{0x5FFF}]\) (16384–24575),超出 this 允許的 0x08000x3FFFthis 被保留給之後編譯抽象資料型別(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 + notpush 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.vmstatic 5 映射到哪裡? 為什麼用組語變數而不是固定位址? (b) R13R15 的用途是什麼? 在練習哪一題的翻譯範例中出現過? (c) 堆疊段最多能長到哪?再長會發生什麼事?

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

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

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

練習 11:SP 機制

初始 \(SP = 258\)\(RAM[256] = 17\)\(RAM[257] = 3\)。 逐步寫出執行 push constant 9addpop 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
算術 addsubneg \(x+y\)\(x-y\)\(-y\)
邏輯 andornot 逐位元 \(\&\)\(|\)\(\lnot\)(兼作邏輯運算)
比較 eqgtlt \(x==y\)\(x>y\)\(x<y\),結果 0xFFFF/0x0000
分支 label X 宣告標籤
goto X 無條件跳躍
if-goto X 彈出堆疊頂,非零則跳
函式 functioncallreturn 下週!

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

八個虛擬記憶體段

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

記憶體映射總表

位址 關鍵字 用途
0 SP 堆疊頂上一格的位址
1 LCL local 基底
2 ARG argument 基底
3 THIS this 基底(pointer 0
4 THAT that 基底(pointer 1
5–12 R5R12 temp
13–15 R13R15 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 representationsThe Hack VM I & IIImplementing 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 的設計取捨).