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::
().expect("error"); loop { // 大端读取 match buf.read_u16:: () { 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] 中。
其它的指令大多数都很类似,不赘述,指令每个部分的意义参考上述第二部分的第四小节(其它指令相关的概念)。