IL and arguments

本文关键字:arguments and IL | 更新日期: 2023-09-27 18:12:23

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

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

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

我在哪里可以找到关于那个主题的更多信息?

我知道虚拟机是抽象的,JIT编译器会处理这些问题,但让我们想象一下,如果IL被解释,就像在。net微框架

IL and arguments

MSIL与虚拟机器的规范一起工作。传递给方法的参数的心智模型是它们存在于数组中。其中Ldarg从该数组中选取一个元素来访问方法参数,并将其推入计算堆栈。操作码。Ldarg_0是更通用的操作码的缩写版本。对于IL指令,它总是选择元素0,从而节省两个字节。操作码也是如此。第二个参数的Ldarg_1。当然,很常见的是,只有当方法有超过4个参数时,Ldarg才会变得"昂贵"。强调一下双引号,这不是你所担心的那种费用。

在运行时的实际参数存储是非常不同的。这取决于你使用的抖动,不同的架构使用不同的方式传递参数。通常,前几个参数通过cpu寄存器传递,其余的通过cpu堆栈传递。像x64或ARM这样的处理器有很多寄存器,所以使用寄存器传递的参数比x86要多。由该体系结构的__clrcall调用约定的规则控制。

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

在步骤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
然后转换成机器指令,例如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

然后为每个虚拟寄存器%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代码那样频繁地使用实际栈。原因是机器寄存器是处理器中最快的内存元素,所以编译器会尽可能地使用它们。只有当机器寄存器不足时,或者当值需要在堆栈上时(例如由于调用约定),才会将值存储在实际堆栈中。

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

例如,第I.12.4.1节有:

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

  1. 传入参数数组由调用者设置为所需的值。
  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个元素,这意味着参数已经在正确的位置。