The IL (now known as CIL, Common Intermediate Language, not MSIL) describes operations on an imaginary stack machine. The JIT compiler takes the IL instructions and compiles it into machine code.
When calling a method, the JIT compiler has to adhere to a calling convention. This convention specifies how the 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 (the caller or the callee). In this example I use the cdecl calling convention, but actual JIT compilers use other conventions.
General approach
The exact details depend on the implementation, but the general approach used by the .NET and Mono JIT compilers for compiling CIL to machine code is as follows:
- 'Simulate' a stack and use it to turn all stack-based operations into operations on 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 available machine registers. For example, the 32-bit x86 architecture has only 8 machine registers.
Of course, there is a lot of optimization going on between these steps.
Example
Let's take an example to explain these 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 is turned into register-based operations (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 is turned into machine instructions, e.g. 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 example:
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
Spilling
By choosing the registers carefully some mov
instructions can be eliminated. When at any point in the code there are more virtual registers used than machine registers available, one machine register must be spilled to be used. When a machine register is spilled, instructions are inserted that push the register's value on the real stack. Later, when the spilled value has to be used again, instructions are inserted that pop the register's value from the real stack.
Conclusion
As you can see, the machine code doesn't use the real stack nearly as often as the IL code used the evaluation stack. The reason is that machine registers are the fastest memory elements of a processor, so the compiler tries to use them as best as possible. A value is only stored on the real stack when there is a shortage in machine registers, or when the value is required to be on the stack (e.g. due to a calling convention).