本文着重在“指令解释器”工作原理上面;而不是一个高度复杂精密的解释器 即包含 JIT “即时编译器”(逐行编译)或称“中间指令编译缓存” 类似于“.NET CLR”、“JAVA JVM”;那么“解释器”是如何工作的 这是一个有点意思的东西;

相信如果是搞过“FC”模拟器的玩家 应该对此不感意外;所谓“解释器”与对“FC”模拟器中模拟“6502 CPU”处理器工作原理是相同的 不同的只是模拟的“指令集”是不同的;但都有一个相同之处 即对应二进制代码(binary machine code)“i386-is”、“amd64-is”的x86/x64的pc处理器无法处理;

而“模拟器”的作用则是令“x86/x64”的pc处理器可以理解并执行它的一种方法;对应到“解释器”是相同de 那么只要你对“模拟器”模拟对应平台CPU有一些了解 那么则等于理解了“解释器”的工作原理;或者这么说可能被人抨击狭隘,但这么说毫无问题;“解释器”与“编译缓存(JIT Stub)”之间不存在冲突 它只是“解释器”提供的一种效率优化的策略;

但“解释器”与“模拟器”又有些不同 不同的地方在于“模拟器”是模拟一个CPU or PPU芯片按照对应平台指令集,它需要模拟“stack、寄存器、中断、 内存访问、L1/2/3 cache、register var条目”等;按照汇编的格式执行;

而“解释器”都是纯对“stack”操作,理解吗?通俗的说,“模拟器”大而全不止需要操作虚拟“stack”与“heap”还要操作其它的;而“解释器”只需要操作虚拟“stack”与“heap”就可以的;那么在这里举一个例子:

char _i = 10;  
char _n = i + 5;
_asm
{
mov ax, byte ptr[_i]
add ax, 5
mov byte ptr[_i], ax
}

上面是一串asm;假设6502 CPU可以工作,模拟器则必须按照此汇编的二进制格式执行它的代码;途中会涉及到ax寄存器 包括对 符号“_i”位于stack上地址solt进行读写;那么“char _i = 10; char _n = i + 5;” 在解释器中它的指令格式一般向那种类型呢?

_il
{
ldloc.0
ldc.i4 5
add 
stloc.0
}

JAVA与.NET解释器上跑的“中间指令”格式基本是相同的;它们都只是操作“stack”与“heap” 至于“instruction”怎么定义 这个完全看“指令集”设计者自己;想怎么设计就怎么设计 只要不违背原则就可以; 

一般的“解释器”是不具备“编译缓存”的;它们都是利用一个简单的循环计次执行指令;而在本中文提供的示例解释器 是没有提供对多个函数调用的支持的;不过支持被解释的指令调用别的函数它并不复杂;

