目录

1、导入导出声明

2、C++函数名称改编与extern “C”

3、函数调用约定与跨语言调用

3.1、函数调用约定

3.2、跨语言调用dll库接口

3.3、函数调用约定以哪个为准

4、def文件的使用

5、在C++程序中引用ffmpeg库中的头文件链接报错问题

6、最后


VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新…)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新…)https://blog.csdn.net/chenlycly/article/details/125529931C++软件分析工具从入门到精通案例集锦(专栏文章正在更新中…)https://blog.csdn.net/chenlycly/article/details/131405795C/C++基础与进阶(专栏文章,持续更新中…)https://blog.csdn.net/chenlycly/category_11931267.html 最近有个前同事打微信电话问一个包含ffmpeg开源库头文件后编译链接失败的问题,其实很简单,只需要在包含头文件时加上extern “C”就可以解决了。今天正好有时间,就来详细讲讲C++ dll动态库编程中关于导出接口相关的内容,包括接口的导入导出声明C++函数名称改编extern “C”作用、标准C接口函数调用约定声明跨语言调用dll接口def文件等内容,本文将通过一个具体的dll动态库实例来详细展开,希望能给大家(特别是新人)提供一定的借鉴或参考。


在这里,给大家重点推荐一下我的两个热门畅销专栏

专栏1(该专栏订阅量接近350个,有很强的实战参考价值,广受好评!)

C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新…)https://blog.csdn.net/chenlycly/article/details/125529931

本专栏根据近几年C++软件异常排查的项目实践,系统地总结了引发C++软件异常的常见原因以及排查C++软件异常的常用思路与方法,详细讲述了C++软件的调试方法与手段,以图文并茂的方式给出具体的实战问题分析实例,带领大家逐步掌握C++软件调试与异常排查的相关技术,适合基础进阶和想做技术提升的相关C++开发人员!

专栏中的文章都是通过项目实战总结出来的,有很强的实战参考价值!专栏文章还在持续更新中,预计文章篇数能更新到200篇以上!

专栏2

C/C++基础与进阶(专栏文章,持续更新中…)https://blog.csdn.net/chenlycly/category_11931267.html

以多年的开发实战为基础,总结并讲解一些的C/C++基础与进阶内容,以图文并茂的方式对相关知识点进行详细地展开与阐述!专栏涉及了C/C++领域的多个方面的内容,同时给出C/C++及网络方面的常见笔试面试题,并详细讲述Visual Studio常用调试手段与技巧!


1、导入导出声明

在Windows平台编写C++ dll动态库时,提供给外部调用的接口需要添加__declspec(dllexport)导出声明,这样外部模块才能调用这些导出接口。一般我们会在dll动态库的api头文件中添加这样的定义:(以下 dll 动态库实例是在 Visual Studio 中创建的,即 IDE 开发环境为 Visual Studio

#ifdef WIN32#ifdef HCNETSDKDLL_EXPORTS#define NET_SDK_API __declspec(dllexport)// 声明为导出#else#define NET_SDK_API __declspec(dllimport)// 声明为导入#endif KdvMt_Pc_EXPORTS#else#define NET_SDK_API#endifWIN32#ifdef __cplusplus extern "C" {#endif // 设置业务消息回调接口NET_SDK_API void __stdcall SetMsgCallBack(PMsgCallBackFunc pMsgCallBackFunc); // 初始化SDK库NET_SDK_API DWORD __stdcall InitNetSDK(); // 登录NET_SDK_API DWORD __stdcall LoginServer(const TLoginParam& tLoginParam);#ifdef __cplusplus }#endif

其中宏HCNETSDKDLL_EXPORTS是dll库中定义的,在dll工程属性配置的C/C++ -> 预处理 -> 预处理定义中可以看到该宏的定义,如下 :

该宏是创建dll工程时自动生成的,宏名称的前半部分就是工程名称。

关于__declspec(dllexport)和__declspec(dllimport)

1)对dll库本身而言,接口是要导出给外部调用的,所以导出接口要声明为__declspec(dllexport);

