Y86指令集(模拟器篇-下篇)
2014-07-09
接上篇,我们从“中间环境”开始,分析模拟器实现中一些关键的部分。
中间环境与单步循环
中间环境中,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
存储内部环境的ESP
而MM2
指向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
。
它发生在需要访问内存的指令rmmovl
、mrmovl
、pushl
、popl
、call
执行前:
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机器码范围内进行的写入,我们需要切换到外部环境中处理。
它发生在rmmovl
、pushl
、call
执行后:
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版”中的call
和ret
直接使用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仓库查看模拟器和汇编器的实现代码。