Back to Home

Integration of native code in .NET: P/Invoke, DllImport, cross-platform compatibility

The article is a detailed guide to integrating native code into .NET applications. It covers P/Invoke and DllImport mechanisms, creating cross-platform solutions, resource management and packaging into NuGet packages. The material contains practical examples and recommendations for mid- and senior-level developers.

How to integrate native code in .NET: practical examples and solutions
Advertisement 728x90

Native Code Integration in .NET: A Practical Developer Guide

Leveraging native code in .NET apps lets you push beyond the standard library's limits. This is especially handy for low-level OS APIs or hardware interfaces like MIDI. This guide covers the essentials: from basic DllImport usage to cross-platform solutions and NuGet packaging.

P/Invoke and DllImport Basics

P/Invoke (Platform Invoke) bridges managed .NET code to native functions. Here's a simple example using the DllImport attribute for the Windows API:

[DllImport("winmm.dll")]
static extern uint midiInGetNumDevs();

Key DllImport features:

Google AdInline article slot
  • Specifying the library name and entry point
  • Automatic data type marshaling
  • Support for various calling conventions

Marshaling converts data types between managed and unmanaged memory. It handles:

  • Primitive types (int, float)
  • Strings (string to LPWSTR)
  • Structures and pointers
  • Allocating and freeing unmanaged memory

Cross-Platform Solutions

Building for multiple OSes brings unique challenges:

  • Different system libraries:

- Windows: winmm.dll

Google AdInline article slot

- macOS: CoreMIDI.framework

- Linux: ALSA via libasound

  • Different API functions:

- Windows: midiInGetNumDevs()

Google AdInline article slot

- macOS: MIDIGetNumberOfSources()

- Linux: snd_seq_* functions

  • Different library paths:

- Windows: simple DLL name

- macOS: full framework path

Use RuntimeInformation to detect the OS:

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    count = midiInGetNumDevs();
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
    count = MIDIGetNumberOfSources();

Building a Native Backend

A unified approach creates your own native layer to abstract platform differences.

Project structure:

  • Native-Windows.cpp β†’ Native.dll
  • Native-macOS.cpp β†’ Native.dylib
  • Native-Linux.cpp β†’ Native.so

Windows implementation example:

extern "C" __declspec(dllexport) int GetInputDevicesCount()
{
    return midiInGetNumDevs();
}

macOS implementation example:

extern "C" int GetInputDevicesCount()
{
    return MIDIGetNumberOfSources();
}

Benefits:

  • Single API across platforms
  • Isolates platform-specific code
  • Simplifies C# code
  • Allows extra logic

Building and Managing Binaries

Native library builds vary by platform:

Windows (MSVC):

cl /LD Native-Windows.cpp /link /OUT:Native.dll

macOS (Clang):

clang++ -dynamiclib -o Native.dylib Native-macOS.cpp -framework CoreMIDI

Linux (GCC):

g++ -shared -fPIC -o Native.so Native-Linux.cpp -lasound

Project file layout:

project/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ Native/
β”‚   β”‚   β”œβ”€β”€ Windows/
β”‚   β”‚   β”‚   β”œβ”€β”€ x64/
β”‚   β”‚   β”‚   β”‚   └── Native.dll
β”‚   β”‚   β”‚   └── arm64/
β”‚   β”‚   β”‚       └── Native.dll
β”‚   β”‚   β”œβ”€β”€ macOS/
β”‚   β”‚   β”‚   β”œβ”€β”€ x64/
β”‚   β”‚   β”‚   β”‚   └── Native.dylib
β”‚   β”‚   β”‚   └── arm64/
β”‚   β”‚   β”‚       └── Native.dylib
β”‚   β”‚   └── Linux/
β”‚   β”‚       └── x64/
β”‚   β”‚           └── Native.so
β”‚   └── Managed/
β”‚       └── MidiWrapper.cs
└── MidiWrapper.csproj

