跨平台使用静态库和共享库的最佳实践

本文详细讨论了在 windows 和 linux 平台使用不同编译器以及 cmake 中关于使用共享库和静态库的相关内容

后文提到了如下几个编译器:

  • linux-gcc 就是 linux 下最常用的编译器了
  • windows-msvc-cl 指 windows 的官方编译器,VS 中默认使用它,其中 cl 是命令行中的编译器命令
  • windows-mingw-gcc 指 windows 下开源的 mingw-64 gcc 编译器实现,某些 windows IDE 会内置这种编译器,比如 CLion 或 QTCreator
    • 绝大多数默认行为与 linux-gcc 保持一致,但不可将二者完全是为行为一致
  • clang 编译器用的少,不熟悉,暂不讨论

共享库符号导出

符号导出是接触 windows 共享库开发之后才了解的概念,以前一直在 linux 下开发,linux 的默认行为比较开放,不特殊设置就会执行导出符号的行为,所以没意识到过这个问题

之所以要有符号导出,是基于共享库的原理做出的设计,共享库文件是在可执行程序运行时才加载的,这有多个好处:

  • 减小了可执行程序文件的大小
  • 共享库可以加载一次,然后在多个可执行程序进程间共享,减少了内存占用
  • 减小更新包大小,当只有共享库的代码改动时,只需要重新编译更新共享库即可应用新代码,不用重新编译整个项目

有这么些好处,那符号导出这个操作到底意味着什么?是个什么原理呢?

注意:处理导入导出符号是编译过程中链接阶段链接器的工作,这里为了方便起见,后文仍然将其视为编译器的功能,以便行文流畅思路清晰

大体上来说是把共享库里被导出的符号与其对应的代码实现拆分,先让可执行程序只拿到其需要的符号,这样就足够完成编译过程,生成可执行程序文件了,然后在运行时把符号对应的代码实现填充给可执行程序,如此一来可执行程序就完整了,可以正常运行了。有点类似于 C 语言中的 extern 关键字的作用,先用 extern 声明一个符号临时占位拿来用着,而这个符号对应的定义却在其他地方。

详细一点的解释是:windows-msvc-cl/windows-mingw-gcc 编译器把共享库已导出的符号收集起来放到一个文件里,这个文件被称之为“导入库”,而把符号对应的代码实现放到另一个文件里,这个文件才是“真正意义上的共享库”,在程序编译期间,编译器只需要拿到“导入库”即可完成编译过程,将导入库里被使用了的符号存进编译好的可执行程序里,在启动可执行程序时,操作系统中的动态链接器(注意不是编译过程中的链接器)负责寻找并把可执行程序依赖的共享库文件加载过来,把共享库中的代码实现跟可执行程序里那些占位符号对应好,至此可执行程序在起来之后,就可以执行共享库里符号对应的代码了。

注意:上面的详细解释之所以只提到了 windows 下的两个编译器而不提 linux-gcc,是因为 linux 系统的 gcc 编译器和动态链接器(ld.so)对共享库文件结构的设计与 windows 不同,linux 下不需要把导出的符号拆分到单独的另一个文件里(导入库),只是一股脑将共享库所有内容存到一个共享库文件中,编译器在编译过程中会从共享库文件里寻找可执行程序需要的符号,找到后就可以生成可执行程序,然后就与 windows 一样了,程序启动时加载共享库文件,装载符号对应的代码实现,进而运行程序。

另外共享库的导入库并不是必须的,在没有共享库的导入库时,windows 系统会尝试直接从共享库中解析导出的符号,但这种方式并不可靠,不推荐这样做。

共享库符号导入导出默认行为

  • linux-gcc:默认导出符号
  • windows-msvc-cl:默认不导出符号,需要导入导出控制声明,来告诉编译器哪些符号是需要导出的
  • windows-mingw-gcc:比较特殊
    • 默认情况下与 linux-mingw-gcc 一样会导出所有符号,但如果有任何一个符号带有了 windows 风格的导入导出控制声明,那么所有需要导出的符号都需要此声明
    • 也就是说,如果不明确得手动控制符号导入导出,那么它就执行 linux-gcc 的默认行为,否则它就变得像 windows-msvc-cl 一样
    • 这个设计还挺有趣,将行为表现的选择权交给用户,也刚好可以满足习惯了 windows 风格的用户

共享库符号导入导出控制声明

  • linux-gcc:
    • 导出声明:__attribute__ ((visibility ("default"))),写不写都行,linux-gcc 编译器默认导出所有符号
    • 导入声明:无
    • 不导出声明:__attribute__ ((visibility ("hidden"))),告诉编译器这个符号不要导出
  • windows-msvc-cl:
    • 导出声明:__declspec(dllexport),告诉编译器此符号需要导出
    • 导入声明:__declspec(dllimport),这个声明是给 include 了共享库头文件的可执行程序使用的
    • 不导出声明:不写任何控制声明即可
      • 如果一个共享库所有符号都不导出,这对开发共享库来说是没有意义的,因为这意味着没有可以执行程序可以使用这个共享库(除非搞了一些奇技淫巧)
      • 而且一个共享库所有符号都不导出,编译器有可能不会生成共享库的导入库的(.lib 文件),这可能会导致编译可执行程序时报错,提示找不到共享库的导入库
  • windows-mingw-gcc:两者都支持,具体行为参考上一小节:“共享库符号导入导出默认行为”

