目录

程序员的自我修养

摘录自《程序员的自我修养》 — 电子工业出版社

早期的计算机由于没有复杂的图形功能,CPU 核心频率不高,因此所有的设备都是直接连接在同一个总线上。

1762739481126

后期由于 CPU核心频率提升,3D游戏和多媒体的发展,图形芯片、CPU、内存间需要交换大量的数据,因此专门设计了高速的北桥芯片;那么为了降低北桥芯片的复杂度,又专门设计了低速的南桥芯片,磁盘、USB、键盘等设备都连接在南桥上,由南桥汇总后,连接到北桥上。

1762739733706

为了提升 CPU的处理能力,能通过增加 CPU数量提升速度,即 SMP,对称处理器,然而他的成本很高,于是当前多数的机器,都可看作他的简化版,多核处理器,Mulit-core Processor,可参考:http://www.gotw.ca/publications/concurrency-ddj.htm

在生成可供链接的库时,需要经过编译的步骤,我们将其分解为 4个步骤:

1762742533664

预编译后的扩展名是 .ii,可通过指令只进行预编译,gcc -E hello.c -o hello.icpp hello.c > hello.i

预编译过程,主要处理源代码中以 # 开始的预编译指令,如 #include、#define 等(保留 #pragma 编译器指令,因为编译器会使用)。经过预编译后文件不含任何宏定义,所有宏、包含的文件都会被插入到 .i 文件中。

无法判断宏定义是否正确、头文件是否包含正确时,可以通过查看预编译文件确定

当前的项目大多数由 cmake 组织编译,若希望保留 .i(预编译)、.s(汇编)文件生成在编译输出目录,可以使用:

# 针对 C 语言(若需保留 .i 文件)
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -save-temps=obj" CACHE STRING "C compiler flags" FORCE)
# 针对 C++ 语言(核心,保留 .i 文件)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -save-temps=obj" CACHE STRING "C++ compiler flags" FORCE)

上述是全局配置,对所有目标都生效,若配置单个目标,可:

# 假设目标名为 your_target(替换为实际目标名)
add_executable(your_target main.cpp)  # 或 add_library

# 针对 GCC/Clang
target_compile_options(your_target PRIVATE -save-temps=obj)

常说这是程序构建的核心部分,该步骤把预处理的文件,进行词法分析、语法分析、语义分析,优化后生产相应的汇编代码。可通过 gcc -S hello.i -o hello.Sgcc -S hello.c -o hello.S 得到汇编文件。

gcc 这个命令是各种真正执行相应命令的后台程序的包装,它会根据不同参数要求去调用预编译程序 cc1、汇编器 as、链接器 ld

汇编器将汇编代码转变为机器可执行的指令,它只是根据汇编指令和机器指令的对照表一一翻译就可以,通过调用汇编器 as 来完成:

as hello.S -o hello.ogcc -c hello.S -o hello.ogcc -c hello.c -o hello.o

每个源代码的模块独立进行编译,然后按须将他们组装起来,这个过程就是链接(Linking)。链接过程主要包括:地址和空间分配、符号决议、重定位这些步骤。

编译器在编译时,并不知道函数的地址,故暂时将调用函数指令的目标地址搁置,等最后链接时来进行目标地址的修正,这个修正的过程,也叫重定位,每个修正的地方,将一个重定位入口

当前流行的可执行文件格式,主要是 Windows 下的 PE(Portable Executeable) 和 Linux 下的 ELF(Executeable Linkable Format),他们都是 COFF(Commnon file format)格式的变种。

动态链接库、静态链接库,都按照可执行文件格式存储,静态链接库稍有不同,他将很多目标文件捆绑在一起形成一个文件,并加上一些索引,可简单理解为包含很多目标文件的文件包。

文件中包含了机器指令代码、数据,同时包含了链接时需要的信息,符号表、调试信息、字符串等。这些信息按不同的属性,以**“节”(Section)的形式存储,也成为“段”(Segment)**。

  1. 代码段(Code Section)

    编译后的机器指令常被放在代码段,常见名有:.code.text

    可以利用 objdump 挖掘他的内容,利用 -s 将所有段内容以十六进制的方式打印出来,-d 可以将所有包含指令的段反汇编。

  2. 数据段

    全局变量、局部静态变量常放在数据段:.data

1762766991265

ELF 文件的开头是一个“文件头”,描述了整个文件的文件属性:文件是否可执行(可执行则包含入口地址)、是动态库/静态库、目标硬件、目标操作系统等信息。包含段表,是一个描述文件中各个段的数组,记录了文件中各个段在文件中的偏移位置以及段属性。可用 readelf -S executable 查看。

从上图中,已初始化的全局变量、局部静态变量保存在 .data 段,未初始化的则保存在 .bss 段**(.bss 段为未初始化的全局变量和局部静态变量预留位置,没有内容,因此在文件中不占空间)**

