Advanced WinDbg Debugging Practices: Managed Breakpoints, Trace Execution, and JIT Monitoring

Setting Breakpoints in Managed Entry Points

When debugging .NET applications, the entry point method (typically Main) consists of MSIL intermediate language rather than native CPU instructions upon initial loading. WinDbg operates strictly on native code, meaning it cannot initially resolve managed symbols. The CLR runtime must first load and the JIT compiler must translate the MSIL to native code before standard software breakpoints can engage. To circumvent this, we configure a module load event breakpoint to pause execution precisely when the target managed assembly loads, allowing the SOS debugging extension to set a deferred managed breakpoint.

Example Application

Consider the following .NET 8.0 program:

namespace CalculatorApp;

class Program
{
    static void Main()
    {
        int total = 0;
        for(int idx = 5; idx > 0; idx--) 
        {
            total += idx * 2;
            Console.WriteLine(total);
        }

        Entity item = new Entity();
        item.Level = 5;
        Console.WriteLine(item.FetchLevel());
        Console.ReadKey();
    }
}

class Entity
{
    public int Level;
    public string Tag;
    public int FetchLevel() { return Level; }
}

When launching a modern .NET (Core) application via WinDbg, the debugger attaches to the AppHost executable (e.g., CalculatorApp.exe), which acts as a native wrapper to host the actual managed assembly (CalculatorApp.dll). Execution halts at the initial system breakpoint inside ntdll.dll.

At this stage, CLR modules are not loaded, preventing direct managed breakpoint placement. We utilize the sxe command to configure an event break upon the managed assembly's module load:

0:000> sxe ld:CalculatorApp.dll
0:000> g

Proceeding execution triggers the module loads for the runtime environment (coreclr.dll, clrjit.dll) and ultimately pauses when CalculatorApp.dll maps into memory:

ModLoad: 00007ff8`b1240000 00007ff8`b1726000   coreclr.dll
...
ModLoad: 00000000`00610000 00000000`00618000   CalculatorApp.dll
ntdll!NtMapViewOfSection+0x14:
00007ff9`bc3304a4 c3              ret

At this point, the SOS extension is active. We can issue a deferred breakpoint targeting the Main method using !bpmd:

0:000> !bpmd CalculatorApp.dll CalculatorApp.Program.Main
Adding pending breakpoints...
0:000> g

Execution continues, the JIT compiler processes the Main method, and the breakpoint successfully triggers at the method's prologue.

Alternatively, one can target coreclr!RunMain, which receives a pointer to the MethodDesc of the Main method as its first parameter, offering another avenue for intercepting execution prior to managed code takeover.

WinDbg breakpoint mechanisms consist of software breakpoints (bp, bu, bm) and hardware breakpoints (ba). Software breakpoints replace the target instruction's first byte with an int 3 opcode (0xCC). Hardware breakpoints utilize CPU debug registers (DR0-DR7) to monitor memory access without altering instruction bytes, enabling breakpoints on read-only memory or data modification events.

Function Tracing with the wt Command

The wt (Watch and Trace) command offers a comprehensive profile of function calls, invocations, and executed instructions within a specific scope. It autonomously traces execution from the current instruction pointer until the active function returns, providing statistical summaries of nested subroutine activity.

Consider a native C++ program to illustrate wt behavior, as JITted managed code often lacks detailed symbol granularity for precise tracing:

extern "C"
{
    int FetchBaseValue(int val)
    {
        return val;
    }

    int ComputeAggregate(int x, int y)
    {
        int step1 = x + y;
        int step2 = FetchBaseValue(step1);
        step2 = FetchBaseValue(step2);
        step2 = FetchBaseValue(step2);
        return step2;
    }

    int main()
    {
        int res1 = ComputeAggregate(3, 4);
        int res2 = ComputeAggregate(5, 6);
        int res3 = ComputeAggregate(10, 20);
    }
}

Loading this executable into WinDbg and setting a one-shot breakpoint on main:

0:000> bp /1 nativeapp!main
0:000> g
Breakpoint 0 hit
nativeapp!main:
00007ff6`8b521830 55              push    rbp

Issuing wt starting at the function entry point traces the entire execution of main until it returns:

