CLR Debugging Architecture
The common language runtime (CLR) debugging API was designed to be used as if it were part of the operating system kernel. In unmanaged code, when a program generates an exception, the kernel suspends the execution of the process and passes the exception information to the debugger by using the Win32 debugging API. The CLR debugging API provides the same functionality for managed code. When managed code generates an exception, the CLR debugging API suspends the execution of the process and passes the exception information to the debugger.
This topic describes how and when the CLR debugging API gets involved and which services it provides.
The CLR debugging API includes the following two major components:
The debugging DLL, which is always loaded into the same process as the program that is being debugged. The runtime controller is responsible for communicating with the CLR and performing execution control and inspection of threads that are running managed code.
The debugger interface, which is loaded into a different process from the program that is being debugged. The debugger interface is responsible for communicating with the runtime controller on behalf of the debugger. It is also responsible for handling Win32 debugging events that come from the process that is being debugged, and either handling these events or passing them to an unmanaged code debugger. The debugger interface is the only part of the CLR debugging API that has an exposed API.
The CLR debugging API does not support cross-machine or cross-process remote usage; that is, a debugger that uses the API must do so from within its own process, as pictured in the following API architecture diagram. This illustration shows where the different components of the CLR debugging API are located and how they interact with the CLR and the debugger.
CLR debugging API architecture
Managed Code Debugger
It is possible to build a debugger that supports only managed code. The CLR debugging API enables such a debugger to attach to a process on demand by using a soft-attach mechanism. A debugger that is soft-attached to a process can subsequently detach from the process.
The CLR debugging API has conflicting requirements pertaining to process architecture. On one hand, there are many compelling reasons to keep the debugging logic in the same process as the program that is being debugged. For example, the data structures are complex, and frequently they are manipulated by functions instead of a fixed memory layout. It is much easier to call the functions directly instead of trying to decode the data structures from outside the process. Another reason to keep the debugging logic in the same process is to improve performance by removing the cross-process communication overhead. Lastly, an important feature of CLR debugging is the ability to run user code in process with the debuggee, which obviously requires some cooperation with the debuggee process.
On the other hand, CLR debugging must coexist with unmanaged code debugging, which can only be performed correctly from an outside process. Also, an out-of-process debugger is safer than an in-process debugger because the interference between the debugger's operation and the debuggee process is minimized in an out-of-process debugger.
Because of these conflicting requirements, the CLR debugging API combines some of each approach. The primary debugging interface is out-of-process and coexists with the native Win32 debugging services. However, the CLR debugging API adds the ability to synchronize with the debuggee process so that it can safely run code in the user process. To perform this synchronization, the API collaborates with the operating system and the CLR to suspend all threads in the process at a location where they do not interrupt an operation and leave the runtime in an incoherent state. The debugger is then able to run code in a special thread that can examine the state of the runtime and call user code if necessary.
When managed code executes a breakpoint instruction or generates an exception, the runtime controller is notified. This component will determine which threads are executing managed code and which threads are executing unmanaged code. Usually, threads that are running managed code will be allowed to continue executing until they reach a state where they may be safely suspended. For example, they may have to complete a garbage collection that is in progress. When the managed code threads have reached safe states, all threads are suspended. The debugger interface then informs the debugger that a breakpoint or exception has been received.
When unmanaged code executes a breakpoint instruction or generates an exception, the debugger interface component receives notification through the Win32 debugging API. This notification is passed to an unmanaged debugger. If the debugger decides that it wants to perform synchronization (for example, so that managed code stack frames can be inspected), the debugger interface must first restart the stopped debuggee process, and then inform the runtime controller to perform the synchronization. The debugger interface is then notified when synchronization has been completed. This synchronization is transparent to the unmanaged debugger.
The thread that generated the breakpoint instruction or exception must not be allowed to execute during the synchronization process. To facilitate this, the debugger interface takes control of the thread by placing a special exception filter in the thread's filter chain. When the thread is restarted, it will enter the exception filter, which will place the thread under the runtime controller's control. When it is time to continue exception processing (or time to cancel the exception), the filter will return control to the thread's regular exception filter chain or return the correct result to resume execution.
In rare cases, the thread that generates the native exception may be holding important locks that must be released before the runtime's synchronization can be completed. (Typically these will be low-level library locks such as locks on the malloc heap.) In such cases, the synchronization operation must time out and synchronization will fail. This will cause certain operations that require the synchronization to also fail.
The In-Process Helper Thread
A single debugger helper thread is used within every CLR process to make sure that the CLR debugging API operates correctly. This helper thread is responsible for handling many of the inspection services provided by the debugging API, in addition to assisting with thread synchronization under certain circumstances. You can use the ICorDebugProcess::GetHelperThreadID method to identify the helper thread.
Interactions with JIT Compilers
To enable a debugger to debug just-in-time (JIT) compiled code, the CLR debugging API must be able to map information from the Microsoft intermediate language (MSIL) version of a function to the native version of the function. This information includes sequence points in the code and local variable location information. In the .NET Framework versions 1.0 and 1.1, this information was produced only when the runtime was in debugging mode. In the .NET Framework 2.0, this information is produced all the time.
Also, JIT-compiled code can be highly optimized. Optimizations such as common sub-expression elimination, inline expansion of functions, loop unwinding, code hoisting, and so on can lead to loss of correlation between the MSIL code of a function and the native code that will be called to execute it. Thus, the JIT compiler's ability to provide correct mapping information is severely affected by these aggressive code optimization techniques. Therefore, when the runtime is run in debugging mode, the JIT compiler will not perform certain optimizations. This restriction enables debuggers to accurately determine the source line mapping and location of all local variables and arguments.
The CLR debugging API provides special modes for debugging in two cases:
Edit and Continue mode. In this case, the runtime operates differently to enable code to be changed later. This is because the layout of certain run-time data structures has to be different to support Edit and Continue. Because this has an adverse effect on performance, do not use this mode unless you want Edit and Continue functionality.
Debugging mode. This mode enables the JIT compiler to omit optimizations. Therefore, it makes the execution of native code match the high-level language source more closely. Do not use this mode unless it is necessary because it also has an adverse effect on performance.
If you debug a program outside Edit and Continue mode, Edit and Continue functionality is not supported. If you debug a program outside debugging mode, most of the debugging features will still be supported, but optimizations may cause odd behavior. For example, single-stepping may appear to jump randomly from line to line in the method, and inlined methods may not appear in a stack trace.
A debugger can enable both Edit and Continue and debugging modes programmatically through the CLR debugging API if the debugger gains control of a process before the runtime has initialized itself. This is sufficient for many purposes. However, a debugger that attaches to a process that has already been running for a while (for example, during JIT debugging) will be unable to start these modes.
To help deal with these problems, a program can be run in JIT mode or debugging mode independently of a debugger. For information about ways to enable debugging, see Debugging, Tracing, and Profiling.
JIT optimizations can make an application less debuggable. The CLR debugging API enables inspection of stack frames and local variables with JIT-compiled code that has been optimized. Stepping is supported but may be imprecise. You can run a program that instructs the JIT compiler to turn off all JIT optimizations to produce debuggable code. For details, see Making an Image Easier to Debug.