一. 首先先回顾一下c文件里面的内容
要理解的首先是声明和定义(定义也是声明)
一个例子:
/ *这是未初始化的全局变量的定义* /
int x_global_uninit;
/ *这是初始化的全局变量的定义* /
int x_global_init = 1;
/ *这是未初始化的全局变量的定义,尽管
*只能在此C文件中通过名称访问的变量* /
static int y_global_uninit;
/ *这是一个初始化的全局变量的定义,尽管
*只能通过此C文件中的名称访问的变量* /
static int y_global_init = 2;
/ *这是一个全局变量的声明,该变量存在
于程序中*其他位置* /
extern int z_global;
/ *这是存在于其他地方的函数的声明
*程序(如果需要,可以预先添加“extern”,但
不是必需的)* /
int fn_a(int x,int y);
/ *这是一个函数的定义,但是因为它被标记为
*静态,所以只能单独在此C文件中通过名称引用* /
static int fn_b(int x)
{
return x + 1;
}
/ *这是一个函数的定义。* /
/ *函数参数为局部变量* /
int fn_c(int x_local)
{
/ *这是未初始化的局部变量的定义* /
int y_local_uninit;
/ *这是初始化的局部变量的定义* /
int y_local_init = 3;
/ *
通过名称引用局部和全局变量及其他*函数的代码* /
x_global_uninit = fn_a(x_local,x_global_init);
y_local_uninit = fn_a(x_local,y_local_init);
y_local_uninit + = fn_b(z_global);
return(y_global_uninit + y_local_uninit);
}
二. C编译器的作用
C编译器的工作就是将C文件从人类可以理解的文本转换成计算机可以理解的文本。编译器输出的文件成为目标文件,这些文件通常以.o为后缀(windows为.obj).
对象文件的内容本质上分为两种:
1)code:对应C文件里的函数定义
2)data: 对应C文件里的全局变量定义(对于已经初始化的全局变量,它的值也会被存储在目标文件中)
无论代码是代表一个变量或函数,只有在编译器以前曾见过该变量或函数的声明才被允许。该声明是一个承诺,承诺它的定义存在在整个程序的某个地方。而链接器的工作就是兑现这些承诺。
但是在编译器生成目标文件的时候如何处理这些承诺呢?编译器会在这些地方留下“空白”。这些空白(引用)具有与之关联的名称,但是还不知道名称对应的值。
解剖对象文件
#nm test.o
fn_a | | U | NOTYPE | | | * UND *
z_global | | U | NOTYPE | | | * UND *
fn_b | 00000000 | t | FUNC | 00000009 | | .text
x_global_init | 00000000 | D | 对象| 00000004 | | .data
y_global_uninit | 00000000 | b | 对象| 00000004 | | .bss
x_global_uninit | 00000004 | C | 对象| 00000004 | | * COM *
y_global_init | 00000004 | d | 对象| 00000004 | | .data
fn_c | 00000009 | T | FUNC | 00000055 | | .text
- U代表未定义的引用,即前面的"fn_a"和"z_global"
- t或T指示代码段的定义位置,t表示静态(static) T标识不是静态
- d或D表示已初始化的全局变量,局部(d)非局部(D)
- b(static)/B(non static)/C(non static)边上未初始化的全局变量
三.链接器的作用:第1部分
前面我们提到过,函数或变量的声明是对C编译器的承诺,承诺程序中的其他地方有该函数或变量的定义,链接程序的工作就是兑现该承诺。
为了说明这一点,让我们在添加一个的C文件:
/* 初始化的全局变量 */
int z_global = 11;
/*第二个全局名为y_global_init,但它们都是静态的 */
static int y_global_init = 2;
/* 声明另一个全局变量*/
extern int x_global_init;
int fn_a(int x, int y)
{
return(x+y);
}
int main(int argc, char *argv[])
{
const char *message = "Hello, world";
return fn_a(11,12);
}
通过这两个图,我们可以看到所有的点都可以连接在一起(如果不能连接,则链接器将发出错误消息)。
至于目标文件,我们可以nm用来检查生成的可执行文件:
Name Value Class Type Size Line Section
fn_b |08048348| t | FUNC|00000009| |.text
fn_c |08048351| T | FUNC|00000055| |.text
fn_a |080483a8| T | FUNC|0000000b| |.text
main |080483b3| T | FUNC|0000002c| |.text
x_global_init |080495b8| D | OBJECT|00000004| |.data
y_global_init |080495bc| d | OBJECT|00000004| |.data
z_global |080495c0| D | OBJECT|00000004| |.data
y_global_init |080495c4| d | OBJECT|00000004| |.data
y_global_uninit |080495cc| b | OBJECT|00000004| |.bss
x_global_uninit |080495d0| B | OBJECT|00000004| |.bss
这具有来自两个对象的所有符号,并且所有未定义的引用均已消失。这些符号也都进行了重新排序,以便将相似类型的事物放在一起
并且添加了一些附加功能,以帮助操作系统将整个事物作为可执行程序处理。(过滤了无关信息)
重复符号
如果链接时一个符号有两个定义怎么办?
在C++中情况比较简单,链接时,一个符号必须只有有一个准确的定义。发生上面这种情况是会编译不通过。
对于C情况没那么简单,虽然任何函数或已初始化的全局变量都必须有一个准确的定义,但未初始化的全局变量的定义可以视为临时定义。然后,C允许不同的源文件对同一对象进行暂定定义。
可以使用-fno-common编译器选项强制其将未初始化的变量放入BSS段中,而不是生成这些公共块。
操作系统的作用
现在,链接器已经生成了一个可执行程序,其中所有对符号的引用都与这些符号的适当定义结合在一起,我们需要暂时停顿一下以了解操作系统在运行程序时的作用。
运行程序需要执行机器代码,因此操作系统显然必须将机器代码从硬盘上的可执行文件传输到计算机的内存中,CPU可以在其中进行获取。程序存储器的这一块称为代码段或文本段。没有数据,代码也没有意义,因此所有全局变量也需要在计算机的内存中有一定的空间。
但是,已初始化和未初始化的全局变量之间存在差异。初始化变量具有开始时需要使用的特定值,并且这些值存储在目标文件和可执行文件中。程序启动时,操作系统会将这些值复制到数据段中的程序内存中。
对于未初始化的变量,操作系统可以假定它们都以初始值0开头,因此无需复制任何值。初始化为0的内存块称为bss segment。
这意味着可以将空间保存在磁盘上的可执行文件中。初始化变量的初始值必须存储在文件中,但是对于未初始化变量,我们只需要计算它们需要多少空间即可。
到目前为止,关于目标文件和链接器的所有讨论都只讨论了全局变量。没有提及前面提到的局部变量和动态分配的内存。
因为这些数据不需要链接程序的参与,因为它们的生存期仅在程序运行时才会发生。但是出于完整性考虑,我们可以在此快速过一下:
1)局部变量分配在一块称为栈的内存上,随着调用和完成不同的函数,内存 会不断增加和缩小
2)动态分配的内存是从称为“堆”的区域中获取的 ,malloc函数跟踪该区域中所有可用空间的位置。
因为堆栈朝一个方向增长而堆朝另一个方向增长。这样,仅当程序在中间相遇时程序才会用完内存(此时内存空间实际上将已满)。
四.连接器的作用:第2部分
(本节完全跳过了链接器的主要功能:重定位。具体介绍看:)
(如果存在同名的动态库和静态库,优先链接动态库)
静态库
在UNIX系统上,用于生成静态库的命令通常为ar,并且它生成的库文件通常具有.a扩展名。这些库文件通常也以“lib” 作为前缀,并通过“-l”选项传递给链接器,后跟库的名称,不带前缀或扩展名(因此,“-lfred”将选择“ libfred.a”)
当链接器遍历要连接在一起的对象文件集合时,它会建立一个尚未解析的符号列表。完成所有明确指定的对象后,链接器在库中查找在此未解析列表上保留的符号。如果未解析的符号在某个模板文件中被定义,则将该对象添加进去,就像用户首先在命令行上给了它一样,然后链接继续。
请注意从库中提取的内容的粒度:如果需要某些特定符号的定义,则将包含该符号定义的整个对象包括在内。这意味着该过程可以向前走一步,也可以向后退一步。新添加的对象可以解析一个未定义的引用,但是它可能附带了自己的一整套新的未定义的引用,供链接器解析。
一个例子:
假设我们有以下目标文件,以及一个链接行 a.,b.,-lx和 -ly。
连接器处理完a.o和b.o之后,将解析b2和a3的引用,剩下x12,y22没有定义。此时,链接器检查第一个库libx.a的符号,发现x1.o满足x12引用。但是这样做也把x23和y12添加到了未定义引用列表(所以现在列表有y22,x23和y12)。
此时链接器仍然在处理libx.a,所以x23也可通过从libx.a引入x2.o来满足引用。但是这也把y11添加到未定义引用列表(现在有y22,y12,y11)。他们都不能通过链接libx.a来解决,所以连接器开始链接liby.a。
链接器引入y1.o和y2.o。y1.o把y21加入未定义引用列表,该引用定义在y2.o中找到。这个时候所有未定义引用列表都找到定义了,并且库中某些对象已包含在最终可执行文件中。
请注意,如果b.o也引用y32,情况将有所不同。如果是这种情况,则链接libx.a将按相同的方式工作。但链接liby.a也将加入y3.0。x31将会被插入到未解析引用符号表中,并且链接将会失败。到此阶段,链接过程已经完成且找不到x31定义(在x3.o中)。
只有需要的目标文件才会被链接到。
动态库
对于像C标准库(通常是C libc)这样的流行静态库有一个明显的缺点,任何可执行文件都拥有一份相同的代码拷贝。如果每个可执行文件都拥有一份printf和fopen之类的代码拷贝,会占用非常多的非必须的磁盘空间。
另一个缺点就是一旦程序链接完成,代码就被永远固定。如果有人发现并修复了printf中的错误,则每个程序必须重新链接才能获得已修复的代码。
为了解决这些问题,引入了共享库(.so/dll)。对于动态库,普通的命令行链接器并不会链接所有的点,而是采用一种“我欠你(IOU)”这样的标签,并将借条的兑现延迟到程序实际运行的那一刻。
归结为:如果链接器发现特定符号的定义在共享库中,则最终的可执行文件中不包含该符号的定义,链接器在可执行文件中记录符号的名称和它来自哪个共享库。
当程序运行时,操作系统安排这些剩余的链接位及时完成,以使程序运行。在 main 函数开始之前,有一个小型的链接器——通常名为 ld.so,将负责检查贴过标签的内容,并完成链接的最后一个步骤:导入库里的代码,并将所有符号都关联在一起。
这意味着所有的可执行文件都没有printf的代码副本。如果有新版本的printf可用,则可以通过重新编译libc.so插入它。
与静态库相比,共享库的工作方式还要另一个很大的不同,这体现在链接的粒度上。如果从特定的共享库中提取一个符号(例如libc.so的printf),则整个共享库都将映射到程序的地址空间中。这与静态库的行为有很大的不同,静态库中只有包含未定义符号的特定对象才会被拉入。
换句话说,共享库本身是由于运行链接程序而产生的(而不是像ar那样形成大量的对象 ),并且解析了同一库中对象之间的引用。在上面的例子中,当在静态库版本中运行时,它将为单个目标文件生成结果集。但在动态库版本中,liby.so只有x31一个未定义未定义符号。而且如果b.o也引用y32,情况也不会有什么改变,因为y3.o和x3.o的内容反正都已经被拉入了。
更大的粒度的原因是因为现代操作系统足够聪明。除了可以节省静态库中的重复磁盘空间,使用同一共享库的不同正在运行的进程也可以共享代码段(但不能共享data / bss段)。为了做到这一点,整个共享库都映射到一个入口,使内部引用都排队到同一个地方。