文章摘要
GoodBoyboyGPT
本文介绍了编译过程中的预处理、编译、汇编和链接四个步骤,以及目标文件、符号管理等相关内容。强调了在链接过程中对地址引用的重要性,讨论了符号的定义和区分,以及弱符号与强符号之间的关系。文章还提到了外部目标文件的符号引用问题,并探讨了 C++ 与 C 的兼容性。
此内容根据文章生成,并经过人工审核,仅用于文章内容的解释与总结

前言

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

编译过程

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

预处理(预编译)(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