C# P/Invoke 基础

P/Invoke 基础

P/Invoke 到底是何方神圣?

官方解释 P/Invoke 是可用于从托管代码访问非托管库中的结构、回调和函数的一种技术。 大多数 P/Invoke API 包含在以下两个命名空间中:System 和 System.Runtime.InteropServices。 使用这两个命名空间可提供用于描述如何与本机组件通信的工具。

P/Invoke 的全称是 Platform Invocation Services(平台调用服务)。名字很唬人,但你完全可以把它理解成 C# 和 C++ 之间的同声传译(C# 等 .NET 语言调用底层 C/C++ 原生函数的方法)。

想象一下这个场景
C# 住在一栋"托管豪宅"里:垃圾有清洁工自动扫(GC),类型安全,住得很舒服。
C++ 则在"野外地摊"干活:速度快、能直接操作硬件,但什么都得自己动手(手动管理内存)。

现在,豪宅里的 C# 想用地摊上的一个现成工具(比如一个高效的图像缩放算法)。怎么办?

  • 自己从零写一个?太累,而且大概率没人家 C++ 写得好。
  • 直接冲出去拿?语言不通,内存布局对不上,一跑就崩。

这时候,P/Invoke 就是 C# 专门请来的翻译官:

  • 帮 C# 找到那个 C++ 函数住在哪栋"大楼"(DLL 文件)。
  • 把 C# 的参数翻译成 C++ 能听懂的样子(比如把 string 变成 char*)。
  • 等 C++ 干完活,再把结果翻译回 C# 能用的格式。
  • 顺便把临时申请的"地摊垃圾"清理掉。

跑通一个 P/Invoke 示例

cs 复制代码
using System;
using System.Runtime.InteropServices;

namespace MyPInvoke_demo01{
    internal class Program
    {
        //[DllImport("user32.dll")]	告诉 CLR:这个函数住在 user32.dll 这座大楼里
        //CharSet = CharSet.Unicode	字符串使用 Unicode 编码,防止乱码
        //public static extern 方法的实现在外部(C++ 那边),这里只是声明
        [DllImport("user32.dll", CharSet = CharSet.Auto)]
        public static extern int MessageBox(IntPtr hWnd, String text, String caption, uint type);
        
        static void Main(string[] args){
            // 调用MessageBox函数显示一个消息框
            //IntPtr.Zero	表示空指针,在这里代表没有父窗口
            MessageBox(IntPtr.Zero, "Hello, World!", "MyPInvoke_demo01", 0);
        }
    }
}

运行,你会看到一个标准的 Windows 消息框。你已经完成了一次跨语言的调用!

核心概念:翻译官的工作规则

1. 规则一:数据类型要对齐 ------ Blittable 与非 Blittable

Blittable 类型(零成本翻译)

int, float, double, byte, long, IntPtr,以及只包含这些类型的结构体。直接扔过去,C++ 那边看都不看就接住了,速度极快。

非 Blittable 类型(需要转换)

