Building a .NET CLR Profiler with C# and NativeAOT using Silhouette
Explore how to create a .NET CLR profiler using C# and NativeAOT with the Silhouette library. This guide demonstrates setting up a basic profiler to log assembly loads, simplifying complex native API interactions.
This article explores the Silhouette library, a tool designed to simplify the creation of .NET CLR profilers using C# and NativeAOT. Our focus here is on a fundamental demonstration: logging assembly loads to illustrate the ease of getting started with Silhouette. While comprehensive guides on .NET profiling APIs exist, including a detailed series on Silhouette by its author, this post offers a streamlined introduction, aiming to quickly demonstrate its practical application rather than delving into the intricacies of the profiling APIs themselves.
Understanding .NET Profiling APIs
While most .NET development occurs within a managed runtime, the underlying .NET framework and runtime frequently interact with native code. Both .NET Core and .NET Framework expose a comprehensive set of unmanaged APIs accessible from native code, categorized into:
- Debugging APIs: For debugging code within the Common Language Runtime (CLR) environment.
- Metadata APIs: For inspecting or generating module and type details without CLR loading.
- Profiling APIs: For monitoring program execution by the CLR.
This article primarily focuses on the Profiling APIs, with Metadata APIs playing a supporting role. While profiling tools like Visual Studio's profiler, JetBrains' dotTrace, or dotnet-trace (viewed in PerfView) commonly leverage these APIs, their utility extends far beyond traditional profiling. For instance, they enable method rewriting for instrumentation, as seen in the Datadog .NET client library.
The inherent power of these APIs is often hampered by their unmanaged nature, typically necessitating C/C++ development. This is precisely where the Silhouette library provides a significant advantage.
NativeAOT: Bridging the Gap from C# to Native Profiling
Microsoft's NativeAOT compilation significantly advances with each .NET release, enabling the compilation of .NET applications into native, standalone binaries. This capability is crucial for .NET profiler development. A NativeAOT binary is self-contained, meaning a loaded profiling binary operates with its own, separate .NET runtime instance, distinct from the profiled application.
However, simply compiling to a native binary isn't sufficient. The binary must expose specific entry points and interfaces, allowing the .NET runtime to load it as if it were a C++ library. This involves:
- Exposing a
DllGetClassObjectmethod, which returns anIClassFactoryinstance. - The .NET runtime then calls
CreateInstanceon this factory to obtain anICorProfilerCallbackinstance (or a higher version likeICorProfilerCallback2,ICorProfilerCallback3, etc.). - Finally, the runtime invokes the
Initializemethod on the profiler instance, providing anIUnknownparameter to fetch anICorProfilerInfoinstance (e.g.,ICorProfilerInfo2,ICorProfilerInfo3, etc.) for querying the profiling API.
These C++-centric APIs are complex and rarely encountered by typical .NET developers. The Silhouette library simplifies this by managing the intricate setup of entry points and exposing .NET types as C++ interfaces. While developers still need a foundational understanding of unmanaged API purpose, usage, and chaining, Silhouette significantly lowers the barrier to entry, enabling profiler logic to be written efficiently in C# instead of C++.
Writing a .NET Profiler in C#
To illustrate Silhouette's ease of use, we will develop a simple .NET profiler that logs loaded assemblies to the console.
Project Setup: Profiler and Test Application
We begin by establishing a basic solution structure comprising two projects: a class library for our profiler (SilhouetteProf) and a simple "Hello World" console application (TestApp) for profiling.
# Create the two projects
dotnet new classlib -o SilhouetteProf
dotnet new console -o TestApp
# Add the projects to a sln file
dotnet new sln
dotnet sln add .\SilhouetteProf\
dotnet sln add .\TestApp\
Next, integrate the Silhouette library into the profiler project:
dotnet add package Silhouette --project SilhouetteProf
For NativeAOT publication and to accommodate Silhouette's source generator (which utilizes unsafe code), we need to modify the SilhouetteProf.csproj file by adding <PublishAot>true</PublishAot> and <AllowUnsafeBlocks>true</AllowUnsafeBlocks> within the <PropertyGroup> section.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>SilhouetteProf</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Add these two -->
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silhouette" Version="3.2.0" />
</ItemGroup>
</Project>
With these prerequisites in place, we can proceed with profiler development.
Developing the Basic Profiler
Creating a .NET profiler with Silhouette involves defining a class that inherits from CorProfilerCallbackBase (or a version-specific derivative like CorProfilerCallback2Base, CorProfilerCallback3Base, etc.). This class must be adorned with a [Profiler] attribute and a unique Guid.
using Silhouette;
namespace SilhouetteProf;
// Use a new random Guid for your profiler
[Profiler("9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6")]
internal partial class MyCorProfilerCallback : CorProfilerCallback5Base
{
}
The [Profiler] attribute activates a Silhouette source generator, which creates the necessary boilerplate for the .NET runtime to instantiate an IClassFactory. This generated code (which can be omitted if custom DllGetClassObject logic is required) handles the native entry point:
namespace Silhouette._Generated
{
using System;
using System.Runtime.InteropServices;
file static class DllMain
{
[UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
public static unsafe HResult DllGetClassObject(Guid* rclsid, Guid* riid, nint* ppv)
{
if (*rclsid != new Guid("9fd62131-bf21-47c1-a4d4-3aef5d7c75c6"))
{
return HResult.CORPROF_E_PROFILER_CANCEL_ACTIVATION;
}
*ppv = ClassFactory.For(new global::SilhouetteProf.MyCorProfilerCallback());
return HResult.S_OK;
}
}
}
Before compilation, the profiler needs an Initialize method implementation:
using Silhouette;
namespace SilhouetteProf;
[Profiler("9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6")]
internal partial class MyCorProfilerCallback : CorProfilerCallback5Base
{
protected override HResult Initialize(int iCorProfilerInfoVersion)
{
Console.WriteLine("[SilhouetteProf] Initialize");
if (iCorProfilerInfoVersion < 5)
{
// Ensure ICorProfilerInfo5 is available
return HResult.E_FAIL;
}
// Instruct the .NET runtime about desired events
return ICorProfilerInfo5.SetEventMask(COR_PRF_MONITOR.COR_PRF_MONITOR_ALL);
}
}
This Initialize method is a minimal example. Silhouette automatically determines the available ICorProfilerInfo version and passes it as iCorProfilerInfoVersion. It is crucial to only invoke methods corresponding to initialized interface versions (e.g., if iCorProfilerInfoVersion is 7, do not call ICorProfilerInfo8).
Native APIs predominantly use HResult values for error handling, which Silhouette conveniently encapsulates in an enum. After verifying feature support, SetEventMask() or SetEventMask2() (using the COR_PRF_MONITOR enum) are used to specify which CLR events the profiler intends to monitor. For this example, ICorProfilerInfo5.SetEventMask() is used to enable all features.
While the profiler could be tested at this point, we will first add more functionality.
Implementing Profiler Functionality
Adding event handlers in a Silhouette profiler is straightforward, typically involving overriding methods in the base class. For instance, the Shutdown method can be overridden to log when the runtime terminates:
protected override HResult Shutdown()
{
Console.WriteLine("[SilhouetteProf] Shutdown");
return HResult.S_OK;
}
For our purpose, we will override AssemblyLoadFinished, which triggers upon an assembly completing its load.
protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
// ... implementation ...
}
The AssemblyLoadFinished method provides an AssemblyId, a strongly-typed wrapper around an IntPtr, which helps prevent common type-mismatch errors prevalent in native profiling APIs. This AssemblyId can be used with ICorProfilerInfo5.GetAssemblyInfo(AssemblyId) to retrieve assembly details.
Silhouette introduces HResult<T>, a discriminated union pattern that encapsulates both an HResult and, upon success, an object T. This elegant solution simplifies the handling of native API calls, which traditionally rely on multiple "out" parameters and HRESULT return values to indicate success or failure. For example, a direct GetAssemblyInfo call might look like:
HRESULT GetAssemblyInfo(
[in] AssemblyID assemblyId,
[in] ULONG cchName,
[out] ULONG *pcchName,
[out, size_is(cchName), length_is(*pcchName)]
WCHAR szName[] ,
[out] AppDomainID *pAppDomainId,
[out] ModuleID *pModuleId);
While HResult<T> still allows explicit HResult checks, it offers a more streamlined approach with HResult<T>.ThrowIfFailed(). This method returns T on success or throws a Win32Exception otherwise, leading to significantly cleaner and more readable code, especially when chaining multiple API calls. While convenient for development and prototyping, its suitability for production-grade profilers warrants careful consideration.
Implementing AssemblyLoadFinished using ThrowIfFailed() to retrieve and print the assembly name:
protected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)
{
try
{
// Retrieve AssemblyInfoWithName, throwing Win32Exception on failure
AssemblyInfoWithName assemblyInfo = ICorProfilerInfo5.GetAssemblyInfo(assemblyId).ThrowIfFailed();
Console.WriteLine($"[SilhouetteProf] AssemblyLoadFinished: {assemblyInfo.AssemblyName}");
return HResult.S_OK;
}
catch (Win32Exception ex)
{
Console.WriteLine($"[SilhouetteProf] AssemblyLoadFinished failed: {ex}");
return ex.NativeErrorCode;
}
}
The benefits of ThrowIfFailed() become even more apparent with chained operations. For instance, implementing ClassLoadStarted involves multiple sequential API calls:
protected override HResult ClassLoadStarted(ClassId classId)
{
try
{
ClassIdInfo classIdInfo = ICorProfilerInfo.GetClassIdInfo(classId).ThrowIfFailed();
using ComPtr<IMetaDataImport>? metaDataImport = ICorProfilerInfo2
.GetModuleMetaDataImport(classIdInfo.ModuleId, CorOpenFlags.ofRead)
.ThrowIfFailed()
.Wrap();
TypeDefPropsWithName classProps = metaDataImport.Value.GetTypeDefProps(classIdInfo.TypeDef).ThrowIfFailed();
Console.WriteLine($"[SilhouetteProf] ClassLoadStarted: {classProps.TypeName}");
return HResult.S_OK;
}
catch (Win32Exception ex)
{
Console.WriteLine($"[SilhouetteProf] ClassLoadStarted failed: {ex}");
return ex.NativeErrorCode;
}
}
This procedural flow, enabled by ThrowIfFailed(), enhances readability compared to repetitive if (result != HResult.S_OK) checks. With this functionality, our profiler is ready for testing.
Testing our New Profiler
Testing our profiler involves three key steps: publishing the test application, publishing the profiler, and configuring the necessary profiling environment variables.
Publishing the Test Application
While it's possible to execute the test application via dotnet run, doing so would involve profiling the .NET SDK itself, which is not our objective. To isolate our profiling target, we publish the "Hello World" application:
dotnet publish .\TestApp\ -c Release
This command generates a publish directory, typically TestApp\bin\Release et10.0\publish\.
Publishing the Profiler
Publishing the profiler is similar, but requires specifying a runtime ID due to NativeAOT. .NET 10 simplifies this with the --use-current-runtime option, which automatically determines the target runtime (e.g., win-x64 on Windows).
dotnet publish .\SilhouetteProf\ -c Release --use-current-runtime
The output, typically found in SilhouetteProf\bin\Release et10.0\win-x64\publish\, will be a single, self-contained DLL (alongside debug symbols). This DLL represents our .NET application compiled as a NativeAOT .NET profiler.

