Y86指令集(模拟器篇-上篇)

2014-06-30

System, Assembly, Y86

Y86是一个极为简化的、用于教学的CPU指令集,介绍篇中,我们介绍了Y86的体系结构及主要的指令。

我们的作业要求完成Y86的模拟器和汇编器。我们提交的实现必须与课程中提供的参照版实现有完全相同的行为,最后的分数由通过的测试case个数决定。所以直接把各个case的结果print到输出文件也是可以的啦~

对于模拟器,我没有使用课程给定的框架,而是从零开始搭建了一个使用JIT编译技术——或者更准确地说是“动态字节码翻译”——的实现。

这份作业在GitHub上有实作的代码。下面整理一些实现过程中的思路。

数据模型

我们需要模拟一块内存、一些寄存器和状态信息。由于是实现Y86机器码到原生x86机器码的翻译,我们还需要存储x86机器码(包括机器码尾部的地址,方便继续追加)以及一个额外的对应表用来记录Y86机器码到x86机器码的对应关系。

内存在初始状态下包含全部Y86字节码(默认从地址0开始),其余区域则填零。Y86有一个有趣的特性,halt指令编码为00,因此跳转到填零的地址时就会停机。

在实现中,除了八个通用寄存器,Y86模拟器的状态(PC、Flags、步骤数等)也被认为是“特殊的”寄存器。

由于模拟器的输出中需要比较初始状态和结束状态,因此需要留一份初始状态的内存备份。同样,寄存器也有一份备份。

模拟器运行过程中所需的数据大致如下:

1 typedef struct {
2     Y_char bak_mem[...];
3     Y_char mem[...];
4     Y_word bak_reg[...];
5     Y_word reg[...];
6     Y_char x_inst[...];
7     Y_addr x_end;
8     Y_addr x_map[...];
9 } Y_data;

x86机器码

x86的机器码的编码方式出于实用考虑较为紧凑。一种常用的格式是:前缀/操作码+寄存器+整数,其中每个寄存器三个bit,剩下最高位的两个bit用来表示一些额外的信息。具体可以参考这套表格

在模拟器里,有三个函数用来写入x86机器码。它们分别向x_inst写入一个字节、一个32位整数和一个指针,并移动x_end

翻译每条Y86指令之前,x_end会被记录到x_map中下标为当前Y86指令地址的元素内。

为了让机器码可以执行,Y_data的内存是直接用mmap分配的。这!是!在!偷!懒!哦!其实应该单独分配x_inst的。

1 // #include <sys/mman.h>
2 
3 mmap(
4     0, sizeof(Y_data),
5     PROT_READ | PROT_WRITE | PROT_EXEC,
6     MAP_PRIVATE | MAP_ANONYMOUS,
7     -1, 0
8 );

然后跳转到x_inst里就能执行代码了。

环境切换

我们将执行Y86程序之前、之后的部分称为“外部环境”,包括载入文件、机器码翻译以及输出结果等过程,而将执行Y86程序本身(调用x_inst中的字节码)称为“内部环境”。其实还有个介于外部和内部的“中间环境”,用来检查步骤数和内存地址,以及帮助执行一些特殊的Y86指令,之后我们会进一步详解这个“中间环境”。

执行翻译后的程序会破坏所有的寄存器和Flags,因此模拟器在外部环境和内部环境之间切换的过程中,需要相应地保存、恢复它们。还好,x86提供了pushalpopalpushfpopf这四个指令,我们可以很方便地完成这一切。

由于栈顶指针ESP破坏后无法用popal恢复,我们借用MMX寄存器来保存它的值。事实上,离开“外部环境”后,我们将大量使用MMX寄存器来暂存数据,以减少对通用寄存器的占用。

在切换过程以及“中间环境”中,ESP将会指向Y_datareg数组中间,这样我们可以方便地将必要的信息写入数组。

reg数组的布局是:

 1 yrl_edi = 0x0,
 2 yrl_esi = 0x1,
 3 yrl_ebp = 0x2,
 4 yrl_esp = 0x3,
 5 yrl_ebx = 0x4,
 6 yrl_edx = 0x5,
 7 yrl_ecx = 0x6,
 8 yrl_eax = 0x7,
 9 yr_cc  = 0x8, // Non-standard: Flags, ZF SF OF
10 yr_rey = 0x9, // Non-standard: Y return address
11 yr_rex = 0xA, // Non-standard: X return address
12 yr_pc  = 0xB, // Non-standard: Y inst pointer
13 yr_len = 0xC, // Non-standard: Y inst size
14 yr_sx  = 0xD, // Non-standard: Step max
15 yr_sc  = 0xE, // Non-standard: MM6: Step counter (decrease)
16 yr_st  = 0xF  // Non-standard: MM7: Stat

yrl_ediyrl_eax是通过pushal保存的,yr_cc通过pushf保存。注意“yrl”的“l”表示“layout”,它和寄存器编号(EAXEDI为0到7)的顺序正好相反。

yrl_rexyrl_rey是执行位置的指针,用于环境切换。为了获得程序当前执行到的位置(EIP),我们使用call指令——它会将当前的EIP压栈。根据离开内部环境时的EIP和对应表x_map,我们就能推算出Y86中PC的值。每次从外部环境进入内部环境前后,都会有一个EIPPC之间的转换过程。

状态码

Y86标准的状态有:正常(AOK)、停机(hlt)、错误(adrins)。

为了方便模拟器内部的信息交换,我将状态扩展到了11个,并且分别约定了状态码:

 1 ys_aok = 0x0, // Started (running)
 2 ys_hlt = 0x1, // Halted
 3 ys_adr = 0x2, // Address error
 4 ys_ins = 0x3, // Instruction error
 5 ys_clf = 0x4, // Non-standard: Loader error
 6 ys_ccf = 0x5, // Non-standard: Compiler error
 7 ys_adp = 0x6, // Non-standard: ADR error caused by mem protection
 8 ys_inp = 0x7, // Non-standard: INS error caused by mem protection
 9 ys_ima = 0x8, // Non-standard: Memory access interrupt
10 ys_imc = 0x9, // Non-standard: Memory changed interrupt
11 ys_ret = 0xA  // Non-standard: Ret interrupt

下篇中,我们将介绍“中间过程”的作用,以及模拟器对一些特殊的Y86指令的处理方式。