2)对要调用dll库的外部模块,则是要引入dll库的导出接口,所以要使用__declspec(dllimport)。

2、C++函数名称改编与extern “C”

C++之所以支持函数重载,是因为C++编译器在编译代码时会对函数名称进行改编。改编后的函数名称包含参数信息,这样就能将重载的函数区分开来了。下面是个简单的函数重载范例:

int AddNum( int a, int b );double AddNum( double a, double b);

重载的函数名称是相同的,但参数类型是不同的。要将函数重载(overload)和函数重写(override)区分开来,两者有着本质的区别。

创建了一个简单的dll动态库工程,在工程中提供了几个导出接口,如下所示:

// NET_SDK_API宏用来指定是导入还是导出#ifdef WIN32#ifdef HCNETSDKDLL_EXPORTS#define NET_SDK_API __declspec(dllexport)#else#define NET_SDK_API __declspec(dllimport)#endif KdvMt_Pc_EXPORTS#else#define NET_SDK_API#endifWIN32// 设置业务消息回调接口NET_SDK_API void SetMsgCallBack(PMsgCallBackFunc pMsgCallBackFunc);// 初始化SDK库NET_SDK_API DWORD InitNetSDK();// 登录NET_SDK_API DWORD LoginServer(const TLoginParam& tLoginParam);

编译代码,生成dll库文件HCNetSDKDll.dll,然后用Dependency Walker查看该dll库导出接口的名称(进行名称改编后的名称):

从上图中可以看出,改编后的函数名称中包含了函数参数信息

注意,在Win10及以上系统中Dependency Walker打开dll库会很慢,可能是Dependency Walker工具比较老,对新的Win10及以上系统兼容性不太好。有时可能需要数分钟才能打开dll文件,在使用时要耐心等待。

