IL (now known as CIL, Common Intermediate Language, not MSIL) describes operations on an imaginary stack machine. The JIT compiler accepts IL instructions and compiles it into machine code.
When calling a method, the JIT compiler must adhere to the calling convention. This convention indicates how arguments are passed to the called method, how the return value is passed back to the caller, and who is responsible for removing the arguments from the stack (caller or callee). In this example, I use the cdecl convention , but the actual JIT compilers use different conventions.
General approach
The exact details are implementation dependent, but the general approach used by the .NET and Mon JIT compilers to compile CIL for machine code is as follows:
- "Simulate" the stack and use it to turn all stack-based operations into operations with virtual registers (variables). There is a theoretical infinite number of virtual registers.
- Turn all IL instructions into equivalent machine instructions.
- Assign each virtual register to a real machine register. There is only a limited number of machine registers available. For example, the x86 32-bit architecture has only 8 machine registers.
Of course, a lot of optimization happens between these steps.
Example
Here is an example to explain the following steps:
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
In step 1, the IL turns into register-based operation dest <- src1, src2 ( 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
Then it turns into machine instructions, for example. 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
Then each virtual register% reg0,% reg1 is assigned a machine register. For instance:
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
Spill
When choosing registers carefully, some mov instructions can be eliminated. When at any point in the code more virtual registers are used than machine registers, it is necessary to use one machine register to use. When the machine register spills, instructions are entered that push the value of the register into the real stack. Later, when the spilled value is to be used again, instructions are inserted that expose the register value from the real stack.
Conclusion
As you can see, machine code does not use the real stack almost as often as the IL code used by the evaluation stack. The reason is that machine registers are the fastest processor memory elements, so the compiler tries to use them as best as possible. The value is stored only in the real stack if there is a shortage in machine registers or when a value in the stack is required (for example, due to a calling agreement).