.data段会在磁盘文件中存储变量的初始值,如 int a=10 会在 .data段存4字节的 0x0A;而 .bss 段不分配 “数据存储字节”。简单来说 .data 段多出了数据本身的大小。

一些小 tips:

上述可大致分为两种段:程序指令和程序数据。代码段属于指令、数据段和.bss段属于数据。这样有诸多好处:

  • 数据通常可读写、而指令只读,因此他们会被分别映射到两个区域,权限可靠
  • 一个是性能提升,CPU 缓存分指令缓存和数据缓存,分开对缓存命中提升有好处
  • 可节约内存空间,若一个系统运行多个程序副本,那么指令都相同,可以只保存一份指令
  1. bss 段

    上述我们可以简单理解,该段存放的是未初始化的全局变量和局部静态变量。而具体实现通常与不同的编译器相关,有些编译器会将全局的未初始化变量预留一个未定义的全局变量符号,等到最终链接成可执行文件时才会在.bss段分配空间。这与“强符号、弱符号”相关,后面会详细分解。

    不过能确定的是,未初始化的静态变量,确认是放在 .bss段。

  2. 只读数据段(.rodata)

    存放只读数据,通常是程序的只读变量(如 const 修饰的变量)和字符串常量。一定程度上保证了程序安全。

    有时候编译器会将字符串常量放到 .data段,而不会单独放在 .rodata 段

  3. 注释信息段(.comment)

    存放编译器版本信息,如字符串:“GCC:(GNU)4.2.0”

  4. 堆栈提示段(.note.GNU-stack)

还有一些如:

常用段名 说明
.debug 调试信息
.dynamic 动态链接信息
.hash 符号哈希表
.line 调试时的行号表,源代码行号与编译后指令的对应表
.note 额外编译器信息,如程序的公司名、发布版本号等
.strtab string table 字符串表,存储 ELF 文件中用到的各种字符串
.symtab symbol table 符号表
.shstrtab section string table 段名表
.plt
.got
动态链接的跳转表和全局入口表
.init
.fini
程序初始化与终结代码段

这些段名由 “.” 作为前缀,表示系统所保留的,我们也可以使用非系统保留的名字作为自己自定义的段,进而做出一些额外的操作。

有一些遗弃的段可不再关注:.sdata、.tdesc、.sbss、.lit4、.lit8、.reginfo、.gptab、.liblist、.conflict 等

如果希望某些代码、变量放到自定义的段中去,可以利用 GCC 提供的扩展机制:

__attribute__((section("FOO"))) int global = 42;
__attribute__((section("BAR"))) void foo() {}

那么,上述代码会存放到对应的段中,“FOO"、”BAR“,他们是段名称。

还能将二进制文件,如图片,音乐,词典等作为目标文件的一个段,能利用 objcopy 工具,如:

$ objcopy -I binary -O elf32-i386 -B i386 image.jpg image.o
# -I binary 指定输入格式”原始二进制“
# -O elf32-i86 指定输出格式,32位x86架构的ELF目标文件
# -B i386 指定目标架构位 32位x86

$ objdump -ht image.o
# -h 显示目标段表 -t 显示符号表(Symbol Table)

目前对 ELF 已经有了简单的了解,下面将最重要的结构提取出来,可以将他当作 ELF 的基本结构图:

1762832682147

上述描述了各式各样的段,段表(Section Header Table)就是保存这些段的基本属性的结构。它记录每个段的短命、段的长度、在文件中的偏移、读写权限等属性。利用 readelf -S 可以获取它,我们通常关注段的类型和标志位。

常用段类型:

  • PROGBITS:程序段、代码段、数据段都是该类型-1
  • SYMTAB:段内容为符号表-2
  • STRTAB:段内容为字符串表-3
  • RELA:重定位表,包含了重定位信息-4

… 等等,

常见标志位:

  • WRITE:在进程空间可写-1
  • ALLOC:该段在进程空间中需要分配空间,代码段、数据段、bss段通常会由该标志-2
  • EXECINSTR:进程空间中可执行,通常指代码段-4

.rel 修饰的段,例如 .rel.text.rel.dyn 等,它的类型是 REL,即一个重定位表(Relocation Table)。链接器在处理目标文件时,须对目标文件中某些部位进行重定位,即代码段、数据段中那些对绝对地址引用的位置,这些重定位的信息都记录在重定位表中。

对绝对地址引用的位置是什么?

静态链接的函数调用,函数地址是确定的,则直接调用绝对地址(动态库 PIC 与位置无关)

嵌入式开发,可能直接操作硬件寄存器的绝对地址

由于 ELF文件中用到许多字符串,如段名、变量名等,而这些字符串长度往往不确定,用固定的结构表示是较困难的。故将字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串,那么引用字符串时,给出数字下标即可。如:

偏移 字符串
0 空串
1 helloworld
6 world