但有时C++编写的dll模块可能会被C语言程序或者其他语言(比如C#)程序调用,需要导出标准C的接口(函数只有函数名,没有其他额外的信息)才能正常被调用。一般C++项目中,各个模块使用的都是C++开发语言,IDE开发工具基本都是一样的,不用考虑这样的问题。

C++编译器在默认情况下会对函数名称进行改编,如何让编译器不对函数名称进行改编呢?可以使用extern “C”将所有的导出接口包起来。extern “C”标识告诉编译器在编译时以C语言的方式去处理,不要对声明的函数接口进行名称改编同样以上述dll工程为例,将导出接口用extern “C”包起来,如下所示:

#ifdef WIN32#ifdef HCNETSDKDLL_EXPORTS#define NET_SDK_API __declspec(dllexport)#else#define NET_SDK_API __declspec(dllimport)#endif KdvMt_Pc_EXPORTS#else#define NET_SDK_API#endifWIN32#ifdef __cplusplus extern "C"// 使用extern "C" {#endif // 设置业务消息回调接口NET_SDK_API void __stdcall SetMsgCallBack(PMsgCallBackFunc pMsgCallBackFunc); // 初始化SDK库NET_SDK_API DWORD __stdcall InitNetSDK(); // 登录NET_SDK_API DWORD __stdcall LoginServer(const TLoginParam& tLoginParam);#ifdef __cplusplus }#endif

然后重新编译代码,再用Dependency Walker查看dll库的导出接口,如下所示:

确实生成了只有函数名的导出接口。

此外,C++中可以导出函数,也可以导出整个类。对于导出类,可以直接在外部直接使用类。但extern “C”只对导出函数起作用,对导出类(整个类导出)的成员函数不起作用。

3、函数调用约定与跨语言调用

让C++实现的dll库导出标准C接口,使用extern “C”就好了,事情到此好像就结束了,但事实上并没有结束。Windows平台上还有个函数调用约定的概念。

3.1、函数调用约定

调用约定是用来声明函数的,常见的函数调用约定有__cdecl C调用、__fastcall快速调用以及__stdcall标准调用等。调用约定决定了函数调用时参数入栈的先后顺序(参数不一定使用栈传递,可能会直接使用寄存器传递),还决定了谁来释放传递参数占用的栈空间不同的开发语言,默认的调用约定可能是不一样的,比如C++中默认的是C调用、C#中默认的是标准调用。如果存在dll库跨语言调用时,一定要明确声明dll库导出接口的调用约定。

Windows提供的系统API函数使用的都是标准调用约定,比如获取窗口文字的API函数GetWindowText:(WINAPI是函数调用约定宏,对应__stdcall标准调用)

#if !defined(_USER32_)#define WINUSERAPI DECLSPEC_IMPORT#define WINABLEAPI DECLSPEC_IMPORT#else#define WINUSERAPI#define WINABLEAPI#endif#ifndef WINAPI#define WINAPI __stdcall#endifWINUSERAPIintWINAPI /* 此处设置函数调用约定为__stdcall*/GetWindowTextW(__in HWND hWnd,__out_ecount(nMaxCount) LPWSTR lpString,__in int nMaxCount);#ifdef UNICODE#define GetWindowTextGetWindowTextW#else#define GetWindowTextGetWindowTextA#endif // !UNICODE

参照Windows系统API函数,我们一般也将dll库的导出接口声明为标准约定。这个地方需要注意一下,除了导出接口都要明确声明调用约定,回调函数也要声明调用约定。给dll库设置回调函数,dll库通过调用回调函数,给主调模块回调数据。回调函数是dll中声明的,但在上层调用模块实现的(完整的函数代码实现),回调函数的调用是在dll库内部的。

关于函数调用约定的详细内容,可以参考我之前写的文章:

C/C++函数的调用约定详解https://blog.csdn.net/chenlycly/article/details/125354572

也可以参看微软官方对函数调用约定的说明:

参数传递和命名约定https://learn.microsoft.com/zh-cn/cpp/cpp/argument-passing-and-naming-conventions在上述页面最下面,给出了Visual C/C++ 编译器支持的调用约定:

点击截图中的调用约定名称,就能跳转到对应调用约定的详细说明页面!

3.2、跨语言调用dll库接口

为什么在跨语言调用的场景下需要明确声明dll库的导出接口的函数调用呢?是有原因的,假设调用C++实现的dll库的上层模块或程序是C#语言开发的,如果在dll的头文件中不明确声明导出接口的调用约定,则在使用Visual Studio编译C++实现的dll文件时,由于没有函数调用约定,默认使用C调用,而C调用下传递参数占用的栈空间是主调函数去释放的,所以dll库中编译生成的函数代码中就不会有清理传递参数占用的栈空间的二进制代码。

而上层C#模块,默认是标准调用,在编译到调用dll库导出接口的代码时,由于标准调用下传递参数占用的栈空间是由被调用函数释放的,所以不会生成释放传递参数栈空间的二进制代码。所以在这种场景下,主调函数不会清理传递参数占用的栈空间,被调函数函数也不会清理被调函数占用的栈空间,这样就导致了栈不平衡,就会导致使用ebp去寻址栈内存出现异常,进而引发崩溃。

我们以前就遇到过这样的问题,我们提供给第三方厂商的软件SDK是用C++实现的,第三方厂商C#开发的程序来调用我们的SDK模块,当时就因为在声明回调函数时没有指定函数调用约定,导致栈不平衡,引发了崩溃

3.3、函数调用约定以哪个为准

使用Visual Studio创建的dll工程,在工程属性配置(C/C++ -> 高级 -> 调用约定)中,默认为_cdecl C调用,如下所示:

这是创建工程时的默认配置。

我们也可以在函数声明处指定调用约定,如下所示:

// 初始化SDK库(将该函数的调用约定指定为__stdcall标准调用)NET_SDK_API DWORD __stdcall InitNetSDK();

当函数前有指定调用约定,且工程属性配置中也有设置调用约定时,以函数前声明的调用约定为准

4、def文件的使用

在我们的示例代码中添加调用约定的声明,声明为__stdcall标准调用,如下所示:

#ifdef WIN32#ifdef HCNETSDKDLL_EXPORTS#define NET_SDK_API __declspec(dllexport)#else#define NET_SDK_API __declspec(dllimport)#endif KdvMt_Pc_EXPORTS#else#define NET_SDK_API#endifWIN32#ifdef __cplusplus extern "C"// 使用extern "C" {#endif // 设置业务消息回调接口NET_SDK_API void __stdcall SetMsgCallBack(PMsgCallBackFunc pMsgCallBackFunc); // 初始化SDK库NET_SDK_API DWORD __stdcall InitNetSDK(); // 登录NET_SDK_API DWORD __stdcall LoginServer(const TLoginParam& tLoginParam);#ifdef __cplusplus }#endif

重新编译代码,然后使用Dependency Walker工具查看新生成的dll文件,结果看到函数符号变了(本来添加了extern “C”标识后,已经导出了标准C接口,结果添加了__stdcall调用约定后,又不再是标准C函数了),如下:

不再是标准的C接口了,接口前面多了个下划线,接口后面多了个数字,这个数字其实是参数占用的栈空间大小。

考虑跨语言调用dll库的场景,我们需要导出标准的C接口,结果即使使用了extern “C”,还是没有生成标准C接口,这可如何是好呢?是有办法的,下面就轮到def模块定义文件登场了!def文件内容比较简单,主要分两块:

1)第一块是LIBRARY语句部分,指明对应的dll库名称;

