• 程序的静态链接


    程序的静态链接

    程序的产生

    程序是由程序员编写,经过编译链接过程,最终能够在计算机中运行的东西。本质上来说编译链接过程其实就是将由人能看懂的代码段翻译成机器能看懂的代码段,然后指导机器的运行,比起程序在机器中被运行,博主更喜欢程序指导机器运行这样的说法。

    编译链接事实上分为4个过程:预编译、编译、汇编、链接,在这里我们笼统地将其分为两个过程:编译和链接,编译包含预编译、编译、汇编。

    编译是将程序员写的代码翻译成机器码,即机器能够解析的二进制码,生成二进制目标文件,既然编译过程已经生成了机器能够解析的二进制码,那为何还需要链接过程呢?

    在学生时代会经常写一些基于C语言的小程序,那时候习惯于将代码的实现和一些声明同时放在一个文件中,编译过程也很简单。

    随着编程水平的提高,自然要尝试写一些更复杂的程序,代码量逐渐增大,这时候就有必要将不同的实现部分放在不同的文件中,第一个是考虑到程序的可读性,再一个就是程序的移植和维护问题。这时候编译器就面临着一个问题:需要编译多个文件以生成一个可执行文件。

    在可执行文件中,每个符号(变量和函数)都将对应程序执行时的唯一地址,那么,在分开编译多个文件时,怎么解决不同文件中的符号地址定位问题:

    • 第一种方案可以是这样:将所有程序文件都作为一个整体,在编译前由编译器将所有源文件全部放到一起形成一个文件,将这个文件再进行编译。
    • 第二种方案可以是这样:所有的源文件分离编译,生成二进制目标文件,暂时不给每个文件中的符号确定执行时硬件地址,而是指定一个相应的逻辑偏移地址,最后再统一地再给所有编译时产生的目标文件中的符号分配实际执行时的地址。

    如果采用第一种方案,会有什么问题呢?

    • 编译时间上的问题:因为将所有源文件集中到一起,每次改动一点源文件都要全部重新编译,大型程序耗时太多。
    • 在引用第三方代码时,必须以源文件的方式引用,占用大量空间且不利于代码保护。
    • 程序将不支持动态扩展,每次添加功能都需要重新编译。
      ...

    毫无疑问,现代编译器采用的是第二种方案,分离式编译的灵活性完美地解决了第一种方案的缺陷所在,而最后为所有目标中符号分配地址的过程就是程序的链接,除此之外,链接将指定程序唯一的入口地址以及一些其它操作。

    目标文件的格式

    目标文件其实有着十分复杂的格式,内部的结构是以段来作区分,如代码段、数据段、BSS段,为了讲解方便,我们暂且只讨论目标文件中这三个必要的段。

    • 代码段:程序代码

    • 数据段:全局变量,静态变量

    • BSS段:程序中未初始化或者初始化为0的全局变量,特点是只在代码文件中占用一个符号位,节省空间,加载时在内存中展开。

    问题的开始

    链接的过程既然是将多个目标文件进行组合,那目标文件中的各个段是以什么样的方式进行组合?

    众所周知,程序是运行在内存中的,每一个函数每一个变量都会在内存中有相应的地址,在分离式编译中,每个源文件单独编译,生成的二进制目标文件中对符号(函数,变量)地址是怎么确认的?会不会有冲突,如果发生冲突是怎么解决的?

    了解编译流程的朋友都知道,在编译时,源文件如果引用了到本文件中没有定义的函数(变量),只要找到了这个函数(变量)的正确声明,编译对它的处理就是记录一个符号,表示这个函数(变量)本文件中无定义,但是编译过程继续,将符号处理的工作抛给链接器,那么链接器又是怎么处理这种符号引用的情况?

    链接器的作用

    对于链接器而言,主要就是解决上述的问题,链接器提供了三个操作:

    • 空间和地址分配
    • 符号解析
    • 重定位

    空间和地址的分配

    不妨想一想,如果给你一堆目标文件,里面包含代码段,数据段,BSS段,让你将它组合成一个文件,你将怎么做?

    最简单的办法就是叠加,一个文件紧跟着上一个文件存放,再使用一个总体的文件头来记录这些信息,这个文件头就像一个目录,可以索引到所有文件。

    这种方式当然是可以实现的,链接出来的程序在特定环境下也是可以运行的,但是回头一想,当二进制文件有很多个时,可执行文件中会存在成百上千个零散的段,程序执行的效率显著降低,而在空间上来说,对单片机而言还好,内存通常以字节或者4字节为单位,而在桌面系统中,例如X86电脑上,内存对其单位是页,一般为4096字节,当某个段仅有一个字节时,也会占用一页的空间,这对空间的浪费不言而喻。

    当然,不难想到的另一个方法就是将相似的段进行合并,这个看起来更可行,链接生成的可执行文件就只有三个连续的段,代码段、数据段、BSS段,实际应用中这种链接方式一直被沿用至今,事情看起来好像确实简单了很多,将多个文件的段糅合到一起,生成一个新的总段,至少解决了空间上的问题,地址的分配也好说,段与段之间相互叠加就可以了。

    符号解析和重定位

    多个目标文件合成一个目标文件时,地址分配是比较好解决的,但是当多个目标文件编译成一个可执行文件,就没那么简单了。

    我们来看下面的例子:

    有两个源文件:test1.c 和 test2.c,程序代码分别为:
    test1.c:

    void func1(void)
    {
        printf("hello world1
    ");
    }
    

    test2.c:

    void func2(void)
    {
        printf("hello world2
    ");
    }
    

    我们将这两个源文件编译成目标文件:

    gcc -c test1.c
    gcc -c test2.c
    

    分别生成了test1.o和test2.o,我们可以通过linux下的nm指令来查看目标文件中的符号表(nm的用法可以查看我另一篇博客):

    nm -n test1.o  
    

    输出:

                     U puts
    0000000000000000 T func1  
    

    在查看test2.o中符号表:

    nm -n test2.o  
    

    输出

                     U puts
    0000000000000000 T func2  
    

    (同时也可以使用objdump -h命令)

    简单解释一下,上述符号表就是在编译成二进制文件时函数和变量产生的对应的符号

    第一列是地址,第二列是当前目标所在段,第三列是对象。

    可以看到,在test1.c中,func1放在test段(即代码段),地址为0;

    在test2.c中,func2放在test段(即代码段),地址同样为0;

    那么问题来了,两个不同文件的目标函数地址都是0,我们都知道,在同一个程序中,函数在运行时需要在内存中确定唯一地址,如果程序同时引用这两个文件,这明显会产生地址冲突。

    这就是分离式编译带来的问题所在,每个源文件都彼此独立编译,在编译时编译器根本不知道这个源文件中的函数及变量将被加载到内存的何处。

    那就退而求其次,编译器假设这个源文件中的符号地址就是从某个地址(一般是0)开始,结果是每个目标文件编译出来都是在0地址处进行叠加放置,做完这些工作之后,编译器就事了拂身去,告诉链接器:符号相对的地址偏移我给你算好了,怎么去安排这些符号的实际内存我就不管啦!

    链接器只好接下这个烂摊子,收集好所有目标文件之后,开始一个个地为这些文件中的符号分配地址,对于这些符号的重新定址就被称为重定位。

    事实上编译器的偷懒行为并非这一个,很明显的,在分离式编译中,假如源文件a需要引用源文件b中的函数func,a中没有此函数的定义,通常就只有两个行为:

    • 编译器在编译a时,发现func没有定义,编译中断

    • 编译器在编译a时,发现func没有定义,但是有个函数声明,在这里做个记号,继续编译

    正确的处理方式当然是第二个,如果引用的每个函数都必须在本函数内有定义,那么分离编译的意义也就不存在了。

    结果就是,编译器遇到未定义的函数或变量,只要有相应声明,就记录下来,编译完成之后,同样告诉链接器:我把这些只有声明没有定义的函数和变量记录下来了,你帮我去找吧,找不找得到我不管啦!

    链接器如果找不到,就报出我们非常熟悉的错误:

        undefined reference to `XXX'
    

    没办法,编译器只好又接下这个烂摊子,对于这些符号的处理被称为符号解析或者说符号决议,主要是在其他文件中找到那些声明而在其他文件中定义的符号,并建立联系。

    单片机下的地址重定位

    对于单片机而言,程序直接操作物理地址,因为没有MMU(内存管理单元)且有其他资源的限制,不存在多进程的概念,重定位时直接将目标文件中的逻辑地址根据链接脚本的设置转换成物理地址,直接下载到flash中运行。

    桌面系统下的地址重定位

    在桌面系统中,情况就不一样了,由于MMU的存在,应用程序操作虚拟地址而非真实的物理地址,重定位的过程就是将目标文件中的逻辑地址根据链接脚本的设置转换成虚拟地址,当程序被加载进内存时,MMU动态地将虚拟地址映射到相应的物理内存。

    这篇文章旨在建立一个链接过程的概念,链接过程的细节且听下回分解。

    好了,关于程序的静态链接 的讨论就到此为止啦,如果朋友们对于这个有什么疑问或者发现有文章中有什么错误,欢迎留言

    原创博客,转载请注明出处!

    祝各位早日实现项目丛中过,bug不沾身.
    (完)

    链接器如果找不到,就报出我们非常熟悉的错误:

        undefined reference to `XXX'
    

    没办法,编译器只好又接下这个烂摊子,对于这些符号的处理被称为符号解析或者说符号决议。

    单片机下的地址重定位

    对于单片机而言,程序直接操作物理地址,因为没有MMU(内存管理单元)且有其他资源的限制,不存在多进程的概念,重定位时直接将目标文件中的逻辑地址根据链接脚本的设置转换成物理地址,直接下载到flash中运行。

    桌面系统下的地址重定位

    在桌面系统中,情况就不一样了,由于MMU的存在,应用程序操作虚拟地址而非真实的物理地址,重定位的过程就是将目标文件中的逻辑地址根据链接脚本的设置转换成虚拟地址,当程序被加载进内存时,MMU动态地将虚拟地址映射到相应的物理内存。

    这篇文章旨在建立一个链接过程的概念,链接过程的细节且听下回分解。

    好了,关于程序的静态链接 的讨论就到此为止啦,如果朋友们对于这个有什么疑问或者发现有文章中有什么错误,欢迎留言

    原创博客,转载请注明出处!

    祝各位早日实现项目丛中过,bug不沾身.
    (完)

  • 相关阅读:
    unity 反编译 step2 dll -->reflector
    unity 反编译 step1 disUnity
    rpg
    cmake使用
    linux mysqld的启动过程
    unity内存加载和释放
    Linux下MySql数据库常用操作
    MySQL主从复制与读写分离(非原创,谢绝膜拜)
    linux下IPTABLES配置详解
    linux下查看端口的占用情况
  • 原文地址:https://www.cnblogs.com/downey-blog/p/10480282.html
Copyright © 2020-2023  润新知