Modern P/Invoke Approaches

LibraryImport Attribute

.NET 7+ introduces LibraryImport, which generates marshaling code at compile time:

[LibraryImport("Native")]
internal static partial int GetInputDevicesCount();

LibraryImport advantages:

  • Compile-time code generation
  • Better performance
  • Stricter type checking
  • Source generator support

Safe Resource Management

IntPtr vs SafeHandle:

  • IntPtr: raw pointer, manual management
  • SafeHandle: safe wrapper with auto-disposal

SafeHandle example:

sealed class MidiHandle : SafeHandle
{
    public MidiHandle() : base(IntPtr.Zero, true) { }
    
    public override bool IsInvalid => handle == IntPtr.Zero;
    
    protected override bool ReleaseHandle()
    {
        return midiInClose(handle) == 0;
    }
}

Callbacks and Async Handling

Callback challenges:

  • Can't call some native functions from callbacks
  • Needs thread synchronization
  • Deadlock risks with misuse

MIDI buffer pattern:

  • Pre-allocate multiple buffers
  • Reuse buffers with data in callbacks
  • Avoid unprepared/close calls in callbacks
  • Use thread-safe data structures

Safe callback example:

private static void MidiCallback(IntPtr handle, uint msg, IntPtr instance, IntPtr param1, IntPtr param2)
{
    // Extract data from buffer
    var data = ExtractMidiData(param1);
    
    // Async processing
    Task.Run(() => ProcessMidiDataAsync(data));
    
    // Re-add buffer
    lock (bufferLock)
    {
        midiInAddBuffer(handle, param1, Marshal.SizeOf<MIDIHDR>());
    }
}

Packaging for NuGet

NuGet structure with natives:

<PackageReference>
  <NativeLibs>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <NativeLib>runtimes/win-x64/native/Native.dll</NativeLib>
  </NativeLibs>
  <NativeLibs>
    <RuntimeIdentifier>osx-arm64</RuntimeIdentifier>
    <NativeLib>runtimes/osx-arm64/native/Native.dylib</NativeLib>
  </NativeLibs>
</PackageReference>

.csproj directives:

<ItemGroup>
  <Content Include="runtimes\win-x64\native\Native.dll">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <PackagePath>runtimes/win-x64/native/</PackagePath>
  </Content>
  <Content Include="runtimes\osx-arm64\native\Native.dylib">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <PackagePath>runtimes/osx-arm64/native/</PackagePath>
  </Content>
</ItemGroup>

Key points:

  • Organize by RID (Runtime Identifier)
  • Target platforms in .csproj
  • Test across systems
  • Support x64 and ARM64

Performance Optimization

Reduce overhead:

  • Minimize managed/unmanaged transitions
  • Use blittable types (no marshaling needed)
  • Cache handles and pointers
  • Batch calls

Blittable types:

  • byte, sbyte, short, ushort, int, uint, long, ulong
  • float, double
  • Structs with only blittables

Optimized struct:

[StructLayout(LayoutKind.Sequential)]
struct MidiMessage
{
    public uint Timestamp;
    public byte Status;
    public byte Data1;
    public byte Data2;
    public byte Reserved;
}

Debugging and Diagnostics

Native debugging tools:

  • WinDbg and CDB (Windows)
  • LLDB (macOS/Linux)
  • Visual Studio Mixed Mode
  • JetBrains Rider native support

Common issues:

  • Access Violation: Bad type marshaling
  • Memory Leaks: Unfreed resources
  • Deadlocks: Callback locks
  • Performance: Frequent P/Invoke

Call logging:

[DllImport("Native", EntryPoint = "GetInputDevicesCount")]
private static extern int NativeGetInputDevicesCount();

public static int GetInputDevicesCount()
{
    Logger.Debug("Calling native GetInputDevicesCount");
    var result = NativeGetInputDevicesCount();
    Logger.Debug($

β€” Editorial Team

Advertisement 728x90

Read Next