IL和论点

ani*_*ine 7 c# clr il cil

IL有一些操作参数的操作码,例如Ldarg.0,Ldarg.1等等.

我知道在call执行操作码之前,这些参数被压入堆栈,在某些情况下Ldarg.0用于获取对this(例如成员)的引用

我的问题是:在启动呼叫时存储的参数在哪里?是否可以从执行的调用中访问调用者堆栈的副本?

我在哪里可以找到有关该主题的更多信息?

更新

我知道虚拟机是抽象的,JIT编译器负责处理这些问题,但让我们想象一下IL是否被解释,就像它在.NET Micro Framework上一样

Han*_*ant 8

MSIL使用虚拟机的规范.传递给方法的参数的心理模型是它们存在于数组中.其中Ldarg从该数组中选取一个元素来访问方法参数并将其推送到评估堆栈.Opcodes.Ldarg_0是更通用的Opcodes.Ldarg IL指令的缩写版本,它通过始终拾取元素0来保存两个字节.对于第二个参数,Opcodes.Ldarg_1的想法相同.当然很常见,当方法有超过4个参数时,Ldarg只会"昂贵".强调双引号,这不是你担心的那种费用.

实际的参数在运行时的存储是非常不同的.它取决于您使用的抖动,不同的架构使用不同的方式传递参数.通常,前几个参数通过cpu寄存器传递,其余的通过cpu堆栈传递.像x64或ARM这样的处理器有很多寄存器,所以使用寄存器传递更多的参数而不是x86.受该架构的__clrcall调用约定规则的约束.


Dan*_*ker 8

IL(现在称为CIL,通用中间语言,而不是MSIL)描述了虚拟堆栈计算机上的操作.JIT编译器接收IL指令并将其编译为机器代码.

调用方法时,JIT编译器必须遵守调用约定.此约定指定如何将参数传递给被调用的方法,返回值如何传递回调用者,以及谁负责从堆栈(调用者或被调用者)中删除参数.在这个例子中,我使用cdecl调用约定,但实际的JIT编译器使用其他约定.

一般的做法

具体细节取决于实现,但.NET和Mono JIT编译器用于将CIL编译为机器代码的一般方法如下:

  1. '模拟'堆栈并使用它将所有基于堆栈的操作转换为虚拟寄存器(变量)上的操作.理论上有无限数量的虚拟寄存器.
  2. 将所有IL指令转换为等效的机器指令.
  3. 将每个虚拟寄存器分配给真实的机器寄存器.只有有限数量的可用机器寄存器.例如,32位x86架构只有8个机器寄存器.

当然,这些步骤之间有很多优化.

我们举一个例子来解释这些步骤:

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
Run Code Online (Sandbox Code Playgroud)

在步骤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
Run Code Online (Sandbox Code Playgroud)

然后它变成机器指令,例如x86:

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
Run Code Online (Sandbox Code Playgroud)

然后为每个虚拟寄存器%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
Run Code Online (Sandbox Code Playgroud)

溢出

通过仔细选择寄存器,mov可以消除一些指令.当代码中的任何一点使用的虚拟寄存器多于可用的机器寄存器时,必须溢出一个机器寄存器才能使用.当机器寄存器溢出时,会插入指令,将寄存器的值压入实际堆栈.之后,当必须再次使用溢出值时,会插入从真实堆栈中弹出寄存器值的指令.

结论

如您所见,机器代码几乎不像IL代码使用评估堆栈那样经常使用实际堆栈.原因是机器寄存器是处理器中最快的存储器元素,因此编译器会尽量使用它们.当机器寄存器不足时,或者当值需要在堆栈上时(例如由于调用约定),值仅存储在实际堆栈上.

  • @animaonline然后,您_really_必须重新表达您的问题。您说您知道它在理论上(评估堆栈)和在实践中(我的帖子)如何工作。那你想知道什么? (2认同)

Jon*_*eet 7

ECMA-335可能是一个很好的起点.

例如,第I.12.4.1节有这样的:

CIL代码生成器发出的指令包含用于CLI的不同实现的足够信息,以使用不同的本机调用约定.所有方法调用都初始化方法状态区域(参见§I.12.3.2),如下所示:

  1. 传入的arguments数组由调用者设置为所需的值.
  2. 局部变量数组对于对象类型以及包含对象的值类型中的字段始终为null.此外,如果在方法头中设置了localsinit标志,则对于所有整数类型,局部变量数组初始化为0,对于所有浮点类型,初始化为0.0.CLI不会初始化值类型,但验证代码将作为方法入口点代码的一部分提供对初始化程序的调用.
  3. 评估堆栈为空.

和I.12.3.2有:

每个方法状态的一部分是一个包含局部变量的数组和一个包含参数的数组.与评估堆栈一样,这些数组的每个元素都可以包含任何单个数据类型或值类型的实例.两个数组都从0开始(即第一个参数或局部变量编号为0).可以使用ldloca指令计算局部变量的地址,使用ldarga指令计算参数的地址.

与每个方法相关联的元数据指定:

  • 输入方法时是否将初始化局部变量和内存池内存.
  • 每个参数的类型和参数数组的长度(但请参阅下面的变量参数列表).
  • 每个局部变量的类型和局部变量数组的长度.

CLI根据目标体系结构插入填充.也就是说,在某些64位架构上,所有局部变量都可以是64位对齐,而在其他架构上,它们可以是8位,16位或32位对齐.CIL生成器不应对阵列中局部变量的偏移做出假设.实际上,CLI可以自由地重新排序局部变量数组中的元素,不同的实现可能会选择以不同的方式对它们进行排序.

然后在分区III中,callvirt(仅作为示例)的描述具有:

callvirt在调用方法之前,将对象和参数从评估堆栈中弹出.如果方法具有返回值,则在方法完成时将其推入堆栈.在被调用方,obj参数作为参数0访问,arg1作为参数1访问,依此类推.

现在这都是规范级别.实际的实现可能决定只是让函数调用继承当前方法的堆栈的前n个元素,这意味着参数已经在正确的位置.