IL 有一些用于操作参数的操作码,例如Ldarg.0
等Ldarg.1
。
我知道这些参数在call
执行操作码之前被推入堆栈,在某些情况下Ldarg.0
用于获取对this
(例如成员)的引用
我的问题是:发起呼叫时这些参数存储在哪里?调用者堆栈的副本是否可以从执行的调用中访问?
我在哪里可以找到有关该主题的更多信息?
更新
我知道虚拟机是抽象的,JIT 编译器会处理这些问题,但让我们想象一下 IL 是否被解释,就像在 .NET Micro Framework 上一样
MSIL 使用虚拟机的规范。传递给方法的参数的心理模型是它们存在于数组中。Ldarg 从该数组中选择一个元素来访问方法参数并将其推送到评估堆栈上。Opcodes.Ldarg_0 是更通用的 Opcodes.Ldarg IL 指令的缩写版本,它通过始终选择元素 0 来节省两个字节。对于第二个参数,Opcodes.Ldarg_1 的想法相同。当然,很常见的是,Ldarg 只有在方法有超过 4 个参数时才会变得“昂贵”。强调双引号,这不是您担心的那种费用。
运行时参数的实际存储是非常不同的。这取决于您使用的抖动,不同的架构使用不同的方式来传递参数。通常,前几个参数通过 cpu 寄存器传递,其余的通过 cpu 堆栈传递。x64 或 ARM 等处理器有很多寄存器,因此使用寄存器传递的参数比 x86 多。受该架构的__clrcall 调用约定的规则控制。
IL(现在称为 CIL,Common Intermediate Language,而不是 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
然后变成机器指令,例如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),如下所示:
- 传入的参数数组由调用者设置为所需的值。
- 对于对象类型和保存对象的值类型中的字段,局部变量数组始终为 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 个元素,这意味着参数已经在正确的位置。