2023 年春節前看到不少公眾號在刷虛擬機器實現的文章,所以過年在家靜下心來看了看,也自己試了試,覺得挺有趣的。此處寫一篇總結,算是給自己一個交代。
零 先聊聊背景
cpu 其實並不理解高階語言程式碼,它只能理解彙編指令。簡單來說(此處懶得畫圖,用 markdown 代替了,下同):
c 語言編譯器 執行
我寫的 c 語言程式碼 -----------> cpu 可執行的彙編指令 <----- cpu
然而 cpu 業界也不是鐵板一塊,最典型的比如 x86 架構和 arm 架構,它們的彙編命令並不相同。簡單來說:
c 語言 x86 編譯器 執行
我寫的 c 語言程式碼 -----------------> x86 cpu 可執行的彙編指令檔案 <-- x86 cpu
c 語言 arm 編譯器 執行
-----------------> arm cpu 可執行的彙編指令檔案 <-- arm cpu
其實還有很多其它種類的彙編指令,不一一列舉。
這樣造成了軟體中跨平臺的困局,由此誕生了一類用於抹平它們區別的軟體:虛擬機器。簡單來說:
虛擬機器編譯器 執行 執行
高階語言程式碼 -----------> 虛擬機器彙編指令 <----- x86 虛擬機器 <---- x86 cpu
執行 執行
<----- arm 虛擬機器 <---- arm cpu
實際情況更加複雜,還要涉及到作業系統問題:
虛擬機器編譯器 執行 執行
高階語言程式碼 -----------> 虛擬機器彙編指令 <---- windows x86 虛擬機器 <--- windows x86 系統
執行 執行
<---- windows arm 虛擬機器 <--- windows arm 系統
虛擬機器軟體是有平臺區分的,但是虛擬機器彙編指令是跨平臺的。最著名的虛擬機器比如 jvm:
java 編譯器 執行
java 程式碼 -----------> class 檔案 <----- windows x86 jvm
執行
<----- windows arm jvm
執行
<----- linux x86 jvm
虛擬機器的出現很好的抹平了不同作業系統或者底層架構的不統一。java 所謂的“一次編譯,到處執行”就是這麼來的。
一 什麼是 LC-3
LC-3 是一套計算機架構,也有一套自己的彙編指令碼,但是相比起 x86 / arm / jvm 這類成熟的商業產品,會簡單很多很多,所以主要也是用於教學使用。LC-3 的整套架構是完備的,理論上可以用於構築任何軟體專案,但是效能其實很弱。
LC-3 虛擬機器就是可以執行 LC-3 彙編指令的軟體。
二 LC-3 虛擬機器中的主要概念
1 暫存器 - register
暫存器是虛擬機器的排程工作臺,用於臨時存放記憶體中的資料或者計算結果。LC-3 中有十個暫存器位,其中前八個是正常存放資料的暫存器,第九個是 pc 指標,第十個是條件指標。
這十個暫存器在 LC-3 中是十個 16 位無符號數字。在 c 語言中是 uint16,在 java 中是 short(注意是無符號的,需要轉換,因為 java 裡沒有無符號數),在 rust 裡是 u16。在實現的時候通常會寫成一個陣列。
下面具體解釋一下 pc 指標和條件指標。
- pc 指標(pc)
pc 指標是用於指向指令碼編號的指標。
舉個例子,一個彙編檔案內共計有 100 條彙編指令,pc 指標剛開始指向 0,每執行完一條就自增 1,一直到結束是 99。如果中途出現 while / for / if else 這樣的語句,可能出現 pc 指標回前或者往後的情況。 條件指標(condition)
條件指標的值只有三種:0 / 2 / 4。
程式碼邏輯中通常會存在 while(xxx) 或者 if(xxx) 這樣的條件判斷。
條件判斷最終會有三種結果:true / false / equals。這三種結果就對應了條件指標的三種值。
所以條件指標的核心是用於存放邏輯判斷的結果,用於處理 pc 指標的跳轉。2 記憶體 - memory
LC-3 中有 65536 個記憶體槽,每個記憶體槽可以儲存一個 16 位無符號數字。
記憶體槽數量和暫存器數量都是規範中定義的,暫時無需探尋其原理或者擴充套件性,因為 LC-3 主要用於教學。
在實踐當中,記憶體通常是一個陣列表示。3 指令型別
LC-3 中一共 16 個指令,指令碼統一是四位,一共可以歸納成四類:
數值的數學運算(operate)
- add - 指令碼 0001,用於兩個變數相加(+)
- and - 指令碼 0101,用於兩個變數相與(&)
- not - 指令碼 1001,用於一個變數取反(c 語言中的 ~,rust 中的 !)
將資料從暫存器儲存到記憶體(store)
- st - 指令碼 0011,用於將暫存器的值寫入記憶體中,記憶體地址使用 pc 指標和偏移量定位
- sti - 指令碼 0110,用於將暫存器的值寫入記憶體中,在 st 指令的基礎上增加一次記憶體定位
- str - 指令嗎 0111,用於將暫存器的值寫入記憶體中,記憶體地址使用常量和偏移量定位
將資料從記憶體提取到暫存器(load)
- ldi - 指令碼 1010,用於將記憶體的值寫入暫存器中,記憶體地址使用 pc 指標和偏移量定位
- ld - 指令碼 0010,用於將記憶體的值寫入暫存器中,記憶體地址使用常量和偏移量定位
- lea - 指令碼1110,用於將記憶體一個記憶體地址寫入暫存器中,記憶體地址使用 pc 指標和偏移量定位
(需要注意的是,ldi 寫入的是記憶體中的值,lea 寫入的是記憶體地址)
業務邏輯,定向 pc 指標(logic)
- br - 指令碼 0000,使用 condition 指標來判斷是否要移動 pc 指標
- jmp - 指令碼 1100,將 pc 指標移動到一個指定的數字上
- jsr - 指令碼 0100,用偏移量或者常量來移動 pc 指標
其它無法歸類的指令(other)
- trap - 指令碼 1111,用來和硬體對接,輸入輸出字串
- res - 指令碼 1101,預留的指令,暫時沒有用
- rti - 指令碼 1000,暫時沒搞清楚是幹啥用的
4 其它指令相關的概念
這些概念在後面的解析指令的過程中會用到。
opCode - 4 位,代表操作的指令碼 DR - 3 位,儲存結果的暫存器地址 pcOffset9 - 9 位,有符號的 pc 指標的偏移 9 位 pcOffset11 - 11 位,有符號的 pc 指標的偏移 11 位 offset6 - 6 位,有符號的記憶體指標偏移量 SR1 - 3 位,第一個暫存器地址,取反等操作只需要一個暫存器地址就夠了 SR2 - 3 位,第二個暫存器地址,比如相加運算就需要兩個暫存器配合(因為兩個變數) baseR - 3 位,代表一個無符號的整數 flag - 1 位,代表指令模式 imm5 - 5 位,代表一個有符號的整數 trapvect8 - 8 位,用於 trap 指令中確認功能
5 指令的解析 -- 以 add 指令為例
以 add 指令為例,它的作用是從某個暫存器 A 內獲取值,然後和另一個數字相加,並存放到另一個暫存器 B 中。
add 指令有兩種組成方式:第一種
| 0001 | --- | --- | 0 | 00 | --- | opCode DR SR1 flag 無效位 SR2
在這種方組成方式中,opCode 是固定的,佔四位;DR 是儲存相加結果的暫存器地址,佔三位;SR1 是第一個獲取值的暫存器地址,佔三位;flag 佔一位,固定是 0;SR2 是第二個獲取值的暫存器地址,佔三位。
處理邏輯的虛擬碼是:register[DR] = register[SR1] + register[SR2]
第二種
| 0001 | --- | --- | 1 | ----- | opCode DR SR1 flag imm5
在這種方組成方式中,opCode 是固定的,佔四位;DR 是儲存相加結果的暫存器地址,佔三位;SR1 是第一個獲取值的暫存器地址,佔三位;flag 佔一位,固定是 1;imm5 是一個有符號的正數,佔五位。
處理邏輯的虛擬碼是:register[DR] = register[SR1] + imm5
三 rust 實現
使用 rust 實現的 LC-3 虛擬機器。
備註:此為 2023 年春節期間的學習作,程式碼較為粗糙,只為理解和學習虛擬機器原理,並練習 rust 語言。
gitee 倉庫地址:https://gitee.com/mikylin/rvm_lc3
(程式碼風格被 java 帶偏了,可能寫的不太 rust)1 讀取檔案
此處讀取檔案,將指令集寫入到虛擬機器的記憶體中。
/// 將彙編檔案載入到記憶體中 pub fn load_file(vm: &mut L3vm, file_name: String) { // 獲取檔案絕對路徑 let mut name = get_file_name(file_name); let f = File::open(name).expect("couldn't open file"); let mut buf = BufReader::new(f); // 檔案第一個字元按照慣例數字 16880,標註了 pc 指標位置是 3000 let mut mem_addr = buf.read_u16::<BigEndian>().expect("error"); loop { // 大端讀取 match buf.read_u16::<BigEndian>() { Ok(instruction) => { // 寫入記憶體 vm.memory_write(mem_addr, instruction); // 是否開啟 debug 日誌,用於觀察每次寫入的是什麼數字 if vm.is_debug() { println!("addr: {}, instr: {}, pc: {}", mem_addr, build_instruction(instruction), vm.pc()); } // 記憶體地址加 1,下一次迴圈會讀取新的記憶體地址 mem_addr += 1; } Err(e) => { if e.kind() == std::io::ErrorKind::UnexpectedEof { return; } panic!("{}", e); } } } } /// 處理檔名,如果使用相對路徑的話,需要修改成絕對路徑 /// 如何處理相對路徑還沒瞭解過 fn get_file_name(file_name: String) -> String { let mut name = file_name.clone(); if file_name.starts_with(".") { name = root(); println!("root: {}", name); name.push_str(&file_name[1..]); } println!("file name: {}", name); name } /// 此處需要引入 project_root 專案,獲取可執行檔案所在的絕對路徑 fn root() -> String { let current_path = project_root::get_project_root().unwrap(); current_path.to_str().unwrap().to_string() }
2 VM
VM 是虛擬機器的主體,負責管理暫存器和記憶體。
/// 虛擬機器構造體 pub struct L3vm { memory: [u16; MEMORY_COUNT], // 記憶體槽 register: [u16; REG_COUNT], // 暫存器槽 debug: bool, // 是否開啟 debug 日誌 } impl L3vm { /// 建立虛擬機器 pub fn new(debug: bool) -> Self { // 暫存器陣列 let mut register: [u16; REG_COUNT] = [0; REG_COUNT]; // 初始化 pc 指標 register[R_PC as usize] = PC_INIT; // 記憶體陣列 let memory: [u16; MEMORY_COUNT] = [0; MEMORY_COUNT]; // 建立虛擬機器物件 L3vm { memory, register, debug } } pub fn is_debug(&self) -> bool { self.debug } /// 讀取暫存器 pub fn register(&mut self, index: u16) -> u16 { if index < 0 || index >= 10 { panic!("registers index must in 0 to 10, but [{}]!", index) } self.register[index as usize] } /// 寫入暫存器 pub fn register_write(&mut self, index: u16, val: u16) { if index < 0 || index >= 10 { panic!("registers index must in 0 to 10, but [{}]!", index) } self.register[index as usize] = val } /// 讀取 pc 指標 pub fn pc(&mut self) -> u16 { self.register(R_PC) } /// 寫入 pc 指標 pub fn pc_write(&mut self, val: u16) { self.register_write(R_PC, val); } /// 讀取 condition 指標 pub fn condition(&mut self) -> u16 { self.register(R_COND) } /// 讀取記憶體 pub fn memory(&mut self, address: u16) -> u16 { // 當要讀取的記憶體地址是監控鍵盤按下的特殊地址的時候,需要做特殊寫入操作 // 這塊邏輯是用於配合 trap 指令 if address == MR_KBSR { // 獲取鍵盤操作 let mut buffer = [0; 1]; std::io::stdin().read_exact(&mut buffer).unwrap(); let b = buffer[0]; if b != 0 { self.memory_write(MR_KBSR, 1 << 15); self.memory_write(MR_KBDR, b as u16); } else { self.memory_write(MR_KBSR, 0) } } self.memory[address as usize] } /// 寫入記憶體 pub fn memory_write(&mut self, address: u16, val: u16) { self.memory[address as usize] = val; } }
3 指令路由
pub fn execute(vm: &mut L3vm) { while vm.pc() < MEMORY_COUNT as u16 { // 每次都需要將 pc + 1,用以跳轉到下一條指令中 let pc = vm.pc(); let instruction = vm.memory(pc); vm.pc_write(pc + 1); // 用操作前四位操作碼,根據操作碼路由相關方法 match instruction >> 12 { OP_ADD => operate::add(vm, instruction), OP_AND => operate::and(vm, instruction), OP_NOT => operate::not(vm, instruction), OP_BR => logic::br(vm, instruction), OP_JMP => logic::jmp(vm, instruction), OP_JSR => logic::jsr(vm, instruction), OP_LD => load::ld(vm, instruction), OP_LDI => load::ldi(vm, instruction), OP_LEA => load::lea(vm, instruction), OP_ST => store::st(vm, instruction), OP_STI => store::sti(vm, instruction), OP_STR => store::str(vm, instruction), OP_TRAP => other::trap(vm, instruction), OP_RES => other::res(vm, instruction), OP_RTI => other::rti(vm, instruction), _ => { println!("not support op code."); } } // debug 日誌 if vm.is_debug() { print_log(instruction, vm); } } }
整個指令是一個 u16 的數字,將其二進位制化之後可以知道,前四位為 opCode,使用 opCode 可以路由到對應的指令實現裡。
4 指令實現
來看一個最簡單的指令 -- not。
not 指令用於將一個數字取反碼。/// not /// 將一個變數進行取反操作 /// /// /// /// 指令組成 1: /// | 1001 | --- | --- | ------ | /// opCode DR SR1 無效位 pub fn not(vm: &mut L3vm, instruction: u16) { // DR let dr = (instruction >> 9) & P_3; // SR1 let sr1 = (instruction >> 6) & P_3; // 從 SR1 中獲取值 let v1 = vm.register(sr1); // 取反並存入 vm.register_write(dr, !v1); // 更新 condition 指標 setcc(vm, dr) }
opCode 穩定為 1001。
DR 和 SR1 都代表一個暫存器的地址,用 register[DR] 或者 register[SR1] 都可以獲取一個暫存器槽,對其進行讀取和寫入操作。
此處的業務邏輯是從 register[SR1] 中獲取一個值,對其取反之後寫入 register[DR] 中。
其它的指令大多數都很類似,不贅述,指令每個部分的意義參考上述第二部分的第四小節(其它指令相關的概念)。