There are a couple different ways to get C# to interoperate with other programming languages (usually C and C++). In the past (before I started my programming career) there was a lot of C++/CLI and COM objects that C# could (and still can) interact with. I’m positive that stuff is still out there. I just haven’t personally touched it much. Mostly because these are technologies exclusive to Windows and don’t mesh well with writing modern cross-platform .NET. In this world, if you have unmanaged components (Microsoft terminology for code not running inside the CLR) you have to settle for the lowest common denominator of communication pathways: the platform’s C ABI. In C# parlance, this means using P/Invoke.
In the past, the way to do P/Invoke was through decorating extern functions with the DllImport attribute. This would allow the .NET runtime to dynamically generate the right interop code at runtime. As of .net 7, the LibraryImport attribute has been the go to alternative to DllImport. It uses modern source generators to create the interop code at compile time. Here’s an example taken from one of my projects that interops with sqlite:
[LibraryImport(LibraryName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
public static partial ResultCode sqlite3_open_v2(string filename, out DbHandle dbHandle, OpenFlags flags, string vfs);What’s already super nice in this example is that strings are automatically handled for you. In C#, they are managed UTF16 objects. However, C doesn’t really have a concept of that. The actual sqlite3_open_v2 function in C accepts a const char* for its filename parameter. So already, you don’t have to write the code to encode the string in a different format, get the bytes, ensure that it’s NULL terminated, and pin a reference so that the garbage collector doesn’t clean up your filename’s memory as the C function is running. Another extra nicety hidden in here is that DbHandle is a custom class that inherits from SafeHandle. It’s not just a typedef for a void pointer. Safe handles take care of another thing for you: resource cleanup. So once you have a reference to a DbHandle you know that once it’s disposed the underlying sqlite resource is cleaned up as well.
internal sealed class DbHandle : SafeHandle
{
public DbHandle() : base(IntPtr.Zero, ownsHandle: true)
{
}
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
var result = Native.sqlite3_close(handle);
return result == ResultCode.SQLITE_OK;
}
}There are other sqlite functions that needed a bit more customization for C# to handle. For example, this might look okay to you at first glance. We want to get the error string associated with a result code to print an informative message about what went wrong when doing other things with sqlite:
[LibraryImport(LibraryName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
public static partial string sqlite3_errstr(ResultCode code);The code generators create this at compile time to fill in the function:
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "10.0.14.5608")]
[global::System.Runtime.CompilerServices.SkipLocalsInitAttribute]
public static partial string sqlite3_errstr(global::Lore.SQLite.ResultCode code)
{
bool __invokeSucceeded = default;
string __retVal = default;
byte* __retVal_native = default;
try
{
{
__retVal_native = __PInvoke(code);
}
__invokeSucceeded = true;
// Unmarshal - Convert native data to managed data.
__retVal = global::System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.ConvertToManaged(__retVal_native);
}
finally
{
if (__invokeSucceeded)
{
// CleanupCalleeAllocated - Perform cleanup of callee allocated resources.
global::System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.Free(__retVal_native);
}
}
return __retVal;
// Local P/Invoke
[global::System.Runtime.InteropServices.DllImportAttribute("sqlite3", EntryPoint = "sqlite3_errstr1", ExactSpelling = true)]
[global::System.Runtime.InteropServices.UnmanagedCallConvAttribute(CallConvs = new global::System.Type[] { typeof(global::System.Runtime.CompilerServices.CallConvCdecl) })]
static extern unsafe byte* __PInvoke(global::Lore.SQLite.ResultCode __code_native);
}The documentation for sqlite3_errstr states that the const char* provided as the return value is memory managed by sqlite itself and should not be freed. However, the code generated by the LibraryImport really wants to free it. This leads to crashes at runtime. Turns out, this behavior is customizable though!
[CustomMarshaller(typeof(string), MarshalMode.ManagedToUnmanagedOut, typeof(NoFreeUtf8Marshaller))]
[CustomMarshaller(typeof(string), MarshalMode.UnmanagedToManagedIn, typeof(NoFreeUtf8Marshaller))]
public static unsafe class NoFreeUtf8Marshaller
{
public static byte* ConvertToUnmanaged(string s) =>
Utf8StringMarshaller.ConvertToUnmanaged(s);
public static string ConvertToManaged(byte* s)
{
var str = Utf8StringMarshaller.ConvertToManaged(s);
return str is not null ? str : "";
}
// Intentionally no Free()
public static void Free(byte* _) { }
}
[LibraryImport(LibraryName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
[return: MarshalUsing(typeof(NoFreeUtf8Marshaller))]
public static partial string sqlite3_errstr(ResultCode code);Since this is just generated code there are hooks to change what is called to construct the return value. Here we can just provide a Marhsaller that will do the same thing as before but specifically does not free the returned pointer.
That’s all for now. I’m sure I’ll run into more corner cases in the future.
Leave a Reply