# 前言

本文为博主阅读《程序员的自我修养 —— 链接、装载与库》这本上古神书书后对自己需求部分的摘取以及来源于网络的资料笔记。

# 编译过程

事实上,编译可以分为四个步骤:

预处理(预编译)(Prepressing)、编译(Compilation)、汇编(Assembly)、链接(Linking)。

# 预编译

首先是将源代码文件和相关的头文件,如 stdio.h 等被预编译器预编译成一个 .i 文件。对于 C++ 来说预编译后的文件扩展名是 .ii 。通过以下命令进行预编译:

1
$gcc -E hello.c -o hello.i

其中 -E 表示只进行预编译。

或者:

1
$cpp hello.c > hello.i

预编译过程主要处理源代码文件中以 # 开始的预编译指令。例如 #include#define 等,主要的处理规则如下:

  • 将所有的 #define 删除,并展开所有的宏定义。
  • 处理所有的条件预编译指令,比如 #if#ifdef#elif#else#endif
  • 处理 #include 预编译指令,将包含的文件插入到该预编译指令的位置。注:整个过程是递归进行的,也就是说包含的文件可能还包含其他文件。
  • 删除所有注释 ///**/
  • 添加行号和文件名标识,例如 #2 "hello.c" 2 ,以便于编译时编译器产生调试用的行号信息以及用于编译时产生编译错误或警告时能够显示行号。
  • 保留所有的 #pragma 编译器指令,因为编译器须要使用它们。

# 编译

编译过程就是把预处理完成的文件进行一系列词法分析、语法分析、语义分析以及优化后生产相应的汇编代码文件。这个过程为整个程序构建的核心部分,也是最复杂的部分之一。使用下面命令可以进行编译过程

1
$gcc -S hello.i -o hello.s

现在版本的 GCC 把预编译和编译两个步骤合并成一个步骤,使用 cc1 程序完成这两个步骤,程序位于 /usr/lib/gcc/i486-linux-gnu/4.1/ (这是书上的,根据实际情况而定)(Linux 上有,Windows 上的 mingw64 没找到……):

1
$/usr/lib/gcc/i486-linux-gnu/4.1/cc1 hello.c

或者使用以下命令:

1
$gcc -S hello.c -o hello.s

以上两种命令都可以得到汇编输出文件 hello.s 。对于 C 语言来说,这个预编译和编译程序是 cc1 ,对于 C++ 来说,对应的程序为 cc1plus ;Objective-C 是 cc1obj ;fortran 是 f771 ;Java 是 jc1

所以实际上 gcc 这个命令只是对这些后台程序的包装,会根据不同的参数要求去调用预编译程序 cc1 、汇编器 as 、连接器 ld

# 汇编

汇编器是将汇编代码转变成机器可以执行的指令,每个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来说比较简单。汇编过程我们可以调用汇编器 as 来完成:

1
$as hello.s -o hello.o

或者:

1
$gcc -c hello.s -o hello.o

或者从 C 源代码开始:

1
$gcc -c hello.c -o hello.o

# 链接

人们把每个源代码模块独立地编译,然后按照须要将它们 “组装” 起来,这个模块组装的过程就是链接(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确的衔接。

链接的过程主要包括了 地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution)和重定位(Relocation) 等这些步骤。

# 目标文件

现在 PC 平台流行的 可执行文件格式(Executable) 主要是 Windows 下的 PE(Portable Executable) 和 Linux 的 ELF(Executable Linkable Format) ,它们都是 COFF(Common file format) 格式的变种。