Configuring Profiling Environment Variables
To connect a profiler to the .NET runtime, specific environment variables must be set. These differ based on whether a .NET Framework or .NET Core (or .NET 5+) application is being profiled. Three primary variables are required:
For .NET Framework Applications:
COR_ENABLE_PROFILING=1: Enables profiling.COR_PROFILER={YOUR_PROFILER_GUID}: Set to the GUID specified in the[Profiler]attribute.COR_PROFILER_PATH=c:\path\to\profiler: Absolute path to the profiler DLL.
For .NET Core / .NET 5+ Applications:
CORECLR_ENABLE_PROFILING=1: Enables profiling.CORECLR_PROFILER={YOUR_PROFILER_GUID}: Set to the GUID specified in the[Profiler]attribute.CORECLR_PROFILER_PATH=c:\path\to\profiler: Absolute path to the profiler DLL.
Platform-specific path variables are also available for multi-platform support. It is recommended to use absolute paths for the profiler DLL to avoid ambiguity. The GUID variable must include the curly braces {}.
Example PowerShell commands for setting variables:
$env:CORECLR_ENABLE_PROFILING=1
$env:CORECLR_PROFILER="{9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6}"
$env:CORECLR_PROFILER_PATH="D:\repos\temp\silouette-prof\SilhouetteProf\bin\Release
et10.0\win-x64\publish\SilhouetteProf.dll"
Once these variables are correctly configured, the profiler can be launched.
Executing the Application with the NativeAOT Profiler
Upon executing the test application, the .NET runtime interprets the CORECLR_ environment variables, loading our NativeAOT profiler. As the application runs, the profiler emits events, which are then logged to the console. This demonstrates the successful tracking of all loaded assemblies during the "Hello World!" application's execution:
.\TestApp.exe
Expected Output:
[SilhouetteProf] Initialize
[SilhouetteProf] AssemblyLoadFinished: System.Private.CoreLib
[SilhouetteProf] AssemblyLoadFinished: TestApp
[SilhouetteProf] AssemblyLoadFinished: System.Runtime
[SilhouetteProf] AssemblyLoadFinished: System.Console
[SilhouetteProf] AssemblyLoadFinished: System.Threading
[SilhouetteProf] AssemblyLoadFinished: System.Text.Encoding.Extensions
[SilhouetteProf] AssemblyLoadFinished: System.Runtime.InteropServices
Hello, World!
[SilhouetteProf] Shutdown
This successful demonstration validates our .NET profiler, developed entirely in C#. While this implementation is foundational, it powerfully illustrates how the Silhouette library drastically simplifies the initial setup compared to direct C++ development. It is important to remember that while Silhouette streamlines event listening and C++ interface interaction, a foundational understanding of native APIs remains beneficial for complex profiling tasks.
Silhouette proves to be an invaluable tool for proof-of-concept development and rapid prototyping, significantly accelerating the creation of custom .NET profilers.
Conclusion
This article provided an overview of the unmanaged .NET profiling APIs and the traditional challenges of interacting with them via C++. We then explored how .NET, leveraging NativeAOT, can produce a binary capable of interacting with these native APIs, combining the advantages of .NET development with native interoperability.
We introduced the Silhouette library and demonstrated its efficacy in simplifying NativeAOT profiler creation through class inheritance and method overrides. A practical example involved developing, publishing, and testing a basic profiler to log assemblies loaded by a console application. The simplicity and efficiency offered by Silhouette make it a compelling solution for developing custom .NET profilers.