目录:
一、前言及背景
1.1需求描述
前面我们有篇总的概述文章,阐述了C++动态库的基本知识和大方向,而其中有很多细分应用场景,需要我们自己打磨,今天我们讨论下:C++以非托管方式(独立于CLR)封装,给第三方编程语言平台进行交换的场景,相信在工作的友友们,是应该比较熟悉这个场景了,特别是对C# 编程用户来说,可能平常供应商已经封装好,你们直接使用而不知不觉,下面我们回忆这整个技术栈的发生、应用!
当我们需要计算性能,并且目标应用编程语言满足不了情况下,
- 比如C# 场景下对大量元素的数组和列表等数据结构运算下运算低效!
- C# 平台没有相应的生态,需要引入第三方(C++生态);
- 常见的算法C# 场景下,自我编写困难,需要引用和改造封装别人写好的C++算法!
1.2应用背景
这种非托管封装交互的方式,是C++封装作为底层库或者SDK运行时,然后开放接口给第三方语言,进行二次开发的常见手段,比如某些传感器(工业相机,定制传感器)或者运动控制器给二次开发人员或者上位机开发者(集成者)进行数据和控制指令交互;还有像Windows系统开放的底层使用的动态链接库,等等。
下面总结下,这种交互方式的应用场景:
- C++只开放交互接口,C# 等第三方语言自己封装和二次开发;
- 不需要以托管方式深度结合进行开发,这个是主要区别的场景特点,同时也要考虑非托管方式是否可持续(稳定性、连续性、前景性,简单来说,就是后面不烂尾),如果实在不行,就要以托管封装形式;
- 不需要表层定制的通信协议(TCP/Modbus等),只是程序间数据获取和交换;
- 简单地数据传输和性能优化,比如一些计算场景下,C++处理比较有优势,这是时候,只要封装并传入数据处理后,获取结果就行,这个比较常见,在计算几何和图形操作时,C++封装几乎是不二之选;
- 目标语言和使用场景的选择,目标平台如果是C# 等一些支持性和适配性较好的语言;
☀️本文主要阐述了以C++非托管方式封装动态链接库(有Lib生成),与C# 进行交互的,并且有lib文件生成情况下,也可供C++应用端调用(其他编程语言自行迁移,作者到时会新建文章篇幅概述)!
二、编程基础知识
2.1非托管方式交互逻辑
非托管代码:直接用如 C、C++ 编写、编译为机器码,不依赖 .NET CLR;
交互方式:通常通过 P/Invoke 从 C# 调用 C++ 的 DLL(.dll),接口需为 C 语言风格;
数据类型:需注意托管(如 string, array)和非托管(如 const char*, int*)类型间的转换。
逻辑架构:
C++ 非托管 DLL <=====> C# 应用程序
└─ 导出函数 extern "C" └─ P/Invoke [DllImport]
└─ 标准类型参数 └─ 调用函数,传入参数,接收返回值
└─ .def 或 __declspec(dllexport) └─ 注意调用约定、内存安全
2.2该方式下C++ 与C# 数据转换对应
不同编程语言,数据结构和元素一一对应是不同的,做数据格式转换要仔细查表和结合经验!
1️⃣基础元素格式:
C# 类型 | C++ 非托管类型(C 接口) | 备注 |
---|---|---|
float |
float |
|
short |
short / int16_t |
|
ushort |
unsigned short / uint16_t |
|
byte |
unsigned char |
|
char |
char (注意编码问题) |
|
string |
const char* 或 char* (需 MarshalAs ) |
|
bool |
bool 或 int (建议用 int ) |
注意:字符串从C++里面传递回来到C# 端,不能直接传递,需要用Marshal.PtrToStringAnsi
函数进行转换!
2️⃣复合元素格式:
C#数据格式 | C++ 数据格式 | 备注 |
---|---|---|
double[] | const double* +数组长度 | 输入对应的数组元素长度,并且其他复合泛型也是对应转换,例如:C# int[]转C++:int* ,结合基本元素转换+指针即可 |
List要转换成doulbe[]再输入 | const* double* +数组长度 | 同上 |
注意:
2.3VS工程下的注意点
VS工程主要是要统一生成环境(X64/X86),初次生成,就要确定什么环境是自己的目标!
2.4C++封装接口
C++非托管形式封装要考虑目标使用平台!因为C++动态库给外面应用非托管引用,会有lib和.exp文件是否生成的必要,如果应用者是C++应用,就需要.lib文件生成,仅是C# 就需要动态库文件(不需要额外生成),如果两者兼具(C++和C# 应用),C++动态库项目就要有.lib和.exp文件生成(主要是Lib文件)。
🌈 如果在VS工程新建动态库工程,在默认的配置下,如果有对应头文件和对应cpp文件(主要决定是cpp文件存在,并在cpp文件中有对头文件的引用),并且在头文件或者.cpp文件中起码有extern "C" __declspec(dllexport)
或者使用.def文件定义的函数方法声明,这样就会生成Lib和.exp文件,否则没有对应文件生成,只有动态链接库dll生成!
在 Visual Studio 中生成 C++ 动态链接库(DLL) 并自动生成 .lib 和 .exp 文件,主要有两种方式:使用 __declspec(dllexport) 或使用 .def 文件,下面细分讨论他。
☀️通常来说,C++接口是以函数接口的形式来对外使用的(不推荐类方式进行对外使用)!我们需要结合函数使用来对应选择合适的生成方式
下面以一个C++函数,进行封装导出为例,该函数为:
int Add(int a, int b);
2.4.1 __declspec(dllexport) 方式
__declspec(dllexport) 是微软在 Visual C++ 编译器中提供的一个修饰符,用于导出符号(函数、变量、类)到 DLL 中,使得其他模块(如 EXE、其他 DLL、C# 程序)可以使用这些符号。
🧩 一、基本语法和作用
关键词 | 作用 |
---|---|
__declspec(dllexport) | 告诉编译器“把这个函数/类导出到 DLL 中”(动态库作为导出引用的载体,这个是主要使用的) |
__declspec(dllimport) | 告诉编译器“这个函数/类是从 DLL 中导入的” |
📌 二、函数导出
下面给出两种不同的声明方式:
1️⃣简单导出(直接在函数声明前加__declspec(dllexport)
):
extern "C" __declspec(dllexport) int Add(int a, int b);
2️⃣使用宏控制导入/导出,例如,定义一个宏#define MYAPI extern "C" __declspec(dllexport)
,在需要导出函数的地方,不用每次在前面都加上extern "C" __declspec(dllexport)
,只需一个MYAPI
就行了,例如:
#define extern "C" MYAPI __declspec(dllexport)
MYAPI int Add(int a, int b);
这种方式比较简写,重用性较高,比较推荐!
另外,如果需要切换导出/导出函数声明,可以这样:
#ifdef MYLIBRARY_EXPORTS
#define extern "C" MYAPI __declspec(dllexport)
#else
#define extern "C" MYAPI __declspec(dllimport)
#endif
MYAPI int Add(int a, int b);
项目属性中定义 MYLIBRARY_EXPORTS 宏:
项目右键 → 属性 → C/C++ → 预处理器 → 预处理器定义
添加:MYLIBRARY_EXPORTS
这样,当编译 DLL 时可以通过宏来切换导出导出函数!
3️⃣VS编程实践:
在test.h文件中:
#define extern "C" MYAPI __declspec(dllexport)
MYAPI int Add(int a, int b);
在test.cpp文件中:
#include "pch.h"
#include "test.h"
✅如果要生成对应的.lib和.exp文件,需要注意以下几点:
- 如果函数声明和定义只在.h文件中,则对应.cpp文件,
#include "pch.h"
要在引用最前头:
test.h文件:
#define extern "C" MYAPI __declspec(dllexport)
MYAPI int Add(int a, int b);
test.cpp文件:
#include "pch.h"//要在最前头
#include "test.h"
- 如果函数定义在.cpp文件中,头文件引用(
test.h
)可以不引用,pch.h
可以不是放在最前头引用:
test.h文件:
test.cpp文件:
#include "test.h"
#include "pch.h"
#define extern "C" MYAPI __declspec(dllexport)
MYAPI int Add(int a, int b);
总结:.cpp文件没导出函数时,pch.h
引用要在最前头,.cpp文件里有导出函数时,除非要有头文件里面有导出函数(并且给外面使用),否则可以不引用对应(test.h)文件,进行简单使用!
2.4.2 .def 文件方式
.def 文件(模块定义文件)是 Windows 编译器(如 MSVC)支持的一种 显示指定导出符号(函数、变量等) 的方式,主要用于 DLL 的构建过程中控制哪些符号被导出、导出名称、序号。它是 __declspec(dllexport) 的替代方案!
✅ 一、.def 文件的基本结构
LIBRARY MyLibrary
EXPORTS
AddFunc
SubFunc @2
HiddenFunc=InternalFunc @3 NONAME
关键字 | 说明 |
---|---|
LIBRARY |
指定 DLL 名称(可省略) |
EXPORTS |
列出要导出的函数 |
@数字 |
指定导出序号(可选) |
别名=真实名 |
重命名导出函数 |
NONAME |
仅通过序号导出,不导出名称(特殊用途) |
🧩 二、基础用法:导出函数名
- 创建 .def 文件(如 Source.def):
LIBRARY Dll1
EXPORTS Add
- C++ 代码无需使用 __declspec(dllexport):
// .cpp文件
int Add(int a, int b) {
return a + b;
}
注意:函数实现必须在 .cpp 中(头文件中只是声明),声明可以在.cpp文件也可在.h文件中!
3.如果要删除.def文件,要在vs工程配置中删除对应名称才行,不然会报链接错误提示!
4. 编译结果
Visual Studio 会根据 .def 文件导出 DLL,并自动生成:
- Dll1.dll
- Dll1.lib
- Dll1.exp
2.4.3结合使用(高级)
- .def 文件导出指定函数
- __declspec(dllexport) 导出其他函数
如果 .def 中指定了 EXPORTS,那只会导出 .def 中列出的函数,其他 __declspec(dllexport) 的函数将被忽略。(.def指定高于dllexport指定)
下面给出对比:
项目 | .def 文件方式 |
__declspec(dllexport) 方式 |
---|---|---|
代码侵入性 | 低(不改C++源代码) | 高(每个函数/类要写修饰) |
控制导出细节(名称/序号) | ✅ 精细控制 | ❌ 只能自动导出 |
可维护性 | 差(函数多了不易维护) | 好(函数导出随声明走,重用性较高) |
是否推荐 | ✔️ 少量函数、跨平台封装时使用 | ✔️ 通用推荐方式 |
2.5C# 封装接口
同样的,对非托管形式的C++动态链接库(函数接口),C# 要写成方法(或类方法或静态形式)的形式去适应对应的C++接口,以此来进行一一对应,下面来一一探讨!
下面以C++的非托管接口函数为例,以此来作为例子对接C# 接口样例:
extern "C" __declspec(dllexport) int AddIntFunc(int a_, int b_) {
return a_ + b_;
}
注:这个是输入两个整数数据,然后计算并返回整数结果的C++函数!
C# 是面向对象语言,所以对应接口在C#封装一般封装在类里面,所在类是静态类(非静态类),方法是静态累方法(或非静态类方法),常见搭配应该是静态类的静态方法居多!
下面在一个静态C# 类的静态方法如下(对应C++实例):
[DllImport("NonCustodialCLI.dll", CallingConvention = CallingConvention.Cdecl)]
public extern static int AddIntFunc(int a_, int b_);
2.5.1C# 封装方法形式
✅函数解释:
关键字 | 含义 |
---|---|
public |
表示该方法是公开的,可以从外部访问,也可以是其他形式(private等),取决于自己封装 |
static |
表示该方法属于类本身,而不是实例化对象 ,这个取决于你是否项用作静态方法使用,不是必须项 |
extern |
表示该方法的实现存在于外部 DLL 中,而不是在 C# 代码中定义的,这个是必须项! |
返回值 int |
表示返回类型是整数 |
AddIntFunc |
方法名称,C# 会通过名称和签名来匹配 DLL 中的函数 (可以与dll函数中签名不一致,但是有另外的写法) |
(int a_, int b_) |
传入的两个整型参数 |
[DllImport("NonCustodialCLI.dll", CallingConvention = CallingConvention.Cdecl)] |
特性(Attribute)声明,在一些需要灵活性设置时,这个非常必要 |
2.5.2特性声明
✅ 功能解释
[DllImport("NonCustodialCLI.dll", CallingConvention = CallingConvention.Cdecl)]
这是一个 特性(Attribute)声明(必须项),告诉 C# 编译器:
从名为 NonCustodialCLI.dll 的 非托管动态链接库 中导入函数;
使用 Cdecl 的调用约定(calling convention);
用于修饰一个后续 extern static 函数声明。
✅常见特性参数定义
参数名 | 用法说明 | 是否必填 | 典型值 |
---|---|---|---|
DllImport("xxx.dll") |
指定 DLL 名称 | ✅ 必须 | "YourLibrary.dll" |
CallingConvention |
函数调用约定,要与 DLL 保持一致 | ✅ 推荐 | Cdecl , StdCall , ThisCall |
CharSet |
用于处理字符串类型参数的编码 | 可选 | Ansi , Unicode , Auto |
EntryPoint |
如果 DLL 中函数名和 C# 声明不同,需要指定 | 可选 | 函数在 DLL 中的真实名字 |
1️⃣. CallingConvention(调用约定): 定义了函数调用时参数如何压栈、由谁清理栈等,必须与 C++ DLL 中函数的实际调用约定一致。
值 | 说明 | 对应 C++ 声明 | 用途 |
---|---|---|---|
Cdecl |
调用者清理堆栈 | __cdecl |
✅ 默认 C/C++ 编译器函数 |
StdCall |
被调用者清理堆栈 | __stdcall |
✅ WinAPI、COM 接口常用 |
ThisCall |
用于类的成员函数 | __thiscall |
C++ 类成员函数 |
FastCall |
使用寄存器传参 | __fastcall |
较少用 |
Winapi |
让系统自动选择调用方式(通常是 StdCall ) |
N/A | 推荐 Win32 API 场景 |
✅常见使用选择: |
- 如果你写的 DLL 是用 C/C++ 编写的标准函数(自己写的C++库):
CallingConvention = CallingConvention.Cdecl
- 如果是调用 Windows API(或第三方 SDK 提供的 DLL):
CallingConvention = CallingConvention.StdCall
2️⃣ CharSet(字符集编码方式):用于控制字符串(如 string, char*, wchar_t*)参数如何从托管转换为非托管。
值 | 编码 | 转换 C++ 类型 | 默认 marshaling |
---|---|---|---|
Ansi |
单字节编码(如 GB2312、ASCII) | char* |
string → LPSTR |
Unicode |
UTF-16 编码(Windows 内部默认) | wchar_t* |
string → LPWSTR |
Auto |
自动根据平台选择(Windows 上是 Unicode ) |
自动 | 根据平台变化 |
注意: C++ 中字符串参数如果是 char*,就用 Ansi;如果是 wchar_t*,就用 Unicode。
3️⃣ EntryPoint (映射 DLL 中实际函数名):用于解决 C++ 导出函数名称与 C# 不一致的情况,或处理 C++ 函数经过 name mangling 后名称变化的问题。
场景 | 示例 | 说明 |
---|---|---|
C# 调用名与 DLL 中函数名不同 | EntryPoint = "InitDemoDll1" |
DLL 中叫 InitDemoDll1 ,C# 名可以叫 InitDemoDll |
DLL 导出函数名为修饰名(name mangled) | EntryPoint = "?Add@@YAHHH@Z" |
指定 C++ 编译后的符号名(慎用) |
2.5.3综合使用:
最简单的使用就是:
[DllImport("NonCustodialCLI.dll")]
public extern static void InitDemoDll();
如果在C# 对应函数中,重新命名就是:
[DllImport("NonCustodialCLI.dll",EntryPoint = "InitDemoDll")]
public extern static void InitDemoDll3();
如果在C# 中,传输字符相关的话,可以自己指定编码格式:
[DllImport("NonCustodialCLI.dll" ,CharSet = CharSet.Ansi)]
public extern static void InitDemoDll(string msg);
如果在C# 中,调用的是自己写的C++动态库:
[DllImport("NonCustodialCLI.dll" ,CallingConvention = CallingConvention.Cdecl)]
public extern static void InitDemoDll();
如果上面都牵扯到,那么可以这么编写:
[DllImport("MyDLL.dll",
CallingConvention = CallingConvention.Cdecl,
CharSet = CharSet.Ansi,
EntryPoint = "InitDemoDll1")]
public static extern int InitDemoDll();
三、实践教程
下面我们一起来实践下吧,并且会注意易错点和盲区,以下为使用环境:
- VS2019;
- C++ mvsc;
- windows10系统(系统差别不大);
- C# .net framework4.8(.net core差不多,自行摸索);
- 应用场景:C++非托管方式封装数据计算,C# 负责应用端(传入数据和获取计算结果),并对接口进行简单封装;
为了方便管理和验证实践过程,我们会新建三个VS 2019工程(可以根据需要跳过,同时三个是为了验证和展示C++非托管方式生成,C# 应用端对应实践,C++应用端对应实践)C++非托管动态库工程,C# 调用这个C++动态库,C++应用端调用这个C++动态库!
下面给出一些背景资料:
- VS2019中编程,一个解决方案,同时,三个生成路径要一样,方便库引用,也可自行分开;
- 一个非托管方式的C++动态链接库的VS工程(生成动态库供其他语言调用);
- 一个windowform (.net framework4.8)的VS工程(直接调用C++动态库);
- 一个C++命令行程序的VS项目(直接调用C++动态库);
新建一个空的解决方案(上面的三个工程放在一起):
解决方案资源管理器->添加->新建项目->,然后按3.1、3.2、3.3分别添加验证的VS项目!(看自己需要)
3.1C++项目封装
在同一个解决方案中,新建C++动态链接库
VS项目,
新建个test.h(自己命名即可)头文件,用于外部编写接口函数:
在test.h添加以下代码:
extern "C" __declspec(dllexport) int AddIntFunc(int a_, int b_) {
return a_ + b_;
}
在test.cpp文件中添加:
#include "pch.h"//.cpp文件没导出函数,所以要放在最前面,如果有导出函数,可以不放在最前面
#include "test.h"
右键该项目,点击重新生成
,可以看到下面有.lib和.exp文件生成:
到这一步,证明生成无多大问题了!
3.2C#应用端
在同一个解决方案中,VS工程新建C# .Net Framework控制台应用
工程,按默认配置新建工程进行下一步(.net framework我用4.8居多,也可以用其他的)!
右键鼠标,进入项目配置,将生成路径改为和C++生成路径一致(不用再拉动态库dll到C# 程序目录),生成目录路径为:..\X64\Debug\
,
在Program.cs文件中,添加对应的名称空间引用,加入C# 端的P/Invoke 函数声明,如下:
Program.cs文件:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleApp1
{
class Program
{
[DllImport("Dll1.dll")]
public extern static int AddIntFunc(int a,int b);//函数声明
static void Main(string[] args)
{
Console.WriteLine($"相加结果:{AddIntFunc(3, 5)}");//函数调用
Console.ReadKey();
}
}
}
注意:先别着急生成,因为前面最开始我们把整个解决方案设为x64(C++动态库和后面C++命令行程序也是x64),但是C# 的命令行VS工程默认是32位的,要把这个勾选去掉,改成和C++环境(生成的DLL)一致!
右键该项目,点击设为启动项目
点击VS中的运行,可以看到:
这样就证明C++生成非托管的动态链接库成功,并且C# 成功用invoke函数匹配上,调用成功!
3.3C++应用端
在同一个解决方案中,VS工程新建c++控制台应用
工程,
添加头文件,右键项目 → 属性选择 → 配置属性 → C/C++ → 常规→ 附加包含目录(Additional Include Directories),填入..\Dll1
(这个是相对路径,Dll1是动态库项目名,test.h文件在里面,友友们可以按绝对路径等,头文件VS能识别即可),如下:
主调用.cpp文件代码如下:
#include <iostream>
#include <conio.h>
#include "test.h"
int main()
{
std::cout << "Hello World!\n";
std::cout << "int加法函数:" << AddIntFunc(5, 8) << std::endl;
_getch();//等待鼠标指示完成
}
右键该项目,点击设为启动项目
点击VS中的运行,可以看到:
这样就证明C++生成非托管的动态链接库成功,并且C++ 应用端用调用成功!
大功告成!
四、参考资料
最后,文中若有不足,敬请批评指正!