Calling Native APIs with P/Invoke and COM Interop in .NET

Bridging Managed and Native Code

Imagine you're building a Windows application that needs to flash the taskbar when a notification arrives. .NET doesn't provide this functionality directly, but the Windows API does through FlashWindowEx. Your app needs to integrate with hardware drivers that only expose C APIs. A legacy COM component handles business logic you can't rewrite. These scenarios require calling unmanaged code from your .NET application.

P/Invoke and COM Interop bridge the gap between managed .NET and unmanaged code. P/Invoke lets you call functions from DLLs like kernel32.dll or your own native libraries. COM Interop enables communication with Component Object Model components. Both mechanisms handle the complexity of marshaling data between managed and unmanaged memory, though you need to understand the rules to avoid crashes and leaks.

You'll learn how to declare and call native functions, marshal different data types safely, manage unmanaged memory, and integrate COM components. By the end, you'll know when to use these techniques and how to implement them correctly.

Getting Started with P/Invoke

Platform Invocation Services lets you call exported functions from unmanaged DLLs. You declare the function signature in C# using the DllImport attribute, and the runtime handles loading the library and marshaling parameters. The function name, calling convention, and parameter types must match the native signature exactly.

Start with simple functions that use basic types like integers and strings. Once you understand the fundamentals, you can tackle complex scenarios with structures, pointers, and callbacks.

Program.cs
using System;
using System.Runtime.InteropServices;

class Program
{
    // Import MessageBox from user32.dll
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    static extern int MessageBox(IntPtr hWnd, string text,
        string caption, uint type);

    // Import GetTickCount from kernel32.dll
    [DllImport("kernel32.dll")]
    static extern uint GetTickCount();

    static void Main()
    {
        // Get system uptime in milliseconds
        var tickCount = GetTickCount();
        var uptime = TimeSpan.FromMilliseconds(tickCount);

        Console.WriteLine($"System uptime: {uptime}");

        // Show a native Windows message box
        MessageBox(IntPtr.Zero,
            $"System has been running for {uptime.Hours} hours",
            "System Info",
            0x00000040); // MB_ICONINFORMATION
    }
}
Output:
System uptime: 5:23:14
[A Windows message box appears]

The CharSet parameter tells the marshaler how to convert strings. Unicode matches Windows's internal representation and works for most APIs. The extern keyword indicates the method is implemented externally. Return types and parameters must use types that the marshaler understands or you'll get runtime errors.

Marshaling Data Between Managed and Unmanaged Code

Marshaling converts data between managed and unmanaged representations. Simple types like int and double copy directly. Strings require conversion because .NET uses UTF-16 while C APIs often use ANSI or UTF-8. Structures need explicit layout control to match native memory layout.

The Marshal class provides helpers for allocating unmanaged memory, copying data, and freeing resources. Use it when automatic marshaling doesn't work or when you need fine control over memory.

Program.cs
using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
struct POINT
{
    public int X;
    public int Y;
}

class Program
{
    [DllImport("user32.dll")]
    static extern bool GetCursorPos(out POINT lpPoint);

    [DllImport("kernel32.dll", CharSet = CharSet.Auto)]
    static extern void GetSystemDirectory(
        [Out] char[] lpBuffer, uint uSize);

    static void Main()
    {
        // Get mouse cursor position
        if (GetCursorPos(out POINT point))
        {
            Console.WriteLine($"Cursor position: ({point.X}, {point.Y})");
        }

        // Get Windows system directory
        var buffer = new char[260]; // MAX_PATH
        GetSystemDirectory(buffer, (uint)buffer.Length);
        var sysDir = new string(buffer).TrimEnd('\0');
        Console.WriteLine($"System directory: {sysDir}");

        // Manual memory marshaling
        var str = "Hello from managed code";
        var unmanagedPtr = Marshal.StringToHGlobalAnsi(str);
        try
        {
            // Pointer now holds ANSI version of string
            var readBack = Marshal.PtrToStringAnsi(unmanagedPtr);
            Console.WriteLine($"Marshaled string: {readBack}");
        }
        finally
        {
            // Always free unmanaged memory
            Marshal.FreeHGlobal(unmanagedPtr);
        }
    }
}
Output:
Cursor position: (1024, 768)
System directory: C:\Windows\System32
Marshaled string: Hello from managed code

The StructLayout attribute controls field ordering and alignment. Sequential layout maintains field order and adds padding for alignment. Always free memory allocated with Marshal methods to prevent leaks. The try-finally pattern ensures cleanup happens even if exceptions occur.

Working with COM Components

COM Interop lets you use Component Object Model libraries from .NET. You add references to COM type libraries, and Visual Studio generates Runtime Callable Wrappers that expose COM interfaces as .NET objects. The runtime handles reference counting and interface queries automatically.

Use COM when integrating with Microsoft Office, Windows Shell extensions, or legacy business components. Most modern .NET development avoids COM, but it remains necessary for certain Windows integrations.

Program.cs - Using Windows Script Host
using System;
using System.Runtime.InteropServices;

