Skip to content

CLR Expression Evaluators

Gregg Miskelly edited this page Aug 24, 2021 · 14 revisions

Overview

If you've written or are writing a compiler for .NET, Visual Studio will use the C# Expression Evaluator (EE) by default if no custom EE exists. This might be acceptable for your purposes. However, if the C# experience isn't what you want/need, you have the option of replacing parts or all of the C# EE with your own implementation.

Microsoft has already implemented the Concord EE interface to support .NET languages (IDkmLanguageExpressionEvaluator). You do have the option to replace this implementation with your own, but this approach requires an enormous amount of work and is not recommended. Instead, you can customize the existing .NET EE to understand your language. There is a sample .NET expression evaluator you can look at in this repo.

Workflow

Here's a diagram of the .NET Expression Evaluator: .NET Expression Evaluator Architecture

For simplicity sake, we are not showing the Concord Dispatcher, or the various components involved in communicating between the UI and EE components.

When the user evaluates an expression in the Watch window, for example a + 1, the workflow is:

  1. The debugger UI requests an evaluation of the expression a + 1. The evaluation request contains not only text, but also the target language, display radix, information about the current code location, and various other flags.
  2. Through the Concord API / Dispatcher, the request gets routed to the "Managed EE" component. This is Microsoft's implementation of IDkmLanguageExpressionEvaluator.
  3. The "Managed EE" component then calls to an Expression Compiler to compile the text into IL. The input of the Expression Compiler is the same as all of the information passed into "Managed EE" component. The correct Expression Compiler is selected by the Dispatcher based on the filters in the vsdconfig files registered with Concord.
  4. If no suitable Expression Compiler exists for the current language, the C# compiler will be used by default.
  5. Once the "Managed EE" has the compiled expression, it sends this information to the "CLR Inspector" component. When remote or the architecture of the target process is different from the debugger (ex: x86 devenv and x64 target), the "CLR Inspector" is in the msvsmon process and this will be a cross-process/machine call.
  6. The "CLR Inspector" executes the compiled expression and works with the CLR debugging APIs (ICorDebug) to get the result value.
  7. Once the "CLR Inspector" has finished executing the expression, it has a DkmClrValue. This is the raw/unformatted value from the CLR. This needs to be formatted into the language being debugged. For example, if the language is C#, the value is a uint32 of number 12, and the display radix is 16, the string the user sees is "0x0000000C".
  8. The "Result Formatter" is selected by the Dispatcher based on the vsdconfig information in the same way that the Expression Compiler is selected. If there is no suitable Result Formatter, C#'s formatter is used.
  9. The result in then routed by the Dispatcher back through the "Managed EE" and then to the UI.

The Watch window, Quick Watch window, Autos window, Immediate window, and DataTips all follow this same workflow. The Locals window uses a similar workflow (more information below...)

Adding support for new languages

