LC-3 虛擬機器學習總結

三流發表於2023-02-02

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] 中。
    其它的指令大多數都很類似,不贅述,指令每個部分的意義參考上述第二部分的第四小節(其它指令相關的概念)。

相關文章