class Program
{
    static void Main()
    {
        // Create COM object using ProgID
        Type shellType = Type.GetTypeFromProgID("WScript.Shell");
        if (shellType != null)
        {
            dynamic shell = Activator.CreateInstance(shellType);
            try
            {
                // Read registry value via COM
                var programFiles = shell.RegRead(
                    @"HKLM\SOFTWARE\Microsoft\Windows\" +
                    @"CurrentVersion\ProgramFilesDir");
                Console.WriteLine($"Program Files: {programFiles}");

                // Create desktop shortcut
                dynamic shortcut = shell.CreateShortcut(
                    @"C:\Users\Public\Desktop\Test.lnk");
                shortcut.TargetPath = @"C:\Windows\notepad.exe";
                shortcut.Description = "Notepad Shortcut";
                shortcut.Save();

                Console.WriteLine("Shortcut created successfully");
            }
            finally
            {
                // Release COM object
                Marshal.ReleaseComObject(shell);
            }
        }
    }
}
Output:
Program Files: C:\Program Files
Shortcut created successfully

Always call Marshal.ReleaseComObject when done with COM objects to release references immediately. Otherwise, objects stay alive until garbage collection runs, potentially locking files or other resources. The dynamic keyword simplifies COM calls by bypassing compile-time type checking.

Safety and Security Considerations

P/Invoke bypasses .NET's type safety and security boundaries. Invalid pointers crash the process. Buffer overruns can corrupt memory or create security vulnerabilities. Always validate parameters before passing them to unmanaged code. Check buffer sizes, ensure pointers aren't null, and validate string lengths.

Use SafeHandle-derived classes instead of IntPtr for handles that need cleanup. SafeHandles guarantee finalizers run and handles get closed even during unexpected shutdowns. The .NET Framework provides SafeFileHandle, SafeWaitHandle, and others for common scenarios.

Never trust data returned from unmanaged code without validation. Buffer overruns in native code can write past array bounds. Validate array sizes and string lengths before using returned data. Use defensive copies when passing managed arrays to unmanaged code to prevent corruption.

Try It Yourself

Build a utility that retrieves system information using native Windows APIs. This demonstrates P/Invoke with different parameter types and return values.

Program.cs
using System;
using System.Runtime.InteropServices;
using System.Text;

class SystemInfo
{
    [DllImport("kernel32.dll")]
    static extern void GetSystemInfo(out SYSTEM_INFO lpSystemInfo);

    [DllImport("kernel32.dll", CharSet = CharSet.Auto)]
    static extern int GetComputerName(StringBuilder lpBuffer, ref uint nSize);

    [StructLayout(LayoutKind.Sequential)]
    struct SYSTEM_INFO
    {
        public ushort processorArchitecture;
        public ushort reserved;
        public uint pageSize;
        public IntPtr minimumApplicationAddress;
        public IntPtr maximumApplicationAddress;
        public IntPtr activeProcessorMask;
        public uint numberOfProcessors;
        public uint processorType;
        public uint allocationGranularity;
        public ushort processorLevel;
        public ushort processorRevision;
    }

    static void Main()
    {
        GetSystemInfo(out SYSTEM_INFO sysInfo);

        Console.WriteLine("System Information:");
        Console.WriteLine($"  Processors: {sysInfo.numberOfProcessors}");
        Console.WriteLine($"  Page size: {sysInfo.pageSize} bytes");
        Console.WriteLine($"  Architecture: " +
            $"{GetArchitecture(sysInfo.processorArchitecture)}");

        var computerName = new StringBuilder(256);
        uint size = (uint)computerName.Capacity;
        if (GetComputerName(computerName, ref size) != 0)
        {
            Console.WriteLine($"  Computer name: {computerName}");
        }
    }

    static string GetArchitecture(ushort arch)
    {
        return arch switch
        {
            0 => "x86",
            9 => "x64",
            12 => "ARM",
            _ => $"Unknown ({arch})"
        };
    }
}
Output:
System Information:
  Processors: 8
  Page size: 4096 bytes
  Architecture: x64
  Computer name: DESKTOP-ABC123
Project.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

The ref keyword passes the size parameter by reference so the API can update it. StringBuilder works better than string for output parameters because it's mutable. Structure layout must match the native definition exactly or you'll get garbage data.

Choosing the Right Approach

Choose P/Invoke when you need specific Windows API functionality that .NET doesn't expose. Prefer managed alternatives when they exist. System.IO.File wraps many file operations that you could do with CreateFile and ReadFile, but the managed version is safer and more portable. Only drop to P/Invoke when managed APIs lack features you need.

Consider cross-platform implications. P/Invoke to Windows DLLs only works on Windows. If cross-platform support matters, check whether .NET Core provides the functionality or use conditional compilation to call different libraries per platform. Libraries like Mono.Posix help with cross-platform native calls.

For new projects, evaluate whether you really need native code. Can you achieve the same result with pure managed code and a bit more work? Native interop adds complexity, reduces portability, and increases the chance of crashes. Use it as a last resort after exhausting managed options.

Frequently Asked Questions (FAQ)

When should I use P/Invoke versus managed alternatives?

Use P/Invoke only when .NET doesn't provide equivalent functionality. Windows-specific features like registry manipulation, device I/O, or system services may require native calls. First check if NuGet packages or System.Runtime.InteropServices provide what you need before writing P/Invoke code.

How do I prevent memory leaks when calling unmanaged code?

Always free memory allocated by unmanaged code using the appropriate deallocation function. Use SafeHandle-derived classes to ensure cleanup happens even when exceptions occur. Never rely on garbage collection to free unmanaged resources. Implement IDisposable and call Dispose explicitly.

Can P/Invoke calls work on Linux and macOS?

Yes, but you call different libraries. Windows uses kernel32.dll and user32.dll, while Linux uses libc.so. Use runtime detection with RuntimeInformation.IsOSPlatform to load the correct library. Consider using libraries like Mono.Posix.NETStandard for cross-platform native calls.

Back to Articles