很显然,这个导入导出控制声明有点长,编写起来比较费劲,而且 windows 下单独编译共享库,以及将共享库头文件与可执行程序一起编译时还需要写不同的控制声明,真是够麻烦,只能手写么?当然不是,我们可以使用宏定义加上宏条件来处理这个问题,一般情况下大多数代码都会编写如下内容来实现基于宏的跨平台导入导出控制声明,但请注意,这个例子是不够完善的,它只支持将共享库源码编译为共享库的情况,如果将同一份源码编译为静态库,再与可执行程序一起编译时,会遇到链接错误的问题,我们先来看看这个不完善的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef MY_API
#ifdef _WIN32
#ifdef BUILD_SHARED_LIBRARY
#define MY_API __declspec(dllexport)
#else
#define MY_API __declspec(dllimport)
#endif
#else
#define MY_API
#endif
#endif

MY_API void hello();

分析看看:

  • 首先判断如果未定义 MY_API 才执行具体的定义内容,这是为了防止 MY_API 宏重定义,很好理解
  • 接着判断了编译目标平台
    • 如果是 windows 则继续向下判断
      • 如果定义了宏 BUILD_SHARED_LIBRARY,就认为当前头文件是与共享库的源码一起编译的,所以定义 MY_API 为 dllexport,将 MY_API 修饰的共享库符号导出(未使用 MY_API 修饰的符号则不导出)
        • 这个 BUILD_SHARED_LIBRARY 宏是需要从外部指定给编译器的,比如命令行手动指定 -D 参数给编译器,或者在 IDE 的某个地方(预处理器)设置,亦或是 cmake 脚本中给这个共享库目标单独设置编译参数指定此宏
      • 否则就将 MY_API 定义为 dllimport,也就是认为当前头文件是与可执行程序的源码一起编译的,需要将共享库符号导入进来,所以应该使用 dllimport
    • 不是 windows 平台,就认为是 linux 平台,定义 MY_API 为空,也就是让编译器执行默认行为,导出全部符号
  • 最后是使用 MY_API 修饰了的要导出的函数

乍一看很完美,既兼容了 windows 和 linux 实现跨平台,又兼容分别编译共享库和可执行文件,简直太爽,少写了好多内容,代码里只需要无脑用 MY_API 修饰符号即可

是的,如果这份共享库的源码只期望用户编译为共享库,那么上述写法没问题,但如果用户要将此共享库源码编译为静态库,并将编译好的静态库供可执行程序链接使用,在编译可执行程序时就会遇到找不到 _imp_*** 的链接错误。我之所以开始仔细研究本文所讨论的内容,就是遇到了这个问题,这个问题又会扯出来不少内容,我们到下个小节继续看。

注意,这个问题只在 windows 平台下出现,本文中的两个 windows 编译器都会遇到,linux 平台则没问题,因为它的 MY_API 宏定义始终是空

将共享库源码编译为静态库时遇到的问题

前面提到在 windows 下,把使用上面 MY_API 宏定义的共享库源码编译为静态库后,在编译使用此静态库的可执行程序时会遇到找不到 _imp_*** 的链接错误,根本原因是在编译可执行程序时 MY_API 宏定义为 dllimport 导致的,我们详细分析下过程。

首先既然要将共享库源码编译为静态库,肯定就不再从外部指定 BUILD_SHARED_LIBRARY 这个宏了,那么此时无论是单独编译静态库时,还是编译可执行程序时,这个头文件中定义的 MY_API 宏都会是 dllimport,但在编译静态库时,编译器忽略了 dllimport 声明,生成的静态库中不包括 _imp_*hello* 符号,而在编译可执行程序时,编译器仍然会根据头文件的声明,寻找 _imp_*hello* 的符号和对应实现以供可执行程序编译链接使用,所以自然从静态库中找不到这样的符号,进而报错

编译可执行程序时具体的报错内容类似:

  • windows-msvc-cl
    • 报错:unresolved external symbol "__declspec(dllimport) void __cdecl hello(void)" (__imp_?hello@@YAXXZ) referenced in function main
  • windows-mingw-gcc
    • 报错:undefined reference to __imp__Z5hellov

简言之就是找不到符号,其实在编译可执行程序前,单独编译静态库时,在处理静态库的 cpp 文件的过程中,编译器已经对 dllimport 声明发出了警告:

  • windows-msvc-cl
    • 警告:warning C4273: 'hello': inconsistent dll linkage
    • 警告:note: see previous definition of 'hello(这里指头文件中的 hello 函数声明)
  • windows-mingw-gcc
    • 警告:redeclared without dllimport attribute: previous dllimport ignored

下面 windows-mingw-gcc 的警告比较直白:由于在 cpp 文件中定义的 hello 函数不包括 dllimport 声明,所以先前在 h 头文件中对 hello 函数的 dllimport 声明将被忽略

但 windows-msvc-cl 的警告无法直接看出编译器会对此警告做出怎样的处理,不过从最后链接失败的结果来看,其处理方法与 windows-mingw-gcc 一样,生成的静态库中不包括 _imp_* 的符号

了解一下静态库的工作原理会对理解这个问题起到很大帮助,下面的内容整理自 chatgpt:

静态库的工作原理和内部结构:

  • 静态库是一个包含多个目标文件的归档文件,这些目标文件通常是在编译源代码文件时生成的 .o 文件(Linux)或 .obj 文件(Windows)(注:呃,可以理解为包含多个目标文件的压缩包)
  • 静态库中通常还包含符号表,用于确定在链接时哪些函数和变量将被提取和链接到可执行文件中
  • 当您将静态库链接到可执行程序或其他库中时,链接器会从静态库中提取所需的目标文件中的函数代码实现,并将它们合并到最终的可执行文件中,这意味着可执行文件将包含静态库中的代码和数据(注意,是直接包含了静态库的代码和数据,所以以后运行可执行程序时就不需要静态库了)
  • 静态库中的函数声明通常是在头文件中,而函数定义则在库的目标文件(.o 或 .obj)中

最后一句是关键,静态库中包含的是函数的定义,而不是声明,而前面导入导出控制声明是在头文件中的,cpp 实现文件中对 hello 函数的定义并不包括导入导出声明 dllimport,所以最终静态库文件中不包括 _imp_* 的符号

但编译可执行程序时,可执行程序代码使用的是静态库的头文件,其中 hello 函数包括导入导出控制声明 dllimport,所以可执行程序需要找 _imp_* 的符号,以便进而找到对应的函数定义链接进来,至此真相大白,你需要,我没有,所以就报错。

好了,说了这么多,那导入导出控制宏应该怎么写呢?其实明白原理之后怎么写都行,这里贴一份我的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef MY_API
#ifdef _WIN32
#if defined(BUILD_STATIC_LIBRARY) || defined(USE_STATIC_LIBRARY)
#define MY_API
#else
#ifdef BUILD_SHARED_LIBRARY
#define MY_API __declspec(dllexport)
#else
#define MY_API __declspec(dllimport)
#endif
#endif
#else
#define MY_API
#endif
#endif

MY_API void hello();

跟之前的相比在 windows 平台下新增了两个宏判断 BUILD_STATIC_LIBRARYUSE_STATIC_LIBRARY,用来确认是否要编译为静态库和是否要使用(链接)静态库,其实这两个宏判断做的事情是一样的,都是将 MY_API 宏定义为空,以便不声明 dllexport 和 dllimport,之所以要使用两个宏判断,是为了在语义上兼容 编译静态库使用静态库 两个场景(这两个场景面向的用户是不同的,只是用一个宏来判断的话,在语义上会比较奇怪):

  • BUILD_STATIC_LIBRARY 给库的开发者使用,用来将源码单独编译为静态库,然后将编译好的静态库文件和头文件交付给其他人,虽然在编译静态库时即便指定了 dllimport 也只是多点编译警告(前面分析了),不会导致最终的静态库不可用,但既然有办法能消除这种警告,我们又何乐而不为呢
  • USE_STATIC_LIBRARY 给可执行程序的开发者用,用来表示我想要链接静态库,静态库的头文件中不能使用 dllimport 声明

这两个新增的宏判断比共享库的宏判断 BUILD_SHARED_LIBRARY 优先级高,只有两个新增的 static 宏都未从外部指定时,才按照以往的惯例去判断是否要编译为共享库,是的话就认为是库的开发者要编译源码为共享库,需要声明 dllexport,否则就认为是可执行程序的开发者在使用这个头文件,而且要链接先前编译好的共享库,所以声明 dllimport

总而言之,在上述定义下,如果要编译或使用(链接)静态库,就需要手动从外部给编译器指定宏 BUILD_STATIC_LIBRARYUSE_STATIC_LIBRARY

关于符号导入导出的内容到这里就结束了,后面讨论如何生成和使用共享库和静态库

关于生成库文件时常规命名规则

不同平台、不同编译器下静态库和共享库文件的常规命名规则是不同的

静态库常规命名规则

  • linux-gcc:libXXX.a
  • windows-mingw-gcc:libXXX.a(同上)
  • windows-msvc-cl:XXX.lib
    • 注意:没有前缀 lib

共享库常规命名规则

  • linux-gcc:libXXX.so
    • 注意:linux-gcc 没有导入库这种概念
  • windows-mingw-gcc:libXXX.dll.a 和 libXXX.dll,前者是共享库的导入库,后者是共享库本身
  • windows-msvc-cl:XXX.lib 和 XXX.dll,前者是共享库的导入库,后者是共享库本身
    • 注意:没有前缀 lib
    • 注意:这里的导入库命名规则与前面 windows-msvc-cl 的静态库相同

命令行生成并使用静态库和共享库

虽然各个编译器在命令行中手动执行编译操作时,都可以指定生成库文件的名字(后面会举几个手动生成库文件的例子),但在使用/链接库文件时却有默认的查找库文件规则,所以最好在手动生成时就使用各平台编译器的常规命名规则来指定名字

这里假设有如下源码文件:

1
2
3
4
5
6
7
8
9
project
├── mylib
│   ├── mylib.h
│   ├── mylib_src1.h
│   ├── mylib_src1.c
│   ├── mylib_src2.h
│   └── mylib_src2.c
└── exe
   └── exe_main.c

上面的目录结构仅作展示使用,为了方便编写,在下面的命令行例子中将假设所有源码文件都在同一个目录下

上述源码文件各自的内容如下:

mylib.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef MYLIB_H
#define MYLIB_H