string(编码可能不同)、bool(C# 1字节,Windows API 的 BOOL 是4字节)、char、object 等。每次调用都涉及内存分配和格式转换,有性能损耗。

开发建议:

  • 图像数据尽量用 byte[],它是 Blittable 的,传递海量像素时效率最高。
  • 结构体中尽量少用 string,改用固定长度的 char[] 或 byte[]。

2. 规则二:谁打电话,谁负责挂断 ------ 调用约定

C++ 函数执行完后,谁负责清理"战场"(堆栈)?这叫做 调用约定 (Calling Convention)。
StdCall: 被调用者(C++)自己清理。Windows API 默认用这个。
Cdecl: 调用者(C#)负责清理。C/C++ 默认用这个,支持可变参数(比如 printf)。

如果你声明错了,程序不会报错,而是在某个随机时刻崩溃(尤其在 Release 下)。解决方案:永远在 DllImport 里显式写清楚。

cs 复制代码
[DllImport("mylib.dll", CallingConvention = CallingConvention.Cdecl)]

3. 规则三:谁的垃圾谁倒 ------ 内存管理

P/Invoke 不会自动释放 C++ 那边分配的内存。

谁分配的内存 谁来释放
C# 的 new byte[1024] GC 自动回收(或你手动 Marshal.FreeHGlobal)
C++ 里的 malloc() 必须调用 free()
C++ 里的 new 必须调用 delete
Windows API 的 GlobalAlloc 必须调用 GlobalFree

安全做法: 对于 C++ 返回的指针,尽量用 SafeHandle 封装起来,这样即使发生异常,也能自动释放

cs 复制代码
// 示例:封装一个文件句柄
public class SafeFileHandle : SafeHandleZeroOrMinusOneIsInvalid{
    public SafeFileHandle() : base(true) { }
    protected override bool ReleaseHandle() => CloseHandle(handle);
    [DllImport("kernel32.dll")] 
    private static extern bool CloseHandle(IntPtr hObject);
}

结构体、回调和字符串

1. 结构体互传 ------ 注意内存对齐

假设 C++ 里有这样一个结构体:

cs 复制代码
struct Point3D {
    float x;
    float y;
    float z;
};

C# 中要这样声明才能对得上:

cs 复制代码
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Point3D{
    public float X;
    public float Y;
    public float Z;
}

LayoutKind.Sequential: 按声明顺序排列字段。
Pack = 1: 按 1 字节对齐,防止编译器自动插入填充字节。

2. 回调函数 ------ 小心委托被 GC"吃掉"

可以把 C# 的方法作为函数指针传给 C++,让 C++ 在某些事件发生时回调它。比如遍历窗口:

cs 复制代码
// 声明委托类型
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
// 实现回调方法
static bool MyCallback(IntPtr hWnd, IntPtr lParam){
    Console.WriteLine($"找到窗口句柄: {hWnd}");
    return true; // 继续枚举
}
// 调用
EnumWindows(MyCallback, IntPtr.Zero);

致命坑: 如果 MyCallback 委托没有被任何变量引用,GC 可能在你不知情的时候回收它,然后 C++ 那边回调时就会访问非法内存,程序直接崩溃。
解决方案: 用一个静态字段持有委托实例。

cs 复制代码
private static EnumWindowsProc callback = MyCallback;
EnumWindows(callback, IntPtr.Zero);

3. 字符串的"读"与"写"

  • 只读字符串: 直接用 string 参数,P/Invoke 会自动转成 const char*。
  • 需要 C++ 填充的字符串缓冲区: 用 StringBuilder,并且预分配足够容量。
cs 复制代码
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

var sb = new StringBuilder(256);
GetWindowText(someHwnd, sb, sb.Capacity);

.NET 7+ 的新福音:LibraryImport

LibraryImport采用源生成器技术,在编译时就生成封送代码,优点:

  • 零运行时反射,性能更好。
  • 支持 AOT(提前编译)场景。
  • 类型检查更严格,错误更早发现。

LibraryImport写法:

cs 复制代码
//注意:方法要声明为 partial static,不需要方法体。
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
static partial int MessageBox(IntPtr hWnd, string text, string caption, uint type);

总结

P/Invoke 是一扇门,门的一边是 .NET 的安逸世界,另一边是原生代码的广袤天地。掌握了它,你就能在 C# 里调用任何 C/C++ 库、任何 Windows API,甚至自己写一个 C++ DLL 给 C# 用。

但能力强也意味着责任重 ------ 数据类型、调用约定、内存管理,哪一个细节没对齐,都可能换来一个 AccessViolationException(访问违规异常)。

集中管理: 把所有 P/Invoke 声明放在一个名为 NativeMethods 的类里,方便维护。
显式优于隐式: 永远写上 CallingConvention、CharSet、SetLastError,不要依赖默认值。
能用 Blittable 就用 Blittable: 高频调用的函数,参数和返回值尽量用 int、float、byte[] 等,避免 string 和 bool。
谁分配谁释放: 对于返回指针的函数,立刻看文档确认释放方式,并用 SafeHandle 或 try-finally 包裹。
善用工具:

  • pinvoke.net:搜常见 Windows API 的正确签名。
  • P/Invoke Interop Assistant:自动生成转换代码。
  • Visual Studio 的"启用本机代码调试":可以同时调试 C# 和 C++。

记住三条

P/Invoke 是翻译官。

能传 int 就别传 string,能传 byte[] 就别传 object。

出了问题先检查:调用约定?内存对齐?谁分配谁释放?

本文参考
Chatgpt https://chatgpt.com/
C# 官方文档 https://learn.microsoft.com/zh-cn/dotnet/standard/native-interop/pinvoke
DeepSeek https://chat.deepseek.com/

相关推荐
直奔標竿1 小时前
Java开发者AI转型第二十五课!Spring AI 个人知识库实战(四)——RAG来源追溯落地,拒绝AI幻觉
java·开发语言·人工智能·spring boot·后端·spring
时空系1 小时前
认识Rust——我的第一个程序 Rust中文编程
开发语言·后端·rust
yqcoder1 小时前
JavaScript 柯里化:把“大餐”拆成“小炒”的艺术
开发语言·javascript·ecmascript
每天吃饭的羊2 小时前
JSZip的使用
开发语言·javascript
xian_wwq2 小时前
【学习笔记】网络与数据安全领域强制性标准
笔记·学习
24白菜头2 小时前
【无标题】
c++·笔记·学习·harmonyos
qq_589568102 小时前
java基础学习,案例练习,即时通讯
java·开发语言·学习
Avalon7122 小时前
Unity3D响应式渲染UI框架UniVue
游戏·ui·unity·c#·游戏引擎