静态链接与动态链接通常与我们对最终可执行文件大小的容忍度有关。静态可执行文件包含运行可执行文件所需的所有代码,因此操作系统将可执行文件加载到内存中,这将取决于比赛。然而,如果我们一遍又一遍地重复代码,比如printf,那么它就会开始占用越来越多的空间。因此,动态可执行文件意味着我们只在可执行文件中存储存根。每当我们想要访问printf时,它都会转到一个动态链接器,并基本上按需加载代码。所以,我们牺牲一点速度来换取一个小得多的可执行文件。
当我听到有人说要把他们的程序编译成可执行文件时,他们实际上是在回避几个阶段。编辑的定义是将从不同来源收集的信息组合在一起产生某种东西。在计算中,我们通常认为编译是将高级语言(如C)转换为低级代码(如汇编语言)。
得到可执行文件之前的最后一个阶段是链接阶段。这就是我们将所有源代码链接在一起(由此得名)以产生一个连贯的可执行文件的地方。这也是所有突出符号需要解决的地方。符号只是函数或全局变量的名称。
我们可以看一下目标代码,这是我们在汇编之后但在链接之前得到的代码。下面是一个示例程序的对象转储。
#include<;stdio.h>;#include<;stdlib.h>;void ome_func(int a,int b);int main(int argc,char*argv[]){if(argc<;3){printf(";没有足够的参数。\n";);return-1;}int a=atoi(argv[1]);int b=atoi(argv[1])。
请注意,函数SOME_FUNC已经原型化,但尚未定义。这将由链接器负责找到符号SOME_FUNC并将其添加到我们的程序中。请注意,当我尝试在没有定义某些_func的情况下链接该程序时会发生什么。
/opt/riscv_1/lib/gcc/riscv64-unknown-linux-gnu/9.2.0/../riscv64-unknown-linux-gnu/bin/ld:/tmp/ccmgFc75.o:在函数.L2';中:test.c:(.text+0x62):未定义对某些_func';集合的引用2:错误:ld返回1个退出状态。
链接器正在查找符号SOME_FUNC,但是找不到它,因此我们得到了一个未定义的引用。我们知道这是在链接器阶段,因为错误是“ld Returned 1 Exit Status”。“ld”的意思是“链接器”。
我们还可以看到函数main的地址是0,这是因为我们没有链接程序。因此,我们的目标代码只包含代码气泡,然后这些代码将由链接器放入我们的可执行文件中的特定位置。
如果我们使用nm命令,该命令用于列出目标文件中的符号,我们可以看到所有未解析的符号。我们的目标代码正在寻找它们,但是在我们拥有一个完整的可执行程序之前,它不需要知道它们在哪里。
您可以看到,在此对象文件中,链接器有一些工作要做。它必须找到ATOI、PUT和SOME_FUNC,对于未定义的符号,它们被标记为U。当我们执行链接器时,我们将指定某些库,例如-lc(C库),将找到这些符号中的大多数。我们的某些_func从未被定义过,所以我们的链接器只有在我们在某个地方定义它之后才能真正成功。
存档文件通常以.a结尾,并包含将添加到最终可执行文件中的代码和其他信息。从本质上讲,存档文件只是将目标文件集合到一个文件中。因此,当我们链接到存档文件时,我们将实际代码从目标代码提取到我们的可执行文件中。存档文件的好处在于,我们不需要将所有符号都添加到可执行文件中-我们只需要那些需要使用的符号。
通常,我们有所有库的存档版本,以支持静态可执行文件。这些可执行文件不需要任何额外加载即可运行。换句话说,这些是自包含的文件。链接器阶段将代码直接从.a文件拉入到可执行文件中,然后一切都完成了。链接器引入的代码越多,可执行文件就越大。
这非常简单,但重点是要有一些链接器可以放入的代码。回想一下,链接器给了我们一个“未定义的引用”错误,因为我们没有定义一些_func。现在,我们已经定义了它,让我们看看会发生什么。
请注意,我仍在使用GCC。这将自动调用链接器并拉入必要的启动文件。如果我们直接调用链接器,我们必须定义_start并告诉程序如何启动,否则我们必须直接指定启动文件。
我指定-static,这样GCC将只拉入.a文件。当我们完成后,文件测试将是一个完全包含的静态可执行文件。如果我们看一下这个文件,我们会发现这个可执行文件需要的所有“东西”都使它成为一个相当大的文件。
是的,也就是4,687,920字节,约合4.7兆字节。但是,如果我们查看符号表,我们会发现没有未解析的符号,因此这是一个自包含的可执行文件。如果我们用精灵加载器加载它,就不需要引入任何外部资源。
我们的链接器必须详尽地遵循每条可能的路线,并引入这些符号,即使它们可能永远不会被调用。我们可以看到,由于所有调用和全局变量(如errno),符号表非常庞大。
00000000000101b0 T ABORT 0000000006bb38 S__ABORT_msg 0000000000029aa8 t add_alias2.isra.0.part.0 000000000497c6 t add_fdes 00000000000297ea t add_module e.isra.0 000000000003aa4e t add_name_to_object.isra.0 000000000003ab5c t add_path.。_dtv 000000000003a40c T__alloc_dir 000000000006ca38 b ANY_OBJECTS_REGIS。
动态库以.so结尾,它代表共享对象。这些库包含不会直接添加到可执行文件中的代码。相反,称为动态链接器的程序将负责从.so文件获取代码并将其添加到执行程序中。我们还可以使用-ldl(动态链接器)库自己添加符号。
我们可以将术语动态视为运行时。也就是说,在程序实际运行之前,我们不会将代码实际加载到程序中。我们可以通过像printf这样简单的东西看到这一点。我们可以检查我们的可执行文件,但是没有看到printf的代码。相反,我们看到的是printf的存根。然后,当程序执行时,该存根将被动态加载器替换。
当我们链接到动态的共享对象时,那些可以在运行时添加的符号将保持未解析状态。我们会把符号的名字放到一个表格里,这样我们就知道它就在某个地方。但是,使用共享对象,我们现在可以在运行时获得未解析的引用(或符号)!如果您有Arch-Linux,并且曾经自己编译过任何东西,那么您可能会遇到这种现象。
让我们继续将test2文件转换为共享对象。对GCC来说,这很容易做到:
Switch-FPIC代表与位置无关的代码。这意味着所有偏移量在库本身之外都不能是相对的,大多数共享库通常都是这种情况。在本例中,生成的代码将放入表中以查找偏移量。这是必需的,因为动态链接器可以将库或库中的特定符号加载到任何位置。
在上面的命令中,我使用-L、SO-L指定库搜索路径。表示在当前目录中查找。然后,我使用-ltest2指定要链接的库。GCC会自动添加前缀lib和附加.so,生成libtest2.so。
首先,我们在我们的库所在的位置编译。我们可以使用ldd命令查看这些共享库。
Smarz@DOMO:~$./test./test:加载共享库时出错:libtest2.so:无法打开共享对象文件:没有这样的文件或目录。
因此,当我运行我的程序时,它会查找某个路径来查找您的库。这类似于PATH环境变量,除了在Linux(和一些UNIX)中,我们使用LD_LIBRARY_PATH。如下所示,如果我更改路径以便动态链接器可以找到我的库,它将正常工作:
当我们链接程序时,许多符号将保持未解析状态。这告诉我们动态链接器负责从共享对象(库)加载哪些符号。我们可以使用nm命令看到这些未解析的符号:
您可以看到put是未解析的(大写U字母),还有一些_func,因为我们现在将它放在一个共享库中。这些符号具有存根,最终将调用解释器来加载该函数的实际代码。
当我们接近这些符号时,动态链接器的工作就是解析它们。事实上,我们可以查看生成的ELF可执行文件,以了解它希望使用什么来解析符号。
您可以看到INTERP部分(解释器),它按名称请求动态链接器。如果我们运行这个动态链接器,我们可以看到它的用途。
为了解析这些符号,链接器将在实际函数的位置放入一个加载器。当我们运行我们的程序时,在我们的程序中找不到某些_func的汇编指令。相反,当我们对某些_func进行函数调用时,它会将我们引向过程链接表(PLT)。然后,过程链接表将通过参考全局偏移表(GOT)来查找加载程序的位置。首先,GET中的地址是加载程序的代码(动态链接器)。在动态链接器加载一些函数代码之后,它会更改GET以反映它将一些函数放在内存中的位置。
如果我们看一下我们的程序中将要使用的函数和其他符号,我们会注意到它们非常、非常短,而且它们看起来几乎完全相同。这是因为我们有一个存根,它从过程链接表PLT加载程序。
我们的某些函数从-1028(T3)加载并跳转到该位置。t3的值是全局偏移表(GOT),我将在下面介绍。我们可以看到-1028(T3)是地址12018,它就在GOT的肉中。
因此,我们转到函数,该函数随后从全局偏移表加载,然后指向过程链接表。在此过程链接表中,如果该过程以前使用过,则该过程的代码(如PUT或SOME_FUNC)将加载到表中。否则,它将是一个存根函数,其工作是调用解释器并加载代码。
全局偏移表是一个很大的表,其中包含我们可以从中加载的偏移量。这是我们测试程序中的表格。
不要担心汇编指令-这些不是指令,但是objdump无论如何都会尝试反汇编它们。
注意,所有函数最初都指向0x0000_0000_0001_04a0,它返回到过程链接表。这是加载符号的解释器例程所在的位置。当符号由解释器加载时,它将修改全局偏移表以反映将其加载到内存中的位置。我们可以通过直接引用正在使用的内存来查看这一点。
#include<;stdio.h>;#include<;stdlib.h>;void ome_func(int a,int b);int main(int argc,char*argv[]){if(argc<;3){printf(";没有足够的参数。\n";);return-1;}int a=atoi(argv[1]);int b=atoi(argv[1])。0x%016lx=0x%016lx\n";,ptr,*ptr);Some_Func(a,b);printf(";0x%016lx=0x%016lx(Some_Func位于0x%016lx)\n";,Ptr,*Ptr,Some_Func);返回0;}
在我们修改后的程序中,我们查看0x12018,这是全局偏移表中我们的SOME_FUNC存根所在的位置(请记住,SOME_FUNC@PLT将我们引用到GOT中的这个位置)。
注意,首先全局偏移表指向0x104e0,这是用于调用解释器的代码,解释器随后加载符号。请注意,当我们加载SOMENT_FUNC时,全局偏移表已经使用内存地址0x400_0081_b460进行了更新。这就是我们的动态链接器加载某些_func函数的代码的地方。
自从我更新了函数后,一些偏移量不再是我们原始程序的偏移量。但是,为了向您展示某些_func函数本身指向过程链接表,让我们来看一下更新后的对象转储:
当我打印一些_func的内存地址时,我们得到0x10510,这就是它在过程链接表中的位置。但是,如果我们查看该表,会注意到它从全局偏移表中获取了实际函数的地址。在本例中,存根位于0x104e0。加载某些_func之后(在其第一次调用之后),更新全局偏移表以反映0x400081b460,这是某些_func的实际汇编指令所在的位置。
如果我们分解这里正在发生的事情,就不会有什么神奇的事情发生。对于动态符号,我们只有一个表来存储它们的地址。表的位置是固定的,因此我们可以将其直接编译到我们的程序中。但是,该表引用的是动态的,因此它可以由动态链接器l.so更新,无论它将我们的代码放在哪里。就这样!