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> gProceeding 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 retAt 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> gExecution 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 rbpIssuing 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 23The 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.dllAfter 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 hitDisassembling 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> gWhen 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],ecxFollowing 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.