// 定义导入导出控制声明的宏 MY_API
#ifndef MY_API
#ifdef _WIN32
#if defined(BUILD_STATIC_LIBRARY) || defined(USE_STATIC_LIBRARY)
#define MY_API
#else
#ifdef BUILD_SHARED_LIBRARY
#define MY_API __declspec(dllexport)
#else
#define MY_API __declspec(dllimport)
#endif
#endif
#else
#define MY_API
#endif
#endif

#endif

mylib_src1.h

1
2
3
4
5
6
7
8
9
#ifndef MYLIB_SRC1_H
#define MYLIB_SRC1_H

// 引入 mylib.h 以便使用导入导出控制声明宏 MY_API
#include "mylib.h"

MY_API void hello();

#endif

mylib_src1.c

1
2
3
4
5
6
#include "mylib_src1.h"
#include <stdio.h>

void hello() {
printf("Hello, World1\n");
}

mylib_src2.h

1
2
3
4
5
6
7
8
9
#ifndef MYLIB_SRC2_H
#define MYLIB_SRC2_H

// 引入 mylib.h 以便使用导入导出控制声明宏 MY_API
#include "mylib.h"

MY_API void hello2();

#endif

mylib_src2.c

1
2
3
4
5
6
#include "mylib_src2.h"
#include <stdio.h>

void hello2() {
printf("Hello, World2\n");
}

exe_main.c

1
2
3
4
5
6
7
#include "mylib_src1.h"
#include "mylib_src2.h"

int main(void) {
hello();
hello2();
}

linux-gcc 命令行生成并使用静态库

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 1. 生成目标文件
# 注意 -D 参数,表示增加定义预处理宏,具体参数含义参见前面介绍的导入导出控制声明部分,下面其他命令行中使用的 -D 参数也是如此
# 注意 -c 参数,表示只对指定源码文件执行编译和汇编过程,这样就可以生成 -o 参数指定的目标文件,以 .o 为后缀是目标文件的常规命名规则
# 注意:最后的源码文件只指定了一个 .c 文件,是因为一个源码文件只能对应一个目标文件,
# 且没有指定源码文件中的 .h 文件,是因为头文件是根据 .c 文件的 #include 自动推断的,不需要指定,也不能指定
# 最多可以使用 -I 参数指定编译器搜索头文件时的目录,这里假设源码都在一起,所以不需要指定 -I 参数
gcc -D BUILD_STATIC_LIBRARY=1 -c -o mylib_src1.o mylib_src1.c
gcc -D BUILD_STATIC_LIBRARY=1 -c -o mylib_src2.o mylib_src2.c

# 2. 生成静态库文件
# 前面提到静态库文件其实可以看作是一堆目标文件的打包归档,使用 ar 命令就可以完成这一操作,
# 生成的静态库文件名为 libmylib.a
ar rcs libmylib.a mylib_src1.o mylib_src2.o

# 3. 使用静态库文件
# 编译可执行程序,其使用/链接静态库文件
# 有如下两种使用方法:

# 3.1. 将静态库文件当作源码文件
gcc -D USE_STATIC_LIBRARY=1 -o exe exe_main.c libmylib.a

# 3.2. 静态链接静态库文件
# 注意 -static 参数,表示要执行静态链接,指定它将告诉链接器不要链接共享库,这在共享库和静态库文件同时存在,但想要使用静态库时有用,
# 因为后面的 -l 参数指定的库文件名不包含完整的名字,如果不指定 -static 参数,链接器将首选链接共享库,除非共享库不存在
# 注意 -L 参数,表示指定搜索库文件的目录,由于 linux 下当前目录不属于默认搜索目录,所以需要指定当前目录
# 注意 -l 参数,其用来指定要链接的库文件,指定的名字按照前文提到的常规命名规则,不需要 lib 前缀和 .a 后缀,只需要 mylib 即可,
# 而且 -l 参数最好放在命令行的最后面,否则会导致找不到符号的定义,这是链接器的工作原理导致的,具体原因这里不再展开说明
gcc -D USE_STATIC_LIBRARY=1 -static -o exe exe_main.c -L . -l mylib


# 4. 执行
./exe

# 预期输出为:
Hello, World1
Hello, World2

# 删除编译产物,以免影响后续测试
rm -rf exe *.a *.lib *.dll *.dll.a *.o *.so

linux-gcc 命令行生成并使用共享库

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 1. 生成目标文件
# 注意 -D 参数,表示增加定义预处理宏,具体参数含义参见前面介绍的导入导出控制声明部分,下面其他命令行中使用的 -D 参数也是如此
# 注意 -c 参数,表示只对指定源码文件执行编译和汇编过程,这样就可以生成 -o 参数指定的目标文件,以 .o 为后缀是目标文件的常规命名规则
# 注意 -fPIC 参数,表示生成位置无关的代码,要生成共享库就需要指定它,因为共享库中的代码需要支持从内存的任何位置被访问到
# 注意:最后的源码文件只指定了一个 .c 文件,是因为一个源码文件只能对应一个目标文件,
# 且没有指定源码文件中的 .h 文件,是因为头文件是根据 .c 文件的 #include 自动推断的,不需要指定,也不能指定
# 最多可以使用 -I 参数指定编译器搜索头文件时的目录,这里假设源码都在一起,所以不需要指定 -I 参数
gcc -D BUILD_SHARED_LIBRARY=1 -c -fPIC -o mylib_src1.o mylib_src1.c
gcc -D BUILD_SHARED_LIBRARY=1 -c -fPIC -o mylib_src2.o mylib_src2.c

