IL有一些使用参数操作的操作码,例如Ldarg.0
,Ldarg.1
等。
我知道在执行call
操作码之前,这些参数被压入堆栈,在某些情况下,Ldarg.0
用于获取对this
的引用(例如成员)< / p>
我的问题是:在启动呼叫时存储的参数在哪里?是否可以从执行的调用中访问调用者堆栈的副本?
我在哪里可以找到有关该主题的更多信息?
更新
我知道虚拟机是抽象的,JIT编译器负责处理这些问题,但让我们想象一下IL是否被解释,就像它在.NET Micro Framework上一样
答案 0 :(得分:8)
MSIL使用虚拟计算机的规范。传递给方法的参数的心理模型是它们存在于数组中。其中Ldarg从该数组中选取一个元素来访问方法参数并将其推送到评估堆栈。 Opcodes.Ldarg_0是更通用的Opcodes.Ldarg IL指令的缩写版本,它通过始终拾取元素0来保存两个字节。对于第二个参数,Opcodes.Ldarg_1的想法相同。当然很常见,当方法有超过4个参数时,Ldarg只会“昂贵”。强调双引号,这不是你担心的那种费用。
运行时参数的实际存储是非常不同的。它取决于您使用的抖动,不同的架构使用不同的方式传递参数。通常,前几个参数通过cpu寄存器传递,其余的通过cpu堆栈传递。像x64或ARM这样的处理器有很多寄存器,所以使用寄存器传递更多的参数而不是x86。受该架构的__clrcall calling convention规则管辖。
答案 1 :(得分:8)
IL(现在称为CIL,通用中间语言,不是MSIL )描述了虚拟堆栈计算机上的操作。 JIT编译器接收IL指令并将其编译为机器代码。
调用方法时,JIT编译器必须遵守调用约定。此约定指定如何将参数传递给被调用的方法,返回值如何传递回调用者,以及谁负责从堆栈(调用者或被调用者)中删除参数。在这个例子中,我使用cdecl调用约定,但实际的JIT编译器使用其他约定。
具体细节取决于实现,但.NET和Mono JIT编译器用于将CIL编译为机器代码的一般方法如下:
当然,这些步骤之间会有很多优化。
我们举一个例子来解释这些步骤:
ldarg.1 // Load argument 1 on the stack
ldarg.3 // Load argument 3 on the stack
add // Pop value2 and value1, and push (value1 + value2)
call int32 MyMethod(int32) // Pop value and call MyMethod, push result
ret // Pop value and return
在步骤1中,IL变为基于寄存器的操作(operation dest <- src1, src2
):
ldarg.1 %reg0 <- // Load argument 1 in %reg0
ldarg.3 %reg1 <- // Load argument 3 in %reg1
add %reg0 <- %reg0, %reg1 // %reg0 = (%reg0 + %reg1)
// Call MyMethod(%reg0), store result in %reg0
call int32 MyMethod(int32) %reg0 <- %reg0
ret <- %reg0 // Return %reg0
然后它变成机器指令,例如86:
mov %reg0, [addr_of_arg1] // Move argument 1 in %reg0
mov %reg1, [addr_of_arg3] // Move argument 3 in %reg1
add %reg0, %reg1 // Add %reg1 to %reg0
push %reg0 // Push %reg0 on the real stack
call [addr_of_MyMethod] // Call the method
add esp, 4
mov %reg0, eax // Move the return value into %reg0
mov eax, %reg0 // Move %reg0 into the return value register EAX
ret // Return
然后为每个虚拟寄存器%reg0,%reg1分配一个机器寄存器。例如:
mov eax, [addr_of_arg1] // Move argument 1 in EAX
mov ecx, [addr_of_arg3] // Move argument 3 in ECX
add eax, ecx // Add ECX to EAX
push eax // Push EAX on the real stack
call [addr_of_MyMethod] // Call the method
add esp, 4
mov ecx, eax // Move the return value into ECX
mov eax, ecx // Move ECX into the return value register EAX
ret // Return
通过仔细选择寄存器,可以消除一些mov
指令。在代码中的任何一点,使用的虚拟寄存器多于可用的机器寄存器,一个机器寄存器必须溢出才能使用。当机器寄存器溢出时,会插入指令,将寄存器的值压入实际堆栈。之后,当必须再次使用溢出值时,会插入从真实堆栈中弹出寄存器值的指令。
正如您所看到的,机器代码几乎不像IL代码使用评估堆栈那样经常使用实际堆栈。原因是机器寄存器是处理器中最快的存储器元素,因此编译器会尽量使用它们。当机器寄存器不足时,或者当值需要在堆栈上时(例如由于调用约定),值仅存储在实际堆栈上。
答案 2 :(得分:6)
ECMA-335可能是一个很好的起点。
例如,第I.12.4.1节有这个:
CIL代码生成器发出的指令包含用于CLI的不同实现的足够信息,以使用不同的本机调用约定。所有方法调用都初始化方法状态区域(参见§I.12.3.2),如下所示:
- 传入的arguments数组由调用者设置为所需的值。
- 局部变量数组对于对象类型和包含对象的值类型中的字段始终为null。另外,如果 localsinit标志在方法头中设置,然后是局部变量 对于所有整数类型,数组初始化为0,对于所有整数类型,初始化为0.0 浮点类型。 CLI不会初始化值类型,但是 经验证的代码将作为初始化程序的一部分提供对初始化程序的调用 方法的入口点代码。
- 评估堆栈为空。
醇>
和I.12.3.2有:
每个方法状态的一部分是一个包含局部变量的数组和一个包含参数的数组。与评估堆栈一样,这些数组的每个元素都可以包含任何单个数据类型或值类型的实例。两个数组都从0开始(即第一个参数或局部变量编号为0)。可以使用ldloca指令计算局部变量的地址,使用ldarga指令计算参数的地址。
与每个方法相关联的元数据指定:
- 输入方法时是否初始化局部变量和内存池内存。
- 每个参数的类型和参数数组的长度(但请参阅下面的变量参数列表)。
- 每个局部变量的类型和局部变量数组的长度。
CLI根据目标体系结构插入填充。也就是说,在某些64位架构上,所有局部变量都可以是64位对齐,而在其他架构上,它们可以是8位,16位或32位对齐。 CIL生成器不应对阵列中局部变量的偏移做出假设。实际上,CLI可以自由地重新排序局部变量数组中的元素,不同的实现可能会选择以不同的方式对它们进行排序。
然后在分区III中,callvirt
的描述(仅作为示例)具有:
在调用方法之前,
callvirt
将对象和参数从评估堆栈中弹出。如果方法具有返回值,则在方法完成时将其推入堆栈。在被调用方,obj参数作为参数0访问,arg1作为参数1访问,依此类推。
现在这一切都在规范级别。实际的实现可能决定只让函数调用继承当前方法堆栈的前n个元素,这意味着参数已经在正确的位置。