这样就不用考虑长度,偏移就代表其对用的字符串。常见的段名有 .strtab.shstrtab,他们分别为字符串表(String Table) 和 段表字符串(Section Header String Table),其中字符串表用来保存普通字符串,段表字符串表用来保存段表中使用的字符串。

链接的过程本质是将多个不同的目标文件相互粘在一起,我们可以将符号看作链接过程的粘合剂,整个链接过程正是基于符号才能正确完成,我们将函数和变量统称为符号(Symbol),函数名和变量名就是符号名(Symbol Name)。每一个目标文件都会有一个相应的符号表(Symbol Table),每个定义的符号有一个对应的值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

符号也能分为多种:

  • 全局符号,能被其他目标文件引用
  • 外部符号(External Symbol),在本目标文件中引用的全局符号,不在本目标中定义
  • 段名,由编译器产生的符号,它的值是该段的起始地址,.text.data
  • 局部符号,只在编译单元内部可见,因此链接器常常忽略它们
  • 行号,目标文件指令与源代码行的对应关系,是可选项

有很多工具可以查看符号,常用 nm 等。

符号表段名通常为 .symtab,其成员非常简单:

typedef struct {
    st_name;        // 符号名,字符串表的下标
    st_value;       // 符号值
    st_size;        
    unsigned char st_info;        
    // 符号类型和绑定信息,成员的低4位表示符号类型,高28位表示符号绑定信息
    // 绑定信息则是,LOCAL-0:局部符号,GLOBAL-1:全局符号,WEAK-2:弱引用
    // 符号类型,则分未知、数据对象(变量、数组)、函数或其他可执行代码、段、文件名
    st_other;       
    st_shndx;       // 符号所在段
    // UNDEF: 表示符号未定义,即该符号被引用,但定义在其他目标中
    // COMMON: 通常未初始化的全局符号定义为该类型
    // ABS: 符号是一个绝对值
}

可使用 readelf -s 清晰的得到其详细的输出格式。


在链接时,可以根据符号的特性,衍生出一些特殊的应用:弱符号(weak symbol)和强符号(strong symbol)

  • 弱符号:通过 __attribute__((weak))(GCC 扩展)或 #pragma weak 声明的符号,如:__attribute__((weak)) void func() { /* 默认实现 */ }
  • 强符号:函数、已初始化的全局变量都是强符号

链接时,有下面的规则:

  1. 当同名强符号冲突时,链接器会报错
  2. 强符号与弱符号同名时,选择强符号(弱符号被覆盖)。
  3. 多个同名弱符号时,链接器任选一个(通常取第一个找到的)。

利用上述特性,在底层库的开发中进行上层代码扩展,如:

  1. 库函数默认实现与用户自定义覆盖

    例:嵌入式系统中,库提供默认的 void error_handler() 弱函数(打印错误),用户可定义自己的 error_handler 强函数(如触发复位),链接时自动覆盖默认实现

  2. 条件编译与模块化组件

    例:日志库中,弱符号 void log_extra() 默认空实现,若链接了 “额外日志模块”,则该模块的强符号 log_extra 会被使用,无需修改主库代码

通常仅在底层开发中作为 “高级技巧” 存在,应用层代码更多依赖动态特性(如插件、反射)实现类似功能

当编译时使用 -g 参数,目标文件中会生成调试信息,可通过 strip 剔除。当前的 ELF 文件**采用 DWARF(Debug With Arbitrary Record Format)**的标准调试信息格式,能通过 readelf 看到 ELF文件多出了不少”debug“相关的段。

使用perf时,默认是 fp回溯,可能因为帧丢失或被优化导致调用链回溯断裂,使用 dwarf 时,则不受其影响。-fomit-frame-pointer 是编译器默认行为,省略帧指针

通过前面的知识,我们清楚链接过程中,就是将几个输入目标文件加工后合并成一个输出文件。可以利用 ar 工具查看文件包含了哪些目标:ar -t libc.a。如果需要确定某个函数被定义在哪个目标文件中,可以使用 objdump -t libc.areadelf -s 查看上下文得出。

那么输入文件的段在合并时,做了哪些动作?

  1. 空间与地址分配

    扫描所有输入目标文件,获取他们各个段的长度、属性和位置,并将符号表中所有的符号定义与符号引用收集,统一放到一个全局符号表。

    这一步链接器能够获得所有目标文件的段长度,并将他们进行相似段合并,计算出文件中各个段合并后的长度与位置,并建立映射关系。

  2. 符号解析与重定位

    通过上一步的信息,读取段的数据、重定位信息,并进行符号解析与重定位、调整代码中的地址等。

利用命令查看二进制:

$ objdump -h executable
...
Sections:
Idx Name          Size      VMA       LMA       File off  Algn
 13 .text         005e88a8  001d46f0  001d46f0  001c46f0  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
                  
 26 .data         000045a8  007f50e0  007f50e0  007c50e0  2**3
                  CONTENTS, ALLOC, LOAD, DATA

