C++动态链接库之非托管封装Invoke,供C#/C++ 等编程语言使用,小白教程——C++动态链接库(一)

发布于:2025-06-28 ⋅ 阅读:(18) ⋅ 点赞:(0)

一、前言及背景

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 boolint(建议用 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 仅通过序号导出,不导出名称(特殊用途)

🧩 二、基础用法:导出函数名

  1. 创建 .def 文件(如 Source.def):
LIBRARY Dll1
	EXPORTS Add
  1. 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++ 应用端用调用成功!

大功告成!

四、参考资料


最后,文中若有不足,敬请批评指正!


网站公告

今日签到

点亮在社区的每一天
去签到