knightley对《程序员的自我修养》的笔记(14)

程序员的自我修养
  • 书名: 程序员的自我修养
  • 作者: 俞甲子/石凡/潘爱民
  • 副标题: 链接、装载与库
  • 页数: 459
  • 出版社: 电子工业出版社
  • 出版年: 2009-4
  • 第115页
    既然每个编译器都能将源代码编译成目标文件,那么有没有不同编译器编译出来的目标文件是不能够相互链接的呢?有没有可能将MSCV编译出来的目标文件和GCC编译出来的目标文件连接到一起,形成一个可执行文件呢?

    提出问题——一个好问题。提出问题至少说明是在思考。

    对于上面这些问题,首先我们想到的是,如果要将两个不同编译器的编译结果链接到一起,那么,首先连接器必须支持这两个编译器产生的目标文件的格式。比如MSVC编译器的目标文件是COFF/PE格式的,而GCC编译的结果是ELF格式的,那么连接器必须同时认识这两种格式才行,否则肯定没戏。那是不是连接器只要同时认识目标文件的格式就可以了呢?
    事实并不是我们想象的这么简单,如果要将两个编译器编译出来的目标文件能够相互链接,那么这两个目标文件必须满足下面这些条件:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等。其中,我们把符号修饰标准,变量内存布局、函数调用方式等这些跟可执行代码二进制兼容相关的内容称为ABI(Application Binary Interface)。

    解答上面提出来的问题。查资料,做实验来解决问题。

    ABI&API
    很多时候我们会碰到API(Application Programming Interface)这个概念,它与ABI只有一字之差,而且非常类似,很多人经常将它们的概念混淆。那么它们之间有什么区别呢?实际上它们都是所谓的应用程序接口,只是它们所描述的接口所在层面不一样。API往往指源代码级别的接口,比如我们可以说POSIX是一个API标准、Windows所规定的的应用程序接口是一个API;而ABI是二进制层面的接口,ABI的兼容程度要比API的更为严格,比如我们可以说C++的对象内存分布(Object Memory Layout)是C++ ABI的一部分。API更关注源代码层面的,比如POSIX规定printf()这个函数的原型,它能保证这个函数定义在所有遵循POSIX标准的系统之间都是一样的,但是它不保证printf在实际的每个系统中执行时,是否按照从右到左将参数压入堆栈,参数在堆栈中如何分布等这些实际运行时的二进制级别的问题。比如有两台机器,一台是Intel X86,另一台是MIPS的,他们都安装了Linux系统,由于Linux支持POSIX标准,所以他们的C运行库都应该有printf函数。但实际上printf在被调用过程中,这些关于参数和堆栈分布的细节在不同机器上肯定是不一样的,甚至调用printf的指令也是不一样的(x86是call指令,MIPS是jal指令),这就是说,API相同并不表示ABI相同。
    ABI的概念其实从开始至今一直存在,因为人们总是希望程序能够在不经任何修改的情况下得到重用,最好的情况是二进制的指令和数据能够不加修改地得到重用。人们始终在朝这个方向努力,但是由于现实的因素,二进制级别的重用还是很难实现。最大的问题之一就是各种硬件平台、编程语言、编译器、链接器和操作系统之间的ABI互相不兼容,由于ABI的不兼容,各个目标文件之间无法相互链接,二进制兼容性更加无从谈起。
    影响ABI的因素非常多,硬件、编程语言、编译器、链接器、操作系统等都会影响ABI。我们可以从C语言的角度来看一个编程语言是如何影响ABI的。对于C语言的目标代码来说,以下几个方面会决定目标文件之间是否二进制兼容:
    1.内置类型(如int、float、char等)的大小和在存储器中的放置方式(大端、小端、对齐方式等)。
    2.组合类型(如struct、union、数组等)的存储方式和内存分布。
    3.外部符号(external-linkage)与用户定义的符号之间的命名方式和解析方式,如函数名func在C语言的目标文件中是否被解析成外部符号_func。
    4.函数调用方式,比如参数入栈顺序、返回值如何保持等。
    5.堆栈的分布方式,比如参数和局部变量在堆栈里的位置,参数传递方法等。
    6.寄存器使用约定,函数调用时哪些寄存器可以修改,哪些需要保存,等等。
    当然这只是一部分因素,还有其他因素我们在此不一一列举了。到了C++的时代,。。。。。。

    上面只是通过编程语言这一个角度来说明问题的,实际问题确实太多了。

    2018-03-25 09:40:59 回应
  • 第90页
    C++为了与C兼容,在符号的管理上,C++有一个用来声明或定义一个C的符号的"extern "C""关键字的用法:
    extern "C" {
    int func(int);
    int var; }
    C++编译器会将在extern "C"的大括号内部的代码当作C语言代码处理。所以很明显,上面的代码中,C++的名称修饰机制将不会起作用。它声明了一个C的函数func,定义了一个整型全局变量var。从上文中我们得知,在Visual C++平台下会将C语言的符号进行修饰,所以上述代码中的func和var的修饰后符号分别是_func和_var;但是在linux版本的GCC编译器下却没有这种修饰,extern "C"里面的符号都为修饰后符号,即前面不用加下划线。如果单独声明某个函数或变量为C语言的符号,那么也可以使用如何格式:
    extern "C" int func(int);
    extern "C" int var;
    上面的代码声明了一个C语言的函数func和变量var。我们可以使用上述的机制来做一个小实验。。。。。。
    很多时候我们都会碰到有些头文件声明了一些C语言的函数和全局变量,但是这个头文件可能会被C语言代码或C++代码包含。比如很常见的,我们的C语言库函数中的string.h中声明了memset这个函数,它的原型如下:
    void *memset(void*, int, size_t);
    如果不加任何处理,当我们的C语言程序包含string.h的时候,并且用到了memset这个函数,编译器会将memset符号引用正确处理;但是在C++语言中,编译器会认为这个memset函数是一个C++函数,将memset的符号修饰成_Z6memsetPvii,这样连接器就无法与C语言库中的memset符号进行链接。所以对于C++来说,必须使用extern "C"来声明memset这个函数。但是C语言又不支持extern "C"语法,如果为了兼容C语言和C++语言定义两套头文件,未免过于麻烦。幸好我们有一种很好的方法可以解决上述问题,就是使用C++的宏“__cplusplus”,C++编译器会在编译C++的程序时默认定义这个宏,我们可以使用条件宏来判断当前编译单元是不是C++代码。具体代码如下:
    #ifdef __cplusplus
    extern "C" {
    #endif
    void *memset (void*, int, size_t);
    #ifdef __cplusplus
    }
    #endif
    如果当前编译单元是C++代码,那么memset会在extern "C"里面被声明;如果是C代码,就直接声明。上面这段代码中的技巧几乎在所有的系统头文件里面都被用到。

    extern "C"这个用法是笔试面试中会考到的地方。这一小节对extern "C"的知识点介绍的很详细,并且有例子说明。并且对于memset这个例子中extern "C"的用法,是当前各种系统头文件中的实际用法,很有说服力。

    2018-03-25 11:11:29 回应
  • 第127页
    链接控制脚本“程序”使用一种特殊的语言写成,即ld的链接脚本语言,这种语言并不复杂,只有为数不多的集中操作。
    。。。。。。(一般链接脚本名都以lds作为扩展名ld script)

    链接器ld自己的脚本语言,具体细节可以看该小节以及后面一个小节的介绍。

    2018-03-25 11:22:51 回应
  • 第190页
    7.3.3 地址无关代码
    那么什么是“-fPIC”呢?使用这个参数会有什么效果呢?
    装载时重定位是解决动态模块中有绝对地址引用的方法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Postion Independent Code)的技术。
    对于现代的机器来说,产生地址无关的代码并不麻烦。我们先来分析模块中各种类型的地址引用方式。这里我们把共享对象模块中的地址引用按照是否跨模块分为两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样我们就得到了如图7-4中的4种情况。
    下面开始分别介绍四种情况的PIC处理方法。。。。。。

    引入地址无关代码PIC的定义。然后开始分类介绍实现方法,具体见本小节后面的内容。

    2018-03-25 13:52:21 回应
  • 第232页
    Linux有一套规则来命名系统中的每一个共享库,它规定共享库的文件命名规则必须如下:
    libname.so.x.y.z
    最前面使用前缀“lib”、中间是库的名字和后缀“.so”,最后面跟着的是三个数字组成的版本号。“x”表示主版本号,“y”表示次版本号,“z”表示发布版本号。三个版本号的含义不一样。
    主版本号表示库的重大升级,不同主版本号的库之间是不兼容的。。。。。。
    次版本号表示库的增量升级,即增加一些新的接口符号,且保持原来的符号不变。。。。。。
    发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行更改。。。。。。

    介绍的很清楚。平时遇到各种库后面跟一堆数字,不知道具体含义,也没有想到过去查查看,这次算是弄明白了。

    2018-04-01 20:23:59 回应
  • 第234页
    那么以“SO-NAME”为名字建立软连接有什么用处呢?实际上这个软连接会指向目录中主版本号相同、次版本号和发布版本号最新的共享库。也就是说,比如目录中有两个共享库版本分别为:/lib/libfoo.so.2.6.1和/lib/libfoo.2.5.3,那么软连接/lib/libfoo.so.2会指向/lib/libfoo.2.6.1。这样保证了所有的以SO-NAME为名的软连接都指向系统中最新版的共享库。
    建立以SO-NAME为名字的软连接的目的是,使得所有依赖某个共享库的模块,在编译、链接和运行时,都是用共享库的SO-NAME,而不使用详细的版本号。我们在前面介绍动态链接文件中的“.dynamic”段时已经提到过,如果某文件A依赖于某文件B,那么A的“.dynamic”段中会有DT_NEED类型的字段,字段的值就是B。现在有一个问题是,这个字段值该如何表示B这个文件呢?如果保存的是B的文件名,即包含次版本号和发布版本号,那么会有什么问题呢?很直接的问题是,这个文件A只能依赖于某个特定版本的B。比如程序A依赖于C语言库,它在编译时,系统中存在的C语言库版本是/lib/libc-2.6.1.so,那么编译完成后,它的“.dynamic”中DT_NEED类型如果保存了/lib/libc-2.6.1.so。当系统将C语言库版本升级至2.6.2或2.7.1时,系统必须保留原来的2.6.1的共享库,否则这个程序A就无法正常运行。
    但是我们知道,因为根据Linux的共享库版本规定,实际上2.6.2或2.7.1版本的共享库是兼容2.6.1的,我们无须继续保留原来的2.6.1,否则系统中将遗留大量的各种版本的共享库,大大浪费了磁盘和内存空间。所以一个可行的方法就是编译输出ELF文件时,将被依赖的共享库的SO-NAME保存到“.dynamic”中,这样当动态连接器进行共享库依赖文件查找时,就会根据系统中各种共享库目录中的SO-NAME软连接自动定向到最新版本的共享库。比如。。。。。。
    当共享库进行升级的时候,如果只是进行增量升级,即保持主版本号不变,只改变次版本号或发布版本号,那么我们可以直接将新版的共享库替换掉旧版,并且修改SO-NAME的软连接指向新版本共享库,即可实现升级;当共享库的主版本号升级时,系统中就会存在多个SO-NAME,由于这些SO-NAME并不相同,所以已有的程序并不会受影响。
    Linux中提供了一个工具叫做“ldconfig”,当系统中安装或更新一个共享库时,就需要运行这个工具,它会遍历所有的默认共享库目录,比如/lib、/usr/lib等,然后更新所有的软连接,使它们指向最新版的共享库;如果安装了新的共享库,那么ldconfig会为其创建相应的软连接。

    知道了Linux系统中某些lib目录下软连接存在的意义了。嗯,读这部分内容,加深了我对linux系统中的一些现象的理解。不错。

    2018-04-01 21:00:04 回应
  • 第241页
    目前大多数包括Linux在内的开源操作系统都遵守一个叫做FHS(File Hierarchy Standard)的标准,这个标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用,这有利于促进各个开源操作系统之间的兼容性。共享库作为系统中重要的文件,他们的存放方式也被FHS列入了规定范围。FHS规定,一个系统中主要由3个存放共享库的位置,它们分别如下:
    /lib,这个位置主要存放系统最关键和基础的共享库,比如动态连接器、C语言运行库、数学库等,这些库主要是那些/bin和/sbin下的程序所要用到的库,还有系统启动时需要的库。
    /usr/lib,这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序和shell脚本直接用到。这个目录下面还包含了开发时可能会用到的静态库、目标文件等。
    /usr/local/lib,这个目录用来放置一些跟操作系统本身并不十分相关的库,主要是一些第三方的应用程序的库。比如我们在系统中安装了python语言的解释器,那么与它相关的共享库可能会被放到/usr/local/lib/python,而它的可执行文件可能被放到/usr/local/bin下。GNU的标准推荐第三方的程序应该默认将库安装到/usr/local/lib下。
    所以总体来看,/lib和/usr/lib是一些很常用的、成熟的,一般是系统本身所需要的库;而/usr/local/lib是非系统所需的第三方程序的共享库。

    工作中,多参照标准,这样团队合作会省去很多麻烦。

    2018-04-21 15:39:24 回应
  • 第243页
    。。。。。。动态连接器会按照下列顺序依次装载或查找共享对象(目标文件):
    1.由环境变量LD_LIBRARY_PATH指定的路径。
    2.由路径缓存文件/etc/ld.so.cache指定的路径。
    3.默认共享库目录,先/usr/lib,然后/lib。
    LD_LIBRARY_PATH对于共享库的开发和测试来说十分方便,但是它不应该被滥用。
    系统中另外还有一个环境变量叫做LD_PRELOAD,这个文件中我们可以指定预先装载的一些共享库或是目标文件。在LD_PRELOAD里面指定的文件会在动态连接器按照固定规则搜索共享库之前装载,它比LD_LIBRARY_PATH里面所指定的目录中的共享库还要优先。无论程序是否依赖于它们,LD_PRELOAD里面指定的共享库或目标文件都会被装载。

    LD_PRELOAD这个环境变量更厉害,不过正常情况下应该尽量避免使用。

    2018-04-21 15:47:54 回应
  • 第319页
    atexit函数也是一个特殊的函数。它接受一个函数指针作为参数,并保证在程序正常退出(只从main里返回或调用exit函数)时,这个函数指针指向的函数会被调用。

    Get到新东西。

    2018-04-21 16:11:56 回应
  • 第337页

    变长参数的实现得益于C语言默认的cdecl调用惯例的自右向左压栈传递方式。

    下面让我们来看va_list等宏应该如何实现。
    va_list实际是一个指针,用来指向各个不定参数。由于类型不明,因此这个va_list以void*或char*为最佳选择。
    va_start将va_list定义的指针指向函数的最后一个参数后面的位置,这个位置就是第一个不定参数。
    va_arg获取当前不定参数的值,并根据当前不定参数的大小将指针移向下一个参数。
    va_end将指针清0。

    2018-04-22 10:38:08 回应
<前页 1 2 后页>