我们将关注点放在 VMA(Virtual Memory Address,虚拟地址)和 Size。当目标文件未链接时, 所有段的 VMA 值为 0,因为虚拟空间没有被分配。链接后,可执行文件中的各个段都被分配到了相应的虚拟地址,可看到 VMA 的值发生改变。

在链接之前,目标文件的符号地址是不确定的,通常使用假地址占位,那么链接器如何确定那些指令需要被调整?

这里需要介绍 重定位表(Relocation Table),这个结构专门保存与重定位相关的信息,它通常是一个或多个段。例如 .text 若有需要被重定向的地方,那么相应的会有一个 .rel.text 段保存代码段的重定位表。可以使用 objdump 查看重定位表:

$ objdump -r alloc.cpp.o

alloc.cpp.o:     file format elf32-littlearm

RELOCATION RECORDS FOR [.text._ZN2cv10fastMallocEj]:
...
000000f8 R_ARM_JUMP24      _ZN2cvL16OutOfMemoryErrorEj
00000104 R_ARM_CALL        __cxa_guard_acquire
...
00000144 R_ARM_GOT_PREL    __stack_chk_guard

可以看到重定位的信息比较简单,

  • offset:该值是重定位项在所在段中的偏移,从段的起始地址开始计算,指向段内需要被重定向的具体位置。如,0xf8 表示在 .text._ZN2cv10fastMallocEj 段中,距离起始地址0xf8 的位置有一条指令需要重定位,引用了 _ZN2cvL16OutOfMemoryErrorEj
  • info:每个处理器指令格式不同,也有自己不同的重定位入口类型。

重定位的过程也伴随这符号解析,每个目标文件都可能定义一些符号,也可能引用到其他目标文件的符号。重定位过程中,每个重定位入口都是对一个符号的引用,当需要对某个符号的引用进行重定位时,需要确定符号的目标地址,这个时候链接器会查找目标文件的全局符号表,找到相应的符号后重定位。

可以通过命令查看符号表:

$ readelf -sW alloc.cpp.o

Symbol table '.symtab' contains 89 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
    63: 00000000     0 SECTION LOCAL  DEFAULT   72 .data.rel.ro._ZTIN2cv5utils12_GLOBAL[.]
    64: 00000000    16 FUNC    GLOBAL HIDDEN     3 _ZN2cv22getAllocatorStatisticsEv
    65: 00000000   360 FUNC    GLOBAL HIDDEN     7 _ZN2cv10fastMallocEj
    66: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND posix_memalign

该块的核心作用是支持多文件共享未初始化全局变量,通过链接时合并同名 common 符号实现共享,但现代代码中已经很少使用。工具链中 -fno-common 将其关闭。

这里提到了一些优化,在嵌入式开发中,通常会利用一些编译选项,减小二进制的体积

-ffunction-sections 和 -fdata-sections 是 GCC/Clang 等编译器的优化选项,用于将程序中的函数和数据按更小的粒度拆分到独立的段(Section) 中,配合链接器的 --gc-sections 选项可实现 “死代码 / 数据消除”(Dead Code/Data Elimination),显著减小最终二进制文件的体积。

  • -ffunction-sections:每个函数会被放入独立的代码段,段名格式为 .text.<函数名>(如 func1 放入 .text.func1func2 放入 .text.func2)。
  • -fdata-sections:每个全局 / 静态变量会被放入独立的数据段,段名格式为 .data.<变量名>.bss.<变量名>(如 g_var 放入 .data.g_var)。

这两个选项单独使用时仅改变段的组织结构,不会直接减小二进制体积,必须与链接器的 –gc-sections 选项配合才能发挥作用。 拆分段会导致目标文件中包含更多段信息,链接时段引用分析时间会增加,从而增加了编译时间。

链接器(如 ld)的核心工作是将多个目标文件(.o)和库文件(.a/.so)合并为一个可执行文件或库,而这一过程的具体规则(如段的地址分配、符号重定位、内存布局)是由链接脚本(Linker Script)控制的

以Ubuntu举例,在 /usr/lib/x86_64-linux-gnu/ldscripts 目录下针对不同架构和字节序有不同配置

由于目前不涉及,仅作了解,连接规则的语法不展开。

每个程序运行起来,拥有自己独立的虚拟地址空间(VMA,Virtual Address Space)。

32 位的机器仅支持 4G内存,其中1G划给操作系统,用户可以用的仅有 3G。

为了解决这种受限的情况,一种方案是扩展使用 64位的机器;一种是 依赖CPU支持物理地址扩展(PAE,Physical Adress Extension)并配合页表结构扩展,让其映射超过 4GB的物理内存

进程建立,需要三个步骤:

  • 创建一个独立的虚拟内地址空间

  • 读取可执行文件头,并建立虚拟空间与可执行文件的映射关系

  • 将 CPU的指令寄存器设置成可执行文件的入口地址,启动运行

    CPU 的指令寄存器是一个特殊寄存器,他的值始终指向下一条要执行的指令在内存中的地址。 故将 PC设置为程序入口的地址——这是程序启动的核心。如,readelf -h a.out | grep "Entry point address" # 输出如 0x400500,我们可以获取入口地址。

