- C++码农日记(全程视频讲解)
- 白振勇编著
- 5672字
- 2022-07-28 18:46:33
第13天 开发一个DLL
视频讲解
今天要学习的案例对应的源代码目录:src/chapter03/ks03_01。本案例不依赖第三方类库。程序运行效果如图3-1所示。
图3-1 第13天案例程序ks03_01运行效果
今天的目标是掌握如下内容。
- 开发一个DLL项目的方法。
- 在EXE中调用DLL的接口。
- 在项目开发中使用命名空间。
在软件项目开发过程中会不可避免地碰到代码复用问题。比如,在项目A中实现的功能也会在项目B中使用。这时就可以把重复的功能封装到DLL模块中。那么,用Qt怎样开发DLL呢?利用Qt开发DLL大概分为两大步:封装DLL和使用DLL。
下面介绍具体步骤。本文将DLL中供EXE调用的类或接口称作引出类、引出接口。
(1)将DLL中引出类(export)的头文件移动到公共include目录。
(2)在DLL的pro项目文件中定义宏。
(3)编写DLL引出宏的头文件。
(4)在DLL引出类的头文件中使用引出宏。
(5)在EXE项目中添加对DLL的引用。
(6)在EXE中调用DLL的接口。
(7)使用命名空间解决重名问题。
(8)使用命名空间的注意事项。
现在介绍如何把src.baseline中ks03_01项目的api_start_as_service()等相关接口封装到firstdll这个DLL项目中,firstdll位于src/base/firstdll目录。这个DLL引出的接口所在的原始头文件(src.baseline中的本节代码)api.h见代码清单3-1。
代码清单3-1
现在介绍将这些接口封装到DLL中的详细开发步骤。在开始之前,在src目录中创建base/firstdll目录作为DLL的项目目录。将src.baseline中ks03_01项目的api_windows.cpp、api_linux.cpp、api.h、service_windows.h、service_windows.cpp这5个文件复制到该目录。
1.将引出类、引出接口的头文件移动到公共include目录
因为要把DLL作为公共模块,所以应该把DLL中的api_start_as_service()等接口所在的头文件api.h移动到公共的include目录,而不是继续放在DLL项目的源代码目录。为整个项目创建公共include目录,该目录与src目录并列。在该include目录下可以创建子目录,从而区分不同子模块的头文件。本案例中将api.h放到公共include目录的子目录base/firstdll中。为了统一头文件命名方式,将api.h改名为service_api.h,因此,需要将代码中包含api.h头文件的代码改为包含service_api.h。
2.在DLL的pro文件中定义宏
既然把头文件移动到其他目录了,那么就要把DLL的pro文件中的INCLUDEPATH配置成头文件所在的目录include/base/firstdll,否则,编译器在构建项目时就找不到这个头文件了。除此之外,还要注意在pro文件中的HEADERS配置项中把头文件的路径写全,并且把DLL的pro文件中的TEMPLATE选项设置为lib。
在Linux/UNIX环境下开发DLL时,无须对引出类或者引出接口做特殊声明,但在Windows下情况有所不同。在Windows下编译引出类所在的头文件时,编译器需要明确知道自己正在构建EXE模块还是DLL模块。如果是构建EXE,编译器看到的头文件中应该对引出的类、接口用_ _declspec(dllimport)进行声明,如代码清单3-2所示。
代码清单3-2
如果是构建DLL,编译器看到的头文件中的引出类应该用_ _declspec(dllexport)进行声明,如代码清单3-3所示。请注意是dllexport,而不是构建EXE时的dllimport。
代码清单3-3
对比代码清单3-2和代码清单3-3后可以得知,编译器在构建EXE和构建DLL时看到的同一个头文件中的内容有些不同,这就需要编写两个头文件。这两个头文件内容基本一致,仅仅是对引出类或引出接口的定义稍有不同,即需要分别使用_ _declspec(dllimport)和_ _declspec(dllexport)关键字。这是Windows下使用MSVC的C++编译器导致的结果。如果需要为所有引出类都提供内容基本一致的两套头文件,那么工作量就太大了,这样不但造成代码冗余,还容易引入其他问题。那该怎么解决这个问题呢?别急,现在就一步步解决它。在DLL的pro文件中定义一个宏_ _FIRSTDLL_SOURCE_ _,宏的拼写最好与项目名称有关,以防跟其他项目冲突。定义这个宏目的是为另一个宏定义做准备。在firstdll.pro中的WIN32编译分支中应该添加DEFINES -= UNICODE,该定义的作用在第10天的学习内容中已经介绍。
3.编写DLL引出宏的头文件
1)关于引出、引入类或者接口用的宏定义
既然在Windows下需要区分_ _declspec(dllimport)和_ _declspec(dllexport)这两个关键字,而且只能为EXE项目和DLL项目提供同一个头文件,那就可以把这两个关键字定义成宏,如代码清单3-4所示。编译器在构建EXE和构建该头文件所属的DLL时,再把这个宏分别解析成_ _declspec(dllimport)和_ _declspec(dllexport)。
代码清单3-4
在代码清单3-4中,根据操作系统的不同将BASE_API定义为不同的关键字。Windows下(WIN32分支)根据是否定义_ _FIRSTDLL_SOURCE_ _宏来进行不同的处理。因为已经在DLL的pro文件中定义_ _FIRSTDLL_SOURCE_ _,所以构建DLL时会执行标号①处的代码,即把BASE_API定义成_ _declspec(dllexport)。而在EXE项目的pro文件中并未定义_ _FIRSTDLL_SOURCE_ _,因此构建EXE时会执行标号②处的代码,即把FIRSTDLL_API定义成_ _declspec(dllimport)。在UNIX/Linux等非Windows操作系统中构建项目时则执行标号③处的代码,也就是单纯定义FIRSTDLL_API宏,以便编译器在解析后面的代码时看到这个符号可以把它当成合法的符号。在Linux/UNIX中,这个符号没有其他含义,仅仅是个符号而已。
代码清单3-4所示的头文件firstdll_export.h在某些情况下可以删掉。比如,该DLL只提供了一个头文件用来定义引出类、引出接口,那么就不用创建firstdll_export.h文件,而是把该头文件的内容直接复制到引出类所在头文件service_api.h的开头部分即可。
2)关于接口参数的压栈顺序、栈的清理
(如果对参数压栈的知识不感兴趣,可以跳过本小节的内容)当完成DLL的开发之后,其他应用程序就可以调用该DLL中的接口了。在进行接口调用时需要用到栈(有时也称堆栈),栈是一种先入后出的数据结构,可以把栈理解成一个先入后出的队列,也就是后进入队列的数据先出来,先进入队列的后出来。栈有一个存储区和一个栈顶指针。栈顶指针指向栈中第一个可用数据项(称为栈顶)。用户可以在栈顶的上方继续向栈中添加数据,这个操作被称为压栈(Push)。压栈以后,栈顶自动变成新加入数据项的位置,栈顶指针也随之修改。用户也可以从栈中取走栈顶,这个操作被称为弹出栈(Pop),弹出栈后,原栈顶的下一个元素变成新的栈顶,栈顶指针随之修改。调用者依次把参数压栈然后调用接口,接口开始执行后在栈中取得参数数据并进行计算,接口计算结束以后,由调用者或者被调用的接口负责把栈恢复原状。因此,这就涉及关于栈的几个问题。
- 调用接口时,接口的参数列表的压栈顺序是怎样的?接口参数列表中的参数是按照从左到右的顺序压栈,还是从右到左的顺序压栈?比如对于接口int function(int a,float b)来说,是参数a先压栈,还是参数b先压栈?
- 接口调用完成后,谁负责清理栈空间?即谁负责把栈恢复原状?
在高级语言中,可以通过接口调用约定来解决这些问题。只要调用者与被调用者使用相同的调用约定进行编译,双方就可以在行为上保持一致。常见的调用约定有_ _stdcall、_ _cdcel和_ _fastcall。它们之间的区别见表3-1~表3-4。
表3-1 几种调用约定的常用场合
表3-3 谁负责清理栈
以_ _stdcall为例介绍一下用法。
注意:接口声明和定义(实现)处的调用约定必须要相同。
(1)错误的用法1。
(2)错误的用法2。
(3)错误的用法3。
表3-4 函数名称修饰规则(以函数functionname()为例)
注:表3-4中的“******”为函数返回值类型和参数类型表。
为了方便,可以定义宏FIRSTDLL_API_CALL,见代码清单3-5。在标号①处,定义宏FIRSTDLL_API_CALL,它的值为WINAPI,在Windows中,WINAPI被定义为_ _stdcall。在标号②处,其他平台只定义FIRSTDLL_API_CALL,并未给它赋值。这样做是因为在本案例介绍的方法中,调用者和被调用者使用同一个头文件进行编译,不存在调用约定不一致的情况。如果未采用本案例介绍的方法,而是为调用者和被调用者各自提供头文件进行编译,当两份头文件中对接口使用了不同的调用约定时,就可能导致问题。因此,本案例定义FIRSTDLL_API_CALL仅仅是为了介绍调用约定的知识,其实可以不用定义FIRSTDLL_API_CALL,也就是不使用调用约定对接口进行修饰。
代码清单3-5
下面介绍一下FIRSTDLL_API_CALL的使用方法。接口NetworkToHost()的声明如下。
接口NetworkToHost()的实现如下。
4.在DLL引出类的头文件中使用引出宏
现在只需要在引出类、引出接口的前面编写FIRSTDLL_API就可以把类或接口引出了,见代码清单3-6。在标号②处,在引出类所在的头文件中包含firstdll_export.h。然后在引出类或引出接口定义代码中增加FIRSTDLL_API字样,见标号③、标号④、标号⑤处。请注意FIRSTDLL_API宏用来定义引出类与引出接口时语法上的不同,在标号③处是在class关键字和类名之间编写FIRSTDLL_API,而标号④、标号⑤处将FIRSTDLL_API写在整个引出接口定义之前。
代码清单3-6
还有很重要的一点,标号①处的注释用来说明:在使用该头文件时需要引入哪个库文件。本案例中,如果需要用到service_api.h这个头文件,就要引入firstdll这个动态链接库。也就是说,在使用该头文件的项目的pro文件中需要引入firstdll库。这样做的目的给使用该头文件的研发人员提供方便。
如果在Windows系统中编译DLL时报错“fatal error C1083:无法打开包括文件:type_traits”,可以在系统环境变量PATH中添加下面内容:%SystemRoot%\system32。
5.在EXE项目中添加对DLL的引用
完成DLL的编写后,需要在EXE中或者其他DLL中引入这个DLL。这需要修改调用者的pro文件,在其LIBS配置项中添加对DLL的引用,如代码清单3-7所示。
代码清单3-7
在代码清单3-6中的标号①、标号③处,对构建Debug版的项目进行配置,在标号②、标号④处,对Release版进行配置。这样就能保证编译器在构建项目时去链接对应版本的lib文件。
6.在EXE中调用DLL的接口
现在进入最后一个环节,在EXE或者其他DLL中调用本案例DLL的接口。其实这跟调用同一个项目中的接口没什么区别。在ks03_01项目中调用firstdll库中的接口一共分两步:第一步,编写include语句包含被调用者所在的头文件;第二步,使用引出类定义对象或调用引出接口。
(1)编写include语句包含引出类所在的头文件。这里使用了相对路径的描述,指的是相对于pro中INCLUDEPATH配置项中的目录。在ks03_01.pro中并未单独配置INCLUDEPATH,INCLUDEPATH的值其实来自ks03_01.pro引用的project_base.pri文件。
(2)使用引出类定义对象或调用引出接口,见代码清单3-8中标号①、标号②、标号③处。
代码清单3-8
注意:本节介绍的方案用于开发静态链接的DLL。使用这种方案开发DLL时,在构建EXE时依赖DLL的lib文件(如a.lib);当EXE构建成功后,在EXE运行时仅仅依赖DLL本身(比如,在Windows上为a.dll,在Linux上可能为a.so.1.0.0),不再依赖DLL的lib文件。
有时候,在编译EXE时可能碰到链接错误。比如,在编译a.exe时,该模块依赖b.dll,并且在a.exe中调用了b.dll中的接口func(),那么就有可能碰到如下的链接错误。
以Windows平台为例,在a.exe项目进行编译时,因为a.exe需要依赖b.dll中的接口func(),所以需要链接b.dll对应的链接库文件b.lib。上述错误指的是,当执行到链接这一步骤时出现链接错误,编译器找不到b.lib文件,或者在b.lib文件中找不到func()这个接口。可能的原因如下。
- 未生成或编译器未找到b.lib。此时,应检查b.dll项目的pro文件,检查TEMPLATE配置项是否为lib。如果已经生成了b.lib,那么就要检查a.exe项目的pro中是否已经把b.lib所在目录添加到a.exe项目pro的QMAKE_LIBDIR配置项中。
- 在b.dll项目中,未引出接口func()。这时,需要将func()从b.dll项目引出。
- b.dll编译位数与a.exe不一致。比如,a.exe编译成64位,而b.dll被编译成32位,这样肯定无法链接成功。此时需要将两个项目按照相同的位数进行编译。
7.使用命名空间解决重名问题
下面要学习的案例对应的源代码目录:src/chapter03/ks03_02。本案例不依赖第三方类库。程序运行效果如图3-2所示。
图3-2 案例程序ks03_02运行效果
无论是进行项目研发还是产品研发,都不可避免会碰到重名问题:头文件名重名、模块名重名、类/结构体重名、接口重名、全局变量重名等。对于头文件名重名和模块名重名的情况,软件开发组织需要制定软件研发管理规范进行制度上的约束,而且还要建立专门的组织进行落地管理。解决类重名、接口重名、全局变量重名问题的方法也很简单:使用命名空间进行管理。下面将介绍如何在Qt开发中使用命名空间解决重名问题。
前面介绍了怎样开发一个DLL,下面在之前的基础上增加命名空间的使用。一般情况下只为DLL代码设置命名空间,不为EXE代码设置命名空间(当需要把EXE与DLL设置为同一个命名空间时除外)。对于某一个DLL项目,一般也只设置一个命名空间。那么,具体该怎样使用命名空间呢?本节的DLL仍然以src/base/basedll项目为例。使用命名空间进行管理一共分为以下两大步。
(1)在DLL中将代码写到命名空间中。
(2)在其他代码中使用命名空间中的类或接口。
下面进行详细介绍。
1)在DLL中将代码写到命名空间中
在DLL中使用命名空间的语法如下:
将命名空间内的代码写在{}内。请注意命名空间不是类定义,因此在{}结束后不写“;”。本案例使用ns_train作为DLL的命名空间。将DLL的h文件和cpp文件的对外引出类和接口写到命名空间ns_train中,见代码清单3-9中标号①处。在命名空间结束时不写“;”,见标号②处。建议软件开发组织建立专门的命名空间管理机构并发布《命名空间管理规范》,以便对新增命名空间进行审批、登记。软件开发组织应该只允许使用批准后的命名空间。
代码清单3-9
注意:命名空间的保护范围应该仅仅是需要引出的类或接口,因此需要把#include"xxx.h"语句排除在外。如果是类的前向声明,那么要区分对待:
- 如果不是该DLL中定义的类,需要把它排除在命名空间之外。
- 如果是该DLL中定义的类,需要把它包含到命名空间之内。
在DLL的cpp文件中用同样的方式把代码写到命名空间里,如代码清单3-10所示。
代码清单3-10
2)在代码中使用命名空间中的类或接口
在EXE或其他DLL中使用basedll中定义的类或接口时,需要使用命名空间,见代码清单3-11。在标号①、标号②处采用了命名空间的语法,即“命名空间名称::类名”“命名空间名称::接口名”的写法。
代码清单3-11
8.使用命名空间的注意事项
目前为止,已在DLL中定义了命名空间并在EXE中使用了DLL中的引出类、引出接口。下面介绍几点注意事项。
1)不在头文件中使用using namespace xxx这种代码
在头文件中使用using namespace xxx的代码可能导致命名空间污染。使用命名空间的示意代码见代码清单3-12。
代码清单3-12
推荐使用标号①处的写法,不推荐标号②处的写法。采用标号②处的写法时,虽然标号③处用CClassInNameSpace定义对象printObject时可以不写“ns_train::”了,但是,如果在同一个文件中包含的其他头文件(属于别的DLL)中存在另一个叫CClassInNameSpace(类名相同)的类时,就会有问题了。所以,建议采用“ns_train::CClassInNameSpace printObject”来定义变量的写法。
2)当需要为EXE项目设置命名空间时,不要把main()函数放到命名空间里
有时候EXE和DLL同属一个大项目,为了方便调用DLL中的类,就会把EXE项目的代码也设置到跟DLL相同的命名空间中。这种情况下应该把main()函数排除在外,否则编译器会认为main()函数属于命名空间,而不会把它当作正常的main()函数入口。因为正常的main()函数入口应该是全局的,所以会导致编译错误。代码清单3-13是无法编译通过的,需要把main()函数从命名空间的范围中排除才行。
代码清单3-13
3)用了命名空间也不是一劳永逸
软件开发组织应制定软件研发管理制度并且严格执行。比如,制定《命名空间管理规范》,规定对外引出的类或接口必须提供命名空间保护、命名空间的名称需要提请相关机构审核等。在规范的软件研发活动中,使用命名空间进行管理是最基础的工作,因为这会避免很多不必要的问题。即使认为目前开发的类不会跟别人重名,也应该从一开始就养成使用命名空间的良好习惯。因为良好的习惯会潜移默化地影响软件研发活动,对软件研发人员的未来之路肯定会产生有益的影响。