Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilerGuide

Decompiling the dynamic keyword: reading call-site IL in Glass.NET

One line of C# with dynamic compiles into a cached call site, a binder, an array of argument-info flags and an invocation through a delegate field — a dozen members of generated scaffolding for a single method call. Here is what the compiler actually emits for dynamic dispatch, how a naive decompiler shows you the raw CallSite machinery, and how Glass.NET recognizes the pattern and gives you back the one line you wrote.

Write one line of C# with dynamic in it and the compiler writes a small program on your behalf. There is no callvirt to a method named Add; there is a cached call site, a binder that describes what “call Add” means, an array of flags describing each argument, and an invocation through a delegate stored in a generated field. A decompiler has to see all of that scaffolding and understand that it adds up to the single expression you typed — otherwise it hands you the scaffolding and lets you reverse-engineer your own source.

This is a working deep-dive on exactly that: what the C# compiler emits for dynamic dispatch, what the raw lowering looks like when nothing folds it back, and how Glass.NET recognizes the call-site pattern and reconstructs the dynamic you started with. It is one of the cleaner demonstrations of why “it decompiles” and “it decompiles into something you can read” are different claims.

The source

Here is the whole program. Three dynamic operations: a member invocation, a binary operator, and an implicit conversion of a dynamic result back to a typed local.

public static class Demo
{
    public static int Run(dynamic a, dynamic b)
    {
        dynamic sum = a.Add(b);   // dynamic member invocation
        dynamic twice = sum + sum; // dynamic binary operator
        int result = twice;        // dynamic -> int conversion
        return result;
    }
}

Nothing exotic. But none of those three operations can be resolved at compile time, because the compiler does not know what a and b are. So instead of emitting calls, it emits the machinery to resolve them later, on first use, against whatever runtime types actually show up.

What the compiler actually emits

For each dynamic operation the compiler generates a cached call site: a static field of type CallSite<T> living in a hidden class (named <>o__0 and the like), where T is the delegate signature of the operation. The first time the operation runs, a null check lazily initializes the site from a CallSiteBinder built by a factory in Microsoft.CSharp.RuntimeBinder.Binder. The binder is handed a set of CSharpBinderFlags and one CSharpArgumentInfo per argument, describing how each value participates (is it a ref, is it a compile-time constant, is it the target of the operation). Then the site’s Target delegate is invoked with the site and the arguments.

Decompiled without recognizing the pattern — which is what you get from a viewer that only undoes the obvious transformations — the member invocation a.Add(b) comes back looking like this:

// Raw lowering of: dynamic sum = a.Add(b);
if (<>o__0.<>p__0 == null)
{
    <>o__0.<>p__0 = CallSite<Func<CallSite, object, object, object>>.Create(
        Microsoft.CSharp.RuntimeBinder.Binder.InvokeMember(
            CSharpBinderFlags.None,
            "Add",
            null,
            typeof(Demo),
            new CSharpArgumentInfo[]
            {
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
            }));
}
object sum = <>o__0.<>p__0.Target(<>o__0.<>p__0, a, b);

That is faithful to the IL and almost useless to read. Every dynamic operation multiplies out the same way — the + becomes a Binder.BinaryOperation site with an ExpressionType.Add, and the int result = twice; becomes a Binder.Convert site targeting typeof(int). One three-line method balloons into three cache classes, three site fields, three lazy initializers and three .Target(...) invocations. The shape is completely mechanical, which is exactly why it can be recognized and undone.

dynamic sum = a.Add(b);compiler loweringCallSite<Func<…>> <>p__0 (cached field)Binder.InvokeMember("Add", …)CSharpArgumentInfo[] { … }.Target(site, a, b)a.Add(b)Glass folds the fourparts back into theone expression

How Glass folds it back

Glass is built on ICSharpCode.Decompiler, the same engine behind ILSpy, and reconstructing dynamic is one of its pattern transforms. The decompiler recognizes the full call-site shape — the <>o__/<>p__ cache, the lazy init guarded by the null check, the Binder.* factory call and the matching .Target(...) invocation — as a single logical operation, reads the binder to learn which operation it is (InvokeMember "Add", BinaryOperation Add, Convert int), reads the CSharpArgumentInfo array to learn how the operands were passed, and collapses the whole thing back into the one expression that produced it. Open Demo.Run in the Glass decompiled view and you get exactly what you wrote:

public static int Run(dynamic a, dynamic b)
{
    dynamic sum = a.Add(b);
    dynamic twice = sum + sum;
    int result = twice;
    return result;
}

The scaffolding is gone because Glass understood what it was for. That is the difference between a tool that shows you IL transliterated into C#-shaped syntax and a tool that shows you source.

dynamic versus object, and where the difference is stored

There is a subtlety the reconstruction has to get right. At the type-system level, dynamic is System.Object — the two are the same type in metadata, and the IL above traffics in object everywhere. So how does Glass know to write dynamic a and not object a?

The compiler leaves a breadcrumb: System.Runtime.CompilerServices.DynamicAttribute. Wherever a dynamic appears in a signature — a parameter, a field, a return type, even a position inside a generic like List<dynamic> — the compiler attaches this attribute to mark which objects were really dynamic in source. For nested cases it carries a bool[] transform that says which positions in a composite type were dynamic. Glass reads that attribute and restores dynamic precisely where it belongs, falling back to object where the attribute is absent. Drop into the assembly-information view and you can see the attribute on the members directly; it is the metadata that makes the erased distinction recoverable at all.

Reading the C#, then dropping to IL

For most purposes the reconstructed dynamic is all you want. But the call-site machinery encodes details that the folded expression deliberately hides, and when those details matter you drop to the IL view alongside the C#. Two are worth knowing about:

  • Argument passing. Each CSharpArgumentInfo carries flags such as IsRef/IsOut and UseCompileTimeType. A dynamic call that passes a ref argument, or that pins an argument to its static type rather than its runtime type, records that in the argument-info array. The folded C# shows foo.Bar(ref x); the IL shows the CSharpArgumentInfoFlags that made it so.
  • Operator identity. A dynamic binary operation stores its operator as a System.Linq.Expressions.ExpressionType (Add, LessThan, Equal, …) and a CSharpBinderFlags that records context like CheckedContext. If you need to confirm that a reconstructed + was compiled in a checked region, the binder flag in IL is where that lives.

Glass puts both views a click apart on purpose. Read the decompiled C# to recover intent — the one line someone actually wrote. Open the IL when you need to see which binder flags and argument-info kinds the compiler chose underneath it. The export path (glass export --in app.dll --out ./src) writes the reconstructed C# to disk as a buildable project, so you can also grep a whole assembly for the dynamic call sites and read them in your own editor. Either way, the win is the same: a dozen members of generated call-site scaffolding become the one expression they stand for, and your time goes to understanding the code instead of the compiler’s bookkeeping.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.