Understanding .NET Compilation
Myth: C# compiles directly to machine code like C++ does. Reality: C# first compiles to an intermediate language that the runtime converts to native code at runtime.
This two-stage approach gives .NET key advantages. Your compiled assemblies run on any platform with a .NET runtime (Windows, Linux, macOS) without recompilation. The JIT compiler optimizes for the specific CPU architecture and can make runtime decisions that ahead-of-time compilers can't.
You'll learn what MSIL is, how C# compiles to it, what JIT compilation does at runtime, how to view IL code with tools like ildasm, and why this intermediate layer matters for cross-platform support. By the end, you'll understand the complete journey from source code to execution.
What Is MSIL
Microsoft Intermediate Language (MSIL), also called Common Intermediate Language (CIL) or just IL, is a CPU-independent instruction set. When you compile C# code, the compiler generates MSIL instructions stored in .dll or .exe assemblies along with metadata describing types, methods, and references.
MSIL uses a stack-based execution model. Instructions push values onto an evaluation stack, perform operations, and pop results. This abstraction works identically regardless of whether your code runs on x64, ARM, or any other architecture.
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
}
// After compilation, the Add method becomes MSIL:
// .method public hidebysig instance int32 Add(int32 a, int32 b) cil managed
// {
// .maxstack 2
// ldarg.1 // Load argument 'a' onto stack
// ldarg.2 // Load argument 'b' onto stack
// add // Pop two values, add them, push result
// ret // Return top of stack
// }
The MSIL version is platform-neutral. The ldarg, add, and ret instructions work the same everywhere. When you run this code, the JIT compiler translates these IL instructions to native x64 or ARM code for your specific CPU.
JIT Compilation at Runtime
The Just-In-Time compiler runs when your code executes. The first time a method is called, the JIT reads its MSIL, generates native machine code optimized for the current CPU, and caches the result. Subsequent calls use the cached native code directly, avoiding recompilation.
This runtime compilation enables platform-specific optimizations. The JIT can use SIMD instructions if available, choose optimal register allocation for your CPU, and inline methods based on actual usage patterns. Ahead-of-time compilation can't make these runtime decisions.
// Source Code (C#)
public int Multiply(int x, int y)
{
return x * y;
}
// ↓ C# Compiler transforms to...
// MSIL (Platform-Independent)
// .method public instance int32 Multiply(int32 x, int32 y)
// {
// ldarg.1
// ldarg.2
// mul
// ret
// }
// ↓ JIT Compiler at runtime transforms to...
// Native x64 Assembly (Platform-Specific)
// mov eax, ecx ; Load x into eax
// imul eax, edx ; Multiply eax by y (edx)
// ret ; Return result in eax
// ↓ CPU executes native code directly
This three-stage process (C# → MSIL → Native) happens transparently. You write C#, ship MSIL in assemblies, and the runtime handles native compilation. The performance cost of JIT compilation is paid once per method per execution, then amortized across all calls.
Viewing IL Code
You can inspect the MSIL your compiler generates using tools like ildasm.exe (IL Disassembler) or ILSpy. This helps you understand what the compiler does with your code, debug optimization issues, or learn how language features work under the hood.
Seeing IL shows you costs hidden in C# syntax. A property access becomes a method call. Foreach creates an enumerator with multiple method calls. This visibility helps you reason about performance when it matters.
// C# code
public string GetGreeting(string name)
{
return $"Hello, {name}!";
}
// Compile with: dotnet build
// View IL with: ildasm bin/Debug/net8.0/YourApp.dll
// Or use ILSpy (GUI tool) or dnSpy
// The IL reveals string concatenation details:
// .method public hidebysig instance string GetGreeting(string name)
// {
// .maxstack 2
// .locals init ([0] string)
// ldstr "Hello, " // Load string constant
// ldarg.1 // Load 'name' argument
// ldstr "!" // Load "!" constant
// call string [System.Runtime]String::Concat(string, string, string)
// ret
// }
// This shows the compiler converts interpolation to String.Concat
Tools like ILSpy provide a friendly GUI for browsing assemblies and viewing both IL and decompiled C#. This is invaluable when working with third-party libraries where you don't have source code.
Benefits of the IL Layer
Cross-platform support: One assembly runs on Windows, Linux, and macOS. You compile once, deploy everywhere with a .NET runtime. No platform-specific builds needed.
Language interoperability: C#, F#, and VB.NET all compile to the same MSIL. They can reference each other's assemblies seamlessly because they share a common intermediate representation.
Runtime optimizations: The JIT sees actual execution patterns and hardware capabilities. It can inline hot methods, eliminate dead code, and use CPU-specific instructions. These optimizations happen per deployment, not once at compile time.
Security and verification: The runtime can verify IL code doesn't violate type safety before executing it. This prevents certain classes of bugs and security vulnerabilities that compiled native code might contain.
Try It Yourself
Create a simple program and explore its MSIL using built-in tools.
Steps
- Create:
dotnet new console -n ILDemo
- Navigate:
cd ILDemo
- Replace Program.cs with the code below
- Build:
dotnet build
- View IL:
dotnet tool install -g ilspy then ilspycmd bin/Debug/net8.0/ILDemo.dll
var math = new SimpleMath();
Console.WriteLine($"Add: {math.Add(5, 3)}");
Console.WriteLine($"Multiply: {math.Multiply(5, 3)}");
Console.WriteLine($"Greeting: {math.GetGreeting("Developer")}");
public class SimpleMath
{
public int Add(int a, int b)
{
return a + b;
}
public int Multiply(int a, int b)
{
return a * b;
}
public string GetGreeting(string name)
{
return $"Hello, {name}!";
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output
Add: 8
Multiply: 15
Greeting: Hello, Developer!
After viewing the IL with ILSpy or ildasm, you'll see each method compiled to a sequence of IL instructions. Notice how simple operations like addition become ldarg/add/ret sequences.