2)第二块是EXPORTS语句部分,用来指定要导出的接口。

我们将要导出的接口都罗列到EXPORTS语句部分里面就好了,这样最终生成的dll库文件中导出的就是标准C接口了。本范例中的def文件如下所示:

要手动生成def文件比较简单,先手动创建一个.txt文件,然后手动将之改成.def后缀,然后手动将LIBRARY语句和EXPORTS语句部分的文字拷贝进来修改一下即可。

这个def文件如何设置到dll工程中呢?其实很简单,在dll工程属性中点击:链接器 -> 输入 -> 模块定义文件,然后将def文件的相对路径设置进去就可以了,如下所示:

所以,在我们这个dll动态库示例工程中,要实现标准C接口的导出,要使用导入导出声明,要声明函数调用约定,要使用extern “C”,也要使用到def文件。

5、在C++程序中引用ffmpeg库中的头文件链接报错问题

一个前同事在其C++项目中引用了ffmpeg开源库中的头文件,结果编译时报链接的错误,如下所示:

找到我,让我帮忙看一下如何处理这个错误。因为ffmpeg库是用C语言开发的,编译生成的dll库的导出接口肯定是标准C接口。在C++项目中直接包含ffmpeg中的头文件,编译时默认链接的是经过名称改编的函数符号,而ffmpeg.lib中的都是标准C接口,函数符号只有函数名,不是改编后的符号,所以链接时找不到,报错了!

其实这处理起来也比较简单,在包含头文件时用extern “C”包住就可以了,如下所示:

重新编译代码,就没问题了,不再报错了。extern “C”标识是告诉C++编译器以C语言方式去处理,去链接标准的C接口,所以在引入的ffmpeg.lib中能找到标准的C接口,所以就不再报错了。

其实很多开源库都是C语言实现的,比如sqlitelibcurl等,在这些开源库提供的api头文件中就添加了extern “C”。比如sqlite开源数据库中的sqlite3.h头文件中:

在curl多协议网络传输开源库中的curl.h头文件中:

6、最后

本文通过一个具体的dll动态库工程实例,详细讲解了如何一步一步地实现标准C导出接口的过程,这其中包括接口的导入导出声明、extern “C”作用、标准C接口、函数调用约定声明、跨语言调用dll接口以及def文件等内容。

这里面涉及到的内容,C++新手是需要了解的,甚至有很多C++老手也不太清楚,希望本文能帮到大家,给大家提供一个借鉴或参考!