C/C++动态库开发与跨语言调用实战演示
简介:跨语言调用是大型项目中常见的需求,本文通过示例讲解如何使用C/C++编写动态链接库(DLL),并实现C#等其他语言对DLL的调用。内容涵盖DLL的创建与调用机制、C++调用其他DLL、C++与C混合编程、C#通过P/Invoke调用C/C++ DLL的方法,以及相关注意事项与实际应用场景。示例文件包含完整的源码和调用流程说明,适合开发者快速掌握跨语言集成开发技巧。 
1. 动态链接库(DLL)基础与创建
动态链接库(Dynamic Link Library,简称 DLL)是 Windows 平台下实现模块化编程和代码复用的核心机制之一。它允许将函数、类、资源等封装为独立的模块,在运行时被多个应用程序共享调用,从而提升代码复用率、减少内存占用,并便于后期维护与升级。
与静态库(.lib)不同,DLL 在程序运行时才被加载,而非编译链接阶段嵌入到可执行文件中。这种机制使得功能模块可独立更新,而无需重新编译整个项目。例如,多个应用程序可以同时调用同一个 DLL 文件中的函数,节省系统资源。
本章将引导读者从零开始搭建开发环境,学习如何使用 Visual Studio 创建一个简单的 DLL 项目,并了解导出函数的基本方式。通过本章内容,读者将掌握 DLL 的基本结构、创建流程以及在 C/C++ 开发中的基础应用场景,为后续深入学习 DLL 之间的调用与跨语言交互打下坚实基础。
2. DLL调用另一个DLL的实现方式
在Windows平台的C/C++开发中,DLL(动态链接库)不仅能够独立提供功能模块,还可以通过相互调用形成模块化的架构。这种架构可以实现代码复用、功能解耦、便于维护和扩展。本章将深入探讨DLL之间调用的实现方式,包括依赖关系的建立、函数导出与导入的方法、以及动态加载DLL的具体实现。
2.1 DLL之间的依赖关系解析
在实际开发中,一个DLL可能依赖于另一个DLL来完成特定功能。理解这种依赖关系及其绑定机制是构建模块化系统的基础。
2.1.1 静态导入与动态加载的区别
在Windows系统中,DLL之间的调用可以分为两种方式: 静态导入(Static Import) 和 动态加载(Dynamic Loading) 。
| 特性 | 静态导入 | 动态加载 |
|---|---|---|
| 调用方式 | 编译时链接 | 运行时加载 |
| 依赖方式 | 通过.lib文件 | 通过LoadLibrary/GetProcAddress |
| 错误处理 | 编译失败或运行时加载失败 | 运行时手动判断 |
| 灵活性 | 低 | 高 |
| 适用场景 | 功能固定、依赖明确的模块 | 插件机制、按需加载 |
- 静态导入 :开发者在编译时通过
.lib文件链接到目标DLL的导出符号。操作系统在程序启动时自动加载该DLL,并解析函数地址。如果DLL缺失或函数不存在,程序将无法启动。 - 动态加载 :使用
LoadLibrary和GetProcAddress在运行时加载DLL并获取函数地址。这种方式适用于插件系统或需要动态决定加载模块的场景。
2.1.2 导出函数的符号绑定机制
在DLL调用中,函数地址的绑定分为 隐式链接(Implicit Linking) 和 显式链接(Explicit Linking) :
- 隐式链接 :通过编译器生成的导入库(.lib文件)进行链接,绑定函数地址的过程由系统完成。
- 显式链接 :通过
GetProcAddress手动获取函数地址,绑定过程由开发者控制。
函数的符号绑定依赖于 导出表(Export Table) 。导出表中记录了函数名称、序号(Ordinal)和相对虚拟地址(RVA),操作系统通过该表来定位函数地址。
// 示例:GetProcAddress 获取函数地址
typedef int (*FuncPtr)(int, int);
HMODULE hModule = LoadLibrary(L"mydll.dll");
if (hModule) {
FuncPtr addFunc = (FuncPtr)GetProcAddress(hModule, "AddNumbers");
if (addFunc) {
int result = addFunc(3, 4);
std::cout << "Result: " << result << std::endl;
}
FreeLibrary(hModule);
}
代码逻辑分析
LoadLibrary:加载指定的DLL文件,返回模块句柄。GetProcAddress:查找DLL中的导出函数地址。FreeLibrary:释放DLL资源。- 通过函数指针调用,实现动态调用。
参数说明:
-hModule:由LoadLibrary返回的模块句柄。
-"AddNumbers":DLL中导出的函数名。
-FuncPtr:函数指针类型,需与DLL中函数签名一致。
2.2 使用__declspec(dllexport)和.def文件导出函数
为了使DLL中的函数能够被其他模块调用,必须将其 导出 。Windows提供了两种主要方式: __declspec(dllexport) 和 .def 文件。
2.2.1 基于关键字的导出方式
使用 __declspec(dllexport) 是最常见的方式,它通过编译器指令将函数标记为导出函数。
// dllmain.cpp
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
return TRUE;
}
// MyDllFuncs.cpp
extern "C" __declspec(dllexport) int AddNumbers(int a, int b) {
return a + b;
}
代码逻辑分析
extern "C":防止C++编译器对函数名进行名称修饰(Name Mangling),保证符号名称与C语言一致。__declspec(dllexport):指示编译器将该函数加入导出表。AddNumbers:导出函数的实现。
2.2.2 使用模块定义文件进行导出控制
.def 文件是模块定义文件,用于控制DLL的导出符号。它不依赖编译器特性,适用于C和C++项目。
; mydll.def
LIBRARY mydll
EXPORTS
AddNumbers @1
LIBRARY:指定DLL的名称。EXPORTS:列出导出的函数名及其序号。
在编译时需将 .def 文件加入链接器输入中,例如在Visual Studio中配置:
Project Properties → Linker → Input → Module Definition File → mydll.def
对比分析
| 方法 | 优点 | 缺点 |
|---|---|---|
__declspec(dllexport) |
简洁,与代码紧密结合 | 与编译器相关,不利于跨平台 |
.def 文件 |
易于集中管理导出函数 | 需维护额外文件,不支持C++特性如重载 |
Mermaid流程图:DLL导出函数机制
graph TD
A[源代码] --> B{导出方式}
B --> C[__declspec(dllexport)]
B --> D[.def文件]
C --> E[编译器处理]
D --> F[链接器处理]
E --> G[生成导出符号]
F --> G
G --> H[生成DLL与.lib文件]
2.3 动态加载DLL并调用其函数
在某些场景下,我们需要在运行时动态加载DLL并调用其函数,以实现插件机制、模块热替换等功能。
2.3.1 LoadLibrary和GetProcAddress的使用方法
Windows API 提供了两个关键函数用于动态加载DLL:
LoadLibrary:加载指定的DLL文件。GetProcAddress:获取DLL中导出函数的地址。
#include <windows.h>
#include <iostream>
typedef int (*AddFunc)(int, int);
int main() {
HMODULE hModule = LoadLibrary(L"mydll.dll");
if (!hModule) {
std::cerr << "Failed to load DLL!" << std::endl;
return -1;
}
AddFunc pAdd = (AddFunc)GetProcAddress(hModule, "AddNumbers");
if (!pAdd) {
std::cerr << "Failed to find function!" << std::endl;
FreeLibrary(hModule);
return -1;
}
int result = pAdd(5, 7);
std::cout << "Result: " << result << std::endl;
FreeLibrary(hModule);
return 0;
}
代码逻辑分析
LoadLibrary(L"mydll.dll"):加载DLL文件,失败返回NULL。GetProcAddress:查找函数地址,失败返回NULL。FreeLibrary:释放DLL资源,避免内存泄漏。typedef int (*AddFunc)(int, int):定义函数指针类型,确保调用签名一致。
2.3.2 异常处理与错误码判断
在动态加载过程中可能出现多种错误,因此需要良好的错误处理机制。
DWORD errorCode = GetLastError();
std::cerr << "Error Code: " << errorCode << std::endl;
| 错误码 | 含义 |
|---|---|
| ERROR_MOD_NOT_FOUND (126) | 找不到DLL文件 |
| ERROR_PROC_NOT_FOUND (127) | 找不到指定函数 |
| ERROR_BAD_EXE_FORMAT (193) | 平台不兼容(如32/64位) |
建议做法:
- 使用
GetLastError()获取具体错误信息。 - 检查路径是否正确,DLL是否已编译。
- 使用
Dependency Walker等工具分析DLL依赖关系。
Mermaid流程图:动态加载DLL流程
graph TD
A[开始] --> B[调用LoadLibrary]
B --> C{是否加载成功?}
C -- 是 --> D[调用GetProcAddress]
C -- 否 --> E[输出错误信息]
D --> F{函数地址是否找到?}
F -- 是 --> G[调用函数]
F -- 否 --> H[输出错误信息]
G --> I[释放DLL资源]
H --> I
I --> J[结束]
本章从DLL之间的依赖关系出发,详细讲解了静态导入与动态加载的区别、函数导出机制( __declspec(dllexport) 与 .def 文件)、以及动态加载DLL的完整流程。通过代码示例、表格对比、流程图展示,读者能够全面理解DLL调用的技术细节和实现方式。这些知识为后续章节中跨语言调用打下了坚实基础。
3. C++调用C语言动态库方法
C++调用C语言编写的动态链接库(DLL)是跨语言编程中一个常见且关键的场景。由于C++具有名称修饰(Name Mangling)机制,而C语言不具备,直接调用C语言导出的函数可能会导致链接失败。因此,掌握C++与C语言接口的兼容机制,尤其是使用 extern "C" 的方法,是构建跨语言模块化的基础。本章将从接口兼容性问题入手,深入解析名称修饰和调用约定的差异,并结合实际代码示例,展示如何构建C语言DLL并在C++项目中正确调用。
3.1 C++与C语言的接口兼容性问题
3.1.1 名称修饰(Name Mangling)与链接失败
C++ 编译器为了支持函数重载、命名空间等特性,会将函数名进行“名称修饰”(Name Mangling),即将函数名与参数类型、返回值等信息一起编码成一个唯一的符号名。例如,函数 void foo(int) 可能会被编译为 _Z3fooi 。
而 C 编译器不会进行名称修饰,函数名在目标文件中以原始形式保留。例如,函数 foo 在 C 编译后的符号就是 _foo 或 foo (视平台而定)。
当 C++ 代码尝试调用 C 语言导出的函数时,链接器会查找修饰后的名称,但由于 C 语言没有进行修饰,导致链接器找不到对应的符号,最终报出链接错误:
error LNK2019: unresolved external symbol _Z3fooi referenced in function ...
为了解决这个问题,C++ 提供了 extern "C" 机制,用于告诉编译器不要对特定函数进行名称修饰,使其与 C 语言兼容。
3.1.2 函数调用约定(Calling Convention)匹配
除了名称修饰之外,函数调用约定(Calling Convention)也是影响接口兼容性的重要因素。调用约定决定了函数参数的压栈顺序、堆栈的清理方式以及寄存器的使用等。
C 语言中常用的调用约定包括:
__cdecl:默认调用约定,由调用者清理堆栈。__stdcall:Windows API 使用较多,由被调用函数清理堆栈。
如果 C++ 调用的 C 函数使用了 __stdcall ,但 C++ 代码中没有指定对应的调用约定,则可能导致堆栈不平衡,从而引发运行时错误。
因此,在调用 C 语言 DLL 时,除了使用 extern "C" 避免名称修饰外,还需要确保调用约定的一致性。
3.2 extern “C”在C++中调用C库的作用
3.2.1 防止C++名称修饰的机制
extern "C" 是 C++ 提供的一种语言特性,用于指定其后声明的函数或变量应以 C 语言的方式进行链接。这意味着编译器将不会对这些符号进行名称修饰,从而确保 C++ 代码可以正确链接到 C 语言导出的函数。
基本语法如下:
extern "C" {
// 函数声明或定义
}
示例:
extern "C" {
void say_hello();
int add(int a, int b);
}
在这种方式下, say_hello 和 add 的函数名不会被修饰,编译器会直接使用原始名称进行链接。
此外,还可以对单个函数使用 extern "C" :
extern "C" void say_hello();
这种机制是跨语言调用中解决链接问题的核心手段。
3.2.2 跨语言调用中的链接器行为
在 C++ 编译过程中, extern "C" 告诉编译器不要修饰函数名。因此,链接器在链接阶段会查找未修饰的符号,与 C 语言 DLL 中导出的函数名相匹配。
例如,假设有一个 C 语言 DLL 导出了函数:
// dllmain.c
#include <windows.h>
extern "C" __declspec(dllexport) void say_hello() {
printf("Hello from C DLL!\n");
}
在 C++ 项目中调用该函数:
#include <windows.h>
#include <iostream>
typedef void (*SayHelloFunc)();
int main() {
HMODULE hDll = LoadLibrary(L"CExample.dll");
if (hDll == NULL) {
std::cerr << "Failed to load DLL" << std::endl;
return 1;
}
SayHelloFunc sayHello = (SayHelloFunc)GetProcAddress(hDll, "say_hello");
if (sayHello == NULL) {
std::cerr << "Failed to get function address" << std::endl;
return 1;
}
sayHello();
FreeLibrary(hDll);
return 0;
}
在这个例子中,由于使用了 extern "C" ,C 语言导出的函数名 say_hello 不会被修饰,因此 C++ 程序通过 GetProcAddress 可以正确找到该函数地址并调用。
3.3 构建C语言DLL并从C++项目中调用
3.3.1 创建C语言DLL示例
下面我们将通过一个完整的示例,演示如何创建一个 C 语言 DLL,并在 C++ 项目中动态加载并调用它。
步骤 1:创建 DLL 项目
使用 Visual Studio 创建一个 Win32 DLL 项目,项目名称为 CExample.dll 。
编写导出函数:
// dllmain.c
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
return TRUE;
}
extern "C" __declspec(dllexport) void say_hello() {
printf("Hello from C DLL!\n");
}
extern "C" __declspec(dllexport) int add_numbers(int a, int b) {
return a + b;
}
说明:
-__declspec(dllexport)用于标记该函数应被导出。
-extern "C"用于防止 C++ 编译器修饰函数名,确保 C++ 调用者可以找到该函数。
-say_hello()和add_numbers()是两个导出函数。
步骤 2:编译生成 DLL 文件
编译项目后,会在输出目录生成 CExample.dll 和 CExample.lib 文件。
3.3.2 C++项目中调用C函数的完整流程
接下来,我们将创建一个 C++ 控制台应用程序,动态加载上述 DLL 并调用其中的函数。
步骤 1:编写主程序代码
#include <windows.h>
#include <iostream>
typedef void (*SayHelloFunc)();
typedef int (*AddNumbersFunc)(int, int);
int main() {
// 加载 DLL
HMODULE hDll = LoadLibrary(L"CExample.dll");
if (hDll == NULL) {
std::cerr << "Failed to load DLL" << std::endl;
return 1;
}
// 获取函数地址
SayHelloFunc sayHello = (SayHelloFunc)GetProcAddress(hDll, "say_hello");
AddNumbersFunc addNumbers = (AddNumbersFunc)GetProcAddress(hDll, "add_numbers");
if (sayHello == NULL || addNumbers == NULL) {
std::cerr << "Failed to get function address" << std::endl;
return 1;
}
// 调用函数
sayHello();
int result = addNumbers(5, 7);
std::cout << "Result of add_numbers(5, 7): " << result << std::endl;
// 释放 DLL
FreeLibrary(hDll);
return 0;
}
步骤 2:运行程序
将 CExample.dll 文件复制到 C++ 项目的输出目录(如 Debug 文件夹),然后运行程序,输出如下:
Hello from C DLL!
Result of add_numbers(5, 7): 12
代码逻辑逐行分析:
| 行号 | 代码 | 说明 |
|---|---|---|
| 1 | #include <windows.h> |
引入 Windows API 头文件,用于 DLL 加载 |
| 4 | typedef void (*SayHelloFunc)(); |
定义函数指针类型,用于获取 DLL 中的函数地址 |
| 5 | typedef int (*AddNumbersFunc)(int, int); |
同上,用于带参数的函数 |
| 8 | HMODULE hDll = LoadLibrary(L"CExample.dll"); |
动态加载 DLL 文件 |
| 10-12 | 错误处理 | 如果加载失败,打印错误信息并退出 |
| 14-15 | GetProcAddress(...) |
获取 DLL 中函数的地址 |
| 17-19 | 错误处理 | 如果找不到函数地址,打印错误并退出 |
| 22 | sayHello(); |
调用 DLL 中的 say_hello() 函数 |
| 23 | int result = addNumbers(5, 7); |
调用 add_numbers() 函数 |
| 26 | FreeLibrary(hDll); |
释放 DLL 模块 |
调用流程图(Mermaid)
graph TD
A[Start] --> B[LoadLibrary("CExample.dll")]
B -->|成功| C[GetProcAddress("say_hello")]
C --> D[调用 say_hello()]
B -->|成功| E[GetProcAddress("add_numbers")]
E --> F[调用 add_numbers()]
B -->|失败| G[输出错误信息]
C -->|失败| G
E -->|失败| G
D --> H[输出结果]
F --> H
H --> I[FreeLibrary]
I --> J[End]
总结
通过本章的学习,我们了解了 C++ 调用 C 语言 DLL 时常见的兼容性问题,特别是名称修饰和调用约定的影响。我们深入解析了 extern "C" 的作用机制,并通过完整的代码示例展示了如何构建 C 语言 DLL 并在 C++ 项目中动态调用其函数。这一过程不仅适用于 Windows 平台的 DLL,也适用于其他平台(如 Linux 的 .so ),只需调整加载方式即可。
在下一章中,我们将进一步探讨 extern "C" 在更广泛跨语言调用场景中的应用,包括在 C# 与 C/C++ 交互中的桥梁作用。
4. extern “C”在跨语言调用中的作用
在现代软件开发中,跨语言调用已成为一种常见需求,尤其是在构建高性能模块、调用底层库或进行系统级编程时。C++作为一门强类型、面向对象的语言,其编译器会对函数名进行名称修饰(Name Mangling),以支持函数重载等特性。然而,这种机制会导致C++函数在链接时生成的符号名与C语言不兼容,从而阻碍了C++模块与C模块之间的直接交互。 extern "C" 正是为了解决这一问题而存在的语言特性,它能够在C++代码中以C语言的语义来声明和定义函数,从而实现跨语言接口的无缝对接。
本章将深入剖析 extern "C" 的语法与语义机制,探讨其在跨语言接口设计中的命名规范,并进一步说明其在C#与C/C++交互中的桥梁作用,帮助开发者在多语言混合编程中实现稳定、高效的模块通信。
4.1 理解extern “C”的语法与语义
extern "C" 是C++标准中提供的一种语言扩展机制,用于告诉编译器将指定的函数或变量按照C语言的规则进行处理,包括函数名不进行名称修饰、使用C语言的调用约定(如 __cdecl )等。通过这种方式,C++模块可以导出或导入与C语言兼容的函数接口,从而实现在不同语言之间的链接与调用。
4.1.1 C++中extern “C”的使用方式
在C++中, extern "C" 可以通过两种方式进行声明: 单个函数声明 和 代码块包裹多个声明 。
单个函数声明
extern "C" void myFunction();
此声明方式表示函数 myFunction 应以C语言的方式进行编译,即不进行名称修饰。这样,该函数在目标文件中生成的符号名将与C语言编译器生成的符号名一致,便于其他模块(如C模块)链接和调用。
代码块包裹多个函数声明
extern "C" {
void func1();
int func2(int a, int b);
double func3(double x);
}
这种方式适用于需要导出多个C语言风格接口的情况,可以避免在每个函数前重复添加 extern "C" 关键字,提高代码可读性和维护性。
示例:导出C语言风格的DLL函数
下面是一个在C++中创建DLL并使用 extern "C" 导出函数的示例:
// dllmain.cpp
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
return TRUE;
}
// exported_functions.cpp
extern "C" __declspec(dllexport) int AddNumbers(int a, int b) {
return a + b;
}
extern "C" __declspec(dllexport) const char* GetMessage() {
return "Hello from C++ DLL";
}
逻辑分析:
- extern "C" 确保函数符号不被修饰,便于C或其他语言调用。
- __declspec(dllexport) 用于标记该函数为导出函数,供外部模块调用。
- AddNumbers 和 GetMessage 将以C语言风格的符号名被导出,如 _AddNumbers@8 (Windows 32位)或 AddNumbers (64位)等。
4.1.2 多个模块中统一导出函数符号
在大型项目中,多个模块可能需要导出相同的函数接口,例如多个DLL提供相同的功能接口,供主程序统一调用。在这种情况下,保持函数符号的一致性至关重要。
示例:多个DLL模块导出相同接口
假设我们有两个DLL模块: MathLib1.dll 和 MathLib2.dll ,它们都导出相同的函数接口:
// MathLib1.cpp
extern "C" __declspec(dllexport) int Calculate(int a, int b) {
return a + b;
}
// MathLib2.cpp
extern "C" __declspec(dllexport) int Calculate(int a, int b) {
return a * b;
}
这两个模块都导出了 Calculate 函数,主程序可以根据需要动态加载不同的DLL并调用对应函数。
HMODULE hModule = LoadLibrary("MathLib1.dll");
typedef int (*CalculateFunc)(int, int);
CalculateFunc pFunc = (CalculateFunc)GetProcAddress(hModule, "Calculate");
int result = pFunc(3, 4); // 调用MathLib1中的加法
逻辑分析:
- 通过 extern "C" ,两个模块的 Calculate 函数在符号表中具有相同的名称。
- 主程序通过动态加载DLL并获取函数指针,实现了运行时的模块切换。
- 该机制适用于插件系统、模块化架构等场景。
4.2 跨语言接口设计中的命名规范
在跨语言调用中,函数命名不仅影响代码的可读性,还关系到链接器能否正确识别和绑定符号。因此,制定一套统一的命名规范对于接口的稳定性与可维护性至关重要。
4.2.1 函数名大小写与前缀约定
在C语言中,函数命名通常使用 小写字母加下划线 的形式(如 calculate_sum ),而在C++中则可能使用 驼峰命名法 (如 calculateSum )。为了避免跨语言调用时因命名差异导致链接失败,建议在导出函数时采用统一的小写加下划线命名风格。
命名约定示例:
extern "C" __declspec(dllexport) int calculate_sum(int a, int b);
同时,为了增强模块的可识别性,可以为导出函数添加统一前缀,例如模块名或功能分类:
extern "C" __declspec(dllexport) int math_calculate_sum(int a, int b);
表格:命名风格对比
| 语言 | 命名风格 | 示例 |
|---|---|---|
| C | 小写+下划线 | calculate_sum |
| C++ | 驼峰命名法 | calculateSum |
| C# | 驼峰命名法 | CalculateSum |
结论:在跨语言接口设计中,推荐统一采用 小写+下划线 命名方式,并使用前缀提高函数可读性和模块隔离性。
4.2.2 避免C++特有特性的暴露
在跨语言调用中,若将C++的特有类型(如类、模板、重载函数等)直接暴露给其他语言(如C#或C),会导致链接失败或类型不匹配。因此,在设计接口时应尽量避免这些特性。
示例:错误暴露C++类
class MyClass {
public:
int value;
MyClass(int v) : value(v) {}
};
extern "C" __declspec(dllexport) MyClass CreateMyClass(int v) {
return MyClass(v);
}
上述代码在C++中是合法的,但若C#尝试调用该函数,会因无法识别 MyClass 类型而导致错误。
正确做法:使用结构体和函数封装
typedef struct {
int value;
} MyStruct;
extern "C" __declspec(dllexport) MyStruct CreateMyStruct(int v) {
MyStruct s;
s.value = v;
return s;
}
逻辑分析:
- 使用结构体替代类,便于C#等语言识别。
- 返回值为值类型而非引用或智能指针,避免生命周期管理问题。
- 接口设计保持简洁、可移植,适合跨语言调用。
4.3 extern “C”在C#与C/C++交互中的桥梁作用
C#作为.NET平台上的主力语言,常需要调用C/C++编写的高性能模块或系统级接口。P/Invoke(Platform Invocation Services)是C#调用非托管代码的主要方式。然而,P/Invoke在绑定符号时依赖于函数名和调用约定,而C++的名称修饰机制会干扰这一过程。 extern "C" 在此场景下成为关键的桥梁。
4.3.1 P/Invoke调用中符号绑定的底层原理
P/Invoke在调用C/C++ DLL函数时,首先会根据函数名查找DLL中的导出符号。C语言函数名在导出时保持原样,而C++函数名则经过修饰,导致符号名不一致,链接失败。
示例:未使用extern “C”导致的链接失败
// C++导出函数
int AddNumbers(int a, int b) {
return a + b;
}
C#调用:
[DllImport("MyDll.dll")]
public static extern int AddNumbers(int a, int b);
结果:运行时抛出 EntryPointNotFoundException ,因为C++编译器生成的符号名为 ?AddNumbers@@YAHHH@Z (MSVC),而非 AddNumbers 。
使用extern “C”后的符号绑定
extern "C" __declspec(dllexport) int AddNumbers(int a, int b) {
return a + b;
}
此时导出符号为 _AddNumbers@8 (32位)或 AddNumbers (64位),C#可通过P/Invoke成功绑定。
4.3.2 导出函数的命名一致性保障
为了确保P/Invoke调用的稳定性,建议采取以下措施:
- 统一函数命名风格 :如前所述,使用小写+下划线方式。
- 指定调用约定 :C#默认使用
__stdcall,而C++默认使用__cdecl,需统一指定。 - 使用.def文件或显式导出符号 :防止编译器自动修饰函数名。
示例:显式导出函数并指定调用约定
extern "C" __declspec(dllexport) int __stdcall MultiplyNumbers(int a, int b) {
return a * b;
}
C#调用时需指定调用约定:
[DllImport("MyDll.dll", CallingConvention = CallingConvention.StdCall)]
public static extern int MultiplyNumbers(int a, int b);
逻辑分析:
- __stdcall 确保调用栈由被调用方清理,符合Windows API调用规范。
- 显式指定调用约定,避免因调用方式不一致导致堆栈损坏。
流程图:P/Invoke调用过程
graph TD
A[C#代码调用DllImport函数] --> B[CLR查找DLL]
B --> C[加载DLL并查找导出函数]
C --> D{函数名匹配导出符号?}
D -- 是 --> E[调用C/C++函数]
D -- 否 --> F[抛出EntryPointNotFoundException]
总结: extern "C" 在C#与C/C++交互中起到了关键作用,它确保了函数符号的一致性,使得P/Invoke能够正确绑定并调用非托管函数。通过合理使用 extern "C" 、统一命名风格和调用约定,开发者可以构建稳定、高效的跨语言调用接口。
5. C#通过P/Invoke调用C/C++ DLL
在现代软件开发中,C# 与 C/C++ 的混合编程已经成为一种常见需求。尤其在需要高性能计算、调用系统底层接口或使用遗留库时,C# 通过 P/Invoke(Platform Invoke)机制调用 C/C++ 编写的 DLL 是一种高效且稳定的解决方案。本章将从 P/Invoke 的基本原理出发,深入探讨其使用方式、典型调用示例以及性能优化与调试技巧,帮助开发者在跨语言调用中实现高可靠性和高性能。
5.1 P/Invoke的基本原理与使用方式
P/Invoke 是 .NET 框架中用于调用非托管函数(如 Win32 API、C/C++ DLL)的一种机制。它通过运行时编译器将 C# 方法映射到 DLL 中的函数,并自动处理参数封送(marshaling)和调用栈的构建。
5.1.1 DllImport特性与函数声明
在 C# 中调用非托管函数,必须使用 DllImport 特性来声明目标函数。该特性定义了 DLL 名称、调用约定、是否设置 SetLastError 等关键参数。
using System;
using System.Runtime.InteropServices;
class Program
{
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int MessageBox(IntPtr hWnd, String text, String caption, uint type);
static void Main()
{
MessageBox(IntPtr.Zero, "Hello from C#!", "P/Invoke Demo", 0);
}
}
代码逻辑分析:
DllImport("user32.dll"):指定要调用的 DLL 名称为user32.dll。CharSet = CharSet.Auto:自动根据平台选择字符集(ANSI 或 Unicode)。MessageBox:定义了与 DLL 中函数签名匹配的 C# 函数。hWnd:窗口句柄,IntPtr.Zero表示无父窗口。text和caption:消息框显示的文本与标题。type:消息框的样式,0 表示只有“确定”按钮。
参数说明:
| 参数名 | 类型 | 说明 |
|---|---|---|
| hWnd | IntPtr | 父窗口句柄,可设为 IntPtr.Zero |
| text | string | 显示的文本内容 |
| caption | string | 消息框标题 |
| type | uint | 消息框按钮和图标样式 |
5.1.2 常用DllImport参数配置
| 参数名 | 说明 |
|---|---|
| DllName | 必填,指定要调用的 DLL 名称 |
| EntryPoint | 指定函数在 DLL 中的导出名称,可省略,默认与 C# 方法名一致 |
| CallingConvention | 调用约定,默认为 Winapi,也可设为 Cdecl、StdCall 等 |
| CharSet | 字符集设定,影响字符串封送方式(Ansi、Unicode、Auto) |
| SetLastError | 是否调用 SetLastError API 保存错误码,默认为 false |
| ExactSpelling | 是否精确匹配函数名,默认为 false,若为 true 则不尝试自动搜索 |
示例:指定 EntryPoint
[DllImport("MyNativeLib.dll", EntryPoint = "AddNumbers")]
public static extern int Add(int a, int b);
5.2 C#中调用C/C++函数的实践示例
本节将演示如何构建一个 C/C++ DLL 并从 C# 调用其中的函数。我们将实现一个简单的加法函数,并传递参数、处理返回值。
5.2.1 简单函数调用与参数传递
步骤 1:创建 C++ DLL 项目(MyNativeLib)
// dllmain.cpp
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
return TRUE;
}
extern "C" __declspec(dllexport) int AddNumbers(int a, int b) {
return a + b;
}
步骤 2:C# 调用 DLL 函数
using System;
using System.Runtime.InteropServices;
class Program
{
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int AddNumbers(int a, int b);
static void Main()
{
int result = AddNumbers(5, 7);
Console.WriteLine("Result: " + result);
}
}
代码逻辑分析:
extern "C":防止 C++ 名称修饰,确保函数名在 DLL 中导出为原样。__declspec(dllexport):标记该函数为 DLL 导出函数。CallingConvention.Cdecl:使用 C 的调用约定,与 C++ DLL 中函数定义一致。AddNumbers(5, 7):调用 DLL 中的函数并获取返回值。
参数说明:
| 参数名 | 类型 | 说明 |
|---|---|---|
| a | int | 第一个加数 |
| b | int | 第二个加数 |
5.2.2 返回值与输出参数的处理方式
当需要从 DLL 函数中返回多个值时,可以使用输出参数(out 或 ref)。
C++ DLL 函数定义:
extern "C" __declspec(dllexport) void GetCoordinates(int* x, int* y) {
*x = 100;
*y = 200;
}
C# 调用代码:
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void GetCoordinates(ref int x, ref int y);
static void Main()
{
int x = 0, y = 0;
GetCoordinates(ref x, ref y);
Console.WriteLine($"Coordinates: ({x}, {y})");
}
参数说明:
| 参数名 | 类型 | 说明 |
|---|---|---|
| x | ref int | 输出 X 坐标 |
| y | ref int | 输出 Y 坐标 |
注意 :必须使用
ref或out修饰符以确保参数按引用传递。
mermaid 流程图:P/Invoke 调用流程
graph TD
A[C# 程序] --> B[调用 DllImport 方法]
B --> C[P/Invoke 查找 DLL]
C --> D[加载 DLL 并查找函数地址]
D --> E[封送参数]
E --> F[调用非托管函数]
F --> G[返回结果]
G --> H[解封结果]
H --> I[返回给 C# 调用者]
5.3 P/Invoke的性能优化与错误排查
虽然 P/Invoke 提供了强大的跨语言调用能力,但其性能开销和潜在错误也不容忽视。本节将介绍如何减少性能损耗并有效排查调用问题。
5.3.1 内存复制与托管/非托管转换开销
P/Invoke 在参数封送过程中会涉及大量内存复制,尤其是字符串、数组等复杂类型。
示例:字符串封送开销对比
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern void ProcessString(string data);
如果 data 是一个很长的字符串,每次调用时都需要从托管内存复制到非托管内存,效率较低。
优化方式:
- 使用 StringBuilder 缓冲字符串输出:
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
public static extern void GetString(StringBuilder buffer, int bufferSize);
-
避免频繁调用:将多个调用合并或缓存结果。
-
使用指针操作(仅适用于 unsafe 代码):
unsafe
{
fixed (char* ptr = "test")
{
NativeFunction(ptr);
}
}
5.3.2 调试P/Invoke调用的常见问题
P/Invoke 调用失败时,错误信息往往不够直观。以下是一些常见问题及排查方法:
常见问题:
-
找不到 DLL 或函数:
- 错误提示:DllNotFoundException或EntryPointNotFoundException
- 解决方法:- 确保 DLL 位于应用程序目录或系统路径中。
- 检查函数名是否正确,尤其是使用
extern "C"后的函数名是否被修改。
-
参数类型不匹配导致崩溃:
- 使用Marshal.SizeOf()检查结构体大小是否一致。
- 使用MarshalAs显式指定封送类型。
示例:使用 Marshal 检查结构体大小
[StructLayout(LayoutKind.Sequential)]
struct MyStruct
{
public int a;
public double b;
}
Console.WriteLine(Marshal.SizeOf<MyStruct>()); // 输出结构体大小
错误码获取与处理:
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SomeFunction();
static void Main()
{
if (!SomeFunction())
{
int error = Marshal.GetLastWin32Error();
Console.WriteLine($"Error code: {error}");
}
}
表格:P/Invoke 调用优化技巧总结
| 优化技巧 | 描述 |
|---|---|
| 使用 StringBuilder | 减少字符串封送开销 |
| 尽量避免频繁调用 | 减少上下文切换 |
| 避免复杂结构体传参 | 使用简单类型或分段传递 |
| 启用 unsafe 模式 | 使用指针提高性能,但需谨慎 |
| 使用 Marshal.AllocHGlobal | 手动管理内存,避免自动封送 |
| 启用 SetLastError | 捕获 Win32 错误码,便于调试 |
本章从 P/Invoke 的基本原理入手,详细讲解了如何通过 DllImport 特性调用 C/C++ 函数,演示了简单的函数调用和参数传递方式,并深入探讨了性能优化与错误排查技巧。通过本章内容,开发者可以掌握在 C# 中高效调用非托管代码的方法,并具备在实际项目中应用和优化 P/Invoke 的能力。
6. 数据类型映射与内存管理
在C#通过P/Invoke调用C/C++ DLL的跨语言交互过程中,数据类型映射与内存管理是两个至关重要的核心问题。由于C#运行在.NET托管环境中,而C/C++代码运行在非托管环境中,二者在数据结构、内存分配和释放机制上存在显著差异。因此,理解并正确处理这些差异,是实现高效、稳定、无内存泄漏的P/Invoke调用的关键。
本章将从数据类型映射、内存管理边界、以及MarshalAs特性的使用三个方面,深入剖析C#与C/C++之间在数据交互中的关键机制,并结合示例代码与流程图,帮助开发者掌握如何在实际项目中规避常见的陷阱与错误。
6.1 托管与非托管数据类型对应关系
6.1.1 基本数据类型映射表
在P/Invoke中,C#的托管数据类型必须与C/C++中的非托管类型进行正确的映射。如果类型映射不一致,可能导致程序崩溃、数据解析错误或不可预知的行为。
下表展示了常见的C#与C/C++基本数据类型的映射关系:
| C# 类型 | C/C++ 类型 | 说明 |
|---|---|---|
byte |
unsigned char |
无符号8位整型 |
sbyte |
signed char |
有符号8位整型 |
short |
short |
有符号16位整型 |
ushort |
unsigned short |
无符号16位整型 |
int |
int |
有符号32位整型 |
uint |
unsigned int |
无符号32位整型 |
long |
long long |
有符号64位整型 |
ulong |
unsigned long long |
无符号64位整型 |
float |
float |
32位浮点数 |
double |
double |
64位浮点数 |
char |
wchar_t |
Unicode字符(如果使用Unicode编译) |
bool |
int 或 bool |
注意:C99中 _Bool 或C++中 bool |
string |
char* 或 wchar_t* |
需配合MarshalAs特性使用 |
⚠️ 注意:C#中
bool类型在默认情况下映射为4字节的int(即非C99的_Bool),这可能导致与C/C++中bool大小不一致的问题,建议使用[MarshalAs(UnmanagedType.Bool)]明确指定。
6.1.2 字符串与数组的跨语言处理
字符串和数组是跨语言调用中最常使用的复合类型,但在C#和C/C++之间存在显著差异,尤其在内存管理和编码方式上。
字符串映射示例
[DllImport("MyNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void PrintMessage([MarshalAs(UnmanagedType.LPStr)] string message);
上述代码中:
MarshalAs(UnmanagedType.LPStr):表示将C#字符串转换为ANSI编码的char*。- 如果使用
UnmanagedType.LPWStr,则转换为Unicode编码的wchar_t*。
C语言函数原型:
extern "C" __declspec(dllexport) void PrintMessage(const char* message) {
printf("Received message: %s\n", message);
}
✅ 建议:在定义字符串参数时,确保C/C++端和C#端使用相同的编码格式(ANSI或Unicode),否则可能导致乱码或访问越界。
数组传递示例
C#中数组传递给C/C++函数时,通常需要使用 [In] 、 [Out] 或 [InOut] 进行修饰,以指定数组的传递方向。
[DllImport("MyNativeDll.dll")]
public static extern void ProcessIntArray([In] int[] data, int length);
对应的C函数原型:
extern "C" __declspec(dllexport) void ProcessIntArray(int* data, int length) {
for (int i = 0; i < length; i++) {
printf("Data[%d] = %d\n", i, data[i]);
}
}
⚠️ 注意:C#数组在传递到非托管代码时会被自动固定(pinned),防止被GC移动,但不建议在多线程环境中频繁调用此类函数。
6.2 内存分配与释放的边界问题
6.2.1 非托管内存的分配与释放策略
在P/Invoke调用中,如果C/C++函数返回的是指向非托管内存的指针,C#端需要负责释放该内存。否则,将导致内存泄漏。
示例:C函数返回动态分配的内存
extern "C" __declspec(dllexport) char* GetDynamicString() {
char* str = (char*)malloc(100);
strcpy(str, "Hello from C");
return str;
}
对应的C#声明:
[DllImport("MyNativeDll.dll")]
public static extern IntPtr GetDynamicString();
在C#中使用:
IntPtr ptr = GetDynamicString();
string message = Marshal.PtrToStringAnsi(ptr);
Console.WriteLine(message);
Marshal.FreeCoTaskMem(ptr); // 或者使用Marshal.FreeHGlobal,取决于C端使用哪个分配函数
⚠️ 注意:C端使用
malloc时,C#端应使用Marshal.FreeHGlobal来释放内存;若C端使用CoTaskMemAlloc,则应使用Marshal.FreeCoTaskMem。
流程图:内存分配与释放流程
graph TD
A[C#调用P/Invoke函数] --> B[C函数分配内存]
B --> C[返回内存指针]
C --> D[C#接收IntPtr]
D --> E[使用Marshal转换数据]
E --> F[调用Marshal释放内存]
6.2.2 避免内存泄漏的跨语言管理机制
为了避免内存泄漏,开发中应遵循以下原则:
- 谁分配谁释放 :C#调用C函数返回的内存指针,应由C#端释放。
- 统一内存分配方式 :建议C/C++端使用
CoTaskMemAlloc和CoTaskMemFree,以便C#端使用Marshal.FreeCoTaskMem统一释放。 - 封装释放逻辑 :提供一个C函数用于释放内存,供C#调用。
例如:
extern "C" __declspec(dllexport) void FreeMemory(void* ptr) {
free(ptr);
}
C#调用:
[DllImport("MyNativeDll.dll")]
public static extern void FreeMemory(IntPtr ptr);
然后在使用后调用:
FreeMemory(ptr);
6.3 使用MarshalAs特性进行类型转换
6.3.1 UnmanagedType枚举与常见映射方式
MarshalAs 特性用于显式指定C#数据类型在非托管代码中的表示方式,特别适用于字符串、数组、结构体等复杂类型。
常见UnmanagedType值说明:
| 枚举值 | 描述 |
|---|---|
UnmanagedType.LPStr |
ANSI编码字符串(char*) |
UnmanagedType.LPWStr |
Unicode编码字符串(wchar_t*) |
UnmanagedType.Bool |
4字节布尔值 |
UnmanagedType.ByValArray |
固定大小数组(常用于结构体) |
UnmanagedType.LPArray |
指向数组的指针 |
UnmanagedType.Struct |
结构体映射 |
UnmanagedType.Interface |
COM接口映射 |
示例:MarshalAs在字符串映射中的应用
[DllImport("MyNativeDll.dll")]
public static extern void PrintUnicodeMessage(
[MarshalAs(UnmanagedType.LPWStr)] string message);
对应的C函数:
extern "C" __declspec(dllexport) void PrintUnicodeMessage(const wchar_t* message) {
wprintf(L"Received Unicode message: %s\n", message);
}
6.3.2 定长与变长结构的处理技巧
定长结构体映射示例
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct Person {
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string Name;
public int Age;
}
C函数原型:
typedef struct {
char Name[32];
int Age;
} Person;
extern "C" __declspec(dllexport) void PrintPerson(Person* person);
变长结构体映射技巧
当结构体中包含变长数组时,C#中应使用 ByValArray 结合 SizeConst 属性定义数组大小,或使用指针传递。
[StructLayout(LayoutKind.Sequential)]
public struct DataPacket {
public int Length;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 1024)]
public byte[] Data;
}
对应的C结构体:
typedef struct {
int Length;
char Data[1024];
} DataPacket;
⚠️ 注意:如果
Data长度不固定,可以使用指针方式传递,并由C端管理内存。
总结性思考
跨语言调用中的数据类型映射与内存管理,是P/Invoke能否稳定运行的关键。开发者不仅要理解C#与C/C++在数据结构和内存机制上的差异,还要熟练使用 MarshalAs 、 StructLayout 、 Marshal 类等工具进行类型转换和内存操作。
在实际开发中,建议:
- 使用
MarshalAs明确指定映射方式,避免默认行为带来的不确定性。 - 统一内存分配和释放机制,避免跨语言内存泄漏。
- 对结构体使用
StructLayout控制内存布局,确保对齐一致。 - 对于复杂结构或变长数据,建议设计清晰的接口规范并封装内存管理逻辑。
下一章将深入探讨结构体与类在C++和C#之间的转换机制,帮助开发者实现更复杂的跨语言对象交互。
7. 结构体与类在C++和C#之间的转换
7.1 结构体在跨语言调用中的映射规则
在C++与C#的跨语言调用中,结构体(struct)是最常见的数据结构之一。由于两种语言的编译器在内存对齐和填充机制上存在差异,结构体在跨语言交互中需要特别注意其内存布局。
7.1.1 结构体对齐与填充问题
C++编译器默认会对结构体成员进行内存对齐以提高访问效率,而C#中默认是按照CLR的规则进行对齐的。如果两边结构体的对齐方式不一致,会导致数据解析错误。
C++结构体示例:
struct MyStruct {
char a;
int b;
short c;
};
在32位系统中,上述结构体的大小通常为12字节(1 + 3填充 + 4 + 2 + 2填充),而C#中如果不加任何修饰,默认的内存布局是自动优化的,可能导致大小不一致。
7.1.2 使用StructLayout特性控制内存布局
在C#中,使用 [StructLayout] 特性可以精确控制结构体的内存布局,确保与C++端一致。
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct MyStruct
{
public byte a;
public int b;
public short c;
}
LayoutKind.Sequential:表示结构体成员按声明顺序排列。Pack = 1:设置内存对齐为1字节,关闭填充。
⚠️ 注意:设置Pack=1虽然可以避免填充问题,但可能会影响性能,需在结构体大小与访问效率之间做权衡。
7.2 C++类与C#对象的交互方式
由于C#无法直接调用C++类的成员函数,跨语言交互通常通过将C++类封装为C风格的接口来实现。
7.2.1 类成员函数与静态方法的暴露
一种常见做法是将C++类的方法封装为全局函数,并通过指针传递对象实例。
extern "C" {
// 创建类实例
MyClass* CreateMyClass(int value) {
return new MyClass(value);
}
// 调用成员函数
void MyClass_SetValue(MyClass* obj, int value) {
obj->setValue(value);
}
int MyClass_GetValue(MyClass* obj) {
return obj->getValue();
}
// 释放资源
void MyClass_Delete(MyClass* obj) {
delete obj;
}
}
7.2.2 使用接口封装C++类功能
在C#中,使用 IntPtr 来保存C++对象的指针,并通过P/Invoke调用对应的方法。
public class MyClassWrapper
{
private IntPtr nativePtr;
[DllImport("MyNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr CreateMyClass(int value);
[DllImport("MyNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void MyClass_SetValue(IntPtr ptr, int value);
[DllImport("MyNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int MyClass_GetValue(IntPtr ptr);
[DllImport("MyNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void MyClass_Delete(IntPtr ptr);
public MyClassWrapper(int value)
{
nativePtr = CreateMyClass(value);
}
public void SetValue(int value)
{
MyClass_SetValue(nativePtr, value);
}
public int GetValue()
{
return MyClass_GetValue(nativePtr);
}
~MyClassWrapper()
{
MyClass_Delete(nativePtr);
}
}
⚠️ 注意:C++类的析构函数不能直接调用,必须显式封装释放函数。
7.3 跨语言对象生命周期管理
跨语言对象的生命周期管理是关键问题,不当的资源释放会导致内存泄漏或访问非法内存。
7.3.1 C++对象在C#中的封装与释放
为了确保C++对象在C#中能被正确释放,通常使用封装类(如 MyClassWrapper )持有原生指针,并在析构函数中调用释放函数。
graph TD
A[C# 创建 Wrapper] --> B[C++ 创建对象]
B --> C[调用 C++ 方法]
C --> D[Wrapper 调用 Delete]
D --> E[C++ 释放对象]
7.3.2 使用IntPtr与Finalizer管理资源
C#使用 IntPtr 来持有C++对象的地址,并通过Finalizer确保对象在回收时能释放对应的非托管资源。
public class NativeResource : IDisposable
{
private IntPtr handle;
private bool disposed = false;
public NativeResource(IntPtr nativeHandle)
{
handle = nativeHandle;
}
~NativeResource()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!disposed)
{
if (handle != IntPtr.Zero)
{
MyClass_Delete(handle); // 调用C++释放函数
handle = IntPtr.Zero;
}
disposed = true;
}
}
}
✅ 建议:在封装非托管资源时,始终实现
IDisposable接口,以支持显式释放资源。
本章通过结构体的内存布局控制、C++类的封装方式以及对象生命周期管理三个方面,深入探讨了C++与C#之间的结构体和类转换机制。下一章将介绍如何在C#中高效调用C++ DLL并进行性能优化。
简介:跨语言调用是大型项目中常见的需求,本文通过示例讲解如何使用C/C++编写动态链接库(DLL),并实现C#等其他语言对DLL的调用。内容涵盖DLL的创建与调用机制、C++调用其他DLL、C++与C混合编程、C#通过P/Invoke调用C/C++ DLL的方法,以及相关注意事项与实际应用场景。示例文件包含完整的源码和调用流程说明,适合开发者快速掌握跨语言集成开发技巧。
更多推荐

所有评论(0)