虚拟机源码分析
概述:这个虚拟机代码是使用c++实现的,实现了两个类,一个是as类,就是负责汇编器工作的类,类中有一个用于存放符号表的数组,所有对符号的引用信息都会在对程序进行扫描之后存放到这个数组之中;另外还有一个MC类的成员,就是虚拟机对应的类成员,在扫描完程序之后把机器代码加载到虚拟机的内存之中,在主函数main之中是这样使用这两个类的: MC *Machine = new MC();
·
概述:这个虚拟机代码是使用c++实现的,实现了两个类,一个是as类,就是负责汇编器工作的类,类中有一个用于存放符号表的数组,所有对符号的引用信息都会在对程序进行扫描之后存放到这个数组之中;另外还有一个MC类的成员,就是虚拟机对应的类成员,在扫描完程序之后把机器代码加载到虚拟机的内存之中,在主函数main之中是这样使用这两个类的:
复制代码
可以看出汇编器类成员是Assembler通过把虚拟机类成员Machine传入构造函数实现的,而只调用了assemble函数,也就是说汇编器类对外的接口只有这个函数,这个函数进行两遍扫描程序生成符号表和把机器指令加载到虚拟机内存之中(这个虚拟机就是AS类的私有成员Machine),在执行完这个调用之后,汇编器就完成了任务,对它进行析构操作;注意assemble函数有一个参数error表示是否出现错误,如果没有错误,虚拟机类成员 Machine就执行interpret执行序,listcode函数生成一个*.map文件,这个文件的内容是程序在运行的时候的内存映像,你可以通过查看这个生成的map文件明白内存中到底有哪些数据。
ok,现在开始具体的分析程序的代码,cpu技术指标中讲述的就是虚拟机MC类的情况,而汇编器工作原理中讲述的是汇编器类AS中的情况,后面还有会结合着两者来讲述原理。
1)CPU技术指标--对应于虚拟机类MC
1.字长:虚拟机模拟的CPU是一个字长为8位的CPU。CPU内包含有累加器,堆栈指针,变址寄存器,指令寄存器,程序计数器,基址指针,标志寄存器。代码中用一个struct来表示CPU的内部结构:
2.指令集:共67条指令,包括数据传送,算术运算,逻辑/位运算,跳转,输入输出,栈操作等几类。
指令表存放在一个enum类型中,而指令是通过一个含有256个字符串的数组实现的:
3.指令格式:单字节或者双字节,格式如下:
复制代码
操作码说明了指令的功能,其实就是上面的表示指令表的enum中每个指令对应的值,而操作数指明了这个操作所涉及的数据或者地址,对于单字节的指令而言,操作数是隐含在操作码之中的,比如说:
4.寻址模式:
为了方便说明问题,对下列的符号进行说明:
a.所用的CPU的内部寄存器名与上面的定义CPU结构的结构体变量一一对应,比如说A为累加器等等。
b.ImmB表示B是一个立即数。
c.[B]表示以B为地址的内存的内容。
d.[X+ImmB]表示X变址寄存器与立即数相加所得的内存单元的内容。
操作数为0~255以内的数值(在汇编指令中可以用符号地址代替),具体操作数所代表的含义由指令来确定,有一下三种:
a.操作数表示一个立即数,用ImmB表示,说明B是一个立即数。
b.操作数表示一个内存地址,用B表示,说明B是一个内存地址。
c.操作数表示一个内存地址的偏移量,而偏移量是以X寄存器所指示的地址为基准的。
寻址方式归纳如下:
5.内存容量:通过下面的代码分配了256字节的内存空间:
2)汇编器的工作原理--汇编器AS的代码分析
汇编器通过void assemble(bool & errors);函数进行两个工作,第一遍扫描处理用于创建符号表,第二遍扫描的主要工作是在第一遍扫描生成的符号表的基础上参照符号表及机器指令,将源程序汇编为机器指令存入虚拟机的内存之中,也就是加载到CPU之中。
符号表是通过如下的结构进行定义的:
简单的说,对于每个出现的符号而言,都需要保存符号的名称,符号地址所代表的内存位置,还有一个链表,存放引用了这个符号的内存地址。
1.第一遍扫描
第一遍扫描通过调用void AS::mksymtab(int &err)函数初始化符号表的,其中err变量会返回到调用它的函数中去,如果为0表示成功,大于0表示发现错误且err的值是错误的数目。第一遍扫描主要是调用lex语法分析程序从源程序中得到一个以空格分隔开的单词,确定单词的种类(具体有哪些单词的种类下面介绍),第一遍扫描要确定的单词种类有:
a)CMMT:注释,直接进入下一个单词的读取,注释被忽略
b)LABEL:地址标号,说明出现了一个地址标号的定义,由于程序中不能出现相同的地址标号,因此在得到一个地址标号的时候必须在符号表中查询是否已经存在相应的符号,如果有的话就说明是重复定义了。
c)REFLABEL:出现了一个对地址标号的引用,可能出现先前引用或者向后引用两种情况,向前引用是指对于尚未出现到的地址(就是目前在符号表中尚未找到的符号)的引用,那么会显示出一条警告信息,留待第二遍扫描的时候才确定地址是否出现;向后引用就是引用已经出现过的地址,那么就把引用这个符号的内存地址加入符号所对应的符号表的链表之中。
d)UNKNOWN:程序中出现了非法字符,输出错误信息。
如果第一遍扫描没有出现问题,那么符号表结构数组中就存放好了程序中出现的符号和相应的信息,这些信息在第二遍扫描中会使用到。
2.第二遍扫描
第二遍扫描是在第一遍扫描地基础之上,参照符号表及相应地机器指令,将源程序汇编成为机器指令程序存入虚拟机地内存之中。第二遍扫描与第一遍扫描同样需要对源程序进行从头到尾的扫描,同样要调用lex词法分析程序分辩单词的类别,不过单词的类别与第一遍扫描不同:
a)ASMCODE:是一个指令助记符,也就是说是这个虚拟机对应的汇编代码,查找机器的指令表,也就是查找前面说过的mnemonics[256]数组,如果得到的是MC_bad,那么就是不可识别的指令,显示出错,否则就把相应的机器指令存入虚拟机内存中,其实这里存入的机器指令就是上面说过存放指令表的enum中指令所对应的数据,通过这个数据作为下标值去索引mnemonics[256](机器指令/助记符对照表)数组和mnxlen[256] (机器指令长度表)数组。
b)REFLABEL:是一个对地址的引用,查找第一遍扫描建立的符号表,符号表中有此符号那么就将其对应的内存地址取出,存放到虚拟机的内存之中,否则就是无效的引用
c)NUM:读入一个数字串,将其转换为数字存入内存中。
d)对于其它的情况,程序予以忽略,继续读取单词直到读取结束。
通过两遍扫描之后,虚拟机的内存中就存放好了已经加载好的机器指令,汇编器的任务也结束了。
3.lex词法分析程序
词法分析器是对源程序进行扫描处理的底层模块,主要的功能是:逐字符读取输入流,以空格为分隔符标记(程序规定凡是ASCII不大于0X20(空格)的字符都是空格),将输入流的字符组合成单词,给主调函数返回当前读入的词法记号的类型并且将单词符号串存入words数组中,同时如果是汇编指令的话就查找机器的指令表,将该单词对应的机器指令存入opcode中,返回主调函数。
lex程序是以自动机方式进行工作的,由当前所处的状态以及所读入的字符来决定下一步的处理,可以识别的字符类型由一个
enum类型来定义:
lex函数每处理一个字符串就把所识别的单词存入words数组,然后返回字符的类别
3)虚拟机执行机器指令的过程
在汇编器把源程序分析完并且把机器指令加载到虚拟机内存以后,汇编器的任务也就结束了。下面的工作由虚拟机也就是这里设计的CPU去执行程序了。在MC虚拟机类中主要有两个函数负责执行程序,下面分别解释:
1.
void interpret(int argc, int trace, char *datafile, char *outfile,int entrance)
负责对虚拟机执行机器指令进行执行之前的准备工作,其中的argc是虚拟机程序的命令行参数的个数,如果用户没有提供,那么就要提问用户让用户指定输入和输出文件名,默认的是stdin和stdout,还有指定是否要进行调试跟踪,还有设置程序的入口点,在分别打开文件成功以后,就调用 execute(entry % 256, data, results, tracing)可是执行程序(注意入口点也是8
位的)。
2.void MC::execute(MC_bytes initpc, FILE * data, FILE * results,bool tracing)
这是虚拟机执行已经加载到虚拟机内存中机器代码的主体程序,在开始的地方要进行一些初始化工作,比如初始化寄存器状态,初始化CPU的运行状态为运行状态等等。紧跟着就是一个大循环用于逐个读取在虚拟机内存中的机器指令然后根据不同的指令所要进行的工作进行处理,每一次执行循环都要更新PC值,也就是程序计数器的值,说白了这里的实现就是通过增加内存数组中的下标实现的,比如说:
每一条指令都有自己所对应的一个case语句,用于处理指令需要执行的操作而且还要相应的设置寄存器的值等等。
需要指出的是由于机器是8位的,所以在增加,或者减少以及得到索引值的时候都要对256取模也就是要进行?折回?操作,具体的可以查看increament,decreament,index三个函数的实现。
3.虚拟机运行时的存储区组织
虚拟机的内存空间地址范围从00H~0FFH,就是256字节,所有的程序,数据以及堆栈都是在这256字节上进行分配的,汇编器对源程序处理完以后都会把机器指令装入到从地址0开始的一片连续的内存单元中,就是说程序总是占据在低端的内存区,在程序代码区以外的内存区作为数据区和堆栈区。由于汇编器不提供任何的内存分配的伪指令,所以用户在编程的时候要有清晰的内存区域的概念,以免破坏了代码和堆栈区造成错误。运行时程序的组织示意图如下:
4.虚拟机对应的命令行参数解析
假设编译生成的可执行文件是myvm,那么命令行参数的格式如下:
myvm [-Trace][-Datafile][Outputfile][-Exx]sourcefile
其中trace用于决定是否对于每条指令都打印出寄存器当前的状态,datafile指定当程序有输入指令时从哪个文件输入,默认
是stdin,outputfile指定程序有输出指令的时候输出到哪个文件中,默认的是stdout,Exx指定入口点,sourcefile是必须提
供的汇编文件。
5.第一个汇编程序--hello world!
每一个程序恐怕刚开始都是从hello word开始,我们也不例外,以下是这个程序的源代码,指令是虚拟机所支持的指令:
你只需要简单的在命令行中输入:./myvm hello.asm就可以把这个程序汇编成这个虚拟机支持的机器指令并且在终端上显示!!
在汇编完程序的时候虚拟机会同时生成两个文件,一个是lst文件,相当于反汇编这个程序的时候可以查看到的内容,如指令的地址,指令对应的OpCode,还有指令的汇编助记符;另一个是map文件,显示的是程序运行的时候虚拟机内存中的内存映像,也就是说你可以通过查看这个文件明白程序是如何加载都内存中的,与这个汇编程序对应的lst文件如下:
需要特别说明一点,在halt指令之后紧跟着的是"hello"这个字符串每个字符的ASCII码的16进制表示,有一些数据是汇编器无法识别的,因为它们本身就不是指令而是数据,但是有一些却被汇编器识别了出来比如说2077这个数据被解释成了"ADD 77",其实这个数据还是数据(有点绕口),是"32 119"这两个数据表示连在一起得到的,32转换为16进制数就是0x20,而119就是"0x77",但是汇编器读到32这个数据的时候,在指令表中对应的助记符是ADD,于是汇编器就根据ADD指令的格式来解释后面的数据119于是就生成了这个数据-"2077",其实还是一个数据,把它作为指令解释的时候也许就是合法的指令,如是而已,这个事实也告诉了我们这样一个事实:在计算机内部,所有的东西都是二进制据,只是看你怎么去解释这些二进制数据而已。
以下是map文件:
理论上来说,用任何编辑器打开可执行文件的时候看到的内容和这个map文件大体都是一致的,比较这个文件开头的数据和lst中的数据你会得到很多的启示,其实在任何一个可执行程序中也会有存放数据和代码的区域,在现实世界中,每个操作系统下都有自己的可执行程序的格式,windows下是PE,而 linux下是elf,在程序加载到内存中的时候加载器会根据不同的文件格式去查找代码和数据然后分别加载到内存的数据区和代码区--只不过,在这个虚拟机中为了简化问题的实现,可执行文件的内容就是内存中的内容,也没有什么格式可言,只是要求不要超过内存的大小(256bytes)就可以了。
如果你能把上面的汇编文件,lst文件和map文件之间的关系弄明白,那么恭喜你,你对计算机的理解又上了一个台阶!
-
MC *Machine = new MC(); //产生一个虚拟机对象实例,Machine指向该实例
-
//生成一个汇编器对象实例,注意使用到了前面生成的虚拟机类成员
-
AS *Assembler = new AS(argv[argc - 1], Machine);
-
//汇编器对源程序代码进行汇编处理,结果存入Machine的内存mem中,
-
//将汇编过程所出现的错误状态返回给主调函数
-
Assembler->;assemble(errors);
-
delete Assembler;
-
if (errors) { //汇编过程有错误则输出提示信息,退出。
-
printf("Unable to execute binary\n");
-
} else { //汇编过程没有错误则执行Machine的内存mem中机器指令。
-
printf("About to execute binary...\n");
-
Machine->;interpret(argc, trace, datafile, outfile, entry);
-
//dump memory to src.map
-
Machine->;listcode(argv[argc - 1]);
-
}
-
delete Machine;
可以看出汇编器类成员是Assembler通过把虚拟机类成员Machine传入构造函数实现的,而只调用了assemble函数,也就是说汇编器类对外的接口只有这个函数,这个函数进行两遍扫描程序生成符号表和把机器指令加载到虚拟机内存之中(这个虚拟机就是AS类的私有成员Machine),在执行完这个调用之后,汇编器就完成了任务,对它进行析构操作;注意assemble函数有一个参数error表示是否出现错误,如果没有错误,虚拟机类成员 Machine就执行interpret执行序,listcode函数生成一个*.map文件,这个文件的内容是程序在运行的时候的内存映像,你可以通过查看这个生成的map文件明白内存中到底有哪些数据。
1)CPU技术指标--对应于虚拟机类MC
1.字长:虚拟机模拟的CPU是一个字长为8位的CPU。CPU内包含有累加器,堆栈指针,变址寄存器,指令寄存器,程序计数器,基址指针,标志寄存器。代码中用一个struct来表示CPU的内部结构:
- //虚拟机的处理器的内部结构定义
-
struct processor {
-
MC_bytes a; // 累加器A
-
MC_bytes sp; // 堆栈指针sp
-
MC_bytes x; // 变址寄存器 x
-
MC_bytes ir; // 指令寄存器 ir
-
MC_bytes pc; // 程序计数器
-
MC_bytes bp; // 基址指针bp
-
bool z, p, c; // 标志寄存器
-
};
-
processor cpu; //虚拟机的cpu
-
status ps; //保存cpu的运行状态
- 部件名称
主要功能 备注
- ALU
主要用于完成算术及逻辑运算的功能 在程序的执行代码中予以体现
- CU
控制单元,控制指令的取值及执行的机构 在程序的执行代码中予以体现
- A
8位长度的累加器,主要用于存放进行运算的
-
操作数,及作为存放运算结果的单元
- SP
8位的栈指针寄存器,主要用于存放堆栈的栈
-
顶地址
- X
是一个8位的变址寄存器,可以存放用于可作
-
为高级语言中的数组来使用的内存的起始地址
- Z,P,C
状态寄存器,主要用于表示各种运算器和操作
-
过程的状态信息,尤其当某种操作导致零值时
-
,则Z变为1;当操作产生了正值时,P变为1;
-
当操作产生了进位时,C变为1,否则为0
- IR
一个8位长度的指令寄存器,主要用于存放当前 对程序员不可见,即程序员不可通过
-
正在执行的二进制机器指令值 指令显示地存取该寄存器
- BP
基址寄存器BP,主要用于保存程序调用过程的
-
栈帧信息,有专门的操作指令
- PC
一个8位的程序计数器,相当于X86中的IP指令 对程序员不可见,即程序员不可通过
-
指令计数器,主要用于存放下一条将被执行的 指令显示地存取该寄存器
-
指令所在的内存单元的地址
2.指令集:共67条指令,包括数据传送,算术运算,逻辑/位运算,跳转,输入输出,栈操作等几类。
指令表存放在一个enum类型中,而指令是通过一个含有256个字符串的数组实现的:
- // 虚拟机机器指令表
- enum MC_opcodes {
-
MC_nop, MC_clra, MC_clrc, MC_clrx, MC_cmc, MC_inc, MC_dec, MC_incx, MC_decx,
-
MC_tax, MC_ini, MC_inh, MC_inb, MC_ina, MC_oti, MC_otc, MC_oth, MC_otb,
-
MC_ota, MC_push, MC_pop, MC_shl, MC_shr, MC_ret, MC_halt, MC_lda, MC_ldx,
-
MC_ldi, MC_lsp, MC_lspi, MC_sta, MC_stx, MC_add, MC_adx, MC_adi, MC_adc,
-
MC_acx, MC_aci, MC_sub, MC_sbx, MC_sbi, MC_sbc, MC_scx, MC_sci, MC_cmp,
-
MC_cpx, MC_cpi, MC_ana, MC_anx, MC_ani, MC_ora, MC_orx, MC_ori, MC_jmp,
-
MC_bze, MC_bnz, MC_bpz, MC_bng, MC_bcc, MC_bcs, MC_tsp, MC_jsr, MC_lbpi,
-
MC_fbpi, MC_tbp, MC_tsb, MC_tabp, MC_bad = 255
- };
- char *mnemonics[256];
//机器指令/助记符对照表
- int
mnxlen[256]; //机器指令长度表
- 在初始化这个数组的时候,就是通过上面的枚举变量作为数组的下标来进行初始化的,比如:
- // 初始机器指令表,位置指令初始化为"???"
-
for (i = 0; i <= 255; i++)
-
mnemonics[i] = "???";
-
//机器指令表填入对应的汇编助记符 0xxx为机器指令值
-
mnemonics[MC_nop] = "NOP"; //ox0
-
mnemonics[MC_clra] = "CLRA"; //ox1
-
mnemonics[MC_clrc] = "CLRC"; //ox2
-
mnemonics[MC_clrx] = "CLRX"; //ox3
-
mnemonics[MC_cmc] = "CMC"; //ox4
- 也就是说,这是用hash的方法实现的。
- 同时还需要初始化一个数组来保存指令的长度,比如
-
// 初始机器指令长度表
-
for (i = 0; i <= 255; i++)
-
mnxlen[i] = 1;
-
mnxlen[MC_lda] = 2;
-
mnxlen[MC_ldx] = 2;
-
mnxlen[MC_ldi] = 2;
-
mnxlen[MC_lsp] = 2;
-
mnxlen[MC_lspi] = 2;
- 需要特别说明的是,指令的数量是远远小于256的,所以必须在前面初始化这两个数组为一个合适的初始值表示未知的变量,然后再使用hash
- 的方法初始化相应的指令对应的值。
3.指令格式:单字节或者双字节,格式如下:
- 字节顺序
功能
- BYTE1
操作码
- BYTE2
操作数
操作码说明了指令的功能,其实就是上面的表示指令表的enum中每个指令对应的值,而操作数指明了这个操作所涉及的数据或者地址,对于单字节的指令而言,操作数是隐含在操作码之中的,比如说:
- 指令:LDX 0
- LDX对应的enum中数据的16进制是1A,而0的16进制表示是00,因此这一条指令的机器指令也就是OpCode是1A 00,其中操作码是1A,在低位,而00是操作数,地址在高位。当这条指令被加载到虚拟机的内存中去的时候,内存中只有"1A 00",虚拟机的CPU也是根据这个来执行指令的。
4.寻址模式:
为了方便说明问题,对下列的符号进行说明:
a.所用的CPU的内部寄存器名与上面的定义CPU结构的结构体变量一一对应,比如说A为累加器等等。
b.ImmB表示B是一个立即数。
c.[B]表示以B为地址的内存的内容。
d.[X+ImmB]表示X变址寄存器与立即数相加所得的内存单元的内容。
操作数为0~255以内的数值(在汇编指令中可以用符号地址代替),具体操作数所代表的含义由指令来确定,有一下三种:
a.操作数表示一个立即数,用ImmB表示,说明B是一个立即数。
b.操作数表示一个内存地址,用B表示,说明B是一个内存地址。
c.操作数表示一个内存地址的偏移量,而偏移量是以X寄存器所指示的地址为基准的。
寻址方式归纳如下:
- 寻址方式
含义 示例
- 累加器/寄存器寻址
操作数在CPU的累加器或者 INC 将A累加器自增一
-
寄存器中
- 直接寻址
指令的第二个字节为操作数所 lsp B,B为操作数所在的内存地址
-
在的内存单元 ,指令功能为将B的内容送入SP寄存器
- 立即寻址
指令的第二个字节为一个立即 ADI ImmB,将指令的ImmB加到A上,结果
-
操作数 保存在A中
- 变址寻址
以变址寄存器X加上一个ImmB所得 LDX ImmB,将变址寄存器X和ImmB相加所得的
-
的内存单元的内容为一个操作数 内存单元内容送入寄存器A中
- 基址寻址
以BP寄存器的内容为操作数 TABP 将A寄存器的内容送入BP基址寄存器中
5.内存容量:通过下面的代码分配了256字节的内存空间:
-
typedef unsigned char MC_bytes;
-
MC_bytes mem[256];// 虚拟机的内存空间
2)汇编器的工作原理--汇编器AS的代码分析
汇编器通过void assemble(bool & errors);函数进行两个工作,第一遍扫描处理用于创建符号表,第二遍扫描的主要工作是在第一遍扫描生成的符号表的基础上参照符号表及机器指令,将源程序汇编为机器指令存入虚拟机的内存之中,也就是加载到CPU之中。
符号表是通过如下的结构进行定义的:
- typedef struct refnodes {
-
MC_bytes refsymaddr; //引用地址标号产生的内存地址
-
struct refnodes *nextref; //指向向后引用地址标号的下一结点的链表指针
- } refnode;
- typedef struct symtab {
-
char symname[MAXSYMLEN]; //符号地址定义名
-
MC_bytes symaddr; //符号地址定义名所代表的内存地址
-
struct refnodes *firstref; //引用该符号地址的首次出现,用以实现链表
- } symboltab;
- symboltab symtabhdr[SYMTABLEN];
//符号表结构数组
- 共定义了SYMTABLEN数量的符号表
简单的说,对于每个出现的符号而言,都需要保存符号的名称,符号地址所代表的内存位置,还有一个链表,存放引用了这个符号的内存地址。
1.第一遍扫描
第一遍扫描通过调用void AS::mksymtab(int &err)函数初始化符号表的,其中err变量会返回到调用它的函数中去,如果为0表示成功,大于0表示发现错误且err的值是错误的数目。第一遍扫描主要是调用lex语法分析程序从源程序中得到一个以空格分隔开的单词,确定单词的种类(具体有哪些单词的种类下面介绍),第一遍扫描要确定的单词种类有:
a)CMMT:注释,直接进入下一个单词的读取,注释被忽略
b)LABEL:地址标号,说明出现了一个地址标号的定义,由于程序中不能出现相同的地址标号,因此在得到一个地址标号的时候必须在符号表中查询是否已经存在相应的符号,如果有的话就说明是重复定义了。
c)REFLABEL:出现了一个对地址标号的引用,可能出现先前引用或者向后引用两种情况,向前引用是指对于尚未出现到的地址(就是目前在符号表中尚未找到的符号)的引用,那么会显示出一条警告信息,留待第二遍扫描的时候才确定地址是否出现;向后引用就是引用已经出现过的地址,那么就把引用这个符号的内存地址加入符号所对应的符号表的链表之中。
d)UNKNOWN:程序中出现了非法字符,输出错误信息。
如果第一遍扫描没有出现问题,那么符号表结构数组中就存放好了程序中出现的符号和相应的信息,这些信息在第二遍扫描中会使用到。
2.第二遍扫描
第二遍扫描是在第一遍扫描地基础之上,参照符号表及相应地机器指令,将源程序汇编成为机器指令程序存入虚拟机地内存之中。第二遍扫描与第一遍扫描同样需要对源程序进行从头到尾的扫描,同样要调用lex词法分析程序分辩单词的类别,不过单词的类别与第一遍扫描不同:
a)ASMCODE:是一个指令助记符,也就是说是这个虚拟机对应的汇编代码,查找机器的指令表,也就是查找前面说过的mnemonics[256]数组,如果得到的是MC_bad,那么就是不可识别的指令,显示出错,否则就把相应的机器指令存入虚拟机内存中,其实这里存入的机器指令就是上面说过存放指令表的enum中指令所对应的数据,通过这个数据作为下标值去索引mnemonics[256](机器指令/助记符对照表)数组和mnxlen[256] (机器指令长度表)数组。
b)REFLABEL:是一个对地址的引用,查找第一遍扫描建立的符号表,符号表中有此符号那么就将其对应的内存地址取出,存放到虚拟机的内存之中,否则就是无效的引用
c)NUM:读入一个数字串,将其转换为数字存入内存中。
d)对于其它的情况,程序予以忽略,继续读取单词直到读取结束。
通过两遍扫描之后,虚拟机的内存中就存放好了已经加载好的机器指令,汇编器的任务也结束了。
3.lex词法分析程序
词法分析器是对源程序进行扫描处理的底层模块,主要的功能是:逐字符读取输入流,以空格为分隔符标记(程序规定凡是ASCII不大于0X20(空格)的字符都是空格),将输入流的字符组合成单词,给主调函数返回当前读入的词法记号的类型并且将单词符号串存入words数组中,同时如果是汇编指令的话就查找机器的指令表,将该单词对应的机器指令存入opcode中,返回主调函数。
lex程序是以自动机方式进行工作的,由当前所处的状态以及所读入的字符来决定下一步的处理,可以识别的字符类型由一个
enum类型来定义:
- //由lex词法分析器给调用者返回的记号种类的枚举类型定义
- //ASMCODE:汇编指令;LABEL:定义地址标号;REFLABEL:引用地址标号;NUM:数字标识;
- //ID:标识符;
CMNT:注释语名标识; UNKNOWN:不可识别的标号
- typedef enum tktype {
-
ASMCODE, LABEL, REFLABEL,
-
NUMBER, ID, NUM, CMNT,UNKNOWN
- } tokentype;
- 详细含义和相应的正则表达式如下:
- 类别
含义 正则表达式
- LABEL
地址标号,以字母开始的后跟任意多个大/小写或 {letter}{letter|digit}*
-
数字的序列并且以冒号结尾,如: {colon}
-
label99:
-
在构造符号表的时候将所有这种类型的符号加入符
-
号表数组中,并且将符号的内存地址也加入数组中
- REFLABEL
地址标号的引用,其格式与LABEL相同,只是没有 {letter}{letter|digit}*
-
后面的引号,用于在指令中作为指令的一个操作数
- NUM
数字串,其格式是一个以可选的正号或者负号起始 {+|-}?{digit}+
-
后面跟任意个数字的序列。由于机器是8位的,所有
-
有符号数的范围是-128~127,所有超出此范围的数据
-
将全部做折回处理
- ASMCODE
汇编指令,是机器的所有指令的助记符,其格式为任意 {letter}+
-
的字母序列。有效的指令通过查询机器指令表进行确定,
-
由于指令表规定的助记符的长度均小于4个字符,所以源
-
程序中合法的指令助记符的长度都应该在4个字符以内。
- CMNT
单行注释,以";"开始 {semicolon}{printable char*}*{\n}
- UNKNOWN
不可识别的字符 任何除了上面的字符以外的字符
lex函数每处理一个字符串就把所识别的单词存入words数组,然后返回字符的类别
3)虚拟机执行机器指令的过程
在汇编器把源程序分析完并且把机器指令加载到虚拟机内存以后,汇编器的任务也就结束了。下面的工作由虚拟机也就是这里设计的CPU去执行程序了。在MC虚拟机类中主要有两个函数负责执行程序,下面分别解释:
1.
负责对虚拟机执行机器指令进行执行之前的准备工作,其中的argc是虚拟机程序的命令行参数的个数,如果用户没有提供,那么就要提问用户让用户指定输入和输出文件名,默认的是stdin和stdout,还有指定是否要进行调试跟踪,还有设置程序的入口点,在分别打开文件成功以后,就调用 execute(entry % 256, data, results, tracing)可是执行程序(注意入口点也是8
位的)。
2.void MC::execute(MC_bytes initpc, FILE * data, FILE * results,bool tracing)
这是虚拟机执行已经加载到虚拟机内存中机器代码的主体程序,在开始的地方要进行一些初始化工作,比如初始化寄存器状态,初始化CPU的运行状态为运行状态等等。紧跟着就是一个大循环用于逐个读取在虚拟机内存中的机器指令然后根据不同的指令所要进行的工作进行处理,每一次执行循环都要更新PC值,也就是程序计数器的值,说白了这里的实现就是通过增加内存数组中的下标实现的,比如说:
-
//如下循环进行实际的取指,分析解释,执行指令过程,直到停机halt或出现运行时错误
-
do {
-
cpu.ir = mem[cpu.pc]; // 从内存中取指令送入指令寄存器
-
pcnow = cpu.pc; // 保存PC的先前值
-
increment(cpu.pc); // PC指向下一条将要执行的指令地址
-
if (tracing)
-
trace(results, pcnow); //若调试,输出全部cpu内部寄存器值
-
case MC_dec: //累加器A自减1,影响标志器
-
decrement(cpu.a);
-
setflags(cpu.a);
-
break;
-
case MC_incx: //变址寄存器 x 自增1,影响标志器
-
increment(cpu.x);
-
setflags(cpu.x);
-
break;
-
........
每一条指令都有自己所对应的一个case语句,用于处理指令需要执行的操作而且还要相应的设置寄存器的值等等。
需要指出的是由于机器是8位的,所以在增加,或者减少以及得到索引值的时候都要对256取模也就是要进行?折回?操作,具体的可以查看increament,decreament,index三个函数的实现。
3.虚拟机运行时的存储区组织
虚拟机的内存空间地址范围从00H~0FFH,就是256字节,所有的程序,数据以及堆栈都是在这256字节上进行分配的,汇编器对源程序处理完以后都会把机器指令装入到从地址0开始的一片连续的内存单元中,就是说程序总是占据在低端的内存区,在程序代码区以外的内存区作为数据区和堆栈区。由于汇编器不提供任何的内存分配的伪指令,所以用户在编程的时候要有清晰的内存区域的概念,以免破坏了代码和堆栈区造成错误。运行时程序的组织示意图如下:
- 内存低端 00H ----------------------
-
| 程序代码区 |
-
|____________ |
-
| 数据区 |
-
|____________|
-
| |
-
| 堆栈区 |
-
|____________| <--sp指针
- 内存高端
FFH
4.虚拟机对应的命令行参数解析
假设编译生成的可执行文件是myvm,那么命令行参数的格式如下:
myvm [-Trace][-Datafile][Outputfile][-Exx]sourcefile
其中trace用于决定是否对于每条指令都打印出寄存器当前的状态,datafile指定当程序有输入指令时从哪个文件输入,默认
是stdin,outputfile指定程序有输出指令的时候输出到哪个文件中,默认的是stdout,Exx指定入口点,sourcefile是必须提
供的汇编文件。
5.第一个汇编程序--hello world!
每一个程序恐怕刚开始都是从hello word开始,我们也不例外,以下是这个程序的源代码,指令是虚拟机所支持的指令:
- ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
- ; hello.asm
- ; purpose: display "Hello world!"
- ; to run: myvm -e0 hello.asm
- ;
- ; snallie@163.net ,2003.3
- ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-
CLRA
-
LDI HELO
-
TAX
- NEXTCH: LDX 0
-
CPI 0
-
BZE QUIT
-
OTA
-
INCX
-
JMP NEXTCH
- QUIT:
HALT
- ;data "Hello world!" string
- ;as defined in C in decimal
- HELO:
72
-
101
-
108
-
108
-
111
-
32
-
119
-
111
-
114
-
108
-
100
-
33
-
10
-
0
你只需要简单的在命令行中输入:./myvm hello.asm就可以把这个程序汇编成这个虚拟机支持的机器指令并且在终端上显示!!
在汇编完程序的时候虚拟机会同时生成两个文件,一个是lst文件,相当于反汇编这个程序的时候可以查看到的内容,如指令的地址,指令对应的OpCode,还有指令的汇编助记符;另一个是map文件,显示的是程序运行的时候虚拟机内存中的内存映像,也就是说你可以通过查看这个文件明白程序是如何加载都内存中的,与这个汇编程序对应的lst文件如下:
- ;lst文件,最左边的是指令地址,中间的是机器指令(OpCode),最后面的是指令助记符(就是汇编代码),你可以通过查看
- ;每个指令对应的助记符,机器指令还有指令的格式来加深对机器指令的理解,相信会明白很多以前一知半解的问题!
- ;其实这个文件的内容和在linux下使用objdump工具反汇编可执行文件的情况是一样的,聪明的你是否想到了反汇编器是如何
- ;工作的?没错!就是通过得到可执行文件中的二进制数据也就是机器指令或者说是OpCode然后来查找指令表来确定的!
- 00
01 CLRA
- 01
1B0F LDI 0F
- 03
09 TAX
- 04
1A00 LDX 00
- 06
2E00 CPI 00
- 08
360E BZE 0E
- 0A
12 OTA
- 0B
07 INCX
- 0C
3504 JMP 04
- 0E
18 HALT
- 0F
48 ???
- 10
65 ???
- 11
6C ???
- 12
6C ???
- 13
6F ???
- 14
2077 ADD 77
- 16
6F ???
- 17
72 ???
- 18
6C ???
- 19
64 ???
- 1A
210A ADX 0A
- 1C
00 NOP
需要特别说明一点,在halt指令之后紧跟着的是"hello"这个字符串每个字符的ASCII码的16进制表示,有一些数据是汇编器无法识别的,因为它们本身就不是指令而是数据,但是有一些却被汇编器识别了出来比如说2077这个数据被解释成了"ADD 77",其实这个数据还是数据(有点绕口),是"32 119"这两个数据表示连在一起得到的,32转换为16进制数就是0x20,而119就是"0x77",但是汇编器读到32这个数据的时候,在指令表中对应的助记符是ADD,于是汇编器就根据ADD指令的格式来解释后面的数据119于是就生成了这个数据-"2077",其实还是一个数据,把它作为指令解释的时候也许就是合法的指令,如是而已,这个事实也告诉了我们这样一个事实:在计算机内部,所有的东西都是二进制据,只是看你怎么去解释这些二进制数据而已。
以下是map文件:
- 起始地址
十六进制数据 用ASCII码解释的数据
- 00(000)
01 1B 0F 09 1A 00 2E 00 36 0E 12 07 35 04 18 48 ........6...5..H
- 10(016)
65 6C 6C 6F 20 77 6F 72 6C 64 21 0A 00 FF FF FF ello world!..???
- 20(032)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- 30(048)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- 40(064)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- 50(080)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- 60(096)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- 70(112)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- 80(128)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- 90(144)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- A0(160)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- B0(176)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- C0(192)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- D0(208)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- E0(224)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
- F0(240)
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ????????????????
理论上来说,用任何编辑器打开可执行文件的时候看到的内容和这个map文件大体都是一致的,比较这个文件开头的数据和lst中的数据你会得到很多的启示,其实在任何一个可执行程序中也会有存放数据和代码的区域,在现实世界中,每个操作系统下都有自己的可执行程序的格式,windows下是PE,而 linux下是elf,在程序加载到内存中的时候加载器会根据不同的文件格式去查找代码和数据然后分别加载到内存的数据区和代码区--只不过,在这个虚拟机中为了简化问题的实现,可执行文件的内容就是内存中的内容,也没有什么格式可言,只是要求不要超过内存的大小(256bytes)就可以了。
如果你能把上面的汇编文件,lst文件和map文件之间的关系弄明白,那么恭喜你,你对计算机的理解又上了一个台阶!
更多推荐
已为社区贡献4条内容
所有评论(0)