DEV Community

Kelly Brown
Kelly Brown

Posted on • Updated on

Building a Better Library Loader in C# - Part 2

In part 1, I went over the challenges with loading native libraries in C#. I then proposed an API worth building that would make life much simpler for library consumers. It involved preparing an interface to define all the C functions as well as a delegate to map C# method names to C function names.

Now, it's time to start building. Before proceeding, let me make something clear: this is not an in-depth tutorial on all things .NET reflection. We're going to leverage the bare minimum to build our loader. Reflection is an incredibly deep topic that can do amazing things. It can consume you if you allow it to.

Starting the New Type

Remember, our goal is to build a type that implements the interface T. In order to create a type, it must be contained within a module, which must be contained within an assembly. Remember to add using System.Reflection.Emit;.

bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
IntPtr libraryHandle = isWindows ?
    WinLoader.LoadLibrary(library) :
    UnixLoader.Open(library, 1);

if (libraryHandle == IntPtr.Zero)
    throw new Exception("Unable to load library: " + library);

// We don't care what types these are.
// I'm just gonna use 'var'.
var assemblyName = new AssemblyName { Name = "MyAssembly" };
var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(
var moduleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName.Name);
var typeBuilder = moduleBuilder.DefineType(
    TypeAttributes.Class | TypeAttributes.Public);

Enter fullscreen mode Exit fullscreen mode

We're building the class MyAssembly.NativeLibrary. It implements interface T and has an empty constructor. We don't need any code in the constructor. The method LoadLibrary<T> itself can take care of any necessary construction since the return value will be a constructed object anyway.

Implementing the Methods

var methodAttributes =
    MethodAttributes.Public |
    MethodAttributes.Final |
    MethodAttributes.HideBySig |
    MethodAttributes.NewSlot |

foreach (MethodInfo method in typeof(T).GetMethods())
    ParameterInfo[] parameters = method.GetParameters();

    // Create a method that matches the interface method
    // definition perfectly. Whatever modifiers are
    // applied to the interface method's parameters or
    // return value are applied to the implementations
    // parameters and return value.
    MethodBuilder methodBuilder = typeBuilder.DefineMethod(
        parameters.Select(p => p.ParameterType).ToArray(),
        parameters.Select(p => p.GetRequiredCustomModifiers()).ToArray(),
        parameters.Select(p => p.GetOptionalCustomModifiers()).ToArray());

    typeBuilder.DefineMethodOverride(methodBuilder, method);

    // To be continued...
Enter fullscreen mode Exit fullscreen mode

First, let me say something about methodAttributes. I'm sure you're wondering what all these flags mean. You may be surprised to learn that I have no idea. Well... that's not true, but this is an important teaching moment. The point is that I didn't need to know what they meant when I first created this code.

My objective here is to implement a method from an interface. So, I simply turned to the truth to know what to put here. I used SharpLab. This tool is amazing. Open it up right now and put this code in.

public class C : System.IDisposable 
    public void Dispose()
Enter fullscreen mode Exit fullscreen mode

Change the right pane to IL mode. You should see output like this:

.class private auto ansi '<Module>'
} // end of class <Module>

.class public auto ansi beforefieldinit C
    extends [System.Private.CoreLib]System.Object
    implements [System.Private.CoreLib]System.IDisposable
    // Methods
    .method public final hidebysig newslot virtual 
        instance void Dispose () cil managed 
        // Method begins at RVA 0x2050
        // Code size 2 (0x2)
        .maxstack 8

        IL_0000: nop
        IL_0001: ret
    } // end of method C::Dispose

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
        // Method begins at RVA 0x2053
        // Code size 8 (0x8)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [System.Private.CoreLib]System.Object::.ctor()
        IL_0006: nop
        IL_0007: ret
    } // end of method C::.ctor

} // end of class C
Enter fullscreen mode Exit fullscreen mode

See my Dispose method there? See that word soup in the definition? It is marked public final hidebysig newslot virtual. Since that is what is expected of an interface implementation, I included them all. What they actually mean is not super important for what we are trying to accomplish. If you enter the CIL rabbit hole, there is no escape. It's a fascinating world, but we have games to make! Focus!

    // Continuing inside the loop...

    string functionName = methodNameToFunctionName(method.Name);

    // You may remember this from part 1.
    IntPtr procAddress = isWindows ?
        WinLoader.GetProcAddress(libraryHandle, functionName) :
        UnixLoader.LoadSymbol(libraryHandle, functionName);

    if (procAddress == IntPtr.Zero)
        throw new Exception("Unable to load function " + functionName);
Enter fullscreen mode Exit fullscreen mode

Alright, now we have our function pointer. This is where the magic happens. Buckle up.

    // Continuing inside the loop...

    ILGenerator generator = methodBuilder.GetILGenerator();

    for (int i = 0; i < parameters.Length; ++i)
        generator.Emit(OpCodes.Ldarg, i + 1); // Arg 0 is 'this'. Don't need that one.

    if (Environment.Is64BitProcess)
        generator.Emit(OpCodes.Ldc_I8, ptr.ToInt64());
        generator.Emit(OpCodes.Ldc_I4, ptr.ToInt32());


Enter fullscreen mode Exit fullscreen mode

This is the part that emits the actual method body. CIL is a stack machine. The goal is to forward each argument onto the C function, so each argument is pushed onto the stack. Then the C function address is pushed onto the stack. Then the native call is invoked via Calli. The return value (if any) is pushed back onto the stack and forwarded back to the caller of this method.

One nice thing about this approach is that the function pointer is effectively hard-coded into the method body. There's no reading of data from some other address. The code itself loads a constant onto the stack.

Finishing the Type

Alright. We're done with that loop. It's time to make the object.

Type type = typeBuilder.CreateType();
return (T)Activator.CreateInstance(type);
Enter fullscreen mode Exit fullscreen mode

If no exceptions are thrown, we should be good to go. It means the type was successfully created, the CIL was (sufficiently) correct, and the object was instantiated. You can now call C functions by way of this interface!

In part 3, we will go over loose ends that need to be addressed before this is a production-ready tool.

Top comments (0)