时间:2023-06-29 20:21:02 | 来源:网站运营
时间:2023-06-29 20:21:02 来源:网站运营
400 行 C 代码实现一个虚拟机:↓推荐关注↓注意:这个虚拟机是Literate Programming 的产物。本文会解释每段代码的原理,最终的实现就是将这些代码片段连起来。
注:编译器也解决了类似的跨平台问题,它将标准的高级语言编写的程序编译成能在不同 CPU 架构上执行的程序。Java Virtual Machine (JVM) 就是一个非常成功的例子。JVM 本身是一个中等大小、程序员完全能够看懂的程序,因此很 容易将它移植到包括手机在内的上千种设备上。只要在设备上实现了 JVM,接下来任何 Java、Kotlin 或 Clojure 程序都无需任何修改就可以直接运行在这个设备上。唯一的开销 来自虚拟机自身以及机器之上的 进一步抽象。大部分情况下,这完全是可以接受的。
相比之下,虚拟机的跨平台方式是自己创建一个标准的 CPU 架 构,然后在不同的物理设备上模拟这个 CPU 架构。编译器方式的优点是没有运行时开销 (runtime overhead),但实现一个支持多平台的编译器是非常困难的,但实现一个虚拟 机就简单多了。
在实际中,人们会根据需求的不同混合使用虚拟机和编译器,因为二者工 作在不同的层次。
/*65536locations*/uint16_tmemory[UINT16_MAX];
enum{R_R0=0,R_R1,R_R2,R_R3,R_R4,R_R5,R_R6,R_R7,R_PC,/*programcounter*/R_COND,R_COUNT};
和内存一样,我们也用数组来表示这些寄存器:uint16_treg[R_COUNT];
enum{OP_BR=0,/*branch*/OP_ADD,/*add*/OP_LD,/*load*/OP_ST,/*store*/OP_JSR,/*jumpregister*/OP_AND,/*bitwiseand*/OP_LDR,/*loadregister*/OP_STR,/*storeregister*/OP_RTI,/*unused*/OP_NOT,/*bitwisenot*/OP_LDI,/*loadindirect*/OP_STI,/*storeindirect*/OP_JMP,/*jump*/OP_RES,/*reserved(unused)*/OP_LEA,/*loadeffectiveaddress*/OP_TRAP/*executetrap*/};
注:Intel x86 架构有几百条指令,而其他的架构例如 ARM 和 LC-3 只有很少的指令 。较小的指令集称为精简指令集(RISC),较大 的指令集称为复杂指令集(CISC)。更大 的指令集本质上通常并没有提供新特性,只是使得编写 汇编更加方便 。一条 CISC 指令能做的事情可能需要好几条 RISC 才能完成。
但是,对设计和制造工程 师来说,CISC 更加复杂和昂贵,设计和制造业更贵。包括这一点在内的一些权衡使得指 令设计也在不断变化。
if (x > 0) { ... }
之类的逻辑条件。enum{FL_POS=1<<0,/*P*/FL_ZRO=1<<1,/*Z*/FL_NEG=1<<2,/*N*/};
注:<< 和 >> 表示移位操作。.ORIG x3000 ; this is the address in memory where the program will be loadedLEA R0, HELLO_STR ; load the address of the HELLO_STR string into R0PUTs ; output the string pointed to by R0 to the consoleHALT ; halt the programHELLO_STR .STRINGZ "Hello World!" ; store this string here in the program.END ; mark the end of the file
和 C 类似,这段程序从最上面开始,每次执行一条声明(statement)。但和 C 不同的是, 这里没有作用域符号 {} 或者控制结构(例如 if 和 while),仅仅是一个扁平的声 明列表(a flat list of statements)。这样的程序更容易执行。注:虽然在开发中编译器(compiler)和汇编器(assembler)的角色是类似的,但二者 是两个不同的工具。汇编器只是简单地将程序员编写的文本编码(encode)成二进制格式 ,将其中的符号替换成相应的二进制表示并打包到指令内。
.ORIG
和.STRINGZ
看起来像是指令,但其实不是,它们称为汇编制导命令 (assembler directives),可以生成一段代码或数据。例如,.STRINGZ
会在它所在的 位置插入一段字符串。AND R0, R0, 0 ; clear R0LOOP ; label at the top of our loopADD R0, R0, 1 ; add 1 to R0 and store back in R0ADD R1, R0, -10 ; subtract 10 from R0 and store back in R1BRn LOOP ; go back to LOOP if the result was negative... ; R0 is now 10!
注:本文不需要读者会编写汇编代码。但如果你感兴趣,你可以使用 LC-3 工具来编写和汇编你自己写的汇编程序。
“The view that machines cannot give rise to surprises is due, I believe, to a fallacy to which philosophers and mathematicians are particularly subject. This is the assumption that as soon as a fact is presented to a mind all consequences of that fact spring into the mind simultaneously with it. It is a very useful assumption under many circumstances, but one too easily forgets that it is false.” — Alan M. Turing
intmain(intargc,constchar*argv[]){{LoadArguments,12}{Setup,12}/*setthePCtostartingposition*/enum{PC_START=0x3000};/*0x3000isthedefault*/reg[R_PC]=PC_START;intrunning=1;while(running){uint16_tinstr=mem_read(reg[R_PC]++);/*FETCH*/uint16_top=instr>>12;switch(op){caseOP_ADD:{ADD,6}break;caseOP_AND:{AND,7}break;caseOP_NOT:{NOT,7}break;caseOP_BR:{BR,7}break;caseOP_JMP:{JMP,7}break;caseOP_JSR:{JSR,7}break;caseOP_LD:{LD,7}break;caseOP_LDI:{LDI,6}break;caseOP_LDR:{LDR,7}break;caseOP_LEA:{LEA,7}break;caseOP_ST:{ST,7}break;caseOP_STI:{STI,7}break;caseOP_STR:{STR,7}break;caseOP_TRAP:{TRAP,8}break;caseOP_RES:caseOP_RTI:default:{BADOPCODE,7}break;}}{Shutdown,12}}
ADD R2 R0 R1 ; add the contents of R0 to R1 and store in R2.
在立即模式中,第二个数直接存储在指令中,而不是寄存器中。这种模式更加方便,因 为程序不需要额外的指令来将数据从内存加载到寄存器,直接从指令中就可以拿到这个值。这种方式的限制是存储的数很小,不超过 2^5 = 32(无符号)。这种方式很适合对一个值 进行递增。用汇编描述就是:ADD R0 R0 1 ; add 1 to R0 and store back in R0
下面一段解释来自 LC-3 规范:If bit [5] is 0, the second source operand is obtained from SR2. If bit [5] is 1, the second source operand is obtained by sign-extending the imm5 field to 16 bits. In both cases, the second source operand is added to the contents of SR1 and the result stored in DR. (Pg. 526)这段解释也就是我们前面讨论的内容。但什么是 “sign-extending”(有符号扩展)?虽然立即 模式中存储的值只有 5 比特,但这个值需要加到一个 16 比特的值上。因此,这些 5 比 特的数需要扩展到 16 比特才能和另一个数相匹配。对于正数,我们可以在前面填充 0, 填充之后值是不变的。但是,对于负数,这样填充会导致问题。例如, -1 的 5 比特表示 是 11111。如果我们用 0 填充,那填充之后的 0000 0000 0001 1111 等于 32!这种 情况下就需要使用有符号扩展( sign extension),对于正数填充 0,对负数填充 1。
uint16_tsign_extend(uint16_tx,intbit_count){if((x>>(bit_count-1))&1){x|=(0xFFFF<<bit_count);}returnx;}
注:如果你如何用二进制表示负数感兴趣,可以查阅二进制补码(Two’s Complement) 相关的内容。本文中只需要知道怎么进行有符号扩展就行了。规范中还有一句:
The condition codes are set, based on whether the result is negative, zero, or positive. (Pg. 526)前面我们定义的那个条件标记枚举类型现在要派上用场了。每次有值写到寄存器时,我们 需要更新这个标记,以标明这个值的符号。为了方便,我们用下面的函数来实现这个功能:
voidupdate_flags(uint16_tr){if(reg[r]==0){reg[R_COND]=FL_ZRO;}elseif(reg[r]>>15){/*a1intheleft-mostbitindicatesnegative*/reg[R_COND]=FL_NEG;}else{reg[R_COND]=FL_POS;}}
现在我们就可以实现 ADD 的逻辑了:{uint16_tr0=(instr>>9)&0x7;/*destinationregister(DR)*/uint16_tr1=(instr>>6)&0x7;/*firstoperand(SR1)*/uint16_timm_flag=(instr>>5)&0x1;/*whetherweareinimmediatemode*/if(imm_flag){uint16_timm5=sign_extend(instr&0x1F,5);reg[r0]=reg[r1]+imm5;}else{uint16_tr2=instr&0x7;reg[r0]=reg[r1]+reg[r2];}update_flags(r0);}
本节包含了大量信息,这里再总结一下:An address is computed by sign-extending bits [8:0] to 16 bits and adding this value to the incremented PC. What is stored in memory at this address is the address of the data to be loaded into DR. (Pg. 532)和前面一样,我们需要将这个 9 比特的 PCoffset9 以有符号的方式扩展到 16 比特,但 这次是将扩展之后的值加到当前的程序计数器 PC(如果回头去看前面的 while 循 环,就会发现这条指令加载之后 PC 就会递增)。相加得到的结果(也就是 PC 加完之后的 值)表示一个内存地址,这个地址中存储的值表示另一个地址,后者中存储的是需要加载到 DR 中的值。
//thevalueoffar_dataisanaddress//ofcoursefar_dataitself(thelocationinmemorycontainingtheaddress)hasanaddresschar*far_data="apple";//Inmemoryitmaybelayedoutlikethis://AddressLabelValue//0x123:far_data=0x456//...//0x456:string='a'//ifPCwasat0x100//LDIR00x023//wouldload'a'intoR0
和 ADD 类似,将值放到 DR 之后需要更新条件标志位:The condition codes are set based on whether the value loaded is negative, zero, or positive. (Pg. 532)下面是我对 LDI 的实现(后面章节中会介绍 mem_read):
{uint16_tr0=(instr>>9)&0x7;/*destinationregister(DR)*/uint16_tpc_offset=sign_extend(instr&0x1ff,9);/*PCoffset9*//*addpc_offsettothecurrentPC,lookatthatmemorylocationtogetthefinaladdress*/reg[r0]=mem_read(mem_read(reg[R_PC]+pc_offset));update_flags(r0);}
后面会看到,这些指令的实现中,大部分辅助功能函数都是可以复用的。abort();
{uint16_tr0=(instr>>9)&0x7;uint16_tr1=(instr>>6)&0x7;uint16_timm_flag=(instr>>5)&0x1;if(imm_flag){uint16_timm5=sign_extend(instr&0x1F,5);reg[r0]=reg[r1]&imm5;}else{uint16_tr2=instr&0x7;reg[r0]=reg[r1]®[r2];}update_flags(r0);}
{uint16_tr0=(instr>>9)&0x7;uint16_tr1=(instr>>6)&0x7;reg[r0]=~reg[r1];update_flags(r0);}
{uint16_tpc_offset=sign_extend((instr)&0x1ff,9);uint16_tcond_flag=(instr>>9)&0x7;if(cond_flag®[R_COND]){reg[R_PC]+=pc_offset;}}
{/*AlsohandlesRET*/uint16_tr1=(instr>>6)&0x7;reg[R_PC]=reg[r1];}
{uint16_tr1=(instr>>6)&0x7;uint16_tlong_pc_offset=sign_extend(instr&0x7ff,11);uint16_tlong_flag=(instr>>11)&1;reg[R_R7]=reg[R_PC];if(long_flag){reg[R_PC]+=long_pc_offset;/*JSR*/}else{reg[R_PC]=reg[r1];/*JSRR*/}break;}
{uint16_tr0=(instr>>9)&0x7;uint16_tpc_offset=sign_extend(instr&0x1ff,9);reg[r0]=mem_read(reg[R_PC]+pc_offset);update_flags(r0);}
{uint16_tr0=(instr>>9)&0x7;uint16_tr1=(instr>>6)&0x7;uint16_toffset=sign_extend(instr&0x3F,6);reg[r0]=mem_read(reg[r1]+offset);update_flags(r0);}
{uint16_tr0=(instr>>9)&0x7;uint16_tpc_offset=sign_extend(instr&0x1ff,9);reg[r0]=reg[R_PC]+pc_offset;update_flags(r0);}
{uint16_tr0=(instr>>9)&0x7;uint16_tpc_offset=sign_extend(instr&0x1ff,9);mem_write(reg[R_PC]+pc_offset,reg[r0]);}
{uint16_tr0=(instr>>9)&0x7;uint16_tpc_offset=sign_extend(instr&0x1ff,9);mem_write(mem_read(reg[R_PC]+pc_offset),reg[r0]);}
{uint16_tr0=(instr>>9)&0x7;uint16_tr1=(instr>>6)&0x7;uint16_toffset=sign_extend(instr&0x3F,6);mem_write(reg[r1]+offset,reg[r0]);}
enum{TRAP_GETC=0x20,/*getcharacterfromkeyboard,notechoedontotheterminal*/TRAP_OUT=0x21,/*outputacharacter*/TRAP_PUTS=0x22,/*outputawordstring*/TRAP_IN=0x23,/*getcharacterfromkeyboard,echoedontotheterminal*/TRAP_PUTSP=0x24,/*outputabytestring*/TRAP_HALT=0x25/*halttheprogram*/};
你可能会觉得奇怪:为什么 trap code 没有包含在指令编码中?这是因为它们没有给 LC-3 带来任何新功能,只是提供了一种方便地执行任务的方式(和 C 中的系统函数类似 )。在官方 LC-3 模拟器中,trap routines 是用汇编实现的。当调用到 trap code 时,PC 会移动到 code 对应的地址。CPU 执行这个函数( procedure)的指令流,函数结束后 PC 重置到 trap 调用之前的位置。注:这就是为什么程序从 0x3000 而不是 0x0 开始的原因。低地址空间是特意留出来 给 trap routine 用的。规范只定义了 trap routine 的行为,并没有规定应该如何实现。在我们这个虚拟机中, 将会用 C 实现。当触发某个 trap code 时,会调用一个相应的 C 函数。这个函数执行 完成后,执行过程会返回到原来的指令流。
注:从键盘获取输入就是一个例子。汇编版本使用一个循环来持续检查键盘有没有输入 ,这会消耗大量 CPU 而实际上没做多少事情!使用操作系统提供的某个合适的输入函 数的话,程序可以在收到输入之前一直 sleep。TRAP 处理逻辑:
switch(instr&0xFF){caseTRAP_GETC:{TRAPGETC,9}break;caseTRAP_OUT:{TRAPOUT,9}break;caseTRAP_PUTS:{TRAPPUTS,8}break;caseTRAP_IN:{TRAPIN,9}break;caseTRAP_PUTSP:{TRAPPUTSP,9}break;caseTRAP_HALT:{TRAPHALT,9}break;}
和前面几节类似,我会拿一个 trap routine 作为例子展示如何实现,其他的留给读者自己 完成。Write a string of ASCII characters to the console display. The characters are contained in consecutive memory locations, one character per memory location, starting with the address specified in R0. Writing terminates with the occurrence of x0000 in a memory location. (Pg. 543)意思是说字符串是存储在一个连续的内存区域。注意这里和 C 中的字符串有所不同:C 中每个字符占用一个 byte;LC-3 中内存寻找是 16 位的,每个字符都是 16 位,占用 两个 byte。因此要用 C 函数打印这些字符,需要将每个值先转换成 char 类型再输出:
{/*onecharperword*/uint16_t*c=memory+reg[R_R0];while(*c){putc((char)*c,stdout);++c;}fflush(stdout);}
这就是 PUTS trap routine 的实现了。如果熟悉 C 的话,这个函数应该很容易理解。现 在你可以按照 LC-3 规范,自己动手实现其他的 trap routine 了。/*readasingleASCIIchar*/reg[R_R0]=(uint16_t)getchar();
putc((char)reg[R_R0],stdout);fflush(stdout);
printf("Enteracharacter:");charc=getchar();putc(c,stdout);reg[R_R0]=(uint16_t)c;
{/*onecharperbyte(twobytesperword)hereweneedtoswapbacktobigendianformat*/uint16_t*c=memory+reg[R_R0];while(*c){charchar1=(*c)&0xFF;putc(char1,stdout);charchar2=(*c)>>8;if(char2)putc(char2,stdout);++c;}fflush(stdout);}
puts("HALT");fflush(stdout);running=0;
voidread_image_file(FILE*file){uint16_torigin;/*theorigintellsuswhereinmemorytoplacetheimage*/fread(&origin,sizeof(origin),1,file);origin=swap16(origin);/*weknowthemaximumfilesizesoweonlyneedonefread*/uint16_tmax_read=UINT16_MAX-origin;uint16_t*p=memory+origin;size_tread=fread(p,sizeof(uint16_t),max_read,file);/*swaptolittleendian*/while(read-->0){*p=swap16(*p);++p;}}
注意读取前 16 比特之后,对这个值执行了 swap16()。这是因为 LC-3 程序是大端 (big-endian),但现在大部分计算机都是小端的(little-endian),因此需要做大小端 转换。如果你是在某些特殊的机器 (例如 PPC)上运行,那就不 需要这些转换了。uint16_tswap16(uint16_tx){return(x<<8)|(x>>8);}
注:大小端(Endianness)是指对于 一个整型数据,它的每个字节应该如何解释。在小端中,第一个字节是最低位,而在大端 中刚好相反,第一个字节是最高位。据我所知,这个顺序完全是人为规定的。不同的公司 做出的抉择不同,因此我们这些后来人只能针对大小端做一些特殊处理。要理解本文中大 小端相关的内容,知道这些就足够了。我们再封装一下前面加载程序的函数,接受一个文件路径字符串作为参数,这样更加方便:
intread_image(constchar*image_path){FILE*file=fopen(image_path,"rb");if(!file){return0;};read_image_file(file);fclose(file);return1;}
enum{MR_KBSR=0xFE00,/*keyboardstatus*/MR_KBDR=0xFE02/*keyboarddata*/};
内存映射寄存器使内存访问稍微复杂了一些。这种情况下不能直接读写内存位置,而要使 用 setter 和 getter 辅助函数。当获取输入时,getter 会检查键盘输入并更新两 个寄存器(也就是相应的内存位置)。voidmem_write(uint16_taddress,uint16_tval){memory[address]=val;}uint16_tmem_read(uint16_taddress){if(address==MR_KBSR){if(check_key()){memory[MR_KBSR]=(1<<15);memory[MR_KBDR]=getchar();}else{memory[MR_KBSR]=0;}}returnmemory[address];}
这就是我们的虚拟机的最后一部分了!只要你实现了前面提到的 trap routine 和指令,你 的虚拟机就即将能够运行了!uint16_tcheck_key(){fd_setreadfds;FD_ZERO(&readfds);FD_SET(STDIN_FILENO,&readfds);structtimevaltimeout;timeout.tv_sec=0;timeout.tv_usec=0;returnselect(1,&readfds,NULL,NULL,&timeout)!=0;}
下面是特定于 Unix 的设置终端输入的代码:structtermiosoriginal_tio;voiddisable_input_buffering(){tcgetattr(STDIN_FILENO,&original_tio);structtermiosnew_tio=original_tio;new_tio.c_lflag&=~ICANON&~ECHO;tcsetattr(STDIN_FILENO,TCSANOW,&new_tio);}voidrestore_input_buffering(){tcsetattr(STDIN_FILENO,TCSANOW,&original_tio);}
当程序被中断时,我们需要将终端的设置恢复到默认:voidhandle_interrupt(intsignal){restore_input_buffering();printf("/n");exit(-2);}signal(SIGINT,handle_interrupt);disable_input_buffering();
Play 2048!{2048 Example 13}Control the game using WASD keys.Are you on an ANSI terminal (y/n)? y+--------------------------+| || || || 2 || || 2 || || || |+--------------------------+
{InstructionC++14}template<unsignedop>voidins(uint16_tinstr){uint16_tr0,r1,r2,imm5,imm_flag;uint16_tpc_plus_off,base_plus_off;uint16_topbit=(1<<op);if(0x4EEE&opbit){r0=(instr>>9)&0x7;}if(0x12E3&opbit){r1=(instr>>6)&0x7;}if(0x0022&opbit){r2=instr&0x7;imm_flag=(instr>>5)&0x1;imm5=sign_extend((instr)&0x1F,5);}if(0x00C0&opbit){//Base+offsetbase_plus_off=reg[r1]+sign_extend(instr&0x3f,6);}if(0x4C0D&opbit){//Indirectaddresspc_plus_off=reg[R_PC]+sign_extend(instr&0x1ff,9);}if(0x0001&opbit){//BRuint16_tcond=(instr>>9)&0x7;if(cond®[R_COND]){reg[R_PC]=pc_plus_off;}}if(0x0002&opbit){//ADDif(imm_flag){reg[r0]=reg[r1]+imm5;}else{reg[r0]=reg[r1]+reg[r2];}}if(0x0020&opbit){//ANDif(imm_flag){reg[r0]=reg[r1]&imm5;}else{reg[r0]=reg[r1]®[r2];}}if(0x0200&opbit){reg[r0]=~reg[r1];}//NOTif(0x1000&opbit){reg[R_PC]=reg[r1];}//JMPif(0x0010&opbit){//JSRuint16_tlong_flag=(instr>>11)&1;pc_plus_off=reg[R_PC]+sign_extend(instr&0x7ff,11);reg[R_R7]=reg[R_PC];if(long_flag){reg[R_PC]=pc_plus_off;}else{reg[R_PC]=reg[r1];}}if(0x0004&opbit){reg[r0]=mem_read(pc_plus_off);}//LDif(0x0400&opbit){reg[r0]=mem_read(mem_read(pc_plus_off));}//LDIif(0x0040&opbit){reg[r0]=mem_read(base_plus_off);}//LDRif(0x4000&opbit){reg[r0]=pc_plus_off;}//LEAif(0x0008&opbit){mem_write(pc_plus_off,reg[r0]);}//STif(0x0800&opbit){mem_write(mem_read(pc_plus_off),reg[r0]);}//STIif(0x0080&opbit){mem_write(base_plus_off,reg[r0]);}//STRif(0x8000&opbit){//TRAP{TRAP,8}}//if(0x0100&opbit){}//RTIif(0x4666&opbit){update_flags(r0);}}{OpTable14}staticvoid(*op_table[16])(uint16_t)={ins<0>,ins<1>,ins<2>,ins<3>,ins<4>,ins<5>,ins<6>,ins<7>,NULL,ins<9>,ins<10>,ins<11>,ins<12>,NULL,ins<14>,ins<15>};
这里的技巧是从 Bisqwit’s NES emulator 学来的。如果你对仿真或 NES 感兴趣,强烈建议观看他的视频。译者:ArthurChiao- EOF -
来源:https://arthurchiao.art/blog/write-your-own-virtual-machine-zh/
原文链接:
https://justinmeiners.github.io/lc3-vm/
关键词:虚拟,实现