Write a custom .NET Core host to control the .NET runtime from your native code

Like all managed code, .NET Core applications are executed by a host. The host is responsible for starting the runtime (including components like the JIT and garbage collector) and invoking managed entry points.

Hosting the .NET Core runtime is an advanced scenario and, in most cases, .NET Core developers don't need to worry about hosting because .NET Core build processes provide a default host to run .NET Core applications. In some specialized circumstances, though, it can be useful to explicitly host the .NET Core runtime, either as a means of invoking managed code in a native process or in order to gain more control over how the runtime works.

This article gives an overview of the steps necessary to start the .NET Core runtime from native code and execute managed code in it.

Prerequisites

Because hosts are native applications, this tutorial covers constructing a C++ application to host .NET Core. You will need a C++ development environment (such as that provided by Visual Studio).

You will also want a simple .NET Core application to test the host with, so you should install the .NET Core SDK and build a small .NET Core test app (such as a 'Hello World' app). The 'Hello World' app created by the new .NET Core console project template is sufficient.

Hosting APIs

There are two different APIs that can be used to host .NET Core. This article (and its associated samples) covers these 2 options.

  • The preferred method of hosting the .NET Core runtime in .NET Core 3.0 and above is with the nethost and hostfxr libraries' APIs. These entry points handle the complexity of finding and setting up the runtime for initialization and allow both launching a managed application and calling into a static managed method.
  • The preferred method of hosting the .NET Core runtime prior to .NET Core 3.0 is with the coreclrhost.h API. This API exposes functions for easily starting and stopping the runtime and invoking managed code (either by launching a managed exe or by calling static managed methods).

Sample Hosts

Sample hosts demonstrating the steps outlined in the tutorials below are available in the dotnet/samples GitHub repository. Comments in the samples clearly associate the numbered steps from these tutorials with where they're performed in the sample. For download instructions, see Samples and Tutorials.

Keep in mind that the sample hosts are meant to be used for learning purposes, so they are light on error checking and are designed to emphasize readability over efficiency.

Create a host using nethost.h and hostfxr.h

The following steps detail how to use the nethost and hostfxr libraries to start the .NET Core runtime in a native application and call into a managed static method. The sample uses the nethost header and library installed with the .NET SDK and copies of the coreclr_delegates.h and hostfxr.h files from the dotnet/runtime repository.

Step 1 - Load hostfxr and get exported hosting functions

The nethost library provides the get_hostfxr_path function for locating the hostfxr library. The hostfxr library exposes functions for hosting the .NET Core runtime. The full list of functions can be found in hostfxr.h and the native hosting design document. The sample and this tutorial use the following:

  • hostfxr_initialize_for_runtime_config: Initializes a host context and prepares for initialization of the .NET Core runtime using the specified runtime configuration.
  • hostfxr_get_runtime_delegate: Gets a delegate for runtime functionality.
  • hostfxr_close: Closes a host context.

The hostfxr library is found using get_hostfxr_path. It is then loaded and its exports are retrieved.

// Using the nethost library, discover the location of hostfxr and get exports
bool load_hostfxr()
{
    // Pre-allocate a large buffer for the path to hostfxr
    char_t buffer[MAX_PATH];
    size_t buffer_size = sizeof(buffer) / sizeof(char_t);
    int rc = get_hostfxr_path(buffer, &buffer_size, nullptr);
    if (rc != 0)
        return false;

    // Load hostfxr and get desired exports
    void *lib = load_library(buffer);
    init_fptr = (hostfxr_initialize_for_runtime_config_fn)get_export(lib, "hostfxr_initialize_for_runtime_config");
    get_delegate_fptr = (hostfxr_get_runtime_delegate_fn)get_export(lib, "hostfxr_get_runtime_delegate");
    close_fptr = (hostfxr_close_fn)get_export(lib, "hostfxr_close");

    return (init_fptr && get_delegate_fptr && close_fptr);
}

Step 2 - Initialize and start the .NET Core runtime

The hostfxr_initialize_for_runtime_config and hostfxr_get_runtime_delegate functions initialize and start the .NET Core runtime using the runtime configuration for the managed component that will be loaded. The hostfxr_get_runtime_delegate function is used to get a runtime delegate that allows loading a managed assembly and getting a function pointer to a static method in that assembly.

// Load and initialize .NET Core and get desired function pointer for scenario
load_assembly_and_get_function_pointer_fn get_dotnet_load_assembly(const char_t *config_path)
{
    // Load .NET Core
    void *load_assembly_and_get_function_pointer = nullptr;
    hostfxr_handle cxt = nullptr;
    int rc = init_fptr(config_path, nullptr, &cxt);
    if (rc != 0 || cxt == nullptr)
    {
        std::cerr << "Init failed: " << std::hex << std::showbase << rc << std::endl;
        close_fptr(cxt);
        return nullptr;
    }

    // Get the load assembly function pointer
    rc = get_delegate_fptr(
        cxt,
        hdt_load_assembly_and_get_function_pointer,
        &load_assembly_and_get_function_pointer);
    if (rc != 0 || load_assembly_and_get_function_pointer == nullptr)
        std::cerr << "Get delegate failed: " << std::hex << std::showbase << rc << std::endl;

    close_fptr(cxt);
    return (load_assembly_and_get_function_pointer_fn)load_assembly_and_get_function_pointer;
}