# 2. 生成共享库文件
# 注意 -shared 参数,必须指定,因为它告诉编译器要生成的是共享库,而不是可执行程序
# 生成的共享库文件名为 libmylib.so
gcc -shared -o libmylib.so mylib_src1.o mylib_src2.o

# 3. 使用共享库文件
# 编译可执行程序,其使用/链接共享库文件
# 注意:这里没有使用 -D 参数指定宏,是因为这里要链接共享库,具体参见前面介绍的导入导出控制声明部分
# 注意 -L 参数,表示指定搜索库文件的目录,由于 linux 下当前目录不属于默认搜索目录,所以需要指定当前目录
# 注意 -l 参数,其用来指定要链接的库文件,指定的名字按照前文提到的常规命名规则,不需要 lib 前缀和 .so 后缀,只需要 mylib 即可,
# 而且 -l 参数最好放在命令行的最后面,否则会导致找不到符号的定义,这是链接器的工作原理导致的,具体原因这里不再展开说明
gcc -o exe exe_main.c -L . -l mylib


# 4. 执行
# 注意 LD_LIBRARY_PATH 环境变量,由于 linux 下当前目录不属于默认搜索目录,所以需要指定当前目录
LD_LIBRARY_PATH=. ./exe

# 预期输出为:
Hello, World1
Hello, World2

# 删除编译产物,以免影响后续测试
rm -rf exe *.a *.lib *.dll *.dll.a *.o *.so

生成共享库也可以跳过目标对象生成的步骤,直接从源码生成共享库文件,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 1. 生成共享库文件
# 注意 -D 参数,表示增加定义预处理宏,具体参数含义参见前面介绍的导入导出控制声明部分,下面其他命令行中使用的 -D 参数也是如此
# 注意 -shared 参数,必须指定,因为它告诉编译器要生成的是共享库,而不是可执行程序
# 注意 -fPIC 参数,表示生成位置无关的代码,要生成共享库就需要指定它,因为共享库中的代码需要支持从内存的任何位置被访问到
# 注意:没有指定源码文件中的 .h 文件,是因为头文件是根据 .c 文件的 #include 自动推断的,不需要指定
# 可以使用 -I 参数指定编译器搜索头文件时的目录,这里假设源码都在一起,所以不需要指定 -I 参数
# 生成的共享库文件名为 libmylib.so
gcc -D BUILD_SHARED_LIBRARY=1 -shared -fPIC -o libmylib.so mylib_src1.c mylib_src2.c

# 2. 使用共享库文件
# 编译可执行程序,其使用/链接共享库文件
# 注意:这里没有使用 -D 参数指定宏,是因为这里要链接共享库,具体参见前面介绍的导入导出控制声明部分
# 注意 -L 参数,表示指定搜索库文件的目录,由于 linux 下当前目录不属于默认搜索目录,所以需要指定当前目录
# 注意 -l 参数,其用来指定要链接的库文件,指定的名字按照前文提到的常规命名规则,不需要 lib 前缀和 .so 后缀,只需要 mylib 即可,
# 而且 -l 参数最好放在命令行的最后面,否则会导致找不到符号的定义,这是链接器的工作原理导致的,具体原因这里不再展开说明
gcc -o exe exe_main.c -L . -l mylib


# 4. 执行
# 注意 LD_LIBRARY_PATH 环境变量,由于 linux 下当前目录不属于默认搜索目录,所以需要指定当前目录
LD_LIBRARY_PATH=. ./exe

# 预期输出为:
Hello, World1
Hello, World2

# 删除编译产物,以免影响后续测试
rm -rf exe *.a *.lib *.dll *.dll.a *.o *.so

windows-msvc-cl 命令行生成并使用静态库

windows 下大多直接使用 VS IDE 上的界面操作设置生成为静态库,以及使用静态库文件,不过还是贴一下相关的命令行代码吧

代码:

1
2
3
4
5
6
7
8
9
10
11
# 具体命令参数就不解释了,参考 linux-gcc 吧,大同小异

cl /DBUILD_STATIC_LIBRARY=1 /c /Fo:mylib_src1.obj mylib_src1.c
cl /DBUILD_STATIC_LIBRARY=1 /c /Fo:mylib_src2.obj mylib_src2.c

lib /OUT:mylib.lib mylib_src1.obj mylib_src2.obj

cl /DUSE_STATIC_LIBRARY=1 /Fe:exe exe_main.c mylib.lib

# 执行:
exe

windows-msvc-cl 命令行生成并使用共享库

windows 下大多直接使用 VS IDE 上的界面操作设置生成为共享库,以及使用共享库文件和导入库文件,不过还是贴一下相关的命令行代码吧

代码:

1
2
3
4
5
6
7
8
9
10
11
# 具体命令参数就不解释了,参考 linux-gcc 吧,大同小异

cl /DBUILD_SHARED_LIBRARY=1 /c /Fo:mylib_src1.obj mylib_src1.c
cl /DBUILD_SHARED_LIBRARY=1 /c /Fo:mylib_src2.obj mylib_src2.c

link /DLL /OUT:mylib.dll mylib_src1.obj mylib_src2.obj

