4917 微處理器是 澳大利亞新南威爾斯大學 (UNSW) 教授 Richard Buckland 所開授課程 COMP1917 裡面所講述的一個專為該課程設計的虛擬微處理器,此微處理器並未在市面上販 售。
4917 是一個 4-bit,具有 4 個暫存器以及 16 個記憶體空間 (4bit * 16) 的微控器,屬 於 Von Newman 架構 (程式和資料儲存在同一份記憶體), 和當今電腦、手機使用的 64-bit CPU 相比相差甚遠,但是非常適合用來學習一個 CPU 的運作以及模擬器的撰寫。
在這篇文章中,我們將使用 Clojure 1.7 的新功能 Reader Conditionals 一次實現 node.js (clojurescript) 以及 JVM (clojure) 的版本。
4917 的暫存器 (Register)
4917 共具有 4 個暫存器,分別為 R0, R1, PC, IS, 其用途如下表
| 暫存器 (Register) | 用途 |
|---|---|
| R0 | 暫存資料用 |
| R1 | 暫存資料用 |
| PC | 儲存目前指向記憶體的位址 |
| IS | 儲存目前執行的指令集 (Instruction Set) |
有些人的 4917 模擬器會將 PC (Program Counter) 命名為 IP (Instruction Pointer), 這個名稱比較常用在 x86 的 CPU 上,但是用途是相同的。
從指令集認識 4917
4917 共有 16 種指令,其中 0 ~ 7 這一類的指令屬於 1-byte 指令,不接受而外的參數, 而 8 ~ 15 這幾個操作碼則會接受 Program counter (或稱為 Instruction pointer) 所指 向的下一個目標作為資料 <data> 來進行接下來的動作,屬於 2-byte 指令。
實際上 4917 為 4-bit 微處理器,其使用的 memory 或是暫存空間都是以 4-bit (1byte = 8bit) 作為單位的,本文參考部分文章的描述,仍以 byte 當作他的單位。 (實際上 4-bit 稱作 nibble)
下表列出了其指令與含義:
1-byte 指令
| 指令 (op code) | 含義 |
|---|---|
| 0 | 結束程式 |
| 1 | R0 和 R1 相加,結果存入 R0 |
| 2 | R0 和 R1 相減,結果存入 R0 |
| 3 | R0 數值加一 |
| 4 | R1 數值加一 |
| 5 | R0 數值減一 |
| 6 | R1 數值減一 |
| 7 | 響蜂鳴器 |
2-byte 指令,指令後面的 byte 的內容以 <data> 表示
| 指令 (op code) | 含義 |
|---|---|
| 8 | 印出 <data> 內容 |
| 9 | 從位址 <data> 讀取資料存入 R0 |
| 10 | 從位址 <data> 讀取資料存入 R1 |
| 11 | 將 R0 內容存入 位址 <data> 內 |
| 12 | 將 R1 內容存入 位址 <data> 內 |
| 13 | 將 PC 指向 位址 <data> (即 JUMP 命令) |
| 14 | 如果 R0 為 0,將 PC 指向 位址 <data> |
| 15 | 如果 R0 不為 0, 將 PC 指向 位址 <data> |
從範例來看 4917 的指令運作
我們可以使用簡單的範例來了解 4917 的運作,假設目前的記憶體內容如下:
這樣 4917 會怎樣運作呢? 首先因為 PC 指向了 8,而 8 是 2-byte 指令,代表印出下一
個位置 (PC + 1) 的內容,因此會在終端機上顯示 5 ,接著程式遇到了 0, 結束這一回
合。
很簡單對不對?我們來看比較難一點點的範例,假設目前記憶體內容如下:
首先剛開始的指令 9 是 2-byte 指令,因此我們必須將 13 當作其參數一起考量,而 [ 9, 13 ] 則代表著
R0 = memory[13] = 6
而接下來的命令 10 同樣也是 2-byte 指令,因此必須將 3 當作其參數一起來看,[ 10, 3 ] 則代表著
R1 = memory[3] = 3
再接下來我們碰到了 1 則是 1-byte 命令,因此目前運作如下
R0 = R0 + R1 = 6 + 3 = 9
再接下來我們碰到了 3 是 1-byte 命令,因此目前運作如下
R0 = R0 + 1 = 9 + 1 = 10
在接著則是命令 11,這是一個 2-byte 命令,會將 R0 的內容寫入到其參數 (9) 所在的記 憶體位址,因此經過這個命令後,記憶體變成如下
最後一個遇到的命令是 8 代表將下一個資料印出來,因此我們就會看到 10 顯示在終端機 上了,也就是說,剛剛的程式進行了以下的運作
R0 = 6 R1 = 3 R0 = R0 + R1 R0 = R0 + 1 print R0
建立我們的 Clojure/Clojurescript 專案
了解到了 4917 的運作模式以及指令集後,我們可以開始寫程式囉,首先使用 lein 建立我們的專案
coldnew@Rosia ~ $ lein new emulator-4917
由於預設的 lein 專案缺少很多東西,因此我們必須一一添加 (或是你可以使用比較合適的
樣板),首先在 project.clj 裡面修改部分設定成如下
(defproject emulator-4917 "0.1.0-SNAPSHOT" ;; skip ... :source-paths ["src"] :dependencies [[org.clojure/clojure "1.7.0"] [org.clojure/clojurescript "0.0-3308" :scope "provided"]] :plugins [[lein-cljsbuild "1.0.6"]] :min-lein-version "2.5.1" :cljsbuild {:builds [{ :source-paths ["src"] :compiler {:output-to "target/emulator-4917.js" :output-dir "target" :source-map "target/emulator-4917.js.map" :target :nodejs :optimizations :none :pretty-print true}}]} :aot [emulator-4917.core] :main emulator-4917.core)
請注意到由於本篇文章將使用 Clojure 1.7 的新功能 Reader Conditionals ,因此
Clojure/Clojurescript 版本必須依照以上設定或選用更高版本,並且 lein 版本也必須
升級到最新版 2.5.1.
而在這個 project.clj 裡面,由於我們為了讓 Clojurescript 編譯速度加快,我們採用
了 none 最佳化,因此必須另外增加一個 run.js 來協助執行編譯出來的 javascript,
其內容如下
// http://stackoverflow.com/questions/25803420/how-to-compile-clojurescript-to-nodejs try { require("source-map-support").install(); } catch(err) { } require("./target/goog/bootstrap/nodejs.js"); require("./target/emulator-4917.js"); require("./target/emulator_4917/core"); emulator_4917.core._main(process.argv[2]); // passing argument
另外,我們必須將 lein 產生出來的 src/emulator_4917/core.clj 改名為
src/emulator_4917/core.cljc 這樣我們才能夠順利使用 Reader Conditionals 這個功
能。
建立初始樣板
我們首先先建立我們程式的雛形,讓其根據不同條件選擇要載入的 library 或是預先執行的方法,我們修改 src/emulator_4917/core.cljc 成以下
(ns emulator-4917.core (:require #?(:cljs [cljs.nodejs :as nodejs]) #?(:cljs [goog.crypt :as gcrypt]) [clojure.string :as str]) #?(:clj (:gen-class))) ;; enable *print-fn* in clojurescript #?(:cljs (enable-console-print!)) (defn -main [& args] (let [arg1 (nth args 0)] (if arg1 (println "TODO: read binary file and execute it.") (println "Error: Please specify filename.")))) ;; setup node.js starter point #?(:cljs (set! *main-cli-fn* -main))
在上面的樣板中,被 #?() 所包圍的東西會根據不同狀況被解析,這就是 Clojure 1.7
的 Reader Conditionals 功能,我們可以用以下範例來了解他的使用,下面的程式會根據
目前是編譯給 Clojure 還是 Clojurescript 來選擇要執行的項目,如果你今天是用在
Clojure (JVM)上,則其會顯示 Hi, Clojure ,反之若是執行在 Clojurescript
(Node.js, browser) 上,則會顯示 Hi, Clojurescript 。
#?(:clj (println "Hi, Clojure") :cljs (.log js/console "Hi, Clojurescript"))
讀取二進制檔案並解析
為了讓這個模擬器更像模擬器,我們讓他讀取二進制檔案到 memory 去來模擬 CPU 載入 ROM 動作,讀取完成後則將資料變成 Clojure 的陣列,好方便之後的程式運作,也就是說, 假設欲讀取的二進制文件內容如下
coldnew@Rosia ~/emulator-4917 $ hexdump -C examples/bell.bin | head -n 1 00000000 77 70 |wp|
我們要想辦法讀取這份文件,並產生 [7, 7, 7, 0] 這樣的陣列才行,而由於牽扯到
了讀取檔案的運作,這部份一定是要分開 Clojure 與 Clojurescript 的實作。
我們先談談在 Clojure 讀取檔案的作法,理論上我們可以使用 slurp 去讀取檔案,但是由
於 slurp 會將讀取到的內容根據編碼來轉換,因此不適合本文的應用,只好使用 Java 的
方式來讀取二進制檔案囉,我們建立一個 parse-binary-file 函式來讀取檔案並且轉換
成 byte-array。
(defn parse-binary-file "Clojure method to read binary file and convert to byte-array." [file] (with-open [out (java.io.ByteArrayOutputStream.)] (clojure.java.io/copy (clojure.java.io/input-stream file) out) (.toByteArray out)))
而在 Clojurescript 中,因為我們是執行在 Node.js 環境上,可以使用 Node.js 的 fs.readFileSync() 方法來讀取二進制檔案,讀取完成後在用 google Closure library 裡面 的 goog.crypt.stringToByteArray 將其轉換成 byte-array.
(defn parse-binary-file "Clojurescript method to read binary file and convert to byte-array." [file] (-> (nodejs/require "fs") (.readFileSync file "binary") .toString gcrypt/stringToByteArray))
最後,將這兩部份的程式碼整合起來,就會變成如下
(defn parse-binary-file [file] #?(:clj (with-open [out (java.io.ByteArrayOutputStream.)] (clojure.java.io/copy (clojure.java.io/input-stream file) out) (.toByteArray out)) :cljs (-> (nodejs/require "fs") (.readFileSync file "binary") .toString gcrypt/stringToByteArray)))
完成了 parse-binary-file 後,由於這樣產生出來的陣列內容為 [ 0x77, 0x70 ] 和
我們期望的 [ 7, 7, 7, 0 ] 有所落差,因此我們需要另外一個方式將 0x77 變成 [ 7,
7 ] 這樣的組合,我們可以用以下函式來達到這件事情
(defn to-4bit-array "Convert 0xf4 to [f 4]" [s] (let [h (bit-and (bit-shift-right s 4) 0x0f) ; (0xf4 >> 4) & 0x0f => f l (bit-and s 0x0f)] ; 0xf4 & 0x0f => 4 [h l]))
接著,我們使用 map 將 to-4bit-array 套用在 parse-binary-file 得到的結果上
(map to-4bit-array (parse-binary-file file)) ; => [ [7, 7] [7, 0] ]
這樣得到的結果仍舊不是我們想要的,因為他變成了多維陣列,因此我們使用 flatten 將 多維陣列變成一維的
(flatten [ [7, 7] [7, 0] ]) ; => (7, 7, 7, 0)
到此為止,我們完成了讀取二進制檔案並將其變成命令陣列的功能,將其合起來就變成
parse-rom ,我們將用他來讀取二進制檔案並傳送命令陣列給 4917 模擬器處理。
(defn parse-rom [file] (flatten (map to-4bit-array (parse-binary-file file))))