现代程序编译时通常会配置 -fPIC 来生成位置无关代码,操作系统默认开启地址空间布局随机化(ASLR)。默认情况下,操作系统会随机分配一个基地址(Base Address),将程序装载到该地址偏移处,指令中的地址会通过 “相对寻址” 或 “重定位” 适配新地址,用于防止缓冲区溢出攻击。

当然假设都不开启的情况下,会按照 VMA的预设绝对地址装载,但不推荐。

当段的数量增多时,若按照段-Section进行装载,那么会造成内存空间的浪费,由于操作系统装载可执行文件时,仅关心段的权限(读、写、可执行),因此,对于相同权限的段,把他们合并到一起当作一个段进行映射,将其看作一个 Segment。可以通过 readelf -l 命令查看它是如何被映射的:

Elf file type is DYN (Shared object file)
Entry point 0x24640
There are 11 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x000000000001f580 0x000000000001f580  R      0x1000
  LOAD           0x0000000000020000 0x0000000000020000 0x0000000000020000
                 0x000000000009d6e9 0x000000000009d6e9  R E    0x1000
  LOAD           0x00000000000be000 0x00000000000be000 0x00000000000be000
                 0x000000000002f974 0x000000000002f974  R      0x1000
  LOAD           0x00000000000edb90 0x00000000000eeb90 0x00000000000eeb90
...
  DYNAMIC        0x00000000000ef968 0x00000000000f0968 0x00000000000f0968
                 0x0000000000000210 0x0000000000000210  RW     0x8
...

 Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.property .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   01     .init .plt .plt.got .text .fini 
   02     .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .data.rel.ro .dynamic .got .data .bss 
   04     .dynamic 
	...
   10     .init_array .fini_array .data.rel.ro .dynamic .got 

上述案例有一些裁剪,但能看出共有11个 Segment,通常只关心”LOAD"类型的 Segment,只有它需要被映射,其他类型在装载过程中起辅助作用。

由于静态链接方式对于内存和磁盘的空间浪费非常严重,且在程序发布上存在依赖问题(库更新,则整个发布件更新)。动态库诞生,它将链接过程推迟到运行时进行。

但在编译过程中,仍然需要动态库的参与,这是由于:编译器并不知道引用的函数地址,当链接器生成可执行文件时,链接器必须知道这个函数的性质。若该函数是一个静态目标模块的函数,则按照静态链接规则将函数地址引用重定位;若是一个定义在动态共享对象中的函数,那么将符号引用标记为一个动态链接符号,不对它进行地址重定位,把这个过程留到装载时在进行。

我们可以通过 cat /proc/<pid>/maps 查看到整个进程虚拟地址空间中,对应动态库的映射地址。

静态链接时提到过重定位,叫做链接时重定位(Link Time Reloction),当前叫做装载时重定位(Load Time Relocation)。

动态库编译时,无法确定最终被加载到进程虚拟内存中的具体地址(因为内存地址由操作系统在运行时分配,且不同进程可能分配不同地址) 当动态库被加载到进程虚拟内存后,操作系统需要通过 “重定位” 把指令中基于 “偏移量” 的访问,修改为基于实际分配的 “绝对地址” 的访问。

因为不同进程加载同一份动态库时,操作系统分配的虚拟内存地址可能不同(受进程地址空间隔离、内存布局随机化等机制影响) ,此时,两个进程中的动态库指令已经因为重定位被改成了不同的二进制内容(绝对地址不同),自然无法再共享同一份指令内存 —— 每个进程必须保留自己被修改过的指令副本。

装载重定位时解决动态模块中有绝对地址引用的办法之一,但他的缺陷是指令部分无法在多个进程间共享,这样失去了动态库节省内存的优势。因此在编译时加入 -fPIC 参数,使用位置无关代码。他的基本思想是:指令那些需要被修改的部分分离出来,和数据部分放在一起,这样指令部分保持不变,而数据部分可以在每个进程中拥有一个副本

