C#调用C++ DLL实战指南:P/Invoke原理、内存对齐与字符串处理

发布时间:2026/7/25 9:00:27
C#调用C++ DLL实战指南:P/Invoke原理、内存对齐与字符串处理 1. 项目概述为什么要在C#里调用C代码干了这么多年工业控制和上位机开发我几乎没遇到过哪个项目能完全避开C。无论是为了复用那些沉淀了十几年的老算法库还是为了榨干硬件性能去处理实时视频流C这块“硬骨头”总是绕不过去。但项目的主体框架和用户界面用C#和WPF来搭又快又稳开发效率高出一大截。这时候一个核心问题就摆在了面前怎么让C#这个“现代管家”和C那个“老派工匠”顺畅地对话这不仅仅是技术问题更是工程上的权衡。你可能会想为什么不全部用C#重写时间成本、测试风险、还有那些只有原作者才懂的“祖传代码”里的精妙或者坑都让重写变得不现实。直接调用现有的C动态链接库DLL就成了最务实的选择。这个过程业内通常叫做“平台调用”P/Invoke或者“互操作”Interop。听起来高大上其实核心就是建立一套双方都能理解的“通信协议”让数据能安全、准确地在托管内存.NET CLR管理和非托管内存C自己管理之间来回穿梭。这次我就以一个完整的实例带你走通从C DLL编写、编译导出到C#侧声明、调用和调试的全过程。我会重点分享那些官方文档里不会写的“坑”和“技巧”比如结构体内存对齐这个“暗雷”字符串编码的“隐形杀手”以及如何优雅地回调Callback。无论你是刚接触互操作的新手还是想系统梳理一下相关知识的老手这篇都能给你带来可直接复用的干货。2. 核心原理与方案选型P/Invoke、C/CLI还是COM在动手之前得先搞清楚我们有几条路可以走。每种方案都有其特定的适用场景和代价。2.1 平台调用P/Invoke轻量直接的桥梁P/Invoke是.NET Framework原生支持的方式也是最常用的一种。它的原理是通过在C#代码中使用[DllImport]特性声明一个外部函数指定其所在的DLL名称、入口点、调用约定等信息。当C#代码调用这个声明的方法时.NET公共语言运行时CLR会负责查找并加载对应的非托管DLL进行必要的参数封送Marshaling然后跳转到C函数中去执行。它的优势非常明显简单直接对于导出函数签名清晰、数据类型简单的C接口extern CDLL几行代码就能调起来。依赖少不需要中间层直接与原生DLL对话。性能好在数据封送开销可控的情况下性能损失很小。但它也有硬性限制和“暗坑”“C”接口限制P/Invoke最适合调用使用extern C导出的、遵循C语言调用约定__cdecl在Windows上x86环境需注意x64环境统一为__fastcall变体的函数。它无法直接调用C的类、成员函数或重载函数因为那些函数名会被编译器“粉碎”Name Mangling。手动封送你需要手动处理所有复杂数据类型的转换比如在C#中定义与C结构体完全内存对齐的对应结构处理字符串指针char*到string或StringBuilder管理非托管内存的分配与释放。这一步是绝大多数错误的来源。异常处理非托管代码中的崩溃如访问违规会导致整个托管进程崩溃难以在C#侧用try-catch捕获。注意对于x86平台C默认的调用约定是__cdecl而很多Windows API使用的是__stdcall。在[DllImport]中必须通过CallingConvention属性明确指定否则会导致运行时栈不平衡程序瞬间崩溃。x64平台下这个问题不复存在因为调用约定已统一。2.2 C/CLI托管与非托管的“混血儿”C/CLI是微软提供的一种特殊语言扩展允许你在同一个项目、甚至同一个文件里编写托管代码.NET和非托管代码原生C。你可以创建一个C/CLI类库项目在其中用原生C实现核心算法然后包装一层薄薄的托管类暴露给C#。它的核心价值在于无缝互操作在C/CLI代码内部托管和非托管对象可以几乎无障碍地交互编译器帮你处理了大部分复杂的封送工作。直接暴露C类你可以将整个C类包装成一个托管类C#可以像使用普通.NET对象一样new它、调用其方法、继承它如果允许。调试方便可以在同一个调试会话中无缝步进托管和非托管代码。代价是引入复杂性你需要学习C/CLI的语法如gcnew、^句柄。部署依赖生成的程序集是混合模式的需要目标机器安装对应版本的.NET Framework和VC运行时。性能折衷虽然比纯P/Invoke封送复杂对象快但在托管/非托管边界频繁穿梭仍有开销。2.3 COM互操作成熟但略显笨重的体系如果你的C代码已经是COM组件或者你打算构建一个长期稳定、被多种语言VB6、Python等调用的组件COM是一个选择。.NET通过运行时可调用包装RCW可以非常方便地使用COM组件就像使用.NET对象一样。然而对于全新的、主要是C#调用的C代码来说开发繁琐需要实现IUnknown、IDispatch等接口编写类型库IDL。注册表依赖COM组件通常需要注册到系统注册表这给绿色部署带来麻烦。过度设计对于单纯的C#/C互调COM显得太重了。结论与选型建议对于绝大多数场景特别是调用现有的、函数接口清晰的算法库或系统APIP/Invoke是首选。它轻量、直接、学习曲线相对平缓。本文的完整实例也将围绕P/Invoke展开。当你需要封装一个复杂的、面向对象的C库并且希望提供更自然的.NET API时再考虑C/CLI。3. 实战第一步创建并导出C动态库DLL理论说再多不如动手写一行。我们从一个最简单的例子开始逐步增加复杂度。3.1 使用Visual Studio创建C DLL项目打开Visual Studio以VS2019/2022为例新建项目。选择“C” - “Windows桌面” - “动态链接库(DLL)”模板。给项目起个名比如NativeMathLibrary。创建完成后你会看到几个默认文件dllmain.cpppch.hpch.cppframework.h。3.2 编写导出函数与头文件我们首先实现一个简单的整数加法函数。关键在于导出声明。在pch.h预编译头文件或新建一个头文件如NativeMath.h中声明// NativeMath.h #pragma once // 定义一个宏用于简化导出声明。 // 当编译DLL时函数需要被导出__declspec(dllexport)。 // 当使用者包含此头文件时函数需要被导入__declspec(dllimport)。 #ifdef NATIVEMATH_EXPORTS #define NATIVEMATH_API __declspec(dllexport) #else #define NATIVEMATH_API __declspec(dllimport) #endif // 使用extern C来防止C名称粉碎并指定调用约定。 // 在x86上明确使用__stdcallCalldingConvention.StdCall更符合Windows习惯也与很多API一致。 // 在x64上调用约定是统一的指定与否影响不大但保持明确是好习惯。 extern C NATIVEMATH_API int __stdcall AddIntegers(int a, int b);在对应的源文件如NativeMath.cpp中实现// NativeMath.cpp #include pch.h // 或 #include NativeMath.h #include framework.h // 定义这个宏这样头文件中的NATIVEMATH_API就会展开为__declspec(dllexport) #define NATIVEMATH_EXPORTS extern C NATIVEMATH_API int __stdcall AddIntegers(int a, int b) { return a b; }关键点解析extern C这是必须的。它告诉C编译器使用C语言的链接规范避免函数名被修饰如?AddIntegersYAHHHZ这样P/Invoke才能通过简单的函数名“AddIntegers”找到它。__stdcall指定调用约定。它决定了函数参数如何压栈、栈由谁清理。在[DllImport]中必须匹配CallingConvention CallingConvention.StdCall。对于只有简单参数的函数__cdecl也可以但在Windows环境下与系统API保持一致__stdcall更稳妥。__declspec(dllexport)这是微软编译器特有的语法用于将一个函数标记为从DLL中导出。3.3 处理复杂数据类型结构体与字符串现在增加难度实现一个函数它接收一个结构体指针和字符串并修改结构体内容。在NativeMath.h中增加// 定义一个简单的结构体 struct SimpleData { int Id; double Value; // 注意这里用一个指针来接收字符串。更安全的做法是传递缓冲区指针和长度。 char Message[256]; // 固定大小的字符数组 }; // 导出函数处理结构体和字符串 extern C NATIVEMATH_API void __stdcall ProcessData(SimpleData* data, const char* inputString);在NativeMath.cpp中实现extern C NATIVEMATH_API void __stdcall ProcessData(SimpleData* data, const char* inputString) { if (data nullptr) return; // 修改结构体成员 >dumpbin /exports NativeMathLibrary.dll你应该能看到类似下面的输出注意修饰名是简单的AddIntegers和ProcessData这正是extern C的功劳。ordinal hint RVA name 1 0 00001000 AddIntegers 2 1 00001010 ProcessData4. C#侧调用详解从声明到高级封送DLL准备好了现在转到C#项目。创建一个C#控制台应用或类库项目。4.1 基础声明与调用首先将编译好的NativeMathLibrary.dll复制到C#项目的输出目录如bin\Debug\net6.0或者放到系统路径能找到的地方。在C#中声明并调用using System; using System.Runtime.InteropServices; // 这是P/Invoke的核心命名空间 namespace CSharpCaller { class Program { // 声明AddIntegers函数 // DllImport特性是最关键的部分 [DllImport(NativeMathLibrary.dll, EntryPoint AddIntegers, // 入口点名称如果C#方法名与DLL函数名相同可省略 CallingConvention CallingConvention.StdCall)] // 必须与C侧的__stdcall匹配 public static extern int AddIntegers(int a, int b); // 方法必须是static extern static void Main(string[] args) { int result AddIntegers(10, 20); Console.WriteLine($10 20 {result}); // 输出 30 } } }一次成功但别急坑在后面。4.2 封送Marshaling复杂类型结构体C#中必须定义一个与C结构体内存布局完全一致的结构。这里的关键是[StructLayout]特性。using System.Runtime.InteropServices; // 使用LayoutKind.Sequential确保字段按照定义的顺序依次排列在内存中。 // CharSet CharSet.Ansi 指定字符串的编码为ANSI对应C的char。 // Pack 1 设置包装大小为1字节取消内存对齐填充。对于简单结构这通常不是必须的 // 但当C结构体使用了#pragma pack(1)时必须匹配。 [StructLayout(LayoutKind.Sequential, CharSet CharSet.Ansi)] public struct SimpleData { public int Id; public double Value; // 对应C中的 char Message[256]; // 使用MarshalAs特性指定这是一个ByValTStr内联的、固定大小的字符数组大小为256。 // 注意SizeConst的值是字符数组的总长度包括字符串终止符\0。 // 所以C中定义的是char[256]这里SizeConst也应该是256。 [MarshalAs(UnmanagedType.ByValTStr, SizeConst 256)] public string Message; // 在封送时CLR会负责将.NET字符串复制到这个固定大小的缓冲区。 } class Program { // 声明ProcessData函数。 // 第一个参数是ref SimpleData表示传递结构体的指针可修改。 // 第二个参数是string对于const char*Marshaler默认会将其转换为ANSI字符串。 [DllImport(NativeMathLibrary.dll, CallingConvention CallingConvention.StdCall)] public static extern void ProcessData(ref SimpleData data, string inputString); static void Main(string[] args) { SimpleData myData new SimpleData { Id 5, Value 3.14, Message // 初始化实际内容将由C函数填充 }; string input Hello from C#!; ProcessData(ref myData, input); Console.WriteLine($ID: {myData.Id}, Value: {myData.Value}, Message: {myData.Message}); // 预期输出ID: 10, Value: 103.14, Message: Hello from C#! } }内存对齐的坑重中之重上面的例子能工作是因为int(4字节)和double(8字节)在默认情况下大多数编译器MSVC的默认对齐规则通常是8字节下Id后面可能会有4字节的填充然后才是Value。我们的[StructLayout(LayoutKind.Sequential)]在默认情况下也遵循CLR的默认对齐通常是8字节所以碰巧匹配了。但如果C侧使用了#pragma pack(1)1字节对齐即紧密排列或者结构体成员顺序不同情况就变了。必须保证两边的内存布局完全一致。排查和确保一致的方法查看C结构体大小在C中用sizeof(SimpleData)打印大小。查看C#结构体大小在C#中用Marshal.SizeOf(typeof(SimpleData))打印大小。必须相等两者大小必须严格相等否则传参后访问成员必然错乱甚至崩溃。使用Pack显式控制在C#的[StructLayout]中设置Pack n来模拟C编译器的对齐方式。如果C用了#pragma pack(1)C#这边就设Pack 1。4.3 字符串传递的深水区编码与生命周期字符串是互操作中最易出错的数据类型之一。核心在于理解指针和内存所有权。场景一C#传递字符串给CC只读就像上面的ProcessDataC参数是const char*。这种情况最简单C#使用string类型即可封送拆收器Marshaler会自动在调用时分配一块非托管内存将.NET字符串Unicode转换为ANSI如果CharSetAnsi或UnicodeCharSetUnicode字符串复制进去函数返回后释放该内存。场景二C返回字符串给C#常见坑点如果C函数返回一个char*或者通过参数输出一个char*你必须明确谁分配内存谁释放内存。错误示范会导致内存泄漏或访问违规// C (危险!) extern C NATIVEMATH_API const char* __stdcall GetErrorMessage() { return Something went wrong; // 返回指向常量字符串的指针勉强可以但不够灵活。 // 如果是动态分配的return new char[100]; // 灾难C#无法释放这块内存。 }// C# [DllImport(NativeMathLibrary.dll)] [return: MarshalAs(UnmanagedType.LPStr)] // 指定返回类型是ANSI字符串指针 public static extern string GetErrorMessage(); // 这样声明CLR会假设它需要释放返回的指针但对于new出来的内存它用错误的释放方式CoTaskMemFree正确做法1C#分配缓冲区C填充这是最安全、最推荐的方式。C#负责分配固定大小的内存缓冲区以指针形式传给CC向其中写入数据。// C extern C NATIVEMATH_API bool __stdcall GetErrorMessageSafe(char* buffer, int bufferSize) { const char* error Error Code: 1001; if (strlen(error) bufferSize) return false; // 检查缓冲区大小 strcpy_s(buffer, bufferSize, error); return true; }// C# [DllImport(NativeMathLibrary.dll)] public static extern bool GetErrorMessageSafe(StringBuilder buffer, int bufferSize); static void Main() { int bufferSize 256; StringBuilder sb new StringBuilder(bufferSize); // StringBuilder用于输出缓冲区 if (GetErrorMessageSafe(sb, bufferSize)) { Console.WriteLine(sb.ToString()); } }使用StringBuilder并指定容量封送拆收器会将其作为可写的字符缓冲区指针LPStr传递。正确做法2使用CoTaskMemAlloc/Free约定如果必须由C分配内存则必须使用Windows API提供的、.NET认识的标准内存分配器。#include ObjBase.h // 包含CoTaskMemAlloc extern C NATIVEMATH_API char* __stdcall GetErrorMessageAlloc() { const char* message Dynamically allocated error; size_t len strlen(message) 1; char* buffer (char*)CoTaskMemAlloc(len); // 使用CoTaskMemAlloc分配 if (buffer) { strcpy_s(buffer, len, message); } return buffer; // 返回指针 }// C# [DllImport(NativeMathLibrary.dll)] [return: MarshalAs(UnmanagedType.LPStr)] public static extern string GetErrorMessageAlloc(); // 现在可以安全使用string了当C#收到这个string时CLR知道这个指针是用CoTaskMemAlloc分配的会在适当的时候调用CoTaskMemFree来释放它。4.4 回调函数Callback与函数指针让C调用C#的函数这是实现事件通知、异步操作的高级技巧。在C中这表现为函数指针在C#中我们需要使用委托Delegate。C侧// 定义函数指针类型 typedef void (__stdcall *LogCallback)(const char* message, int severity); // 导出一个函数用于设置回调 extern C NATIVEMATH_API void __stdcall SetLogger(LogCallback callback); // 在DLL内部保存回调函数指针 static LogCallback s_logCallback nullptr; extern C NATIVEMATH_API void __stdcall SetLogger(LogCallback callback) { s_logCallback callback; } // 一个内部函数用于触发回调 void InternalLog(const char* msg) { if (s_logCallback ! nullptr) { s_logCallback(msg, 1); // 调用C#传过来的函数 } }C#侧// 1. 定义与C函数指针签名匹配的委托 // 调用约定必须匹配StdCall [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Ansi)] public delegate void LogCallbackDelegate(string message, int severity); class Program { // 2. 声明设置回调的DLL函数 [DllImport(NativeMathLibrary.dll)] public static extern void SetLogger(LogCallbackDelegate callback); // 3. 实现一个符合委托签名的方法 public static void MyLogger(string msg, int severity) { Console.WriteLine($[{severity}] {msg}); } static void Main() { // 4. 创建委托实例指向静态方法 LogCallbackDelegate callback new LogCallbackDelegate(MyLogger); // 5. 将委托传递给C SetLogger(callback); // 关键必须保持委托实例callback不被垃圾回收 // 因为传递给C的只是一个函数指针如果C#侧的委托对象被GC回收 // C再调用这个指针就会导致访问违规。 // 通常的做法是将委托保存为一个类级别的静态变量。 GC.KeepAlive(callback); // 这只是示意更常见的是用字段持有引用。 } }回调函数的核心陷阱生命周期管理。传递给非托管代码的委托其底层函数指针必须在整个回调可能被调用的期间都有效。如果委托实例被垃圾回收了回调将指向无效内存。因此你必须确保有一个根引用如类的静态字段一直持有这个委托对象。5. 高级议题与性能优化5.1 封送拆收器Marshaling的开销与unsafe代码对于频繁调用或传递大量数据的场景如图像处理P/Invoke的封送开销可能成为瓶颈。每次调用CLR都需要检查参数类型。将托管对象如数组的内容复制到非托管内存或固定其内存地址。调用非托管函数。将输出数据从非托管内存复制回托管对象。进行必要的清理。对于大型数组复制数据的开销巨大。此时可以考虑使用unsafe代码和指针直接操作内存。[DllImport(NativeMathLibrary.dll)] public static extern unsafe void ProcessImage(byte* imageData, int width, int height); static void ProcessImageFast(byte[] imageData, int width, int height) { unsafe { // 固定托管数组防止GC在非托管操作期间移动它 fixed (byte* ptr imageData) { ProcessImage(ptr, width, height); } // fixed块结束后数组恢复可移动状态 } }使用unsafe需要项目启用“允许不安全代码”。这要求你对指针操作有深刻理解否则极易引入内存损坏和稳定性问题。5.2 处理C异常C异常无法直接穿越托管/非托管边界被C#的try-catch捕获。如果C函数抛出异常且未被内部捕获会导致进程崩溃。最佳实践C内部捕获在所有导出函数的边界用try...catch(...)捕获所有异常并转换为错误码返回。extern C NATIVEMATH_API int __stdcall SafeDivide(int a, int b) { try { if (b 0) throw std::runtime_error(Division by zero); return a / b; } catch (...) { // 返回一个特定的错误码或记录日志 return INT_MIN; // 用特殊值表示错误 } }使用SEH结构化异常处理转换更高级的做法是使用_set_se_translator将结构化异常如访问违规转换为C异常然后再按方法1处理。但这属于更底层的错误处理机制。5.3 多线程安全如果你的C DLL不是线程安全的那么在C#多线程环境下调用它就需要加锁。一个简单的做法是在C#侧使用lock语句确保同一时间只有一个线程进入该DLL的特定函数或一组相关函数。private static readonly object _dllLock new object(); public static int ThreadSafeAdd(int a, int b) { lock (_dllLock) { return AddIntegers(a, b); } }更优的设计是如果可能让C DLL本身是线程安全的无全局/静态状态或使用线程同步原语。6. 调试与问题排查实战记录互操作调试是门艺术。问题常常表现为神秘的“访问冲突”、“内存损坏”或“堆栈不平衡”。6.1 常见错误与症状System.EntryPointNotFoundException原因找不到指定的入口点。排查检查DLL路径是否正确平台x86/x64是否匹配。使用dumpbin /exports确认导出函数名。特别注意是否因为缺少extern C导致函数名被粉碎。检查[DllImport]的EntryPoint名称是否拼写正确大小写是否敏感通常Windows不敏感但最好一致。检查调用约定CallingConvention是否匹配。System.Runtime.InteropServices.MarshalDirectiveException原因封送拆收器无法处理某个参数或返回类型。排查检查结构体的[StructLayout]是否正确Pack值是否与C匹配。检查字符串字段的[MarshalAs]特性是否正确SizeConst是否足够大。检查委托的[UnmanagedFunctionPointer]特性是否正确。程序崩溃Access Violation原因通常是非托管代码访问了无效内存。排查生命周期问题C#传递给C的指针如通过fixed获取的数组指针在非托管函数执行期间被GC移动或回收了。确保在fixed语句块内调用函数。回调函数问题传递给C的委托被GC回收了。确保长期持有委托引用。缓冲区溢出C写入了超过C#缓冲区大小的数据。确保缓冲区大小正确传递并检查。内存对齐问题结构体布局不一致导致C访问了错误的内存偏移量。用sizeof和Marshal.SizeOf对比大小。6.2 调试技巧同时调试C#和C代码这是最强大的手段。你需要将C项目加入C#解决方案。在C#项目属性中将“调试器类型”设置为“混合托管和本机”或“本机”。在C#代码中调用P/Invoke的地方设置断点。启动调试F5。当命中C#断点后单步进入F11。如果一切配置正确调试器会跳转到C DLL的源代码中。确保C项目生成了调试符号PDB文件并且源代码路径可用。6.3 日志与追踪在复杂问题面前日志是你的好朋友。可以在C DLL的关键位置如函数入口、出口、内存操作前后添加日志输出写入文件或OutputDebugString。在C#侧可以使用System.Diagnostics.Debug.WriteLine。通过对比两边的日志可以清晰地看到执行流程和数据状态从而定位问题。7. 完整实例一个简单的图像像素处理库让我们用一个更贴近实战的例子收尾一个C DLL接收图像数据字节数组进行反色处理并通过回调报告进度。C DLL (ImageProcessor.h/cpp):// ImageProcessor.h #pragma once #ifdef IMAGEPROC_EXPORTS #define IMAGEPROC_API __declspec(dllexport) #else #define IMAGEPROC_API __declspec(dllimport) #endif typedef void (__stdcall *ProgressCallback)(int currentPixel, int totalPixels); extern C IMAGEPROC_API bool __stdcall InvertImage( unsigned char* pixelData, int width, int height, int stride, // 每行字节数可能包含填充字节 ProgressCallback callback );// ImageProcessor.cpp #define IMAGEPROC_EXPORTS #include ImageProcessor.h #include algorithm extern C IMAGEPROC_API bool __stdcall InvertImage( unsigned char* pixelData, int width, int height, int stride, ProgressCallback callback) { if (!pixelData || width 0 || height 0 || stride width * 3) // 假设是24位RGB return false; int totalPixels width * height; for (int y 0; y height; y) { unsigned char* row pixelData (y * stride); for (int x 0; x width; x) { // 反色每个通道用255减 row[x * 3 0] 255 - row[x * 3 0]; // B row[x * 3 1] 255 - row[x * 3 1]; // G row[x * 3 2] 255 - row[x * 3 2]; // R int currentPixel y * width x 1; if (callback (currentPixel % 10000 0)) // 每处理10000个像素回调一次 { callback(currentPixel, totalPixels); } } } return true; }C# 调用端:using System; using System.Runtime.InteropServices; using System.Drawing; // 使用System.Drawing.Common NuGet包 using System.Drawing.Imaging; class Program { [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate void ProgressCallbackDelegate(int current, int total); [DllImport(ImageProcessor.dll, CallingConvention CallingConvention.StdCall)] public static extern bool InvertImage( IntPtr pixelData, int width, int height, int stride, ProgressCallbackDelegate callback ); // 静态变量持有回调委托防止被GC回收 private static ProgressCallbackDelegate _progressCallback; public static void ReportProgress(int current, int total) { float percent (float)current / total * 100; Console.WriteLine($处理进度: {percent:F2}%); } static void Main() { // 1. 加载一张图片 using (Bitmap bitmap new Bitmap(test.jpg)) { // 2. 锁定位图数据获取指向像素数据的指针 BitmapData bmpData bitmap.LockBits( new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb // 必须与C代码假设的格式匹配 ); try { // 3. 设置回调 _progressCallback new ProgressCallbackDelegate(ReportProgress); // 4. 调用C函数进行处理 bool success InvertImage( bmpData.Scan0, // 像素数据的起始地址 (IntPtr) bitmap.Width, bitmap.Height, bmpData.Stride, // 每行实际字节数包含填充 _progressCallback ); if (success) Console.WriteLine(图像反色处理成功); else Console.WriteLine(处理失败。); } finally { // 5. 解锁位图数据 bitmap.UnlockBits(bmpData); } // 6. 保存结果 bitmap.Save(test_inverted.jpg); } } }这个实例涵盖了P/Invoke的核心要素指针传递IntPtr、复杂参数图像尺寸和步长、回调函数以及关键的内存生命周期管理Bitmap.LockBits/UnlockBits确保了像素数据在调用期间被固定。