X

曜彤.手记

随记,关于互联网技术、产品与创业

吉 ICP 备10004938号

链接、装载与库相关记录(二)


本文承接上文“链接、装载与库相关记录(一)”。

第七章:动态链接

  1. (Page:204)在静态链接情况下,可执行文件中包含着需要用到模块对应的目标文件副本。因此,多个可执行文件之间可能会有多份对同一个模块目标文件中段的副本,这在一定程度上浪费了磁盘和内存空间。同时静态链接对程序依赖模块的更新也不友好。每次依赖模块有更新时,都需要重新链接、发布整个程序。当依赖的模块越多时,程序的更新频率也会越快。
  2. (Page:205)当同一块物理内存被频繁访问时,该部分数据将可能被移至 CPU 缓存,以提高访问效率。
  3. (Page:206)Linux 下的动态链接文件被称为动态共享对象(DSO),一般以 “.so” 为扩展名。Windows 下通常以 “.dll” 为扩展名。
  4. (Page:207)当使用了动态链接方式的程序运行时,系统的动态链接器(Dynamic Loader)会将程序所需要的所有动态链接库全部装载到进程的地址空间,并将程序中所有未决议的符号绑定到相应的动态链接库中,并进行符号重定位工作。
  5. (Page:207)动态链接相较于静态链接,会导致程序在性能上的一些损失。但可以通过应用诸如 PLT 延迟绑定等方式来进行优化。据估算,动态链接相较于静态链接,性能损失大约在 5% 以下。
  6. (Page:209)如果某个函数是一个定义于其他静态目标中的函数,则链接器将会按照静态链接的规则,将目标文件中的符号地址进行引用重定位;而若函数是一个定义于其他动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接符号,不对它进行地址重定位,而是把这个过程留到装载时进行
  7. (Page:210)使用了动态链接的应用程序其入口为加载到其 VAS 中的操作系统动态链接器由它完成对共享库的加载和处理,然后将控制权交还给程序继续执行
  8. (Page:211)共享对象的最终装载地址在编译时是不确定的(程序头中第一个 LOAD 段的 VirtAddr 地址为 0x0),而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
  9. (Page:213)默认情况下单纯的“装载时重定位(基址重置)”无法解决共享库指令部分在多个进程间的共享,是由于装载时重定位需要修改指令,因此没法做到同一份指令被多个进程共享
  1. (Page:214)共享模块(.so)内的四种基本地址引用方式:
  1. (Page:217)GOT 全局偏移表中存放了指向外部变量的指针数组。整个 GOT 被初始化在 .data 数据段中,因此可以在模块装载时被修改,且每个进程都可以有独立的副本,互不影响。
  2. (Page:218)查看 ELF 可执行文件/共享库的 GOT 内容:
objdump -R <file>
readelf -r <file>
  1. (Page:221)ELF 共享库在编译时,会把对外部符号的引用以及共享库使用到的全局变量通过 GOT 来进行动态重定向处理。而且该类型的共享库必须以 PIC 的形式进行编译。
  2. (Page:223)使用 PLT 来实现延迟绑定,即:当函数第一次被用到时才进行绑定(进行符号查找、重定位等),如果没有用到则不进行绑定。
  3. (Page:224).got 用来保存全局变量引用的地址;.got.plt 用来保存函数引用的地址。.got.plt 表的前三项是有特殊意义的:

默认情况下,.got.plt 中从第四项开始的表项最初存放的是对应该符号在 .plt 中的第二条 “push n” 指令的地址,此处的 n 为该符号在 .rel.plt 重定位表中的索引。当程序引用符号时,该处指令会将符号索引以及模块 ID 压入栈中,然后调用 _dl_runtime_resolve() 方法来解析符号地址。解析结束后,对应 .got.plt 的表项的值会变成该符号的地址,并且当符号再次通过 .plt 解析时会直接引用到该 .got.plt 中的地址,而不需要再次解析。

  1. (Page:227)在动态链接的 ELF 可执行文件中,有一个专门的 “.interp” 段用来存放需要的动态链接器的路径。
  2. (Page:228)“.dynamic” 段中保存了动态链接器所需要的基本信息。
  3. (Page:228)查看 .dynamic 段的内容:
readelf -d <file>
ldd <file>  # 查看 ELF 可执行程序/共享库的依赖树; 

  1. (Page:230)“.dynsym” 为动态符号表,表示了动态链接模块之间的符号导入导出关系,只保存与动态链接相关的符号;而 “symtab” 中一般会保存所有的符号。为了辅助 “.dynsym”,一般还会设置 “.dynstr” 动态符号字符串表以保存符号名对应的字符串。而为了加快符号在程序运行时的查找过程,一般还会设有符号哈希表(.hash)。
  2. (Page:231)使用了 PIC 模式的共享对象也需要进行符号重定位,即通过动态重定位表 “.rel.dyn” 与 “.rel.plt”。前者是对数据引用的修正,它修正的位置位于 “.got” 以及数据段;而后者是对函数引用的修正,修正位置位于 “.got.plt”
  1. (Page:234)应用程序的进程堆栈中还保存了动态链接器需要的一些辅助信息数组(Elf32_auxv_t)。
  2. (Page:237)动态链接器的自举:动态链接器本身不可以依赖于其他任何共享对象,且其自身所需要的全局和静态变量的重定位工作由它本身完成。
  3. (Page:238)在动态链接器完成自举后,其会将可执行文件与链接器本身的符号表都合并到一个符号表中,即“全局符号表”。随后链接器会通过可执行文件的 .dynamic 段中的 DT_NEEDED 入口来递归加载(图的广度优先遍历)所有依赖的共享库,并将其符号表合并至全局符号表中。
  4. (Page:241)全局符号介入(Global Symbol Interpose):当一个符号被加入到全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略(此为动态链接器的行为)。
  5. (Page:243)Linux 内核在执行 execve() 系统调用时并不关心 ELF 文件是否是可执行的(e_type),它只是简单按照程序头表里的描述对文件进行装载然后把控制权交给 ELF 的入口(有 “.interp” 段就是动态链接器的 e_entry,否则是 ELF 文件的 e_entry)。因此共享库其实与可执行文件区别不大,甚至也可以被执行。
  6. (Page:245)常用的动态库操作系统调用:dlopen(打开)、dlsym(查找符号)、dlerror(错误处理)、dlclose(关闭);

第八章:Linux 共享库的组织

  1. (Page:253)ABI 对于不同的语言来说,主要包括一些诸如函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等。
  2. (Page:255)Linux 下的共享库命名规范:libname.so.x.y.z;其中,“x” 表示主版本号,重大升级;“y” 表示次版本号,增量升级;“z” 表示发布版本号,错误修复和性能改进。一般只有主版本号不同才可能会产生向下兼容的问题。名称中的 “name” 为共享库的链接名,可以在编译时使用:
# -lname 指定链接时需要查找库 libname.so 的最新版;
clang++ main.cc -o main.so -fPIC -shared -lname -L. 
  1. (Page:257)Linux 下使用 SO-NAME 机制来管理共享库的版本。如一个共享库版本为 libfoo.so.2.6.1,则其 SO-NAME 为 libfoo.so.2,操作系统并根据该名称产生一个指向最新版本(次版本号和发布版本不同)共享库的软链接。同样的,为了保持兼容性,在输出 ELF 时也会将 .dynamic 中 DT_NEEDED 类型保存为所依赖库的 SO-NAME。
  2. (Page:258)Linux 下可以使用 ldconfig 来更新共享库 SO-NAME 的软连接
  3. (Page:259)Solaris 下的符号版本机制:使用符号版本脚本文件来控制共享库中的符号版本。
  4. (Page:264)FHS(File Hierarchy Standard)标准,该标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用。目前包括 Linux 在内的大多数开源操作系统都遵循该标准。
  5. (Page:264)GUN 标准推荐第三方程序应该默认将库安装到 “/usr/local/lib” 下。“/lib” 与 “/usr/lib” 下是一些很常用的、成熟的,一般是系统本身需要的库;而 “/usr/local/lib” 是非系统所需的第三方程序的共享库。
  6. (Page:265)如果 DT_NEEDED 中保存的是绝对路径,则动态链接器会搜索以下位置来查找目标动态库:

