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

2014-07-09

System, Assembly, Y86

上篇,我们从“中间环境”开始,分析模拟器实现中一些关键的部分。

中间环境与单步循环

中间环境中,ESP指向reg数组,换一种说法就是reg数组的一部分变成了运行时栈。这个部分如下:

1 yr_cc  = 0x8, // Non-standard: Flags, ZF SF OF
2 yr_rey = 0x9, // Non-standard: Y return address
3 yr_rex = 0xA  // Non-standard: X return address

需要切换到中间环境时,执行如下代码。其中MM1存储内部环境的ESPMM2指向reg[yr_rex]

1 movd %esp, %mm1
2 movd %mm2, %esp
3 call (%esp)

从中间环境返回内部环境后,只需要从MM1中还原ESP就可以继续程序的执行了。

中间环境中执行的代码,首先是记录Flags,并保存EAX中的值,以便之后的代码使用这个寄存器:

1 y86_check:
2     pushf
3     movd %eax, %mm3

然后检查位于MM7的状态码。如果状态是ys_aok(正常运行),那么继续,否则跳转到之后的代码,按状态码进行相应的处理:

1 y86_check_1:
2     movd %mm7, %eax
3     testl %eax, %eax
4     jnz y86_int

然后是存储在MM6的步骤数。为了方便计算,我们从步骤总数(yr_sx)开始,每执行一步减1,到0时就应该退出了:

1 y86_check_2:
2     movd %mm6, %eax
3     decl %eax
4     movd %eax, %mm6

最后,还原EAX和Flags,返回内部环境:

1 y86_call:
2     movd %mm3, %eax
3     popf
4     ret

另外,代码库中有一个“max版”,在y86sim.c的基础上去除了中间环境。“max版”执行Y86程序时的性能几乎和对应的原生x86程序相同,不过它不能按步数停机,也不包含内存地址等的检查。

特例

在程序执行过程中,会出现一些特殊的情形,是翻译后的x86程序无法直接处理的:

  • 访问不存在的内存地址,抛出地址错误;
  • 写入到不存在的内存地址;
  • 写入Y86机器码,修改程序自身;
  • ret指令跳转到不存在或没有被翻译过的地址。

因此,遇到上面这些情形时,程序会通过一些特殊的状态码来标记它们,并暂时离开Y86的执行过程(从内部环境切换到中间环境、外部环境)。

在实现中,前两个情形可以合并,即“内存地址合法性检查”。我们为它分配了一个状态码ys_ima

它发生在需要访问内存的指令rmmovlmrmovlpushlpoplcall执行前:

1 y86_int_ima:
2     movd %mm4, %eax
3     andl $Y_MASK_NOT_MEM, %eax
4     jnz y86_int_brk
5     xorl %eax, %eax
6     movd %eax, %mm7
7     jmp y86_call

第三个情形对应状态码ys_imc,模拟器为了方便实现,又加了一条约定,认为Y86机器码只存在于内存中靠前部的区域。

对于在Y86机器码范围内进行的写入,我们需要切换到外部环境中处理。

它发生在rmmovlpushlcall执行后:

1 y86_int_imc:
2     movd %mm4, %eax
3     cmpl 16(%esp), %eax
4     jg y86_check_2
5     jmp y86_fin

在外部环境中,会重新把修改后的Y86指令翻译成x86机器码,然后继续程序的执行。

我们对ret指令分配一个单独的状态码ys_ret。遇到ret指令时,程序会直接切换到外部环境,用普通的模拟器的方式执行这条指令。

指令翻译

计算、数据移动等指令会被直接翻译为对应的x86指令。

跳转指令会将跳转目标压入中间环境的堆栈中,然后借用环境切换过程完成跳转。“max版”中是直接跳转。

涉及内存访问和堆栈操作的指令需要对地址加上或减去mem数组的偏移。为了避免破坏Flags,地址运算主要通过lea指令完成。

“max版”中的callret直接使用x86的指令,在停机后再将内存、寄存器中所有x_inst地址范围内的值转换成对应的Y86机器码地址。这个实现是不严谨的,但实际使用中很少产生问题。

还有一个很容易被忽视的细节。

在指令翻译中,需要注意Y86机器码是有分割歧义的。这很像中文分词——“中外科学名著作品大全”如果从第二个字开始解读,就会变成“外科/学名/著作品/大全”,这也是通顺的。

说到中文的分割,我不禁想起了书名最后一个字被遮住的那本——

《动词大词##》

《动词大词典》……(图片来源:@蓝雪枫)

我们来看一个Y86中的例子,从位置0、1、2、3、4开始,机器码可以代表完全不同的指令序列:

1 70 00 20 20 10
2 ^ jmp 0x10202000
3    ^ nop ; mov %edx, %eax ; hlt
4       ^ mov %edx, %eax ; hlt
5          ^ mov %ecx, %eax
6             ^ hlt

因此我们需要借助x_map来解决这个问题。

翻译程序每次遇到跳转到未知地址的jmp指令,都会将x_map标记为一个特殊的值。一段程序翻译完成后会扫描一遍x_map,如果存在被标记的还没有翻译的位置,就会继续从这个位置开始翻译。如果翻译过程中遇到了x_map已经存在的地址(也就是已经翻译的),则会直接插入一个跳转并结束翻译。

遇到错误指令时,考虑到指令并不一定会被执行到,因此会就地插入一个类似halt的指令并返回错误对应的状态码。这样,只有执行到相应位置时,程序才会返回错误状态。

模拟器的实现中还有更多的细节,限于篇幅就不详细梳理了。有兴趣的读者可以到GitHub仓库查看模拟器和汇编器的实现代码