Step 3 - Load managed assembly and get function pointer to a managed method

The runtime delegate is called to load the managed assembly and get a function pointer to a managed method. The delegate requires the assembly path, type name, and method name as inputs and returns a function pointer that can be used to invoke the managed method.

// Function pointer to managed delegate
component_entry_point_fn hello = nullptr;
int rc = load_assembly_and_get_function_pointer(
    dotnetlib_path.c_str(),
    dotnet_type,
    dotnet_type_method,
    nullptr /*delegate_type_name*/,
    nullptr,
    (void**)&hello);

By passing nullptr as the delegate type name when calling the runtime delegate, the sample uses a default signature for the managed method:

public delegate int ComponentEntryPoint(IntPtr args, int sizeBytes);

A different signature can be used by specifying the delegate type name when calling the runtime delegate.

Step 4 - Run managed code!

The native host can now call the managed method and pass it the desired parameters.

lib_args args
{
    STR("from host!"),
    i
};

hello(&args, sizeof(args));

Create a host using coreclrhost.h

The following steps detail how to use the coreclrhost.h API to start the .NET Core runtime in a native application and call into a managed static method. The code snippets in this document use some Windows-specific APIs, but the full sample host shows both Windows and Linux code paths.

The corerun host shows a more complex, real-world, cross-platform example of hosting using coreclrhost.h.

Step 1 - Find and load CoreCLR

The .NET Core runtime APIs are in coreclr.dll (on Windows), in libcoreclr.so (on Linux), or in libcoreclr.dylib (on macOS). The first step to hosting .NET Core is to load the CoreCLR library. Some hosts probe different paths or use input parameters to find the library while others know to load it from a certain path (next to the host, for example, or from a machine-wide location).

Once found, the library is loaded with LoadLibraryEx (on Windows) or dlopen (on Linux/macOS).

HMODULE coreClr = LoadLibraryExA(coreClrPath.c_str(), NULL, 0);

Step 2 - Get .NET Core hosting functions

CoreClrHost has several important methods useful for hosting .NET Core:

  • coreclr_initialize: Starts the .NET Core runtime and sets up the default (and only) AppDomain.
  • coreclr_execute_assembly: Executes a managed assembly.
  • coreclr_create_delegate: Creates a function pointer to a managed method.
  • coreclr_shutdown: Shuts down the .NET Core runtime.
  • coreclr_shutdown_2: Like coreclr_shutdown, but also retrieves the managed code's exit code.

After loading the CoreCLR library, the next step is to get references to these functions using GetProcAddress (on Windows) or dlsym (on Linux/macOS).

coreclr_initialize_ptr initializeCoreClr = (coreclr_initialize_ptr)GetProcAddress(coreClr, "coreclr_initialize");
coreclr_create_delegate_ptr createManagedDelegate = (coreclr_create_delegate_ptr)GetProcAddress(coreClr, "coreclr_create_delegate");
coreclr_shutdown_ptr shutdownCoreClr = (coreclr_shutdown_ptr)GetProcAddress(coreClr, "coreclr_shutdown");

Step 3 - Prepare runtime properties

Before starting the runtime, it is necessary to prepare some properties to specify behavior (especially concerning the assembly loader).

Common properties include:

  • TRUSTED_PLATFORM_ASSEMBLIES This is a list of assembly paths (delimited by ';' on Windows and ':' on Linux) which the runtime will be able to resolve by default. Some hosts have hard-coded manifests listing assemblies they can load. Others will put any library in certain locations (next to coreclr.dll, for example) on this list.
  • APP_PATHS This is a list of paths to probe in for an assembly if it can't be found in the trusted platform assemblies (TPA) list. Because the host has more control over which assemblies are loaded using the TPA list, it is a best practice for hosts to determine which assemblies they expect to load and list them explicitly. If probing at run time is needed, however, this property can enable that scenario.
  • APP_NI_PATHS This list is similar to APP_PATHS except that it's meant to be paths that will be probed for native images.
  • NATIVE_DLL_SEARCH_DIRECTORIES This property is a list of paths the loader should probe when looking for native libraries called via p/invoke.
  • PLATFORM_RESOURCE_ROOTS This list includes paths to probe in for resource satellite assemblies (in culture-specific subdirectories).

In this sample host, the TPA list is constructed by simply listing all libraries in the current directory:

void BuildTpaList(const char* directory, const char* extension, std::string& tpaList)
{
    // This will add all files with a .dll extension to the TPA list.
    // This will include unmanaged assemblies (coreclr.dll, for example) that don't
    // belong on the TPA list. In a real host, only managed assemblies that the host
    // expects to load should be included. Having extra unmanaged assemblies doesn't
    // cause anything to fail, though, so this function just enumerates all dll's in
    // order to keep this sample concise.
    std::string searchPath(directory);
    searchPath.append(FS_SEPARATOR);
    searchPath.append("*");
    searchPath.append(extension);

    WIN32_FIND_DATAA findData;
    HANDLE fileHandle = FindFirstFileA(searchPath.c_str(), &findData);

    if (fileHandle != INVALID_HANDLE_VALUE)
    {
        do
        {
            // Append the assembly to the list
            tpaList.append(directory);
            tpaList.append(FS_SEPARATOR);
            tpaList.append(findData.cFileName);
            tpaList.append(PATH_DELIMITER);

            // Note that the CLR does not guarantee which assembly will be loaded if an assembly
            // is in the TPA list multiple times (perhaps from different paths or perhaps with different NI/NI.dll
            // extensions. Therefore, a real host should probably add items to the list in priority order and only
            // add a file if it's not already present on the list.
            //
            // For this simple sample, though, and because we're only loading TPA assemblies from a single path,
            // and have no native images, we can ignore that complication.
        }
        while (FindNextFileA(fileHandle, &findData));
        FindClose(fileHandle);
    }
}

Because the sample is simple, it only needs the TRUSTED_PLATFORM_ASSEMBLIES property:

// Define CoreCLR properties
// Other properties related to assembly loading are common here,
// but for this simple sample, TRUSTED_PLATFORM_ASSEMBLIES is all
// that is needed. Check hosting documentation for other common properties.
const char* propertyKeys[] = {
    "TRUSTED_PLATFORM_ASSEMBLIES"      // Trusted assemblies
};

const char* propertyValues[] = {
    tpaList.c_str()
};

Step 4 - Start the runtime

coreclrhost.h APIs start the runtime and create the default AppDomain all with a single call. The coreclr_initialize function takes a base path, name, and the properties described earlier and returns back a handle to the host via the hostHandle parameter.

void* hostHandle;
unsigned int domainId;

// This function both starts the .NET Core runtime and creates
// the default (and only) AppDomain
int hr = initializeCoreClr(
                runtimePath,        // App base path
                "SampleHost",       // AppDomain friendly name
                sizeof(propertyKeys) / sizeof(char*),   // Property count
                propertyKeys,       // Property names
                propertyValues,     // Property values
                &hostHandle,        // Host handle
                &domainId);         // AppDomain ID

Step 5 - Run managed code!

With the runtime started, the host can call managed code. This can be done in a couple of different ways. The sample code linked to this tutorial uses the coreclr_create_delegate function to create a delegate to a static managed method. This API takes the assembly name, namespace-qualified type name, and method name as inputs and returns a delegate that can be used to invoke the method.

doWork_ptr managedDelegate;

// The assembly name passed in the third parameter is a managed assembly name
// as described at https://docs.microsoft.com/dotnet/framework/app-domains/assembly-names
hr = createManagedDelegate(
        hostHandle,
        domainId,
        "ManagedLibrary, Version=1.0.0.0",
        "ManagedLibrary.ManagedWorker",
        "DoWork",
        (void**)&managedDelegate);

In this sample, the host can now call managedDelegate to run the ManagedWorker.DoWork method.

Alternatively, the coreclr_execute_assembly function can be used to launch a managed executable. This API takes an assembly path and array of arguments as input parameters. It loads the assembly at that path and invokes its main method.

int hr = executeAssembly(
        hostHandle,
        domainId,
        argumentCount,
        arguments,
        "HelloWorld.exe",
        (unsigned int*)&exitCode);

Step 6 - Shutdown and clean up

Finally, when the host is done running managed code, the .NET Core runtime is shut down with coreclr_shutdown or coreclr_shutdown_2.

hr = shutdownCoreClr(hostHandle, domainId);

CoreCLR does not support reinitialization or unloading. Do not call coreclr_initialize again or unload the CoreCLR library.

Conclusion

Once your host is built, it can be tested by running it from the command line and passing any arguments the host expects. When specifying the .NET Core app for the host to run, be sure to use the .dll that is produced by dotnet build. Executables (.exe files) produced by dotnet publish for self-contained applications are actually the default .NET Core host (so that the app can be launched directly from the command line in mainline scenarios); user code is compiled into a dll of the same name.

If things don't work initially, double-check that coreclr.dll is available in the location expected by the host, that all necessary Framework libraries are in the TPA list, and that CoreCLR's bitness (32-bit or 64-bit) matches how the host was built.

Hosting the .NET Core runtime is an advanced scenario that many developers won't require, but for those who need to launch managed code from a native process, or who need more control over the .NET Core runtime's behavior, it can be very useful.