ldconfig 会将各个共享库的 SO-NAME 与对应的共享库文件位置缓存在 /etc/ld.so.cache 文件中,以加快动态链接器的查找速度。

  1. (Page:266)动态链接器的共享对象(目标文件)查找顺序:
  1. (Page:267)可以利用 LD_DEBUG=all 环境变量来打开动态链接器的调试功能,以查看完整的动态链接过程。该环境变量的可选值:

  1. (Page:268)为生成的共享库指定 SO-NAME(-Xlinker 只能传递一对参数;-Wl 可以同时传递多个参数):
clang lib.c -o lib.so -shared -fPIC -Wl,-soname,lib.so.1
  1. (Page:269)清除共享库/可执行文件中的所有符号和调试信息:
strip <file>
  1. (Page:271)为共享库指定构造/析构函数(具体执行实际依实现而定,不保证可以使用标准库中的对象):
void __attribute__((constructor)) foo() {};
void __attribute__((destructor)) bar() {};

设定优先级(构造函数数字小的将在数字大的之前运行,析构函数与之相反):

void __attribute__((constructor(5))) foo() {};
void __attribute__((destructor(10))) bar() {};
  1. (Page:271)共享库脚本:共享库本身是一个符合特定规则的脚本文件,即:可以把几个现有的共享库通过一定方式组合起来,从用户角度看就是一个新的共享库。比如 libC.so 的内容可以为以下的文本内容:
GROUP( libA.so libB.so )

第九章:Windows 下的动态链接

(略)

第十章:内存

  1. (Page:308)大多数操作系统都会将内存空间中的一部分(高地址)挪给内核使用,而应用程序则无法直接访问这一段内存,这一部分被称为内核空间。在剩下的用户空间中,也有如下一些特殊区域:
  1. (Page:310)在计算机系统中,栈总是向下增长的。在 i386 下,栈顶由名为 ESP(X86_64 下为 RSP 寄存器)的寄存器进行定位。压栈操作使栈顶地址减小,弹出使栈顶地址增大。根据栈的增长方向,栈底实际位于高地址处。
  2. (Page:310)栈保存了一个函数调用所需要的维护信息,即:堆栈帧/活动记录。其中包括如下几方面内容:
  1. (Page:311)在 i386 中,一个函数的活动记录用 EBP 与 ESP 这两个寄存器划定范围。ESP 指向栈的顶部,即活动记录顶部。而 EBP 指向了函数活动记录中存放调用函数之 EBP 值的位置,EBP 又被称为“帧指针”。

  1. (Page:311)i386 下的函数调用规则:
  1. (Page:312)尽量不要使用 GCC 的 “-fomit-frame-pointer” 参数。该参数会取消帧指针,而是通过 ESP 直接计算帧上变量的位置。其好处是可以多出一个 EBP 寄存器,坏处则是会使帧上寻址速度变慢等。
  2. (Page:316)函数的调用方(操作系统:处理参数)和被调用方(函数本身:在函数体中拿出并使用参数)。
  3. (Page:316)一个 Calling Convention 通常会规定如下几方面内容:
  1. (Page:317)cdecl(C declaration)调用惯例:即 C 语言默认在 8086/IA32 下使用的调用惯例。它固定参数传递从右至左的顺序压入栈中;由函数调用方从栈中取出参数名字修饰会在函数名称前加 “_”(目前 GCC/Clang 已经默认不会为 C 的符号添加 Leading-Underscore)。
  2. (Page:318)在进入函数执行之前,需要先向栈中压入函数需要的参数,以及函数的返回地址。进入函数体后,则会保存旧 EBP 的值,为局部变量在栈上分配空间等。
  3. (Page:321)i386 下其他常见的调用惯例:stdcall、fastcall、pascal。

  1. (Page:321)C++ thiscall 调用惯例:专用于类成员函数的调用。

在调用 C++ 非静态成员函数时使用此约定。基于所使用的编译器和函数是否使用可变参数,有两个主流版本的 thiscall。 对于 GCC 编译器,thiscall 几乎与 cdecl 等同:调用者清理堆栈,参数从右到左传递。差别在于 this 指针,thiscall 会在最后把 this 指针推入栈中,即相当于在函数原型中是隐式的左数第一个参数。