模块中各类型的地址引用方式有几种:

  • 模块内部函数调用、跳转

    由于被调用函数与调用者处于同一个模块,他们间的相对位置是固定的,因此内部跳转都可以是相对地址的调用,或者是基于寄存器的相对调用,这些指令不需要重定位

  • 模块内部的数据访问,如模块中定义的全局变量、静态变量

    指令中不能包含数据的绝对地址,故需要相对寻址。可以根据布局规则寻址:一个模块前通常是若干页代码,后紧跟若干页数据,这些页之间的相对位置固定,因此一个指令与他要访问模块内部数据的相对位置也是固定的。那么以当前指令加上固定的偏移量,就可以访问内部数据了

    模块内部的全局变量访问是一个特殊情况

    如果模块引用了定义在共享对象的全局变量,如下:

    extern int global;
    int foo() {
        global = 1;
    }

    当进行编译时,无法根据上下文判断 global 是定义在共享对象中还是其他目标文件中,即无法判断是否为跨模块调用。若上述代码是可执行文件的一部分,但 global 的定义在共享对象中,会出现冲突:可执行文件的变量地址要在链接过程中确定,但共享库在装载时才能确定地址。因此解决方法是,在 .bss 段中创建一个副本,然后两所有这个变量的指令都指向可执行文件中的这个副本。

    总结来说,ELF 编译共享库时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,通过 GOT 来实现变量访问。装载共享库时,如果可执行文件拥有副本,那么动态链接器将 GOT 中的相关地址指向该副本;若变量在共享模块中被初始化,还会将初始化值复制到程序主模块中的变量副本;若主程序没有副本,那么 GOT 中相应地址就指向模块内部的该变量副本。

  • 模块外部的数据访问,如其他模块定义的全局变量

    模块间的访问则更复杂,由于需要使得代码地址无关,模块间的数据访问目标地址要等到装载时才能确定。当前 ELF 的做法是在数据段里建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可通过 GOT 中相对应的项间接引用。

    链接器在装载模块时查找每个变量的地址,填充 GOT中的各个项,确保指针指向的地址正确。GOT 本身在数据段,因此他在模块装载时被修改,且每个进程都有独立的副本,互不影响。

    那么如何做到地址无关的?首先确定 GOT 相当于当前指令的偏移,随后根据变量地址在 GOT 中的偏移就能得到变量的地址。

  • 模块外部函数调用、跳转

    同上述 GOT 跳转

可以看到,在动态库中存在 .got 这样的段。

动态库有很多优势,相比静态库要灵活许多,是牺牲一部分性能为代价的。其主要原因是需要进行复杂的 GOT 定位,间接寻址,再进行跳转;另一个原因是动态链接的工作在运行时完成。

在一个程序的运行过程中,很多函数可能不会被用到,因此采用了延迟绑定(Lazy Binding)的做法,即函数第一次被用到时才进行绑定(符号查找、重定位等)。使用 PLT (Procedure Linkage Table)的方法来实现,ELF 将 GOT 拆分成两个表 .got.got.plt,其中 .got 用来保存全局变量引用的地址, .got.plt 保存函数引用的地址。调用函数并不直接通过 GOT 跳转,而是在这个过程中增加了一层间接跳转。

这里继续分享一下其他用于动态链接的段。

  1. interp 段

    该段名是 interpreter 解释器的缩写,他的内容保存的是一串字符串,是可执行文件所需要的动态链接器的路径。再 x64 下通常是 /lib64/ld-linux.so.2,可以通过 readelf -l out | grep interp 查看。

  2. dynamic 段

    最重要的结构就是该段,保存了动态链接器所需要的基本信息,如依赖于哪些共享对象、动态连接符号表的位置等。可以通过 readelf -d out 查看其中的内容,可以得到依赖信息。

  3. dynsym

    为了完成动态链接,要需要以来符号相关文件信息,类似于静态链接的符号表段 .symtab,动态库有动态符号表段(Dynamic Symbol Table),与 .symtab 保存了所有符号不同的是,它只保存了动态链接相关符号。

  4. 重定位表

    类似于静态链接有 .rel.text.rel.data 重定位表,动态库有与之对应的结构为 .rel.dyn.rel.pltdyn 是对数据引用的修正,是位于 got 的数据段,而 plt 是对函数引用的修正,修正的位置是 got.plt 段。可利用 readelf -r 查看重定位表信息。

然后还有一些不一一说明,如:.dynstr 动态链接字符串地址等。

动态链接步骤基本分为3步:启动动态链接器本身、装载所需要的共享对象、重定位与初始化。

  1. 动态链接器自举

    操作系统将进程的控制权交给动态链接器时,自举代码开始执行。首先找到自己的 GOT,找到 .dynamic 段,并通过该段信息,获取本身的重定位表、符号表等,从而得到链接器本身的重定位入口,将他们全部重定位。从这里开始,动态链接器才能使用自己的全局、静态变量。

  2. 装载共享对象

    完成基本自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,称为全局符号表(Global Symbol Table)。然后会开始寻找所依赖的共享对象,并读取其 ELF 文件头和 .dynamic 段,将相应的代码段和数据段映射到进程空间中。

    开发过程中,可能存在,一个共享对象里的全局符号被另一个共享对象的同名全局符号覆盖的现象,被称为全局符号介入(Global Symbol Interpose)。这里涉及到符号优先级的问题,当一个符号要被加入全局符号表时,若相同的符号名已经存在,可后加载的会被忽略

    为了提高函数调用效率,动态库不对外调用的函数可以使用 static 关键字定义函数。修饰后的函数可避免 PLT/GOT 间接跳转,编译器 -fvisibility=hidden 也可获得类似效果。但可能效果比较微弱。

  3. 重定位和初始化

    上述步骤完成后,已经有了全局符号表,链接器开始遍历可执行文件和共享对象的重定位表,将他们的 GOT/PLT 中的需要重定位的地方进行修正。

    重定位完成后,若有 init 段,那么会执行该段的代码,实现特有的初始化过程。

    init 较老旧了,当前现代推荐使用 init_arry,可以按照数组索引顺序执行,指定优先级。其中 __attribute__((constructor)) 是触发该特性的常用方式