uint64_t CLRCALL clr_call(uint64_t* s, uint64_t* localvar, uint64_t* argvar, uint8_t* il, uint32_t illen)
{
	register uint8_t* size = &il[illen];
	register uint8_t* eip = il;
	while (eip < size)
	{
		register uint8_t opc = *eip++;
		if (opc == IL_ADD) {
			IL_EXP(s, +);
		}
		else if (opc == IL_SUB) {
			IL_EXP(s, -);
		}
		else if (opc == IL_MUL) {
			IL_EXP(s, *);
		}
		else if (opc == IL_DIV) {
			IL_EXP(s, / );
		}
		else if (opc == IL_REM) {
			IL_EXP(s, %);
		}
		else if (opc == IL_STLOC_0) {
			*localvar = *--s;
		}
		else if (opc == IL_STLOC_1) {
			localvar[1] = *--s;
		}
		else if (opc == IL_STLOC_2) {
			localvar[2] = *--s;
		}
		else if (opc == IL_STLOC_3) {
			localvar[3] = *--s;
		}
		else if (opc == IL_DUP) {
			*s++ = s[-1];
		}
		else if (opc == IL_BR) {
			IL_JMP(eip, il);
		}
		else if (opc == IL_CEQ) {
			IL_EXP(s, == );
		}
		else if (opc == IL_BGT) {
			IL_EXP(s, > );
		}
		else if (opc == IL_BLT) {
			IL_EXP(s, < );
		}
		else if (opc == IL_BGE) {
			IL_EXP(s, >= );
		}
		else if (opc == IL_BLE) {
			IL_EXP(s, <= );
		}
		else if (opc == IL_BRFALSE) {
			if (!*--s)
				IL_JMP(eip, il);
		}
		else if (opc == IL_BRTRUE) {
			if (*--s)
				IL_JMP(eip, il);
		}
		else if (opc == IL_LDLOC_0) {
			*s++ = *localvar;
		}
		else if (opc == IL_LDLOC_1) {
			*s++ = localvar[1];
		}
		else if (opc == IL_LDLOC_2) {
			*s++ = localvar[2];
		}
		else if (opc == IL_LDLOC_3) {
			*s++ = localvar[3];
		}
		else if (opc == IL_LDARG) {
			IL_LDA(eip, argvar, s);
		}
		else if (opc == IL_LDARG_0) {
			*s++ = *argvar;
		}
		else if (opc == IL_LDARG_1) {
			*s++ = argvar[1];
		}
		else if (opc == IL_LDARG_2) {
			*s++ = argvar[2];
		}
		else if (opc == IL_LDARG_3) {
			*s++ = argvar[3];
		}
		else if (opc == IL_LDC_I4) {
			IL_LDINT(uint32_t, eip, s);
		}
		else if (opc == IL_LDC_I4_0) {
			*s++ = 0;
		}
		else if (opc == IL_LDC_I4_1) {
			*s++ = 1;
		}
		else if (opc == IL_LDC_I4_2) {
			*s++ = 2;
		}
		else if (opc == IL_LDC_I4_3) {
			*s++ = 3;
		}
		else if (opc == IL_LDC_I8) {
			IL_LDINT(uint64_t, eip, s);
		}
		else if (opc == IL_LDC_I8_0) {
			*s++ = 0;
		}
		else if (opc == IL_LDC_I8_1) {
			*s++ = 1;
		}
		else if (opc == IL_LDC_I8_2) {
			*s++ = 2;
		}
		else if (opc == IL_LDC_I8_3) {
			*s++ = 3;
		}
		else if (opc == IL_LDC_R4) {
			IL_LDRNT(float, eip, s);
		}
		else if (opc == IL_LDC_R4_0) {
			IL_LDRNT2(float, s, 0.0f);
		}
		else if (opc == IL_LDC_R4_1) {
			IL_LDRNT2(float, s, 1.0f);
		}
		else if (opc == IL_LDC_R4_2) {
			IL_LDRNT2(float, s, 2.0f);
		}
		else if (opc == IL_LDC_R4_3) {
			IL_LDRNT2(float, s, 3.0f);
		}
		else if (opc == IL_LDC_R8) {
			IL_LDRNT(double, eip, s);
		}
		else if (opc == IL_LDC_R8_0) {
			IL_LDRNT2(double, s, 0.0f);
		}
		else if (opc == IL_LDC_R8_1) {
			IL_LDRNT2(double, s, 1.0f);
		}
		else if (opc == IL_LDC_R8_2) {
			IL_LDRNT2(double, s, 2.0f);
		}
		else if (opc == IL_LDC_R8_3) {
			IL_LDRNT2(double, s, 3.0f);
		}
		else if (opc == IL_RET)
			return *--s;
		else if (opc == IL_POP)
			--s;
		else if (opc == IL_CALL) {

		}
	}
	return 0;
}

EIP是一个很重要的东西;在汇编中它存放“当前指令”下一条指令的地址的寄存器,而在“解释器”中它类似的;同样是存放“下一条指令地址”; 跳转指令都是通过改变EIP的地址到目标地址的位置实现的;


比如执行JMP RAX则代表把RAX的地址放入到EIP中 那么在这条指令执行结束后 则会JMP到RAX指向的地址处开始执行;放在“解释器”中概念是相同的 但“解释器”中只有虚拟的“EIP”寄存器 操作的是“stack” 如果是c/c++则可以显示声明成“register”变量 从“cpu”中分配寄存器条目;但需要看c/c++编译器是否支持 不支持的话还是等于操作“stack”;


另一个值得注意的则是“stack size”(栈大小);一般解释器的指令在被编译成二进制时 每个“解释方法”都会有一个关键参数“stack size” ; 而这个参数是在编译成对应指令方法时则已经计算好的(注意);JAVA与.NET的应用反编译查阅方法时可见;