Generally, supporting a new language requires implementing the following interfaces (see below the table for more explanation about each interface:

Interface Purpose
IDkmClrExpressionCompiler Components implementing this interface are used to generate executable PE blobs using the compiler for the current language.
IDkmClrFormatter Components implementing this interface are used to convert DkmClrValues into value and type strings appropriate for the current language.
IDkmLanguageFrameDecoder Components implementing this interface are used to format frames of the call stack into the strings that are visible for each row of the Call Stack window.

IDkmClrExpressionCompiler

For the purpose of discussing this interface, assume we are stopped at the "return" statement in the following C# function:

        private static int Func(int arg1)
        {
            int local1 = arg1 + 1;
            return local1;
        }

CompileExpression:

This method is used to convert a textual expression and context information into a PE blob that represents the compiled expression. This method is called when the user evaluates from the Watch, Quick Watch, Immediate windows, hovers over values in the editor (DataTips), or sets a conditional breakpoint.

To see how this method works, say the user types "local1 + 10" into the Watch window. Eventually the Dispatcher will call IDkmClrExpressionCompiler.CompileExpression. The input is the expression (DkmLanguageExpression) and the current instruction address (DkmClrInstructionAddress). There is also some context information about the evaluation (DkmInspectionContext). If CompileExpression is called to compile a breakpoint condition, this context information will be null. The output is an error message in case of compile error and a compiled inspection query (DkmCompiledClrInspectionQuery) if the compilation was successful. One of the two output values should be set.

The inspection query contains a PE file with code like the following in it:

.class public QueryClass
{
   .method public hidebysig static int32 QueryMethod(int32 arg1) cil managed
   {
      .locals init ([0]int32 local1)
      ldloc.0
      ldc.i4.s 10
      add
      ret
   }
}

Note that the arguments of the query method match the arguments of the method "Func" we are evaluating in. In addition, the query method contains all of the locals of Func in the same order. If needed, the query method may contain additional temporary locals after the matching locals. The code above loads the value of local slot 0 "local1", adds 10 to it and returns it.

An inspection query can be created by calling DkmCompiledClrInspectionQuery.Create. It takes the following parameters:

  1. Runtime Instance: The runtime instance that owns the current code location. This value is available from the instruction address parameter.
  2. DkmCustomDataContainer: This value should be null. This parameter exists due to a design limitation of the original DkmCompiledInspectionQuery API
  3. LanguageId: The current language ID. This value is available from the expression parameter to CompileExpression.
  4. Binary: The PE file containing the compiled version of the expression. Example is above...
  5. TypeName: The name of the type containing the query method ("QueryClass" in the example above).
  6. Method: The name of the query method ("QueryMethod" in the example above).
  7. Format Specifiers: A list of format specifier strings if the parser found any.
  8. Compilation Flags: A set of flags to provide the debugger some information about the expression. The most important flags to set, when appropriate, are DkmClrCompilationResultFlags.BoolResult and DkmClrCompilationResultFlags.PotentialSideEffect. Setting BoolResult allows the expression to be used for a "When True" breakpoint condition. Setting PotentialSideEffect prevents the debugger from automatically evaluating an expression without user interaction. This should be set for expressions that may modify the state of the process.
  9. Result Category: This should normally be DkmEvaluationResultCategory.Data. This value is used by the debugger to select the icon to show for the value in the inspection windows.
  10. Access: The access type of the field/property (public, private, protected, etc. in C#). This value is used by the debugger to select the icon modifier in some cases.
  11. Storage Type: The storage type of the value ("None" or "Static" can be used in .NET). This value is used by the debugger to select the icon modifier in some cases.
  12. Type Modifier Flags: Any additional type modifiers for the value ("None" or "Constant" for example). This value is used by the debugger to select the icon to show for the value in the inspection windows.
  13. DkmClrCustomTypeInfo: This is an optional parameter that can be used to pass additional type information to the formatter. This is used in C# to support dynamic types. 'dynamic' is not a .NET type and the actual metadata type is System.Object. The C# Expression Compiler passes information to its Result Provider about which types are dynamic using DkmClrCustomTypeInfo.

CompileAssignment

This method is similar to CompileExpression, but it is used when the user edits a value from one of the variable inspection windows. Using the same example C# code above, assume the user edits the value of "local1" in the Locals window and enters the text "99". Eventually the Dispatcher will call IDkmClrExpressionCompiler.CompileAssignment. The input is the expression "99", the current instruction address, a DkmEvaluationResult (this is the result of the evaluation to get the value of "local1"). The output is an error message or compiled inspection query - same as CompileExpression.

The Expression should generate a PE file with code to assign the given expression to the previous evaluation result (The L-Value). Typically the compiler will use DkmEvaluationResult.FullName to get an L-Value string and compile something to the effect of local1 = 99. The PE file should end up with code equivalent to:

.class public QueryClass
{
   .method public hidebysig static void QueryMethod(int32 arg1) cil managed
   {
      .locals init ([0]int32 local1)
      ldc.i4.s 99
      stloc.0
      ret
   }
}

The Expression Compiler should create a DkmClrInspectionQuery as described in the CompileExpression section.

GetClrLocalVariableQuery

This method is used when the user views the Locals window or an extension uses the DTE to get local variables or arguments. The input to this method is an inspection context, instruction address for the current location, and a boolean parameter to indicate if only arguments were requested. The return value is a 'DkmCompiledClrLocalsQuery'.

As an example, assume we are stopped in this code:

        private static string Func(int arg1, int arg2)
        {
            int local1 = arg1 + arg2;
            string local2 = "Hello";

            return local2 + local1.ToString();
        }

In this case, the Expression Compiler will generate a PE file similar to:

.class public QueryClass
{
   .method public hidebysig static int32 M1(int32 arg1, int32 arg2) cil managed
   {
      .locals init ([0]int32 local1, [1]string local2)
      ldarg.0
      ret
   }
   .method public hidebysig static int32 M2(int32 arg1, int32 arg2) cil managed
   {
      .locals init ([0]int32 local1, [1]string local2)
      ldarg.1
      ret
   }
   .method public hidebysig static int32 M3(int32 arg1, int32 arg2) cil managed
   {
      .locals init ([0]int32 local1, [1]string local2)
      ldloc.0
      ret
   }
   .method public hidebysig static string M4(int32 arg1, int32 arg2) cil managed
   {
      .locals init ([0]int32 local1, [1]string local2)
      ldloc.1
      ret
   }
}

The parameters to DkmCompiledClrLocalsQuery.Create are similar to the parameters for DkmCompiledClrInspectionQuery.Create. The difference is that instead of a method name parameter, there is a read only collection of DkmClrLocalVariableInfo. DkmClrLocalVariableInfo is a pairing of a variable name along with the query method to get the value of the variable. It also contains custom type information, full name, and result category. These values should be the same as what CompileExpression would set if evaluating the variable directly.

Intrinsics

Debugger intrinsics are used to support expressions such as $exception as well as the Make Object ID feature and pseudo-variables. This is another big topic and more information is available here.

IDkmClrFormatter

Implementing this interface allows components to customize the way values are displayed in the variable inspection windows. C#'s implementation does the following:

  1. Formats numeric values using the "0x..." syntax when hex display is enabled.
  2. Escapes special characters in strings using the C# backslash syntax and wraps strings in quotes.
  3. Renames some .NET type names to their C# equivalents (Examples: "System.Int32" -> "int", "System.Boolean" -> "bool").

The methods on IDkmClrFormatter are GetValueString, GetTypeName, HasUnderlyingString, and GetUnderlyingString

GetValueString

This method gets the string value to display to the user given a raw value. The parameters are:

  1. DkmClrValue: This is the raw value that resulted from executing the inspection query. If the value can be directly marshalled from the debuggee process to the debugger process, DkmClrValue.HostObjectValue will contain the marshalled value. For example if the value is an int32, DkmClrValue.HostObjectValue will be a boxed int32. The debugger can marshal all primitive types and a few non-primitive types (like System.Decimal).
  2. DkmInspectionContext: Contains information about the context of the evaluation. The context contains some formatting specific information like the display radix and evaluation flags like DkmEvaluationFlags.NoQuotes.
  3. Format specifiers: If your Expression Compiler outputs format specifiers beyond those already understood by the debugger, the debugger will pass them in this parameter.

If there was an error executing the query, DkmClrValue.ValueFlags will have the DkmClrValueFlags.Error bit set. In this case GetValueString, should cast DkmClrValue.HostObjectValue to a string and return that.

GetTypeName

This method gets the string value to display given a raw type. The parameters are:

  1. DkmInspectionContext: Contains information about the context of the evaluation. It's unlikely that GetTypeName will need to bother with it, but the Dispatcher needs to have it as a parameter to route the GetTypeName call to the correct formatter.
  2. DkmClrType: The type to get the value for. More information below...
  3. DkmClrCustomTypeInfo: If your expression compiler emitted custom information about this type, this parameter contains that information.
  4. Format specifiers: If your Expression Compiler outputs format specifiers beyond those already understood by the debugger, the debugger will pass them in this parameter.

Information about DkmClrType:

DkmClrType contains the information needed to uniquely identify a type, but has very little functionality beyond that. If your Formatter is written in C++, you'll need access to a metadata reader (IMetadataImport will work). You can access the raw metadata blocks using DkmClrModuleInstance.GetMetadataBytesPtr() and open the reader against the raw blocks.

If your Formatter is written in C#, you can use the debugger's metadata reader and type system. The debugger's type system is named LMR and lives in the namespace Microsoft.VisualStudio.Debugger.Metadata. You can get the LMR type by calling DkmClrValue.GetLmrType(). A LMR type is very similar to System.Type and for the most part you can use LMR as you would use Reflection.

GetUnderlyingString

Gets the raw string to show in the string/xml/html visualizer. Most formatter implementations can delegate to the C# implementation by calling DkmClrValue.GetUnderlyingString(inspectionContext).

HasUnderlyingString

This method determines if a value has an underlying string. If this method returns true, the debugger will show a "Magnifying Glass" icon in the UI. Clicking it will allow the user to select a string visualizer. Most formatter implementations can delegate to the C# implementation by calling DkmClrValue.HasUnderlyingString(inspectionContext).

IDkmLanguageFrameDecoder

Implementing this interface allows components to customize the way stack frames are displayed in the debugger UI.

GetFrameName

This method generates the string value to display in the Call Stack window (or other debugger UI) for a stack frame.

GetFrameName takes the following parameters:

  1. DkmInspectionContext: Contains information about the context of the evaluation. This is required to get parameter values - if the DkmVariableInfoFlags parameter has the DkmVariableInfoFlags.Values bit set.
  2. DkmWorkList: The worklist this asynchronous operation is part of. It should be passed to any other asynchronous Concord API calls that GetFrameName needs to call.
  3. DkmStackWalkFrame: This is the raw stack frame from the debug monitor. It contains an instruction address that can be used to find the code location for this frame. GetFrameName should return the name of the method this code location is within.
  4. DkmVariableInfoFlags: Contains flags indicating how the stack frame should be formatted. When GetFrameName is called to populate a row the Call Stack window, the flags reflect the options the user has checked in the context menu.
  5. Completion Routine: This delegate must be called when GetFrameName is done. Failure to call the completion routine will cause the debugger to hang.

GetFrameReturnType

GetFrameReturnType can be called from the debugger automation APIs and should return the name of the frame's return type. The parameters and usage are similar to GetFrameName.

Clone this wiki locally