对于静态程序来说,程序入口就是 ELF 头文件里 e_entry 指定的入口;而动态链接的可执行文件来说则不同。

内核会首先分析 interp 段,将动态链接器映射到进程地址空间,然后将控制权交给动态链接器,系统加载并启动这个动态链接器并将目标程序的路径作为参数传递给他,加载依赖的动态库、完成重定位,最后跳转到程序的入口 \_start 。动态链接器本身就是一个可以执行的程序。

业务的arm单板中,通过 ld-linux.so.3 来启动就是手动利用动态链接器启动。

可以通过动态链接器提供的 API,在程序运行过程中打开、关闭动态库,去实现一些插件的能力。它们是 dlopen、dlclose、查找符号 dlsym、错误处理 dlerror,程序可以通过 API 对动态库进行操作,他们被定义在 <dlfcn.h> 中。

  1. dlopen

    void *dlopen(const char *filename, int flag);

    若路径是绝对路径则尝试打开,若相对路径则在环境变量中搜索。若 filename 为 NULL,则返回的是全局符号的句柄,这有些类似于高级语言反射的特性(但缺少元数据,参数、返回值等信息),还是无法做到高级语言那样的反射特性。第二个参数表示符号解析方式,分别是延迟绑定、加载完成立即绑定。

  2. dlsym

    void *dlsym(void *handle, char *symbol);

    用于查找符号,没有则返回 NULL,若查找的是函数则返回地址,若是常量则返回值。若找到了,则 dlerror 返回 NULL,否则会返回相应的错误信息。

    这里也会涉及符号优先级,前面提到了当某个符号已存在,后装载的相同符号会被忽略,使用dlsym查找时,则不是装载序列的规则,而是依赖序列(Dependency Ordering)的优先级,即已dlopen打开的哪个对象为根节点,对他进行广度优先遍历,知道找到符号为止。

  3. dlclose

    将一个已经加载的模块卸载,系统维持了一个加载引用计数器,当dlopen时加一,dlclose时减一。卸载时执行 .finit 段代码,然后将相应符号从符号表去除,取消进程和模块的映射关系,然后关闭模块文件。

由于动态链接的诸多优点,大量程序开始使用动态链接机制,导致系统里存在数量庞大的共享对象。因此需要好的方法去组织这些对象,便于长期维护、升级。

伴随程序功能丰富、或者修正原有Bug、增加新的功能、改进性能等。得益于动态库的灵活性,程序本身和动态库可以独立开发和更新。在简单的分类情况下,共享库更新可分为两类:兼容更新、不兼容更新。

不兼容更新指共享库改变了原有接口,这里接口特指二进制接口,即 ABI (Application Binary Interface)

ABI 对不同语言来说,主要包括注入函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等方面的规则。

导致 C语言共享库 ABI 改变行为的主要有下面 4点:

  • 导出函数被删除
  • 导出函数接口发生变化,如函数返回值、参数被更改
  • 导出数据的结构发生变化,如共享库定义的结构体变量的结构发生改变,如结构成员删除、顺序改变或其他引起结构体内存布局变化的行为
  • 导出函数的行为发生改变,即产生的结果与以前不一样,不满足旧版本对规定的行为准则

非常不推荐导出接口为 C++ 的共享库,使用 C 会方便的多。由于 C++ 复杂的特性,遵守不少准则仍然很难防止 ABI 不兼容。

为了解决兼容性问题,Linux 规定了共享库文件名的规则:libname.so.x.y.z

其中 x 表示主版本号(Major Version Number),y表示次版本号(Minor Version Number),z表示发布版本号(Release Version Number)

  • 主版本号表示重大升级,不同主版本号的库是不兼容的
  • 次版本号表示增量升级,增加新的接口符号,且保持原有的符号不变。在主版本号相同的情况下,高次版本号向后兼容
  • 发布版本号表示库的一些错误修正、性能改进等。不添加任何新的接口、部队接口进行更改

当然也存在不遵守上述规定的发布件,如 glibc 使用 libc-x.y.z.so 这种方式,动态链接器 ld-x.y.z.so,运行时装载库 libdl 等。

可以看到,共享库的兼容通常与主版本号有关,若保留各个版本的共享库,会浪费磁盘与内存,因此 Linux 普遍采用 SO-NAME 的方式记录共享库的依赖关系。即共享库文件名去掉次版本号和发布版本号,保留主版本号。

