Skip to main content

How .NET Debugging Works

info

This page explains how debug-mcp works internally. You don't need this to use the tool — start with Getting Started.

This document explains the .NET debugging infrastructure that debug-mcp uses.

Overview

.NET applications run on the Common Language Runtime (CLR). The CLR exposes debugging functionality through a set of COM interfaces collectively known as ICorDebug. These interfaces allow external processes (debuggers) to:

  • Control process execution (start, stop, step)
  • Set breakpoints in managed code
  • Inspect the runtime state (threads, stacks, variables)
  • Evaluate expressions

The Debugging Stack

Key ICorDebug Interfaces

Process & Thread Control

InterfacePurpose
ICorDebugEntry point. Manages debugger initialization
ICorDebugProcessControls a debugged process (continue, stop, terminate)
ICorDebugThreadRepresents a managed thread
ICorDebugControllerBase for process/appdomain control

Code Inspection

InterfacePurpose
ICorDebugModuleRepresents a loaded assembly
ICorDebugAssemblyAssembly-level information
ICorDebugFunctionA method in the debuggee
ICorDebugCodeIL or native code for a function

Stack & Variables

InterfacePurpose
ICorDebugFrameA stack frame (base interface)
ICorDebugILFrameIL frame with locals and arguments
ICorDebugNativeFrameNative (JITted) frame
ICorDebugChainChain of frames (managed/native)
ICorDebugValueRuntime value (variable, field, etc.)
ICorDebugObjectValueObject instance
ICorDebugStringValueString value
ICorDebugArrayValueArray value

Breakpoints

InterfacePurpose
ICorDebugBreakpointBase breakpoint interface
ICorDebugFunctionBreakpointBreakpoint at IL offset in function
ICorDebugModuleBreakpointBreakpoint on module load
ICorDebugStepperBreakpointOne-shot for stepping

Expression Evaluation

InterfacePurpose
ICorDebugEvalExecute code in debuggee context
ICorDebugEval2Extended evaluation (generics, etc.)

Debugging Session Lifecycle

1. Initialize Debugger

// Load dbgshim and get ICorDebug
var clrDebug = DbgShim.CreateDebuggingInterfaceFromVersion(
runtimeVersion, // e.g., "v4.0.30319" or ".NET 8.0"
runtimePath // Path to runtime directory
);

// Initialize
clrDebug.Initialize();

// Set callback handler
clrDebug.SetManagedHandler(new DebugEventHandler());

2. Attach or Launch

Launch:

clrDebug.CreateProcess(
applicationPath,
commandLine,
processAttributes,
threadAttributes,
inheritHandles: false,
creationFlags: DEBUG_ONLY_THIS_PROCESS,
environment,
currentDirectory,
startupInfo,
out processInfo,
debuggingFlags,
out ICorDebugProcess process
);

Attach:

clrDebug.DebugActiveProcess(
processId,
win32Attach: false,
out ICorDebugProcess process
);

3. Handle Events

The debugger receives callbacks through ICorDebugManagedCallback:

public class DebugEventHandler : ICorDebugManagedCallback
{
void Breakpoint(
ICorDebugAppDomain appDomain,
ICorDebugThread thread,
ICorDebugBreakpoint breakpoint)
{
// Process is now stopped
// Inspect state, then call Continue()
}

void StepComplete(
ICorDebugAppDomain appDomain,
ICorDebugThread thread,
ICorDebugStepper stepper,
CorDebugStepReason reason)
{
// Step operation completed
}

void Exception(
ICorDebugAppDomain appDomain,
ICorDebugThread thread,
int unhandled)
{
// Exception thrown
}

// Many more callbacks...
}

4. Set Breakpoints

// Get the function
module.GetFunctionFromToken(methodToken, out ICorDebugFunction function);
function.GetILCode(out ICorDebugCode code);

// Create breakpoint at IL offset
code.CreateBreakpoint(ilOffset, out ICorDebugBreakpoint breakpoint);
breakpoint.Activate(true);

5. Control Execution

// Continue
process.Continue(outOfBand: false);

// Stop/Break
process.Stop(timeout: 0);

// Step over
thread.CreateStepper(out ICorDebugStepper stepper);
stepper.SetInterceptMask(CorDebugIntercept.INTERCEPT_ALL);
stepper.Step(bStepIn: false); // Step over
process.Continue(false);

6. Inspect State

Get Stack Trace:

thread.EnumerateChains(out ICorDebugChainEnum chains);
while (chains.Next(1, out ICorDebugChain chain, out _) == 0)
{
chain.EnumerateFrames(out ICorDebugFrameEnum frames);
while (frames.Next(1, out ICorDebugFrame frame, out _) == 0)
{
if (frame is ICorDebugILFrame ilFrame)
{
// Get function info, IL offset, etc.
}
}
}

Get Local Variables:

ilFrame.EnumerateLocalVariables(out ICorDebugValueEnum locals);
while (locals.Next(1, out ICorDebugValue value, out _) == 0)
{
// Read variable value based on type
var genericValue = value as ICorDebugGenericValue;
genericValue.GetValue(out object val);
}

Evaluate Expression:

thread.CreateEval(out ICorDebugEval eval);

// For simple property access: obj.Property
eval.CallFunction(
propertyGetterFunction,
new[] { objectValue }
);

process.Continue(false);
// Wait for EvalComplete callback
// Get result from ICorDebugEval.GetResult()

Source Mapping

To map source code lines to IL offsets, debuggers use symbol files:

PDB Formats

FormatExtensionDescription
Windows PDB.pdbTraditional format, Windows only
Portable PDB.pdbCross-platform, part of .NET Core
Embedded PDBIn DLLPDB embedded in assembly

Mapping Process

Reading Portable PDBs

using System.Reflection.Metadata;

var reader = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
var metadata = reader.GetMetadataReader();

// Get method debug info
var debugInfo = metadata.GetMethodDebugInformation(methodHandle);

// Get sequence points (source <-> IL mapping)
foreach (var sp in debugInfo.GetSequencePoints())
{
// sp.StartLine, sp.EndLine
// sp.Offset (IL offset)
}

Challenges & Solutions

Async/Await Code

Problem: Async methods are rewritten by the compiler into state machines.

Solution:

  • Track state machine types (<Method>d__X)
  • Map back to original source using attributes
  • Step through continuation points

Just My Code

Problem: User doesn't want to step into framework code.

Solution:

  • Use [DebuggerNonUserCode] and [DebuggerStepThrough] attributes
  • Filter modules by assembly metadata
  • Configure stepper with SetUnmappedStopMask

Optimized Code

Problem: Release builds inline methods, eliminate variables.

Solution:

  • Detect optimized code via module flags
  • Warn user about limited debugging
  • Use ICorDebugILFrame2 for better variable access

Generic Types

Problem: Generic instantiations create new types at runtime.

Solution:

  • Use ICorDebugType interface for type parameters
  • Handle open vs closed generic types
  • Special handling for value type instantiations

Performance Considerations

  1. Minimize Continue/Stop cycles — Each stop/continue has overhead
  2. Cache symbol information — PDB reading is expensive
  3. Batch variable reads — Enumerate once, process all
  4. Use conditional breakpoints sparingly — Evaluated on every hit
  5. Expression evaluation is slow — Requires code execution in debuggee

References