cl /Fe:exe exe_main.c mylib.lib

# 执行:
exe

windows-mingw-gcc 命令行生成并使用静态库

与 linux-gcc 命令行生成并使用静态库一样

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 具体命令参数就不解释了,参考 linux-gcc 吧,大同小异

# 1. 生成目标文件
gcc -D BUILD_STATIC_LIBRARY=1 -c -o mylib_src1.o mylib_src1.c
gcc -D BUILD_STATIC_LIBRARY=1 -c -o mylib_src2.o mylib_src2.c

# 2. 生成静态库文件
# 生成的静态库文件名为 libmylib.a
ar rcs libmylib.a mylib_src1.o mylib_src2.o

# 3. 使用静态库文件
# 编译可执行程序,其使用/链接静态库文件
# 有如下两种使用方法:

# 3.1. 将静态库文件当作源码文件
gcc -D USE_STATIC_LIBRARY=1 -o exe exe_main.c libmylib.a

# 3.2. 静态链接静态库文件
gcc -D USE_STATIC_LIBRARY=1 -static -o exe exe_main.c -L . -l mylib


# 4. 执行
./exe

# 预期输出为:
Hello, World1
Hello, World2

# 删除编译产物,以免影响后续测试
rm -rf exe *.a *.lib *.dll *.dll.a *.o *.so

windows-mingw-gcc 命令行生成并使用共享库

与 linux-gcc 命令行生成并使用共享库几乎一样,但共享库的文件后缀应该是 dll,而且应该生成共享库的导入库文件

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 具体命令参数就不解释了,参考 linux-gcc 吧,大同小异

# 1. 生成目标文件
gcc -D BUILD_SHARED_LIBRARY=1 -c -fPIC -o mylib_src1.o mylib_src1.c
gcc -D BUILD_SHARED_LIBRARY=1 -c -fPIC -o mylib_src2.o mylib_src2.c

# 2. 生成共享库文件
# 生成的共享库文件名为 libmylib.dll
# 注意:默认不生成共享库的导入库文件,参见 2.1 的方法生成导入库
gcc -shared -o libmylib.dll mylib_src1.o mylib_src2.o

# 2.1. 生成共享库文件和导入库文件
# 注意 -Wl 参数,用来指定需要生成共享库的导入库
# 生成的共享库文件名为 libmylib.dll
# 生成的共享库的导入库文件名为 libmylib.dll.a
gcc -shared -Wl,--out-implib,libmylib.dll.a -o libmylib.dll mylib_src1.o mylib_src2.o

# 3. 使用共享库文件
# 编译可执行程序,其使用/链接共享库文件
# 注意:不管有没有生成共享库的导入库都可以可执行程序编译成功,但推荐生成并自动使用导入库
gcc -o exe exe_main.c -L . -l mylib


# 4. 执行
./exe

# 预期输出为:
Hello, World1
Hello, World2

# 删除编译产物,以免影响后续测试
rm -rf exe *.a *.lib *.dll *.dll.a *.o *.so

生成共享库也可以跳过目标对象生成的步骤,直接生成共享库文件,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 具体命令参数就不解释了,参考 linux-gcc 吧,大同小异

# 1. 生成共享库文件
# 生成的共享库文件名为 libmylib.dll
# 注意:默认不生成共享库的导入库文件,参见 1.1 的方法生成导入库
gcc -D BUILD_SHARED_LIBRARY=1 -shared -fPIC -o libmylib.dll mylib_src1.c mylib_src2.c

# 1.1. 生成共享库文件和导入库文件
# 注意 -Wl 参数,用来指定需要生成共享库的导入库
# 生成的共享库文件名为 libmylib.dll
# 生成的共享库的导入库文件名为 libmylib.dll.a
gcc -D BUILD_SHARED_LIBRARY=1 -shared -Wl,--out-implib,libmylib.dll.a -fPIC -o libmylib.dll mylib_src1.c mylib_src2.c

# 2. 使用共享库文件
# 注意:不管有没有生成共享库的导入库都可以可执行程序编译成功,但推荐生成并自动使用导入库
gcc -o exe exe_main.c -L . -l mylib


# 4. 执行
./exe

# 预期输出为:
Hello, World1
Hello, World2

# 删除编译产物,以免影响后续测试
rm -rf exe *.a *.lib *.dll *.dll.a *.o *.so

cmake 生成并使用静态库和共享库

cmake 是跨平台的构建系统,当使用 cmake 时,具体用哪个编译器是推荐使用 cmake 的 -D 和 -G 参数指定的,比如:

1
2
3
4
5
6
7
# linux-gcc 或 windows-mingw-gcc
cmake -DCMAKE_C_COMPILER=/opt/bin/gcc -DCMAKE_CPP_COMPILER=/opt/bin/g++ ../

# windows-msvc-cl 比较特殊,一般不直接指定编译器,而是使用 -G 参数指定生成器为 VS
cmake -G "Visual Studio 15 2017" ../

# 除了 -G 参数,还有 -A -T 参数可以指定 windows-msvc-cl 编译器的具体细节,比如平台或工具集版本

由于其跨平台,所以后面将不再按照不同平台及编译器分别描述各个小节的内容,只在需要注意不同平台的差别时分别列出

cmake 生成静态库和共享库

静态库还是共享库