目标文件就是源代码编译后但未进行链接的那些 中间文件(Windows 的 .obj 和 Linux 下的 .o 它跟可执行文件的内容与结构很相似,所以一般跟可执行文件一起采用一种格式存储。

从广义上来看目标文件和可执行文件的格式几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件。

不光是可执行文件, 动态链接库(DLL,Dynamic Linking Library) 以及 静态链接库(Static Linking Library) 文件都按照可执行文件格式存储。

平台 动态链接库 静态链接库
Windows .dll .lib
Linux .so .a

它们在 Windows 下都按照 PE-COFF 格式存储,Linux 下按照 ELF 格式存储。

# 符号

链接的过程本质就是要把多个不同的目标文件之间相互 “粘” 在一起。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。每个函数或变量都有自己独特的名字,避免链接过程中不同变量和函数之间的混淆。

在链接中,我们将函数和变量统称为 符号(Symbol) ,函数名或变量名就是 符号名(Symbol Name)

# extern “C”

C++ 为了与 C 兼容,在符号管理上,C++ 有一个用来声明或定义一个 C 的符号的 extern "C" 关键字用法:

1
2
3
4
extern "C"{
int func(int);
int var;
}

C++ 编译器会将在 extern "C" 的大括号内部的代码当做 C 语言代码处理。

注:但是 C 语言不支持 extern "C" 语法。

为了兼容 C++ 和 C,可以使用 C++ 的宏 __cplusplus ,C++ 编译器会在编译 C++ 的程序时默认定义这个宏,因此可以使用条件宏来判断当前编译单元是否为 C++ 代码。

1
2
3
4
5
6
7
#ifdef __cplusplus
extern "C"{
#endif
void *memset (void *,int,size_t);
#ifdef __cplusplus
}
#endif

# 弱符号与强符号

多个目标文件中含有相同名字全局符号的定义,那么这些目标文件链接的时候将会出现符号重复定义的错误。

这种符号的定义可以被称为 强符号(Strong Symbol) ,有些符号的定义可以被称为 弱符号(Weak Symbol)

对于 C/C++ 语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。

通过 GCC 的 __attribute__((weak)) 来定义任何一个强符号为弱符号。

链接器会按照如下规则处理和选择被多次定义的全局符号:

  1. 不允许强符号被多次定义,否则链接器报符号重复定义错误。
  2. 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
  3. 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。

# 弱引用和强引用

对外部目标文件的符号引用在目标文件最终被链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,连接器就会报符号未定义错误,则在被称为 强引用(Strong Reference) 。与之相对的还有 弱引用(Weak Reference)

在处理弱引用时,如果该符号未被定义,则链接器对于该引用不报错。一般对于未定义的弱引用,链接器默认其为 0,或者是一个特殊的值,以便于程序代码能够识别。

# 调试信息

目标文件内还可能保存有调试信息,用于调试程序。

在 GCC 编译时加上 -g 参数,编译器就会在产生的目标文件内加上调试信息。

在 Linux 下可以使用 strip 命令去掉 ELF 文件中的调试信息:

1
$strip foo

# 静态库链接

GCC 下:

1
gcc -o hello hello.c -L/path/to/library -lexample -static

MSVC 下:

1
cl /EHsc hello.c example.lib

或者:

1
cl main.c /EHsc /link /LIBPATH:C:\\path\\to\\libs mydll.lib

# 动态库链接

GCC 下:

1
gcc -o hello hello.c ./Lib.so

MSVC 下:

1
cl hello.c example.lib

或者:

1
cl main.c /link /LIBPATH:C:\\path\\to\\libs mydll.lib

Windows 下动态链接库的.lib 文件仅包含链接时用的符号信息,不包含目标文件。

# 静态库制作

首先编译为目标文件:

1
gcc -c hello.c hello1.c

然后使用 ar 工具进行打包:

1
ar rcs libhello.a hello.o hello1.o

Visual C++ 也提供了与 Linux 下的 ar 类似的工具,叫 lib.exe ,可以用来创建、提取、列举.lib 文件中的内容。

1
2
cl /c hello.c
lib hello.obj

# 动态库制作

# 编译命令

# GCC

1
gcc -fPIC -shared -o libexample.so example.c

-shared 表示产生共享对象

-fPIC 表示地址无关代码,

tips:如果代码不是地址无关的,它就不能被多个进程之间共享。

# MSVC

1
cl /LD hello.c

# 代码

# ELF

ELF 默认导出所有的全局符号

# DLL

静态库不需要使用此关键字

需要显式地告诉编译器需要导出的符号,否则编译器默认所有符号都不导出。

MSVC 编译器提供了一系列 C/C++ 的扩展来指定符号的导入导出,对于一些支持 Windows 平台的编译器比如 Intel C++、GCC Windows 版(mingw GCC、cygwin GCC)等都支持这种扩展。

我们可以通过 __declspec 属性关键字来修饰某个函数或者变量,当我们使用 __declspec(dllexport) 时,表示该符号是从本 DLL 导出的符号, __declspec(dllimport) 表示该符号是从别的 DLL 导入的符号。

在 C++ 中如果希望导入或者导出的符号符合 C 语言的符号修饰规范,那么必须在这个符号的定义之前加上 extern "C" ,以防止 C++ 编译器进行符号修饰。

除了使用 __declspec 扩展关键字指定导入导出的符号外,也可以使用 .def 文件来声明导入导出的符号。可以被当做 link 链接器的输入文件,用于控制链接过程。

.def 文件中的 IMPORT 或者 EXPORTS 段可以用来声明导入导出符号,这个方法不仅对 C/C++ 有效,对其他语言也有效。

使用 def 文件定义导出函数和使用 __declspec(dllexport) 导出,产生的导出符号是不太一样的,使用 dumpbin /exports jcdll.dll 查看导出符号,发现前者导出符号和函数名完全一样,后者会被编译器打乱一点,变成 ?add@@YAHHH@Z?subtract@@YAHHH@Z ,对于隐式链接来说无妨,编译器会处理,对于显式链接,也就是用 GetProcAddress 来获取函数指针时,要写 ?add@@YAHHH@Z 才能获取到函数指针。

例如:

1
cl hello.c /LD .DEF hello.def

hello.def 的文件内容:

1
2
3
4
5
LIBRARY hello
EXPORTS
Example1
Example2
...

# 显式运行时链接

# ELF(Linux)

支持动态链接的系统往往都支持一种更灵活的模块加载方式,叫做 显式运行时链接(Explicit Run-time Linking) 。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。

具体有 4 个函数: 打开动态库(dlopen)查找符号(dlsym)错误处理(dlerror) 以及 关闭动态库(dlclose) 。它们的声明和相关常量被定义在系统标准头文件 <dlfcn.h>

# dlopen()

dlopen () 函数用来打开一个动态库,并将其加载到进程的地址空间。完成初始化过程,它的 C 原型定义为:

1
void * dlopen(const char *filename, int flag);

第一个参数为被加载动态库的路径。如果将此参数设置为 0,那么 dlopen 返回的将是全局符号的句柄。

第二个参数 flag 表示函数的解析方式,常量 RTLD_LAZY 表示使用延迟绑定,当函数第一次被用到时才进行绑定,即 PLT 机制;而 RTLD_NOW 表示当模块被加载时即完成所有的函数绑定工作,如果有任何未定义的符号引用的绑定工作没法完成,dlopen () 将返回错误。以上两种方式必须二选一。常量 RTLD_GLOBAL 可以跟上面两者中任意一个一起使用,它表示将被加载的模块的全局符号合并到当前进程的全局符号表中,使得以后加载的模块可以使用这些符号。

# dlsym()

该函数为运行时链接的核心部分,通过这个函数找到所需要的符号。它的定义如下:

1
void * dlsym(void *handle, char *symbol);

第一个参数是由 dlopen () 返回的动态库句柄,第二个参数则为要查找的符号的名字,一个以 \0 结尾的 C 字符串。如果找到了相应的符号,则返回符号的值,如果没有找到相应的符号,,则返回 NULL。

如果查找的符号是函数,则返回函数的地址,如果查找的是变量,则返回变量的地址,如果查找的是常量,则返回该常量的值。

如果常量的值刚好为 NULL 或者 0,则需要搭配 dlerror () 函数进行判断,如果符号找到了,那么 dlerror () 返回 NULL,如果没找到,则会返回相应的错误信息。

# dlerror()

每次调用 dlopen ()、dlsym ()、dlclose () 后,都可以调用 dlerror () 函数来判断上一次调用是否成功。dlerror () 返回值类型为 char * ,如果调用成功则返回 NULL,如果调用失败则返回相应的错误信息。

# dlclose()

它的作用是将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用 dlopen () 加载某模块时,相应的计数器加一,每次使用 dlclose () 卸载某模块时,相应的计数器减一。只有当计数器值减到 0 时,模块才被真正的卸载掉。

# DLL(Windows)

与 ELF 类似,DLL 也支持运行时链接。Windows 提供了 3 个 API:

  • LoadLibrary(或者 LoadLibraryEX),这个函数用来装载一个 DLL 到进程的地址空间,它的功能跟 dlopen 类似。
  • GetProcAddress,用来查找某个符号的地址,与 dlsym 类似。
  • FreeLibrary,用来卸载某个已加载的模块,与 dlclose 类似。

# 共享库构造和析构函数

GCC 提供了一种共享库的构造函数,只要在函数声明时加上 __attribute__((constructor)) 的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会再共享库加载时被执行,即在程序的 main 函数之前执行。如果使用 dlopen () 打开共享库,共享库的构造函数会在 dlopen () 返回之前被执行。

与共享库构造函数相对应的是析构函数,可以在函数声明时加上 __attribute__((destructor)) 的属性,这种函数会在 main 函数执行完毕后执行(或者程序调用 exit () 时执行),如果共享库是运行时加载的,那么析构函数将会在 dlclose () 返回前执行。

__attribute__ 语法是 GCC 对 C 和 C++ 语言的扩展,在其他编译器上并不通用。

如果使用这种析构或者构造函数,则不可以使用 GCC 的 -nostartfiles-nostdlib 这两个参数。因为这些析构和构造函数是在系统默认的标准运行库或启动文件内被运行的,没有这些辅助结构,它们可能不会被运行。

# 常用开发工具命令行参考

# gcc,GCC 编译器

  • -E:只进行预处理 c: 只编译不链接。
  • -c:只编译不链接。
  • -o :指定输出文件名。
  • -S:输出编译后的汇编代码文件
  • -I:指定头文件路径。
  • -name:指定 name 为程序入口地址。
  • -freestanding:编译独立的程序,不会自动链接 C 运行库、启动文件等
  • -finline-functions,-fno-inline-functions:启用 / 关闭内联函数。
  • -g:在编译结果中加入调试信息,-ggdb 就是加入 GDB 调试器能够识别的格式。
  • -L :指定链接时查找路径,多个路径之间用冒号隔开。
  • -nostartfiles:不要链接启动文件,比如 crbegin.o。
  • -nostdlib:不要链接标准库文件,主要是 C 运行库。
  • -O0:关闭所有优化选项。
  • -shared:产生共享对象文件。
  • -static:使用静态链接。
  • -Wall:对源代码中的多数编译警告进行启用。
  • -fPIC:使用地址无关代码模式进行编译。
  • -fPFE:使用地址无关代码模式编译可执行文件。
  • -XLinker
  • -WI
  • -fomit-frame-pointer:禁止使用 EBP 作为函数帧指针。
  • -fho-builtin:禁止 GCC 编译器内置函数。
  • -fno-stack-protector:是指关闭堆栈保护功能。
  • -ffunction-sections:将每个函数编译到独立的代码段。
  • -fdata-sections:将全局 / 静态变量编译到独立的数据段。

# ld, GNU 链接器

  • -static:静态链接。
  • -l:指定链接某个库。
  • -e name:指定 name 为程序文口。
  • -r:合并目标文件,不进行最终链接。
  • -L:指定链接时查找路径,多个路径之间用冒号隔开。
  • -M:将链接时的符号和地址输出成一个映射文件。
  • -o:指定输出文件名。
  • -s:清除输出文件中的符号信息。
  • -S:清除输出文件中的调试信息。
  • -T :指定链接脚本文件。
  • -version-script :指定符号版本脚本文件。
  • -soname :指定输出共享库的 SONAME。
  • -expor-dynamic:将全局符号全部导出。
  • -verbose:链接时输出详细信息。
  • -rpath :指定链接时库查找路径。

# objdump GNU 目标文件可执行文件查看器

  • -a:列举.a 文件中所有的目标文件。
  • -b bfdname:指定 BFD 名。
  • -C:对于 C++ 符号名进行反修饰(Demangle)。
  • -g:显示调试信息。
  • -a:对包含机器指令的段进行反汇编。
  • -D:对所有的段进行反汇编。
  • -f:显示目标文件文件头。
  • -h:显示段表。
  • -l:显示行号信息。
  • -P:显示专有头部信息,具体内容取决于文件格式。
  • -r:显示重定位信息。
  • -R:显示动态链接重定位信息 1o
  • -s:显示文件所有内容。
  • -S:显示源代码和反汇编代码 (包含 - d 参数)。
  • -W:显示文件中包含有 DWARF 调试信息格式的段。
  • -t:显示文件中的符号表。
  • -T:显示动态链接符号表。
  • -x:显示文件的所有文件头。

# cl, MSVC 编译器

  • /c:只编译不链接。
  • /Za:禁止语言扩展。
  • /link:链接指定的模块或给链接器传递参数。
  • /Od:禁止优化。
  • /O2:以运行速度最快为目标优化。
  • /O1:以最节省空间为目标优化。
  • /GR 或 / GR-:开启或关闭 RTTI。
  • /Gy:开启函数级别链接。
  • /GS 或 / GS-:开启或关闭。
  • /Fa [file]:输出汇编文件。
  • /E:只进行预处理并且把结果偷出。
  • /I:指定头文件包含目录。
  • /Zi:启用调试信息。
  • /LD:编译产生 DLL 文件。
  • /LDd:编译产生 DLL 文件 (调试版)。
  • /MD:与动态多线程版本运行库 MSVCRT.LIB 链接。
  • /MDd:与调试版动态多线程版本运行库 MSVCRTD.LIB 链接。
  • /MT:与静态多线程版本运行库 LIBCMT.LIB 链接。
  • /MTd:与调试版静态多线程版本运行库 LIBCMTD.LIB 链接
  • /BASE:address:指定输出文件的基地址。
  • /DEBUG:输出调试模式版本。
  • /DEF:filename:指定模块定义文件.DEF。
  • /DEFAULTLIB:library:指定默认运行库。
  • /DLL:产生 DLL。
  • /ENTRY:symbol:指定程序入口。
  • /EXPORT:symbol:指定某个符号为导出符号。
  • /HEAP:指定默认堆大小。
  • /LIBPATH:dir:指定链接时库搜索路径。
  • /MAP [:filename]:产生链接 MAP 文件。
  • /NODEFAULTLB [:ibrary]:禁止默认运行库。
  • /OUT:filename:指定输出文件名。
  • /RELEASE:以发布版本产生输出文件。
  • /STACK:指定默认栈大小。
  • /SUBSYSTEM:指定子系统。

# dumpbin,MSVC 的 CoFF/PE 文件查看器

  • /ALL:显示所有信息
  • /ARCHIVEMEMBERS:显示.LIB 文件中所有目标文件列表。
  • /DEPENDENTS:显示文件的动态链接依赖关系。
  • /DIRECTIVES:显示链接器指示。
  • /DISASM:显示反汇编。
  • /EXPORTS:显示导出函数表。
  • /HEADERS:显示文件头。
  • /IMPORTS:显示导入函数表。
  • /LINENUMBERS:显示行号信息
  • /RELOCATIONS:显示重定位信息。
  • /SECTION:name :显示某个段。
  • /SECTION:显示文件概要信息。
  • /SYMBOLS:显示文件符号表。
  • /TLS:显示线程局部存储 TLS 信息。

# 参考

《程序员的自我修养 —— 链接、装载与库》

https://blog.csdn.net/JackDual/article/details/117259389

https://www.codenong.com/cs110455445/

https://blog.csdn.net/wohu1104/article/details/110789570