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] 中。
其它的指令大多数都很相似,不赘述,指令每个局部的意义参考上述第二局部的第四大节(其它指令相干的概念)。