在微软 Visual C++ 编译器中,this 指针通过 ECX 寄存器传递,其余同cdecl 约定。当函数使用可变参数,此时调用者负责清理堆栈。thiscall 约定只在微软 Visual C++ 2005 及其之后的版本被显式指定。其他编译器中,thiscall 并不是一个关键字。

  1. (Page:327)对于函数返回值,一般 4 字节以内用 EAX 返回,5-8 字节用 EAX+EDX,即 EAX 存储低四字节,EDX 存储高四字节的方式。对于更大尺寸的对象,C 语言通常会使用一个临时的栈上对象作为中转,因此性能会有所损失(i386)。
  2. (Page:328)为什么需要堆?因为栈上的数据在函数返回时就会被释放掉,所以无法将数据传递至函数外部。而全局变量(以及静态变量被存放在 .data 段中,直接映射到进程的 VAS 中)没有办法动态地生成,只能在编译时定义,且缺乏表现力。
  3. (Page:329)进程的堆空间一般由程序的运行库进行管理,其动态申请的过程也并非是直接系统调用的方式,因为这种方式的性能开销过大。而一般情况下是程序向操作系统申请一块大小适当的堆空间,然后由程序自己(运行库)管理这块空间。
  4. (Page:330)两个用来分配堆空间的系统调用:brk() 与 mmap()。
  1. (Page:330)Glibc 中的 malloc 函数是这样处理用户的空间请求的:对于小于 128KB 的请求会在现有的堆空间中,通过堆分配算法为它分配一块空间并返回;对于大于 128KB 的请求会通过 mmap() 系统调用分配一块匿名空间,然后在这个匿名空间中为用户分配空间。
  2. (Page:338)常用的堆分配算法:

第十一章:运行库

  1. (Page:342)典型的程序运行步骤:
  1. (Page:343)Glibc 的程序入口 _start 由汇编实现,并且和平台相关。在调用 _start 之前,装载器会把用户的参数和环境变量压入栈中,栈顶元素为 argc,接下来是 argv 和环境变量的数组。以下为 i386 对应的代码实现:
...
_start:
    /* Clear the frame pointer.  The ABI suggests this be done, to mark
       the outermost frame obviously.  */
    xorl %ebp, %ebp

    /* Extract the arguments as encoded on the stack and set up
       the arguments for `main': argc, argv.  envp will be determined
       later in __libc_start_main.  */
    popl %esi        /* 将 argc 的值存入 %esi */
    movl %esp, %ecx        /* 将栈顶地址(argv + env)存入 %ecx */

    /* Before pushing the arguments align the stack to a 16-byte
    (SSE needs 16-byte alignment) boundary to avoid penalties from
    misaligned accesses.  Thanks to Edward Seidl 
    for pointing this out.  */
    andl $0xfffffff0, %esp
    pushl %eax        /* Push garbage because we allocate
                   28 more bytes.  */

    /* Provide the highest stack address to the user code (for stacks
       which grow downwards).  */
    pushl %esp

    pushl %edx        /* Push address of the shared library
                   termination function.  */

#ifdef SHARED
    /* Load PIC register.  */
    call 1f
    addl $_GLOBAL_OFFSET_TABLE_, %ebx

    /* Push address of our own entry points to .fini and .init.  */
    leal __libc_csu_fini@GOTOFF(%ebx), %eax
    pushl %eax
    leal __libc_csu_init@GOTOFF(%ebx), %eax
    pushl %eax

    pushl %ecx        /* Push second argument: argv.  */
    pushl %esi        /* Push first argument: argc.  */

    pushl BP_SYM (main)@GOT(%ebx)

    /* Call the user's main function, and exit with its value.
       But let the libc call main.    */
    call BP_SYM (__libc_start_main)@PLT
#else
    /* Push address of our own entry points to .fini and .init.  */
    pushl $__libc_csu_fini
    pushl $__libc_csu_init

    pushl %ecx        /* Push second argument: argv.  */
    pushl %esi        /* Push first argument: argc.  */

    pushl $BP_SYM (main)

    /* Call the user's main function, and exit with its value.
       But let the libc call main.    */
    call BP_SYM (__libc_start_main)
#endif

    hlt            /* Crash if somehow `exit' does return.  */
...

整个执行流程为:_start -> __libc_start_main -> (…) -> exit -> _exit;

其中 __libc_start_main() 内部主要完成以下事情:

  1. (Page:344)查看所有系统环境变量:
export
  1. (Page:351)在 Linux 下,内核中每一个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每个元素都指向一个内核的打开文件对象。而 fd 就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在该表中找到一个空项,让这一项指向生成的打开文件对象并返回下标。由于该表处于内核,因此用户无法访问,即便拥有 fd,也只能通过系统提供的函数来操作
  2. (Page:358)一个 C 语言运行库(CRT)大致包括如下功能:
  1. (Page:361)i386 下变长参数的实现得益于 C 语言默认的 cdecl 调用惯例的参数自右向左压栈传递方式,以及由调用方清除堆栈两个条件。
// !! can be only used under X86_32 aka i386 !!;
#include <stdio.h>

int __cdecl sum(int num, ...) {
  int* p = &num + 1;
  int ret = 0;
  while(num--) {
    ret += *p++; 
  }
  return ret;
}
int __cdecl main(int argc, char** argv) {
  printf("%d\n", sum(3, 1, 2, 3));
  return 0;
}

* i386 与 X86_64 体系下在 Linux 中有着不同的 Calling Convention。在 X86_64 下,更多的参数将使用寄存器来传递,而非直接 push 到栈中。在各个体系下的可用调用规范如下表所示:

  1. (Page:364)运行库是平台相关的,它们与操作系统结合的十分紧密。Glibc 与 MSVCRT 事实上是标准 C 语言运行库的超集,它们各自对 C 标准库进行了一些扩展(比如增加了线程相关的函数)。
  2. (Page:367)crt1.o 中包含的是程序的入口函数 _start;crti.o 与 crtn.o 内提供了用于初始化函数的指令,分别用于辅助输出文件中的 .init 与 .finit 段(_init() / _finit())以进行全局的构造和析构。而将普通函数直接放入 .init 会破坏该段的结构,普通函数的 ret 指令会另 _init() 函数提前返回,而无法完成正常的初始化过程。crti.ocrtn.o 提供了可以在 main() 之前和之后运行代码的机制,而真正的全局构造和析构是由 GCC 中的 crtbeginT.ocrtend.o 来实现的。

第十二章:系统调用

  1. (Page:407)系统调用是应用程序(包括运行库)与操作系统内核之间的接口。
  2. (Page:408)在 X86_32 下,Linux 使用 0x80 号中断作为系统调用的接口;EAX 寄存器用于表示系统调用的接口号。每个系统调用都对应于内核源码中的一个以 “sys_” 开头的函数。当系统调用返回时,EAX 又作为调用结果的返回值(X86_64 下为 RAX 寄存器)。注:目前 i386 使用 SYSENTER 指令进行系统调用;X86_64 使用 SYSCALL 指令系统调用,两者本身内部仍然是通过中断的方式进行的,只不过不需要再经过“中断向量表”的查询。
  3. (Page:412)中断:中断号 -> 中断处理程序(ISR, Interrupt Service Routine);内核中存在着“中断向量表”记录着中断号与中断处理程序的对应关系。硬件中断一般来自于硬件的异常或其他事件的发生,比如电源掉电、键盘被按下等;软件中断通常是一条指令。
  4. (Page:413)系统调用号(X86_64,Linux):/usr/include/asm-generic/unistd.h
  5. (Page:418)系统调用实现源码查询,点此处
          global    _start

          section   .text
_start:   mov       rax, 1                  ; system call for write
          mov       rdi, 1                  ; file handle 1 is stdout
          mov       rsi, message            ; address of string to output
          mov       rdx, 13                 ; number of bytes
          syscall                           ; invoke operating system to do the write
          mov       rax, 60                 ; system call for exit
          xor       rdi, rdi                ; exit code 0
          syscall                           ; invoke operating system to exit

          section   .rodata
message:  db        "Hello, world!", 11      ; note the newline at the end

这里给出一个基于 NASM 汇编的 X86_64 Linux 上可以编译运行的 “Hello, world!” 实现(NASM 是一款基于英特尔 x86 架构的汇编与反汇编工具,十分好用,这里先吹一波)。这里首先将 RAX 寄存器设置为 1,对应 sys_write 系统调用的序号;然后然通过 RDIRSIRDX 来设置系统调用函数的参数,即 fd(0)、要写入的字符串以及长度信息。最后再通过调用 syscall 指令来触发系统调用(写入 + 退出)。

第十三章:运行库实现

(略)



这是文章底线,下面是评论
  暂无评论,欢迎勾搭 :)