.dynamic 段保留 SO-NAME,常用的做法是以 SO-NAME 为名字建立软连接,在升级时修改 SO-NAME 的软连接指向新版本共享库即可。

链接器进行动态连接时,只进行主版本号判断,那么若程序使用了次版本号降级的共享库运行,则可能产生缺少某些符号的错误。这种次版本号交会问题,并没有因为 SO-NAME 的存在而得到改善。

Linux 下的 Glibc 从 2.1后开始支持基于符号的版本机制(Symbol Version ing)的方案,基本思路是让每个导出和导入的符号都有一个相关联的版本号,它实际做法类似于名称修饰法。即共享库每一次次版本号升级,就给新的次版本号中添加的全局符号打上相应的标记,则能看到共享库每个符号都有相应的标签,如“V_1.1”、“V_1.2”。

可惜的是 Linux 系统下的符号版本机制并没有被广泛应用,主要使用的还是 Glibc 软件包中所提供的 20多个共享库,运行过程中发现缺失GLIBC_2.5、GLIBC_2.6 就是上述原因。

那么如何使用呢?在 Linux 下,使用 ld 链接一个共享库时,可以用 --version-script 参数,如果使用 GCC,则使用 -Xlinker 参数加 --version-script,相当于把 --version-script传递给 ld链接器。

.dynamic 段中 DT_NEED 类型项中保存的是绝对路径,那么直接按照该路径查找;若是相对路径,则会在 /lib、/usr/lib、和由 /etc/ld.so.conf 配置文件指定的目录中查找共享库。

若每次查找共享库时都遍历这些目录,那么将非常耗时,因此Linux系统中有 ldconfig 的程序,该程序的作用就是为共享库目录下各个共享库创建、删除或更新相应的 SO-NAME(即相应的符号链接),并将其收集起来存放到 /etc/ld.so.cache 文件中,建立一个 SO-NAME 的缓存。这个缓存的结构经过了特殊的设计,能加快查找过程。

不同系统会略有差异,如 FreeBSD 的缓存文件是 /var/run/ld-elf.so.hints

LD_LIBRARY_PATH

若该环境变量不为空,进程启动时会优先查找该变量指定的目录,Linux 中有方法能实现与他类似的功能,即运行动态链接器来启动程序,如:

$ ld-linux.so.2 -library-path /home/user /bin/ls

可以总结使用该变量时的查找顺序:环境变量->ld.so.cache指定路径->默认共享库目录,先/usr/lib,后 /lib

LD_PRELOAD

用于指定预装载的共享库或目标文件。该变量中指定的文件,会在动态链接器按照固定规则搜索共享库前装载,无论程序是否依赖他,LD_PRELOAD 里指定的共享库或目标文件都会被装载。

由于全局符号介入机制的存在,LD_PRELOAD 指定的共享库或目标文件中的全局符号会覆盖后面加载的同名全局符号,这样可以方便的做到改写某些函数而不影响其他函数,对于调试或测试非常有用。不过正常情况应该尽量避免使用。

系统配置文件有 /etc/ld.so.preload,他的作用与 LD_PRELOAD一样

LD_DEBUG

这个变量可以打开动态链接器的调试功能,当设置这个变量时,动态链接器会在运行时打印出各种信息,对于调试共享库有非常大的帮助。它能够设置的值有:

  • files:打印出整个装载过程,显示程序依赖哪个共享库并按照什么步骤装载和初始化,共享库装载时地址等
  • bindings:显示动态链接的符号绑定过程
  • libs:显示共享库查找过程
  • versions:显示符号的版本依赖关系
  • reloc:显示重定位过程
  • symbols:显示符号表查找过程
  • statistics:动态链接过程中的统计信息
  • all:显示以上所有
  • help:帮助信息

创建共享库默认是不产生 SO-NAME 的,若希望指定输出共享库的 SO-NAME ,可以如下:

$ gcc -shared -Wl,-soname,libMylib.so.1 -o libMylib.so.1.0.0 mylib.c

正常编译出的共享库或可执行文件都携带有符号信息和调试信息,但对于发布版本来说,这些符号信息的用处并不大,因此可以使用 strip 工具清除掉这些文件的符号和调试信息,达到缩小尺寸的目的。

同时,也可以利用 ld 的参数在输出文件时就不产生符号信息,其中 -S 消除调试符号信息,-s 消除所有符号信息。用 gcc时可用 -Wl,-S 进行参数的传递。

事实上,共享库还可以是符合一定格式的链接脚本文件,通过该文件能将多个共享库通过一定的方式组合起来,从用户角度就是一个新的共享库,如:

编写链接脚本:

/* combined.lds:定义组合库的依赖关系 */
GROUP( /lib/lic.so.6 /lib/libm.so.2)

$ ld -shared -o libcombined.so -T combined.lds

通过 -T 指定使用的链接脚本,并输出新的动态库对象。