有意思的是这个“stack size”到底是如何计算得出的;事实上目标编译器在组成这些二进制指令时 是经过了一次对指令的执行的;只不过这种执行有些特殊,它只是计算指令对stack的最大入栈数;就等于方法的“stack size”;

ldarg.0
ldarg.1
add
stloc.0

比如上述的指令;stack size就等于2 那么是如何得出的呢?首先它把参数0 solt push到stack 然后参数1也push进去;add指令pop栈的两个参数 此时栈是空的 然后add把两个值加上以后 在push上去;所以上述指令的stack size等于2;就是这么来的。


它是解释器在执行这个函数所需要检查的凭据;即用“总栈大小”减去“方法栈大小”,小于一定数额则爆栈“栈溢出”; 如果你的解释器为了避免因为执行中间指令而“解释器爆栈” 则可以采取“非递归调用”避免;


但如果需要编写“解释器”那么适合选择那些“开发工具”呢;一般通常会采取“c/c++”、如果要稍微可以效率低点 可以选择“c#” 如果优化的好的话 效率与“c/c++”会差不多 除了无法利用cpu缓存优化以外 但有个前提是你采取“C# 原生指针”;


int main()
{
	uint8_t il[] = {
		IL_LDC_I4,
		1,
		0,
		0,
		0,
		IL_LDLOC_0,
		IL_ADD,
		IL_DUP,
		IL_STLOC_0,
		IL_LDC_I4,
		(20000000) & 0xFF,
		(20000000 >> 8) & 0xFF,
		(20000000 >> 16) & 0xFF,
		(20000000 >> 24) & 0xFF,
		IL_CEQ,
		IL_BRFALSE,
		0,
		0,
		0,
		0,
		IL_LDLOC_0,
		IL_RET
	};
	uint64_t localvar[1] = { 0 };
	uint64_t argvar[1] = { 100 };
	uint64_t stack[3];

	timeBeginPeriod(1);
	{
		uint32_t tick = timeGetTime();
		uint64_t out = clr_call(stack, localvar, argvar, il, 22);
		tick = (timeGetTime() - tick);
		printf("ms: %d, out: %d\r\n", tick, out);
	}
	timeEndPeriod(1);
	system("pause");
	return 0;
}

“解释器”是如何调用别的方法呢;在本文中提供“解释器”代码 是没有支持调用其它函数的;但不妨可以谈一谈基本本文中的代码应该修改加以支持调用;首先你需要一个hash_map的Method Table;而你从“中间指令”二进制代码中读出的“MethodToken”则应该方法表内某个方法;那么你的解释器就可以向下调用clr_call函数;

传递重新计算封装过后的“localvar、argvar、stack”的地址进去;它实际上就是代表目标方法的“stackframe” 但如果你嫌麻烦的话 则可以从堆中重新分配虚拟的“stackframe chunk” 在函数调用完成后释放;但这会造成内存碎片的问题也会降低系统分配内存的效率;如果有必要这么做则建议使用内存池;

解释器架构风格案例 解释器工作原理_缓存

“解释器编译缓存”主要有两种方式,一为“逐步编译”另一类则是“完全编译”;“完全编译”就会造成启动应用速度很慢的问题;即便是“逐步编译”都会令人感到非常不舒服;那么“完全编译”呢?


“解释器编译缓存”是真正意义上当前运行“解释器”的机器真正可执行的“汇编指令”;一般解释器会采取文件内存映射的方式把文件映射到内存;然后读写这块缓存文件在其

文件映射流上存放动态编译的汇编代码;


值得一提的是“解释器”编译的汇编代码对于地址的JMP都会采取计算地址的(RVA)严格的;即便是amd64-is平台也是相同的;而不是采取(VA)地址;这是为了在二次映射“编译缓存”文件时可以复用以前即时编译好的汇编代码缓存;

不过由于是“文件映射”方式 可能读写“编译缓存”的效率 不是那么理想;此时可以采取镜像复制;即主要读写一块具体的内存(存放编译的汇编代码及描述结构)一定周期后镜像拷贝到到被映射的文件中;


一般“解释器编译缓存”编译的汇编代码一般有两类形式;即如上图所示;

一种是完全的编译成目标机器的汇编代码;加以执行其目标代码,而另一种则是采取混合编译;它可以更好的协同“解释器”工作;有些东西即时编译汇编可能不好处理;那么则可以采取协同的方式进行;但这会影响“解释器”执行指令的工作效率;算是中庸之道也~!