cmake 的 add_library 命令用于添加的库目标,至于要添加的库目标是静态库还是共享库,可以由这个命令的参数指定,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 静态库
add_library(mylib STATIC
mylib.h
mylib_src1.h
mylib_src1.c
mylib_src2.h
mylib_src2.c
)

# 共享库
add_library(mylib SHARED
mylib.h
mylib_src1.h
mylib_src1.c
mylib_src2.h
mylib_src2.c
)

但上面不是推荐做法,更好的做法是不在使用 add_library 时指定要添加的库目标是静态库还是共享库,比如:

1
2
3
4
5
6
7
8
# 静态库 或 共享库
add_library(mylib
mylib.h
mylib_src1.h
mylib_src1.c
mylib_src2.h
mylib_src2.c
)

当这样使用 add_library 时,cmake 默认将生成静态库

那这种情况下怎么控制生成静态库还是共享库呢?答案是 cmake 的选项或者说变量 BUILD_SHARED_LIBS(注意,不要与我们前面实现的导入导出控制声明中的 BUILD_SHARED_LIBRARY 混淆),这个选项是 cmake 为 add_library 准备的,一般可以在自己的 CMakeLists.txt 中使用如下命令添加此选项:

1
2
# 默认值为 OFF
option(BUILD_SHARED_LIBS "Enable build shared libs" OFF)

这样源码到底生成静态库还是共享库的选择权将交给用户,当不设置或设置这个选项为 OFF 时,则编译出静态库,否则编译出共享库:

1
2
3
4
5
6
7
# 静态库
cmake -DBUILD_SHARED_LIBS=OFF ../
或直接不设置
cmake ../

# 共享库
cmake -DBUILD_SHARED_LIBS=ON ../

结合导入导出控制声明宏

现在我们可以使用 cmake 的标准选项来获取到用户期望将库源码编译为静态库还是共享库,但我们在前面讨论过,共享库的源码如果没有经过特殊处理,是无法直接编译为静态库使用的,我们为此实现了一份自己的“导入导出控制声明宏”,具体内容参见“将共享库源码编译为静态库时遇到的问题”

所以仅仅知道 cmake 选项 BUILD_SHARED_LIBS 的值是什么还不够,cmake 中的可执行程序将无法直接使用静态库,要解决这个问题还需要让这个选项能够配合我们的导入导出控制声明宏,所以在项目的 CMakeList.txt 中添加如下内容,设置我们自己的条件宏,来最终确定代码里使用的导入导出控制宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 默认值为 OFF
option(BUILD_SHARED_LIBS "Enable build shared libs" OFF)

# 添加库目标 mylib
add_library(mylib
mylib*
)
# 根据 cmake 选项 BUILD_SHARED_LIBS 的值,设置我们的预编译宏,控制导入导出控制声明
if (BUILD_SHARED_LIBS)
target_compile_definitions(mylib PRIVATE -DBUILD_SHARED_LIBRARY)
else ()
target_compile_definitions(mylib PRIVATE -DBUILD_STATIC_LIBRARY)
endif ()

# 添加可执行程序目标
add_executable(exe
exe_main.cpp
)
# 根据 cmake 选项 BUILD_SHARED_LIBS 的值,设置我们的预编译宏,控制导入导出控制声明
if (BUILD_SHARED_LIBS)
# do nothing
else ()
target_compile_definitions(exe PRIVATE -DUSE_STATIC_LIBRARY)
endif ()

# 让可执行程序链接静态库或共享库,具体链接哪个由 cmake 自动判断(根据搜索优先级,详见下文)
target_link_libraries(exe PRIVATE
mylib
)

库文件的名字

现在我们知道了 cmake 如何控制生成静态库还是共享库,接下来看看如何控制生成的库文件的名字

让人感到欣喜的是 add_library 这个命令可以根据不同的运行/目标不同平台直接生成符合常规命名规则的库目标文件名字,也就是说我们不用特意判断各个平台,cmake 会帮助我们选择最合适的最终命名,我们需要做的只是指定一下库文件的“核心名字”,比如我要在 linux 平台生成名为 libmylib.a 的静态库,则直接设置 add_library 命令的目标名为 mylib 即可,不需要包含前缀 lib 和 后缀 a

至于不同平台下 cmake 到底都生成什么名字的库文件,参见我们前面讨论的“关于生成库文件时的常规命名规则”一节

如果我就是想控制生成库的名字该怎么做,虽然不建议,但 cmake 仍然提供了设置方法,我们可以通过设置 cmake 的几个变量来控制生成库文件的名字,这几个变量是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 静态库名字前缀
CMAKE_STATIC_LIBRARY_PREFIX
# 静态库名字后缀
CMAKE_STATIC_LIBRARY_SUFFIX

# 共享库名字前缀
CMAKE_SHARED_LIBRARY_PREFIX
# 共享库名字后缀
CMAKE_SHARED_LIBRARY_SUFFIX

# 共享库的导入库名字前缀
CMAKE_IMPORT_LIBRARY_PREFIX
# 共享库的导入库名字后缀
CMAKE_IMPORT_LIBRARY_SUFFIX

