DEV Community

Tomaž Vinko
Tomaž Vinko

Posted on

Control .NET runtime from native code

Introduction

You can find many examples how to P/Invoke C++ code from C#, but there aren't so many the other way around - calling C# functions from C++.

There are many advantages of hosting .NET runtime from C++, but the main two I deal with in real life are:

  • It's not mandatory for the customer to have installed .NET Core. NET Core can be shipped within your application so you can control the version
  • Second goodie is that you can load heavy work to C++ and not so performance critical to C#. So you have two benefits - power of C++ and coding speed of C#. Of course you can achieve the same with P/Invoke but I like it another way, because I have more control over .NET runtime. But hey, that's just me :)

Example is taken from the official dotnet github repository, I just made a few adoptions.

Code is located here, so let's dive right into it.

C# code

This is managed C# function that we're going to call from C++:

public static int Hello(IntPtr arg, int argLength)
{
    if (argLength < System.Runtime.InteropServices.Marshal.SizeOf(typeof(LibArgs)))
    {
        return 1;
    }

    LibArgs libArgs = Marshal.PtrToStructure<LibArgs>(arg);
    Console.WriteLine($"Hello, world! from {nameof(Lib)} [count: {s_CallCount++}]");
    PrintLibArgs(libArgs);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

There's nothing fancy about it. It just prints some text (Hello world of course).
But if you look closely, you can see strange parameter type IntPtr.
You don't see many of those declarations when coding strictly inside the .NET environment. But as soon as you start with languages interoperability, this is your only channel for passing arguments back and forth.
Official explanation is a little boring: A platform-specific type that is used to represent a pointer or a handle.
You can think of it as the starting position of the memory chunk where the argument is located.

Some more details for bravest ones: it's internally represented as void* but exposed as an integer. You can use it whenever you need to store an unmanaged pointer and don't want to use unsafe code.
You don't have to know what void* pointer is atm, but that would be a must when you'll dive deeper into the magic world of languages interoperability by yourself.

Ok, let's move forward. We have some sanity checks at the start. The size of LibArgs (structure is explained in following steps) must match the size passed as second argument.

But the winning line of our function is

LibArgs libArgs = Marshal.PtrToStructure<LibArgs>(arg);
Enter fullscreen mode Exit fullscreen mode

Marshal.PtrToStructure copies (marshals) data from unmanaged block of memory to managed (C#) object.
This is the most crucial part in the whole story. There are some caveats when writing functions that are shared between languages.
You'll earn some of the hardest memory bugs if you screw up here.

Let's take a closer look at the LibArgs structure definition:

[StructLayout(LayoutKind.Sequential)]
public struct LibArgs
{
    public IntPtr Message;
    public int Number;
}
Enter fullscreen mode Exit fullscreen mode

This is fundamental type in C# (and other modern languages). They are simple at the first sight, but can become complicated very fast.
If you stay just inside the .NET environment, you don't have to worry how fields are laid out in memory.

Let's say that you have a few byte fields, int and short fields and you calculate that struct size is 12 bytes.
No, no, wrong. The fact is that compiler add padding bytes to align data within a struct. The reason is that some CPUs like data to be aligned on address boundaries.
Holy s**t, you created the application that crashes randomly.

But don't worry (or you maybe should with complicated structs), because this padding can be controlled explicitly with StructLayout attribute:

[StructLayout(LayoutKind.Sequential)]
Enter fullscreen mode Exit fullscreen mode

We are using Sequential enum (this is also the default value).
Compiler will assign structure sequentionaly as listed in the definition.
With Explicit enum for example, we could specify the size of each field.

Armed with knowledge till now, it's not hard to figure out what one more interesting line in our application does:

Marshal.PtrToStringUni(libArgs.Message)
Enter fullscreen mode Exit fullscreen mode

Let's move now to C++ side.

C++ code

There are few steps before starting with C# interaction.

Load HostFxr

First step is to load HostFxr and get pointers to a few functions that we're gonna need.
Those are

  • hostfxr_initialize_for_runtime_config: initializes a host context and prepares for initialization of the .NET Core runtime
  • hostfxr_get_runtime_delegate: gets a delegate for runtime functionality
  • hostfxr_close: closes a host context
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);
}
Enter fullscreen mode Exit fullscreen mode

It's worth to mention that the function get_hostfxr_path locates the right hostfxr which is then loaded with load_library call.
You can also load it from your custom location. But let's just keep things simple for now.

Initialize & start the .NET Core runtime

Now we need to get a runtime delegate that allows loading a managed assembly.

const string_t config_path = root_path + STR("DotNetLib.runtimeconfig.json");
    load_assembly_and_get_function_pointer_fn load_assembly_and_get_function_pointer = nullptr;
    load_assembly_and_get_function_pointer = get_dotnet_load_assembly(config_path.c_str());
    assert(load_assembly_and_get_function_pointer != nullptr && "Failure: get_dotnet_load_assembly()");
Enter fullscreen mode Exit fullscreen mode

Load managed assembly and get function pointer to a managed method

Now it's time to load our managed library and get a function pointer to the managed Hello method.
To do this, we need few information:

  • assembly path
  • type name
  • method name
const string_t dotnetlib_path = root_path + STR("DotNetLib.dll");
const char_t *dotnet_type = STR("DotNetLib.Lib, DotNetLib");
const char_t *dotnet_type_method = STR("Hello");
// <SnippetLoadAndGet>
// 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);
// </SnippetLoadAndGet>
assert(rc == 0 && hello != nullptr && "Failure: load_assembly_and_get_function_pointer()");
Enter fullscreen mode Exit fullscreen mode

Run managed code

Finally! It's time to fire our Hello World managed function.

for (int i = 0; i < 3; ++i)
{
    // <SnippetCallManaged>
    lib_args args
    {
        STR("from host!"),
        i
    };

    hello(&args, sizeof(args));
    // </SnippetCallManaged>
}
Enter fullscreen mode Exit fullscreen mode

And here is the result of our hard work
Alt Text

Conclusion

Although the result of this tutorial isn't much - black screen with Hello World text, you can just imagine the possibilities that opens up.

You can also check my Languages Interoperability project that seamlessly combines different programming languages.

Top comments (0)