0:000> wt
Tracing nativeapp!main to return address 00007ff6`8b521d69
    6     0 [  0] nativeapp!main
    1     0 [  1]   nativeapp!ILT+855(__CheckForDebuggerJustMyCode)
   12     0 [  1]   nativeapp!__CheckForDebuggerJustMyCode
    9    13 [  0] nativeapp!main
    1     0 [  1]   nativeapp!ILT+950(ComputeAggregate)
    8     0 [  1]   nativeapp!ComputeAggregate
...
118 instructions were executed in 117 events (0 from other threads)

Function Name                               Invocations MinInst MaxInst AvgInst
nativeapp!ILT+855(__CheckForDebuggerJustMyCode)             4       1       1       1
nativeapp!ComputeAggregate                                  1      27      27      27
nativeapp!main                                              1      23      23      23

The output columns indicate:

  • First column: Instructions executed within the current function before transitioning to a nested call or returning.
  • Second column: Cumulative instructions executed by deeper subroutines since entering the current depth level.
  • Third column (brackets): Current call stack depth relative to the traced root function.

A summary table concludes the trace, detailing invocation counts, minimum, maximum, and average instruction counts per function. If a function contains conditional branches, the MinInst and MaxInst values will differ.

The wt command accepts parameters to filter trace scope:

  • -l Depth: Restricts tracing to a maximum call depth.
  • -m Module: Traces only calls within the specified module.
  • -i Module: Ignores calls originating from the specified module.
  • -or: Displays return value registers upon function exits.

If wt is invoked while positioned over a call instruction, it traces the targeted subroutine and returns to the next instruction. If invoked outside a recognized symbol or call instruction, it behaves identically to a single step (p).

Monitoring JIT Code Generation using Hardware Breakpoints

When a managed method is invoked for the first time, the CLR redirects execution through a prestub (ThePreStub) to invoke the JIT compiler. Once compiled, the runtime patches the method's descriptor slot with the native code address, bypassing JIT on subsequent calls. Hardware breakpoints (ba) allow us to intercept this precise patching mechanism.

Using a .NET Framework variant of the application (where the EXE is the assembly), we halt execution on the JIT module load:

0:000> sxe ld:clrjit
0:000> g
...
ModLoad: 71670000 716ee000   clrjit.dll

After the JIT module loads, SOS becomes available, and we set a breakpoint on Main:

0:000> !bpmd ManagedApp Program.Main
Adding pending breakpoints...
0:000> g
Breakpoint 0 hit

Disassembling the method reveals the instantiation and method calls. Inspecting the instantiation instruction yields the MethodTable (MT) for the Entity class:

00a7086a b9804e7d00      mov     ecx,7D4E80h (MT: ManagedApp.Entity)

Dumping the MethodTable exposes the method descriptors, including FetchLevel(), which currently holds a JIT status of NONE and a temporary entry point:

0:000> !dumpmt -md 7D4E80h
...
MethodDesc Table
   Entry MethodDe    JIT Name
...
00a70455 007d4e60   NONE ManagedApp.Entity.FetchLevel()

The memory location storing the native entry point pointer resides at the MethodDesc slot. By examining this memory address, we identify the uncompiled pointer value:

0:000> dp 007d4e68 L4
007d4e68  00a70455 ...

We configure a hardware write breakpoint on this memory address to monitor 4-byte modifications. Upon triggering, the debugger prints a notification, dumps the modified memory, displays the call stack, and continues execution:

0:000> ba w4 007d4e68 ".echo 'Entry point patched!';dp 007d4e68 L1; k; gc"
0:000> g

When the Entity.FetchLevel() method undergoes JIT compilation, the runtime writes the new native code address into the MethodDesc slot. The hardware breakpoint captures this event:

'Entry point patched!'
007d4e68  00a70958
 # ChildEBP RetAddr      
00 0019f2cc 739580ef     clr!MethodDesc::SetStableEntryPointInterlocked+0x37
...

The call stack reveals the exact runtime function responsible for patching the entry point: clr!MethodDesc::SetStableEntryPointInterlocked. Disassembling this function pinpoints the specific CPU instruction performing the atomic exchange:

738b9192 f00fb10b lock cmpxchg dword ptr [ebx],ecx

Following this operation, the memory slot permanently holds the JITted native address (00a70958), bypassing the JIT prestub for all future invocations of FetchLevel(). This interception technique provides deep insight into the CLR's dynamic code generation lifecycle.

Tags: WinDbg debugging .NET JIT Compilation Hardware Breakpoints

Posted on Sat, 07 Nov 2026 16:14:12 +0000 by aarchaic