它们在不同平台/编译器下的默认值为:

  • linux-gcc
    • CMAKE_STATIC_LIBRARY_PREFIX=lib
    • CMAKE_STATIC_LIBRARY_SUFFIX=.a
    • CMAKE_SHARED_LIBRARY_PREFIX=lib
    • CMAKE_SHARED_LIBRARY_SUFFIX=.so
    • CMAKE_IMPORT_LIBRARY_PREFIX=
    • CMAKE_IMPORT_LIBRARY_SUFFIX=
  • windows-msvc-cl
    • CMAKE_STATIC_LIBRARY_PREFIX=
    • CMAKE_STATIC_LIBRARY_SUFFIX=.lib
    • CMAKE_SHARED_LIBRARY_PREFIX=
    • CMAKE_SHARED_LIBRARY_SUFFIX=.dll
    • CMAKE_IMPORT_LIBRARY_PREFIX=
    • CMAKE_IMPORT_LIBRARY_SUFFIX=.lib
  • windows-mingw-gcc
    • CMAKE_STATIC_LIBRARY_PREFIX=lib
    • CMAKE_STATIC_LIBRARY_SUFFIX=.a
    • CMAKE_SHARED_LIBRARY_PREFIX=lib
    • CMAKE_SHARED_LIBRARY_SUFFIX=.dll
    • CMAKE_IMPORT_LIBRARY_PREFIX=lib
    • CMAKE_IMPORT_LIBRARY_SUFFIX=.dll.a

可以看到它们的默认值就是我们在前面讨论的“关于生成库文件时的常规命名规则”

只需要将它们设置为你期望的内容,cmake 就会根据设置的值,生成你期望的库文件名字,不过我还是要再提一遍,不建议这样做

cmake 使用静态库和共享库

如果你是按照本文前面所讨论的推荐做法编写的 CMakeList.txt 以及导入导出控制声明相关的宏,那么让可执行程序使用静态库或共享库是非常简单的,只需要为可执行程序定义 cmake 的 target_link_libraries 命令即可,其他的什么都不用特殊判断,包括目标平台、编译器、静态库还是共享库,这些 cmake 都会自动处理,一切都那么自然而美好,具体做法参见“结合导入导出控制声明宏”小节

让 cmake 同时生成静态库和共享库

一般这种需求会出现在库的开发者那里,而不是库的使用者(即可执行程序开发者)那里,因为不可能让一个可执行程序同时链接静态库和共享库,除非想同时生成两个可执行程序,一个全部静态链接,一个全部动态链接

对于库的开发者来说,不建议这样做,根据上面的讨论,我们已经可以自由控制同一份库源码编译为静态库还是共享库,改一下 cmake 的 BUILD_SHARED_LIBS 选项重新编译一遍没什么难的

如果非要同时编译出静态库和共享库,那么就需要使用两个 cmake 的 add_library 命令,而且库目标名称不能相同,这意味着编译好的库文件名称破坏了“常规命名规则”,那么可执行程序的开发者将不得不特殊判断并分别设置使用静态库时要链接的库文件名字。而且不能再使用 cmake 的 BUILD_SHARED_LIBS 选项,从外部控制单独编译静态库或共享库

下面是一种可能的同时编译出静态库和共享库 cmake 方案:

1
2
3
4
5
6
7
8
9
10
11
# 添加静态库目标 mylib_static
add_library(mylib_static STATIC
mylib*
)
target_compile_definitions(mylib_static PRIVATE -DBUILD_STATIC_LIBRARY)

# 添加共享库目标 mylib_shared
add_library(mylib_shared STATIC
mylib*
)
target_compile_definitions(mylib_shared PRIVATE -DBUILD_SHARED_LIBRARY)

优先级问题

还有最后一个问题,当静态库和共享库同时存在,且都在编译器的库搜索路径内,那编译可执行程序时,命令行编译器或 cmake 会选择哪个库?

在命令行下,如果使用将静态库当作源文件的方式去编译可执行程序,就不存在这个问题,如果使用的是 -l 选项指定库名的方法,则本文讨论到的几个编译器默认都会优先使用共享库去链接,如果希望优先链接静态库,就指定 -static 选项(前面的命令行编译 demo 中有用到),这样编译器就不会再去使用共享库

cmake 默认也是优先选择共享库去链接,想改变这一行为,需要引入 cmake 的两个变量:

  • CMAKE_FIND_LIBRARY_PREFIXES
  • CMAKE_FIND_LIBRARY_SUFFIXES

它们的值是复数的,可以包含多个内容,前后缀的顺序就影响着 cmake 选择库文件的优先级,它们在各个平台/编译器下的默认值为:

  • linux-gcc
    • CMAKE_FIND_LIBRARY_PREFIXES=lib
    • CMAKE_FIND_LIBRARY_SUFFIXES=.so;.a
  • windows-msvc-cl
    • CMAKE_FIND_LIBRARY_PREFIXES=
    • CMAKE_FIND_LIBRARY_SUFFIXES=.lib
  • windows-mingw-gcc
    • CMAKE_FIND_LIBRARY_PREFIXES=lib;
    • CMAKE_FIND_LIBRARY_SUFFIXES=.dll.a;.a;.lib

根据需要设置调整前缀、后缀值的顺序即可

其中 windows-msvc-cl 的 CMAKE_FIND_LIBRARY_SUFFIXES 比较特殊,只有一个 .lib 后缀,这是因为 windows-msvc-cl 下的静态库和共享库的导入库常规命名规则都是以 .lib 结尾


版权声明:本文受版权保护,未经授权不得转载。