Open the compiled artifact and connect it back to the C# source.
Open Hello.dll in ILSpy
Install the ILSpy extension for VS Code, then open bin/Debug/net10.0/Hello.dll with ILSpy.
Selecting the assembly itself shows its metadata: its name, version, target framework, and compilation settings. Among the comments at the top is:
// Entry point: Program.<Main>$This identifies the method where execution begins. The assembly metadata view does not show that method’s body, so the absence of Console.WriteLine in this view does not mean our code is missing.
In VS Code’s Explorer, find ILSpy: Assemblies. Expand Hello, then the global namespace containing Program. Select Program, or expand it and select <Main>$, to see the decompiled code.
For our Hello World program, ILSpy shows:
using System;
using System.Runtime.CompilerServices;
[CompilerGenerated]
internal class Program
{
private static void <Main>$(string[] args)
{
Console.WriteLine("Hello, World!");
}
}Here is the statement we wrote in Program.cs. We used top-level statements, so we did not write a class or a Main method ourselves. The compiler supplied the Program class and the entry-point method named <Main>$. The [CompilerGenerated] attribute marks the class as generated by the compiler.
The unusual method name is a compiler-generated name, not a name we could use to declare an ordinary C# method. ILSpy is reconstructing C# from the compiled assembly; this is not the original source file stored inside the DLL.
Find the instructions that print Hello World
Hello.dll contains the method’s intermediate language (IL) instructions and metadata. To inspect the instructions, activate the decompiled editor, click ILSpy’s output language button in the status bar, and choose IL. See ILSpy’s decompilation instructions.
Selecting Program in IL view shows the generated class and its two methods. Here is the output from our build:
.class /* 02000002 */ private auto ansi beforefieldinit Program
extends [System.Runtime]System.Object
{
.custom /* 0C00000C */ instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Methods
.method /* 06000001 */ private hidebysig static
void '<Main>$' (
string[] args
) cil managed
{
// Method begins at RVA 0x2050
// Header size: 1
// Code size: 12 (0xc)
.maxstack 8
.entrypoint
IL_0000: ldstr "Hello, World!" /* 70000001 */
IL_0005: call void [System.Console]System.Console::WriteLine(string) /* 0A00000D */
IL_000a: nop
IL_000b: ret
} // end of method Program::'<Main>$'
.method /* 06000002 */ public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
// Method begins at RVA 0x205d
// Header size: 1
// Code size: 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor() /* 0A00000E */
IL_0006: nop
IL_0007: ret
} // end of method Program::.ctor
} // end of class ProgramThe .entrypoint directive identifies <Main>$ as the method where the program starts. Its body contains four instructions:
ldstrloads a reference to the string onto the evaluation stack.callinvokesConsole.WriteLine(string)with that string as its argument.nopperforms no operation; compilers can include it to support debugging.retreturns from the method.
This is where the assembly says to print Hello, World!. The string literal is stored in the assembly’s user-string heap, and ldstr refers to it through a metadata token. The call instruction uses a metadata token referring to System.Console.WriteLine; the implementation of that framework method lives outside our Hello.dll.
Where do we call the .NET implementation, and how is the argument passed?
The call into the .NET library happens at IL_0005:
IL_0000: ldstr "Hello, World!" /* 70000001 */
IL_0005: call void [System.Console]System.Console::WriteLine(string) /* 0A00000D */Read the call’s target as follows:
[System.Console]identifies the assembly containing the referenced type.System.Consoleidentifies the type.WriteLine(string)identifies the overload accepting one string argument.voidmeans the method returns no value.
The argument comes from the preceding ldstr instruction. IL passes arguments through an evaluation stack: ldstr pushes a reference to the "Hello, World!" string, then call consumes that reference as the method’s string parameter. Because WriteLine is static, no Console instance is needed. Because it returns void, it leaves no return value on the stack. See the .NET documentation for call.
| Step | Evaluation stack | What happens |
|---|---|---|
Before ldstr | Empty | No argument has been loaded yet. |
After ldstr | Reference to "Hello, World!" | The string argument is ready. |
After call returns | Empty | WriteLine has consumed the argument and returned no value. |
The (string) in the call describes the parameter’s type; it does not contain the argument’s value. That value is supplied by ldstr. Likewise, the entry point’s string[] args parameter holds command-line arguments and is unrelated to this string literal.
This stack describes IL behavior. When the JIT translates the method into native instructions, it chooses the actual registers or memory locations used to pass the argument.
Is this an operating-system call or a call to the SDK? It is a managed method call into the .NET runtime libraries. The SDK provides build tools; the running program uses the runtime and its libraries. The Console.WriteLine(string) implementation forwards the value to Console.Out.WriteLine(value). With normal standard output, the output path eventually reaches platform-specific code that writes bytes through the operating system. That later OS call is not directly present in our Hello.dll’s IL.
Where do the numbers in IL_0000, IL_0005, IL_000a, and IL_000b come from?
ILSpy creates these labels from each instruction’s byte offset from the start of the method’s IL instructions, written in hexadecimal. The first instruction starts at offset zero. Each following offset is the previous offset plus the previous instruction’s encoded size.
| Label | Offset in decimal | Instruction | Encoded size | Next offset |
|---|---|---|---|---|
IL_0000 | 0 | ldstr | 1-byte opcode + 4-byte token = 5 bytes | 0 + 5 = 5 |
IL_0005 | 5 | call | 1-byte opcode + 4-byte token = 5 bytes | 5 + 5 = 10 |
IL_000a | 10 | nop | 1-byte opcode | 10 + 1 = 11 |
IL_000b | 11 | ret | 1-byte opcode | 11 + 1 = 12 |
In hexadecimal, a means 10, b means 11, and c means 12. That is why the labels jump from 0005 to 000a, and why the method reports Code size: 12 (0xc). The last instruction starts at byte 11 and occupies one byte, making the total 12 bytes.
The text "Hello, World!" does not occupy space inline in the ldstr instruction. Its four-byte operand is a token referring to the string stored elsewhere in the assembly, so the instruction takes five bytes regardless of this string’s length.
These labels are offsets, not instruction numbers or native memory addresses. They restart at zero for each method, which is why the constructor also has an IL_0000 label.
What do the hexadecimal tokens mean?
The hexadecimal comments identify tokens that ILSpy has resolved into readable names:
| Token | Meaning in this build |
|---|---|
02000002 | The Program type definition. |
06000001 | The <Main>$ method definition. |
70000001 | The user-string token for "Hello, World!". |
0A00000D | The member reference to Console.WriteLine(string). |
These values can change between builds. They identify entries within the assembly, rather than native memory addresses. The RVA comment is different: it gives the method body’s relative virtual address within the loaded assembly image. The IL_ offsets start at the method’s first instruction, after its header.
The .maxstack 8 line describes the method’s maximum evaluation-stack capacity. This method uses only one stack slot at a time; the value of eight comes from the tiny method-header format used here. It does not mean eight arguments or eight local variables.
Why is there a second method?
The .ctor method is the generated instance constructor for Program. ldarg.0 loads the current instance (this), and the following call invokes the base System.Object constructor.
Our entry point is static, so it can run without creating a Program instance. This constructor is not part of the path that prints Hello World.
.NET assembly versus assembly language
A .NET assembly is the compiled file containing IL and metadata. Assembly language usually means a textual representation of machine instructions for a particular processor. Despite the shared word, inspecting a .NET assembly’s IL is a different layer from inspecting the native instructions that the JIT will generate.
Overview · Next: Runtime and JIT