程序员的自我修养阅读笔记

编译和链接

将编译和链接合并到一起的过程称为构建(Build)

从源文件生成最终可执行目标文件共有4个步骤:

  • 预处理(Prepressing)
  • 编译(Compilation)
  • 汇编(Assembly)
  • 链接(Linking)

预处理

命令行指令:

gcc -E hello.c -o hello.i

预处理实际上使用的是cpp程序:

cpp hello.c > hello.i

预编译过程主要处理那些源代码文件中的以#开始的预编译指令。处理规则如下:

  • 将所有的#define删除,并且展开所有的宏定义
  • 处理所有条件预编译指令,比如#if#ifdef#elif#else#endif
  • 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。这个过程是递归进行的。
  • 删除所有的注释///* */
  • 添加行号和文件名标识,比如#2 hello.c 2,便于编译器产生调试信息。
  • 保留所有的#pragma编译器指令,因为编译器需要使用它们。

编译

命令行指令:

gcc -S hello.c -o hello.s
# 或者基于预处理得到的.i文件
gcc -S hello.i -o hello.s

编译实际上使用的是cc1程序:

/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i

汇编

命令行指令:

gcc -c hello.s -o hello.o

汇编实际上使用的是as程序:

as hello.s -o hello.o

链接

链接使用的是ld程序(命令过于复杂,不再列出)。

实际上gcc这个命令只是对cpp、cc1、as、ld程序的封装。

编译器

不予记录,感兴趣可以选修《编译原理》课程。

链接器

当程序修改时,一些指令的地址会发生改变。重新计算各个目标的地址的过程叫做重定位

链接的主要工作就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。

链接过程主要包括了地址和空间分配符号决议重定位等这些步骤。

符号决议有时也被称为符号绑定

决议的意思偏向于静态链接,而绑定的意思偏向于动态链接

每个模块的源代码文件经过编译器编译成目标文件(.o文件),目标文件和库(Library)一起链接形成最终可执行文件。

最常见的库是运行时库(Runtime Library)

库其实是一组目标文件的包。

目标文件里有什么

目标文件的格式

现在PC平台流行的可执行文件格式主要是:

  • Windows下的PE(Portable Executable)。
  • Linux的ELF(Executable Linkable Format)。

它们都是COFF(Common file format)格式的变种。

除此之外,动态链接库(Dynamic Linking Library)和静态链接库(Static Linking Library)文件也都按照可执行文件格式存储。

Windows下的动态链接库文件后缀为**.dll**,静态链接库文件后缀为**.lib**。

Linux下的静态链接库文件后缀为**.so**,静态链接库文件后缀为**.a**。

ELF文件的分类

ELF文件可以分为以下几类:

  1. 可重定位文件:包含代码和数据,可以用来链接成可执行文件或共享目标文件。静态链接库可以归为该类。Windows下的文件后缀为**.obj**,Linux下的文件后缀为**.o**。

  2. 可执行文件:包含了可以直接执行的程序。Linux下一般没有拓展名,Windows下后缀为**.exe**。

  3. 共享目标文件:包含了代码和数据。用途有以下两种:

    • 链接器使用这种文件和其他的可重定位文件和共享目标文件链接,产生新的目标文件。
    • 动态链接器可以将几个共享目标文件与可执行文件结合,作为进程映像的一部分来运行。

    Linux下的文件后缀为**.so**,Windows下文件后缀为**.dll**。

  4. 核心转储文件:进程意外终止时,系统可将进程相关的一些信息转储到核心转储文件。Linux有一种core dump行为(WSL1似乎并未实现该功能)。

我们可以通过file 命令来查看相应的文件格式信息。

目标文件是什么样的

目标文件中包含了编译后的机器指令代码、数据,还包括了链接时所须的一些信息,比如符号表、调试信息、字符串等。

目标文件将这些信息按不同的属性以节(Section)的形式存储,有时候也叫做段(Segment)

  • 程序源代码编译后的机器指令经常被放在代码段里,代码段一般叫.text
  • 全局变量和局部静态变量数据经常放在数据段,数据段一般叫.data
  • 未初始化的全局变量和局部静态变量(默认值均为0)一般放在一个叫.bss的段里。
  • .rodata段用来存放只读数据,如const常量和字符串常量等。

.bss段只是为未初始化的全局变量和局部静态变量预留位置,它并没有内容,在文件中也不占据空间。

有些编译器会将全局的未初始化变量存放在目标文件.bss段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间。

总体来说,程序源代码被编译以后主要分成两种段:程序指令程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。

数据和指令分段的好处有:

  • 程序被装载后,数据和指令分别被映射到两个虚存区域。这样就可以分别设置这两个区域的读写权限。进而防止程序的指令被有意或无意的更改。
  • 指令区和数据区的分离有利于提高程序的局部性。(现代CPU中的缓存也被设计为数据缓存和指令缓存分离)。
  • 系统中运行多个该程序的副本时,可以共享一些只读区域,如指令部分,进而大量的节省空间。这也是最重要的原因。

挖掘.o文件

我们可以使用如下命令来查看一个.o文件的分段情况;

objdump -h SimpleSection.o

使用-x选项可以查看更多的信息。

可以使用size 命令来查看ELF文件的各个段的长度。

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

除了前面提到的一些最常用的段之外,还有一些其他常见的段:

程序员的自我修养阅读笔记_第1张图片

自定义段

gcc提供了一个拓展机制,使程序员可以定义变量所处的段:

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

全局变量或函数之前加上__attribute__((section("name")))属性就可以把相应的变量或函数放到以"name"作为段名的段中。

ELF文件结构描述

ELF的大致结构如图所示:

程序员的自我修养阅读笔记_第2张图片

我们可以使用readelf命令来查看ELF文件的信息。

ELF文件头(ELF Header)

ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等信息。

我们可以使用如下命令来查看ELF文件头:

readelf -h SimpleSection.o

输出信息大致如下:

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          488 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         12
  Section header string table index: 11

32位版本的文件头结构Elf32_Ehdr如下:

typedef struct {
    unsigned char e_ident[16];
    Elf32_Half e_type;			// ELF文件类型,ET_REL表示可重定位文件、ET_EXEC表示可执行文件,ET_DYN表示共享目标文件
    Elf32_Half e_machine;		// ELF文件的CPU平台属性
    Elf32_Word e_version;		// ELF版本号
    Elf32_Addr e_entry;			// ELF程序的入口地址(操作系统加载完程序后,从该地址开始执行指令)
    Elf32_Off  e_phoff;			
    Elf32_Off  e_shoff;			// 段表在文件中的偏移
    Elf32_Word e_flags;			// ELF标志位,用来标识ELF文件平台的属性
    Elf32_Half e_ehsize;		// ELF文件头本身的大小
    Elf32_Half e_phentsize;		
    Elf32_Half e_phnum;
    Elf32_Half e_shentsize;		// 段表描述符的大小,一般等于sizeof(Elf32_Shdr)
    Elf32_Half e_shnum;			// 段表描述符数量,即ELF文件中段的数量。
    Elf32_Half e_shstrndx;		// 段表字符串表所在段在段表中的下标
} Elf32_Ehdr;

ELF魔数

最开始的4个字节是所有ELF文件都必须相同的标识码,分别为0x7F、0x45、0x4c、0x46,第一个字节对应ASCII字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASCII码。

第5个字节用于标识ELF的文件类,0x01表示是32位的,0x02表示是64位的;第6个字是字节序,规定该ELF文件是大端的还是小端的。第7个字节规定ELF文件的主版本号,一般是1,因为ELF标准自1.2版以后就再也没有更新了。后面的9个字节ELF标准没有定义,一般填0,有些平台会使用这9个字节作为扩展标志。

段表

段表是以Elf32_Shdr结构体为元素的数组,数组元素的个数等于段的个数。

typedef struct {
  Elf32_Word    sh_name;		// 段名
  Elf32_Word    sh_type;		// 段的类型
  Elf32_Word    sh_flags;		// 段的标志位
  Elf32_Addr    sh_addr;		// 段虚拟地址,即该段被加载后在进程地址空间中的虚拟地址
  Elf32_Off     sh_offset;		// 段偏移,该段在文件中的偏移
  Elf32_Word    sh_size;		// 段的长度
  Elf32_Word    sh_link;		// 段链接信息
  Elf32_Word    sh_info;		// 段链接信息
  Elf32_Word    sh_addralign;	// 段地址对其
  Elf32_Word    sh_entsize;		// 项的长度。如符号表每一项的长度。
} Elf32_Shdr;

这些字段的可选值请参考程序员的自我修养P77-79。

可以使用以下命令查看目标文件的段表结构:

readelf -S SimpleSection.o

输出大致如下:

There are 12 section headers, starting at offset 0x1e8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       000000000000005f  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  000000a0
       0000000000000008  0000000000000000  WA       0     0     4
  [ 3] .bss              NOBITS           0000000000000000  000000a8
       0000000000000004  0000000000000000  WA       0     0     4
  [ 4] FOO               PROGBITS         0000000000000000  000000a8
       0000000000000004  0000000000000000  WA       0     0     4
  [ 5] BAR               PROGBITS         0000000000000000  000000ac
       000000000000000b  0000000000000000  AX       0     0     1
  [ 6] .rodata           PROGBITS         0000000000000000  000000b7
       0000000000000005  0000000000000000   A       0     0     1
  [ 7] .comment          PROGBITS         0000000000000000  000000bc
       000000000000002b  0000000000000001  MS       0     0     1
  [ 8] .note.GNU-stack   PROGBITS         0000000000000000  000000e7
       0000000000000000  0000000000000000           0     0     1
  [ 9] .note.gnu.propert NOTE             0000000000000000  000000e8
       0000000000000020  0000000000000000   A       0     0     8
  [10] .eh_frame         PROGBITS         0000000000000000  00000108
       0000000000000078  0000000000000000   A       0     0     8
  [11] .shstrtab         STRTAB           0000000000000000  00000180
       0000000000000062  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

重定位表

.rel.text段的类型(sh_type)为SHT_REL,是一个重定位表。

字符串表

ELF文件中用到了很多字符串,比如段名、变量名等。一种常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。

一般字符串表在ELF文件中也以段的形式保存,常见的段名为.strtab.shstrtab。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。

字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的就是段名。

链接的接口——符号

如果目标文件B用到了目标文件A中的函数foo,那么我们就称目标文件A**定义(Define)了函数foo,称目标文件B引用(Reference)**了目标文件A中的函数foo。这两个概念也同样适用于变量。

在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)

每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

符号的分类:

  • 定义在本目标文件的全局符号,可以被其他目标文件引用。
  • 在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号(External Symbol),也就是我们前面所讲的符号引用。
  • 段名,这种符号往往由编译器产生,它的值就是该段的起始地址。
  • 局部符号,这类符号只在编译单元内部可见。这些符号对于链接过程没有作用,链接器往往忽略它们。
  • 行号信息,即目标文件指令与源代码中代码行的对应关系。

ELF符号表结构

符号表通常位于文件中的.symtab段,符号表的结构如下:

typedef struct {
    Elf32_Word st_name;			// 符号名,该成员实际为该符号名在字符串表中的下标
    Elf32_Addr st_value;		// 符号对印的值
    Elf32_Word st_size;			// 符号大小
    unsigned char st_info;		// 符号类型和绑定信息
    unsigned char st_other;		// 目前为0,未被使用
    Elf32_Half st_shndx;		// 符号所在的段
} Elf32_Sym;

符号类型和绑定信息:该成员低4位表示符号的类型,高28位表示符号绑定信息。

符号所在段:如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标;更多情形请参考程序员的自我修养P83。

可以使用以下命令查看符号表:

readelf -s SimpleSection.o  # 注意是小写的s,大写的S表示查看段表

输出大致如下:

Symbol table '.symtab' contains 22 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS SimpleSection.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     8: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 static_var.1923
     9: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 static_var2.1924
    10: 0000000000000000     0 SECTION LOCAL  DEFAULT    9 
    11: 0000000000000000     0 SECTION LOCAL  DEFAULT   10 
    12: 0000000000000000     0 SECTION LOCAL  DEFAULT   11 
    13: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
    14: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 global_init_var
    15: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM global_uninit_var
    16: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    5 global
    17: 0000000000000000    11 FUNC    GLOBAL DEFAULT    6 foo
    18: 0000000000000000    40 FUNC    GLOBAL DEFAULT    1 func1
    19: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    20: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    21: 0000000000000028    55 FUNC    GLOBAL DEFAULT    1 main

特殊符号

当我们使用ld作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它:

  • __executable_start,该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址。

  • __etext或_etext或etext,该符号为代码段结束地址,即代码段最末尾的地址。

  • _edata或edata,该符号为数据段结束地址,即数据段最末尾的地址。

  • _end或end,该符号为程序结束地址。

符号修饰和函数签名

主要是关于name mangling的内容,不予记录。

extern “C”

主要用于C++程序和C程序的交互,不予记录。

弱符号和强符号

对于C/C++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号

我们还可以通过gcc的拓展__attribute__((weak))来将一个强符号定义为一个弱符号。

链接器会按如下规则处理与选择被多次定义的全局符号:

  1. 不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。
  2. 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
  3. 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。

强引用和弱引用

对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议。

对于强引用来说,如果没有找到该符号的定义,链接器就会报符号未定义错误。

对于弱引用来说,如果该符号未被定义,则链接器对于该引用不报错。

我们可以通过gcc的拓展__attribute__((weakref))声明对一个外部函数的引用为弱引用。

调试信息

如果我们在GCC编译时加上-g参数,编译器就会在产生的目标文件里面加上调试信息。

在Linux下,我们可以使用strip命令来去掉ELF文件中的调试信息。

静态链接

后面部分内容以如下两个文件为例:

// a.c
extern int shared;

int main() {
	int a = 100;
	swap(&a, &shared);
}

// b.c
int shared = 1;

void swap(int *a, int *b) {
	*a ^= *b ^= *a ^= *b;
}

通过gcc -c a.c b.c命令得到a.ob.o文件。

空间与地址分配

对于多个目标文件的链接可以采取按序叠加策略和相似段合并策略。

现在的链接器空间分配的策略基本上都采用相似段合并策略,使用这种方法的链接器一般都采用两步链接法

  1. 空间和地址分配。
  2. 符号解析与重定位。

我们可以使用如下命令链接两个目标文件:

ld a.o b.o -e main -o ab
  • -e main表示将main函数作为程序入口,ld链接器默认的程序入口为_start。
  • -o ab表示链接输出文件名为ab,默认为a.out。

符号解析与重定位

重定位

当源代码a.c在被编译成目标文件时,编译器并不知道“shared”和“swap”的地址,因为它们定义在其他目标文件中。所以编译器就暂时把地址0看作是“shared”的地址,-4看作是“swap”函数的地址。

链接之后,这两个参数的值都被设置成正确的值。

重定位表

对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。

重定位表往往是ELF文件中的一个重定位段

如果.text段有要被重定位的地方,那么就会一个.rel.text段保存了相应的重定位表。

如果.data段有要被重定位的地方,那么就会一个.rel.data段保存了相应的重定位表。

每个重定位的地方叫一个重定位入口

对于32位的Intel x86系列处理器来说,重定位表是一个Elf32_Rel结构的数组:

typedef struct {
    Elf32_Addr r_offset;	// 重定位入口的偏移地址。对于可重定位文件来说,这个值是要修正位置的第一个字节相对于段起始的偏移,对于可执行文件或共享对象文件来说,是要修正位置的第一个字节的虚拟地址
    Elf32_Word r_info;		// 低8位表示重定位入口的类型,高24位表示重定位入口的符号在符号表中的下标。
} Elf32_Rel;

可以使用以下指令来查看目标文件的重定位表:

objdump -r a.o

输出大致如下(以下结果从书上复制而来,是32位的输出结果):

a.o:     file format elf32-i386

RELOCATION RECORDS FOR [.text]:
OFFSET   TYPE              VALUE 
0000001c R_386_32          shared
00000027 R_386_PC32        swap

指令修正方式

指令有两种修正方式:

  • 重定位类型为R_386_32:绝对寻址修正,S+A
  • 重定位类型为R_386_PC32:相对寻址修正,S+A-P

A = 保存在被修正位置的值。

P = 被修正的位置(相对于段开始的偏移量或者虚拟地址),该值可通过r_offset计算得到。

S = 符号的实际地址,即由r_info的高24位指定的符号的实际地址。

绝对寻址修正和相对寻址修正的区别就是:

  • 绝对寻址修正后的地址为该符号的实际地址
  • 相对寻址修正后的地址为符号距离被修正位置的地址差

COMMON块

当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。

COMMON类型的链接规则是针对同名符号都是弱符号的情况。

如果其中有一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。

如果链接过程中有弱符号大小大于强符号,那么ld链接器会报如下警告:

ld: warning: alignment 4 of symbol 'global' in a.o is smaller than 8 in b.o

当编译器将一个编译单元编译成目标文件的时候,如果该编译单元包含了弱符号(未初始化的全局变量就是典型的弱符号),那么该弱符号最终所占空间的大小在此时是未知的,因为有可能其他编译单元中该符号所占的空间比本编译单元该符号所占的空间要大。所以编译器此时无法为该弱符号在BSS段分配空间,因为所须要空间的大小未知。但是链接器在链接过程中可以确定弱符号的大小,因为当链接器读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定了,所以它可以在最终输出文件的BSS段为其分配空间。所以总体来看,未初始化全局变量最终还是被放在BSS段的。

可以使用gcc拓展__attribute__(nocommon)来避免COMMON段的使用。

int global __attribute__((nocommon));

C++相关问题

重复代码消除

C++的语言特性导致它可能会产生大量的重复代码,如模板、虚函数表等。

以模板为例,一种有效的消除重复代码的手段是:将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例。当不同的编译单元以相同的类型实例化该模板后,也会生成同样的名字,进而链接的时候可以辨别出这些相同的模板实例段,然后进行合并。

全局构造与析构

ELF文件定义了两种特殊的段:

  • .init:该段里面保存的是可执行指令,它构成了进程的初始化代码。当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段的中的代码。
  • .fini:该段保存着进程终止代码指令。当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。

可以借助这两个特殊段实现C++的全局构造和析构函数。

静态库

静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。

可以使用以下命令查看静态库文件中包含了那些目标文件:

ar -t libc.a

可以使用ar的-x选项对静态库进行解压。

链接过程控制

一个链接脚本(a.script):

ENTRY(nomain)

SECTIONS
{
	. = 0x08048000 + SIZEOF_HEADERS;
	tinytext : { *(.text) *(.data) *(.rodata) }
	/DISCARD/ : { *(.comment) }
}

使用方法:

ld -T a.script a.o b.o

详细的编写规则指南请参考程序员的自我修养P127-131。

可执行文件的装载与进程

进程虚拟地址空间

操作系统中虚拟内存的内容,请参考操作系统相关书籍。

装载的方式

人们希望在不添加内存的情况下,让更多的程序运行起来,所以采用了一种动态装载的方法:将程序常用的部分驻留在内容中,而将一些不太常用的数据存放在磁盘中。

覆盖装入页映射是两种很经典的动态装载方法。

覆盖装入把管理内存的任务交给了程序员,页映射则把任务交给了操作系统。所以覆盖装入已经被淘汰了。

页映射

建立大致如下的映射关系:

程序员的自我修养阅读笔记_第3张图片

然后通过FIFO、LRU等算法决定如何淘汰内存页。

从操作系统的角度看可执行文件的装载

进程的建立

进程的建立有三个步骤:

  • 创建一个独立的虚拟地址空间。
  • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
  • 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

页错误

请参考操作系统相关书籍。

进程虚存空间分布

ELF文件链接视图和执行视图

我们可以将具有相同权限的段(Section)合并到一起当作一个段(Segment)进行映射。

描述Segment的结构叫做程序头(Program Header),可以通过如下命令来查看程序头:

readelf -l sleep.elf
# .elf文件通过`gcc -static sleep.c -o sleep.elf`命令得到

输出大致如下:

Elf file type is EXEC (Executable file)
Entry point 0x401bc0
There are 10 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000000518 0x0000000000000518  R      0x1000
  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
                 0x00000000000937ed 0x00000000000937ed  R E    0x1000
  LOAD           0x0000000000095000 0x0000000000495000 0x0000000000495000
                 0x0000000000026655 0x0000000000026655  R      0x1000
  LOAD           0x00000000000bc0c0 0x00000000004bd0c0 0x00000000004bd0c0
                 0x0000000000005170 0x00000000000068c0  RW     0x1000
  NOTE           0x0000000000000270 0x0000000000400270 0x0000000000400270
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x0000000000000290 0x0000000000400290 0x0000000000400290
                 0x0000000000000044 0x0000000000000044  R      0x4
  TLS            0x00000000000bc0c0 0x00000000004bd0c0 0x00000000004bd0c0
                 0x0000000000000020 0x0000000000000060  R      0x8
  GNU_PROPERTY   0x0000000000000270 0x0000000000400270 0x0000000000400270
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x00000000000bc0c0 0x00000000004bd0c0 0x00000000004bd0c0
                 0x0000000000002f40 0x0000000000002f40  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.property .note.gnu.build-id .note.ABI-tag .rela.plt 
   01     .init .plt .text __libc_freeres_fn .fini 
   02     .rodata .stapsdt.base .eh_frame .gcc_except_table 
   03     .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data __libc_subfreeres __libc_IO_vtables __libc_atexit .bss __libc_freeres_ptrs 
   04     .note.gnu.property 
   05     .note.gnu.build-id .note.ABI-tag 
   06     .tdata .tbss 
   07     .note.gnu.property 
   08     
   09     .tdata .init_array .fini_array .data.rel.ro .got 

从Section的角度看ELF文件就是链接视图,从Segment的角度看ELF文件就是执行视图

ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存Segment的信息。程序头表是如下结构体的数组(32位):

typedef struct {
    Elf32_Word p_type;			// Segment的类型,如LOAD、DYNAMIC等
    Elf32_Off p_offset;			// Segment在文件中的偏移
    Elf32_Addr p_vaddr;			// Segment第一个字节在进程虚拟地址空间的起始位置
    Elf32_Addr p_paddr;			// Segment的物理装载地址
    Elf32_Word p_filesz;		// Segment在ELF文件中所占的长度
    Elf32_Word p_memsz;			// Segment在虚拟地址空间所占的长度
    Elf32_Word p_flags;			// Segment的权限属性,如R,W,X等
    Elf32_Word p_align;			// Segment的对齐属性
} Elf32_Phdr;

堆和栈

Linux下可以通过/proc文件夹来查看进程的虚拟空间分布:

# 首先让程序在后台运行
$ ./sleep.elf&
[1] 1644
# 然后根据1644这个进程ID(PID)来找到/proc文件下的对应进程的maps文件
$ cat /proc/1644/maps
# 以下内容复制自书中的输出,我自己电脑上的输出结果比这个复杂一些
08048000-080b9000 r-xp 00000000 08:01 2801887    ./sleep.elf
080b9000-080bb000 rwxp 00070000 08:01 2801887    ./sleep.elf
080bb000-080de000 rwxp 080bb000 00:00 0          [heap]
bf7ec000-bf802000 rw-p bf7ec000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]

段地址对齐

实际中,通常每个段的长度都不是页长度的整数倍,一种最简单的映射办法就是每个段分开映射,对于长度不足一个页的部分则占一个页。但是这样可能会导致非常多的内部碎片,浪费磁盘空间。

为了解决这种问题,有些UNIX系统采用了一个很取巧的办法,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次。

程序员的自我修养阅读笔记_第4张图片

Linux内核装载ELF过程简介

ELF可执行文件的装载处理过程叫做load_elf_binary(),它的主要步骤是:

  1. 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
  2. 寻找动态链接的“.interp”段,设置动态链接器路径。
  3. 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
  4. 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址。
  5. 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。

动态链接

为什么要动态链接

动态链接可以节省内存和磁盘空间,方便程序的开发与发布。

动态链接把链接的过程推迟到了运行时

简单的动态链接

可以使用如下命令将一个代码源文件编译成一个共享对象文件:

gcc -fPIC -shared -o Lib.so Lib.c

然后通过以下命令使用该共享对象文件:

gcc -o p1 p1.c ./Lib.so

同样可以使用readelf -l Lib.so命令来查看共享对象文件的程序头(Program Header),输出大致如下:

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

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000560 0x0000000000000560  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000179 0x0000000000000179  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x00000000000000dc 0x00000000000000dc  R      0x1000
  LOAD           0x0000000000002e10 0x0000000000003e10 0x0000000000003e10
                 0x0000000000000220 0x0000000000000228  RW     0x1000
  DYNAMIC        0x0000000000002e20 0x0000000000003e20 0x0000000000003e20
                 0x00000000000001c0 0x00000000000001c0  RW     0x8
  NOTE           0x00000000000002a8 0x00000000000002a8 0x00000000000002a8
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x00000000000002c8 0x00000000000002c8 0x00000000000002c8
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_PROPERTY   0x00000000000002a8 0x00000000000002a8 0x00000000000002a8
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_EH_FRAME   0x000000000000201c 0x000000000000201c 0x000000000000201c
                 0x000000000000002c 0x000000000000002c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000002e10 0x0000000000003e10 0x0000000000003e10
                 0x00000000000001f0 0x00000000000001f0  R      0x1

 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 .plt.sec .text .fini 
   02     .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.gnu.property 
   06     .note.gnu.build-id 
   07     .note.gnu.property 
   08     .eh_frame_hdr 
   09     
   10     .init_array .fini_array .dynamic .got 

地址无关代码

共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。(但可执行文件基本可以确定)

为了使共享对象能够在任意地址加载,那么就需要对程序进行重定位。静态链接时采用的重定位叫做链接时重定位,而动态链接需要采用装载时重定位

gcc参数-shared即表示输出的共享对象使用装载时重定位。

地址无关代码

共享对象模块中的地址引用大致可以分为4种情况:

  1. 模块内部的函数调用、跳转等。

    因为跳转采用的是相对地址,所以这种情况不需要重定位。

  2. 模块内部的数据访问。

    一种可行的方法是通过thunk函数实现。大致思路就是根据当前的PC值,然后加上偏移量,最终得到变量的地址。

  3. 模块外部的数据访问。

    ELF会在数据段里建立一个全局偏移表(GOT),里面存放着变量的偏移值。在编译时可以确定GOT相对于当前指令的偏移,然后根据GOT表里的偏移值,经计算就可以得到变量的地址。

  4. 模块外部的函数调用、跳转等。

    同样借助GOT表进行实现,不过GOT表中存储的直接就是目标函数的地址。要调用目标函数时,可以直接通过GOT中的项进行间接跳转。

-fpic与-fPIC

gcc的-fPIC参数即可以产生位置无关代码。

-fpic-fPIC的区别是:

  • -fpic产生的代码较小,而且较快。而-fPIC产生的代码较大。
  • -fPIC的可移植性比-fpic更好。

我们通常使用-fPIC就好了。

可以使用如下命令判断一个共享对象文件是否为PIC的:

readelf -d foo.so | grep TEXTREL

如果有输出的话,就说明该文件不是PIC的。

共享模块的全局变量问题

如果共享对象定义了一个全局变量global,那么gcc在-fPIC的情况下,会把对global的引用按照跨模块模式产生代码。

数据段地址无关性

如果数据段有绝对地址引用,如:

static int a;
static int *p =a;

那么编译器和链接器会产生一个重定位表,里面包含了一些重定位入口。

在装载时,如果动态链接器发现共享对象有这样的重定位入口,就会对该共享对象进行重定位。

如果我们在编译共享对象时不使用-fPIC选项,那么就会产生一个不使用地址无关代码,而使用装载时重定位的共享对象。

如果代码不是地址无关的,那么它就不能被多个进程之间共享。

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PIC的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。

延迟绑定(PLT)

延迟绑定实现

延迟绑定的基本思想就是函数第一次被用到时才进行绑定

ELF通过PLT(Procedure Linkage Table)来实现延迟绑定。

当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项。

以bar()函数为例,设其在PLT中的项的地址为bar@plt,它的实现大致如下:

bar@plt:
	jmp *(bar@GOT)
	push n
	push moduleID
	jmp _dl_runtime_resolve

第一次运行jmp *(bar@GOT)时,会跳到下一条指令push n,执行完_dl_runtime_resolve函数后,会将bar()的真正地址填入bar@got中。所以之后的jmp *(bar@GOT)都会直接跳转到bar()函数中。

ELF将GOT拆分成两个表——.got.got.plt,其中.got用来存放全局变量的引用地址,.got.plt用来存放函数引用的地址。

.got.plt的前3项时具有特殊意义的:

  • 第一项保存的是.dynamic段的地址,这个段描述了本模块动态链接相关的信息。
  • 第二项保存的是本模块的ID。
  • 第三项保存的是_dl_runtime_resolve()的地址。

第二项和第三项由动态链接器在装载共享模块时将它们初始化。

动态链接相关结构

动态链接时,操作系统首先会读取可执行文件的头部,检查文件的合法性,然后从头部中的Program Header中读取每个Segment的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,但是接下来不会和静态链接一样的将控制权交给可执行文件的入口地址,而是会先启动一个动态链接器,并将控制权交给动态链接器,等待动态链接工作完成后,动态链接器会将控制权转交给可执行文件的入口地址。

.interp段

在动态链接的ELF可执行文件中,有一个专门的.interp段用来指明所需动态链接器的路径。

我们可以通过以下命令来查看.interp段的内容:

objdump -s a.out

还可以通过readelf工具来查看动态链接器的路径:

readelf -l a.out | grep interpreter

.dynamic段

动态链接ELF中最重要的结构是.dynamic段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。

该段是如下结构的数组:

typedef struct {
    Elf32_Sword d_tag;
    union {
        Elf32_Word d_val;
        Elf32_Addr d_ptr;
    } d_un;
} Elf32_Dyn;

关于参数的可选值,请参考程序员的自我修养P205。

我们可以使用readelf工具来查看.dynamic段的内容:

readelf -d Lib.so

输出大致如下:

Dynamic section at offset 0x2e20 contains 24 entries:  Tag        Type                         Name/Value 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6] 0x000000000000000c (INIT)               0x1000 0x000000000000000d (FINI)               0x116c 0x0000000000000019 (INIT_ARRAY)         0x3e10 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes) 0x000000000000001a (FINI_ARRAY)         0x3e18 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes) 0x000000006ffffef5 (GNU_HASH)           0x2f0 0x0000000000000005 (STRTAB)             0x3d8 0x0000000000000006 (SYMTAB)             0x318 0x000000000000000a (STRSZ)              127 (bytes) 0x000000000000000b (SYMENT)             24 (bytes) 0x0000000000000003 (PLTGOT)             0x4000 0x0000000000000002 (PLTRELSZ)           48 (bytes) 0x0000000000000014 (PLTREL)             RELA 0x0000000000000017 (JMPREL)             0x530 0x0000000000000007 (RELA)               0x488 0x0000000000000008 (RELASZ)             168 (bytes) 0x0000000000000009 (RELAENT)            24 (bytes) 0x000000006ffffffe (VERNEED)            0x468 0x000000006fffffff (VERNEEDNUM)         1 0x000000006ffffff0 (VERSYM)             0x458 0x000000006ffffff9 (RELACOUNT)          3 0x0000000000000000 (NULL)               0x0

我们可以使用ldd工具来查看一个程序主模块或共享库依赖于哪些共享库。

ldd p1

输出大致如下:

	linux-vdso.so.1 (0x00007fffed158000)	./Lib.so (0x00007fc317bd0000)	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fc3179d0000)	/lib64/ld-linux-x86-64.so.2 (0x00007fc317bf4000)

动态符号表

p1程序依赖于Lib.so,引用到了里面的foobar()函数,我们就称p1导入(Import)了foobar函数,foobar是p1的导入函数(Import Function)

站在Lib.so的角度来看,它实际上定义了foobar()函数,并且提供给其他模块使用,我们称Lib.so导出(Export)了foobar()函数,foobar是Lib.so的导出函数(Export Function)。

ELF有一个专门的动态符号表段用来保存符号的导入导出关系。这个段的段名通常叫做.dynsym.dynsym段只保存与动态链接相关的符号。

很多时候动态链接的模块同时拥有.dynsym.symtab两个表,.symtab中往往保存了所有符号,包括.dynsym中的符号。

动态符号表可能有一些辅助的表,如动态符号字符串表.dynstr和符号哈希表.hash

我们可以使用readelf工具来查看ELF文件的动态符号表和哈希表:

readelf -sD Lib.so

动态链接重定位表

就算一个动态链接的可执行文件(或共享对象)使用的是PIC方法,它仍然需要重定位。

动态链接重定位相关结构

与静态链接的.rel.text.rel.data段类似,动态链接拥有与之类似的.rel.dyn(修正数据段)和.rel.plt(修正函数引用)。

可以使用如下指令来查看一个动态链接文件的重定位表:

readelf -r Lib.so

输出大致如下:

Relocation section '.rela.dyn' at offset 0x488 contains 7 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000003e10  000000000008 R_X86_64_RELATIVE                    1130
000000003e18  000000000008 R_X86_64_RELATIVE                    10f0
000000004028  000000000008 R_X86_64_RELATIVE                    4028
000000003fe0  000100000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000003fe8  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000003ff0  000400000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000003ff8  000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section '.rela.plt' at offset 0x530 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000004018  000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000004020  000500000007 R_X86_64_JUMP_SLO 0000000000000000 sleep@GLIBC_2.2.5 + 0

观察输出的Type字段,其中R_X86_64_JUMP_SLO是对.got.plt的重定位,而R_X86_64_GLOB_DAT则是对.got的重定位。

值得关注的是R_X86_64_RELATIVE类型的重定位入口,这种重定位实际上就是基址重置,对于:

static int a;
static int *p = a;

在编译时,共享对象的地址是从0开始的,我们假设该静态变量a相对于起始地址0的偏移为B,即p的值为B。一旦共享对象被装载到地址A,那么实际上该变量a的地址为A+B,即p的值需要加上一个装载地址A。R_X86_64_RELATIVE类型的重定位入口就是专门用来重定位指针变量p这种类型的,变量p在装载时需要加上一个装载地址值A,才是正确的结果。

动态链接时进程堆栈初始化信息

进程初始化时,堆栈中不仅有进程执行环境和命令行参数等信息,还保存了动态链接器所需的一些辅助信息数组:

typedef struct {
    uint32_t a_type;
    union {
        uint32_t a_val;
    } a_un;
} Elf32_auxv_t;

具体类型值请参考程序员的自我修养P211-212。

动态链接的步骤与实现

动态链接的过程可分为3步:

  1. 启动动态链接器本身。
  2. 装载所有需要的共享对象。
  3. 重定位和初始化。

动态链接器本身也是一个共享对象,所以它要实现自举(这个过程需要受到很多限制)。

装载共享对象

完成基本自举后,动态链接器会将可执行文件和链接器本身的符号表都合并到一个符号表当中,称为全局符号表

符号优先级:当一个符号被加入到全局符号表时,如果相同的符号名已经存在,则后加入的符号会被忽略

显式运行时链接

主要就是以下函数的使用,以下代码复制自我的CSAPP阅读笔记:

#include 

void *dlopen(const char *filename, int flag); 
// 加载和链接共享库filename, 用以用RTLD_GLOBAL选项打开的库解析filename种的外部符号
// flag参数要么包括RTLD_NOW, 告诉链接器立即解析对外部符号的引用
// 要么包括RTLD_LAZY, 告诉链接器推至符号解析直到执行来自库中的代码
// 如果成功则返回指向句柄的指针,若出错则返回NULL

void *dlsym(void *handle, char *symbol);
// 输入一个指向前面已经打开的共享库的句柄和一个symbol名字
// 如果该符号存在,就返回该符号的地址,否则返回NULL

int dlclose(void *handle);
// 如果没有其他共享库还在使用这个库,dlclose函数就卸载这个共享库

const char *dlerror(void);
//如果前面对于dlopen, dlsym, dlclose的调用失败,则为错误信息,否则为NULL

Linux共享库的组织

共享库版本

共享库兼容性

共享库的更新可以简单的分为两类:

  • 兼容更新。所有的更新只是在原有的共享库基础上添加一些内容,所有原有的接口都保持不变。
  • 不兼容更新。共享库更新改变了原有的接口,使用该共享库原有接口的程序可能不能运行或运行不正常

以下是常见的更改类型对兼容性的影响:

程序员的自我修养阅读笔记_第5张图片

共享库版本命名

Linux中,共享库的文件命名规则必须如libname.so.x.y.z

最前面使用前缀“lib”、中间是库的名字和后缀“.so”,最后面跟着的是三个数字组成的版本号。“x”表示主版本号(Major Version Number),“y”表示次版本号(Minor Version Number),“z”表示发布版本号(Release Version Number)。

主版本号表示库的重大升级,不同主版本号的库之间是不兼容的。

次版本号表示库的增量升级,向后兼容低版本号的库(主版本号相同的情况下)。

发布版本号表示库的一些错误的修正、性能的改进,不修改和添加接口,不同的发布版本号之间完全兼容(主、次版本号都相同的情况下)。

SO-NAME

每个共享库都对应一个SO-NAME,SO-NAME即共享库的文件名去掉次版本号和发布版本号。

在Linux系统中,系统会为每个共享库在它所在的目录创建一个跟“SO-NAME”相同的并且指向它的软链接(Symbol Link)。

SO-NAME表示一个库的接口,接口不向后兼容,SO-NAME就发生变化。

链接名

在使用gcc时,我们可以使用-l参数来链接共享库。比如需要链接一个libXXX.so.2.6.1的共享库,只需要在编译器命令行里面指定-lXXX即可。

符号版本

基于符号的版本机制

基本思想就是为每一个导出和导入的符号都关联一个版本号。

我们可以观察前几章的部分输出,一些函数,如sleep,是以sleep@GLIBC_2.2.5的形式出现的。

共享库系统路径

FHS(File Hierachy Standard)标准规定,一个系统中主要有3个存放共享库的位置:

  • lib,这个位置主要存放系统最关键和基础的共享库,比如动态链接器、C语言运行库、数学库等,这些库主要是那些/bin和/sbin下的程序所需要用到的库,还有系统启动时需要的库。
  • /usr/lib,这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序或shell脚本直接用到。这个目录下面还包含了开发时可能会用到的静态库、目标文件等。
  • /usr/local/lib,这个目录用来放置一些跟操作系统本身并不十分相关的库,主要是一些第三方的应用程序的库。

共享库查找过程

任何一个动态链接的模块所依赖的模块路径保存在“.dynamic”段里面,由DT_NEED类型的项表示。动态链接器对于模块的查找有一定的规则:如果DT_NEED里面保存的是绝对路径,那么动态链接器就按照这个路径去查找;如果DT_NEED里面保存的是相对路径,那么动态链接器会在/lib、/usr/lib和由/etc/ld.so.conf配置文件指定的目录中查找共享库。

为了加快共享库的查找,Linux系统中有一个缓存文件,位于/etc/ld/so/cache。缓存未命中时,才回去遍历/lib与/usr/lib。如果还未找到,则报错。

环境变量

LD_LIBRARY_PATH

这个环境变量可以用来临时改变某个应用程序的共享库查找路径。(查找优先级在cache之上)

LD_PRELOAD

这个文件中我们可以指定预先装载的一些共享库甚或是目标文件。在LD_PRELOAD里面指定的文件会在动态链接器按照固定规则搜索共享库之前装载,它比LD_LIBRARY_PATH里面所指定的目录中的共享库还要优先。无论程序是否依赖于它们,LD_PRELOAD里面指定的共享库或目标文件都会被装载。

LD_DEBUG

这个变量可以打开动态链接器的调试功能,进而使动态链接器运行时打印出一些有用的信息。

共享库的创建与安装

共享库的创建

最关键的参数即-shared-fPIC,我们还可以使用-W1,-soname,(是使用者指定的字符串)来指定输出共享库的SO-NAME。

除了使用LD_LIBRARY_PATH之外,我们还可以使用ld的-rpath参数,或者gcc的-W1,-rpath,参数来指定共享库查找路径。

清除符号信息

我们可以使用strip工具来清除共享库或可执行文件的所有符号和调试信息。

strip libfoo.so

除了使用“strip”工具,我们还可以使用ld的“-s”和“-S”参数,使得链接器生成输出文件时就不产生符号信息。“-s”和“-S”的区别是:“-S”消除调试符号信息,而“-s”消除所有符号信息。我们也可以在gcc中通过“-Wl,-s”和“-Wl,-S”给ld传递这两个参数。

共享库的安装

最简单的办法就是将共享库复制到某个标准的共享库目录,如/lib、/usr/lib等,然后运行ldconfig。

共享库的构造和析构函数

GCC提供了一种共享库的构造函数,只要在函数声明时加上__attribute__((constructor))的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行(同样会在dlopen之前执行)。

与共享库构造函数相对应的是析构函数,我们可以使用在函数声明时加上__attribute__((destructor))的属性,这种函数会在main()函数执行完毕之后执行(或者是程序调用exit()时执行)。

声明语法如下:

void __attribute__((constructor)) init_function(void);
void __attribute__((destructor)) fini_function(void);

有多个构造和析构函数时,我们还可以给它们指定优先级:

void __attribute__((constructor(5))) init_function(void);
void __attribute__((constructor(10))) init_function2(void);

对于构造函数来说,优先级数字越小越先执行,而对于析构函数来说,则刚好相反。

内存

程序的内存布局

一般来说,应用程序使用的内存空间中有如下区域:

  • 栈:栈用于维护函数调用的上下文。栈通常在用户空间的最高地址处分配,通常有数兆字节的大小。
  • 堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆通常存在于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量。
  • 可执行文件映像:存储可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。
  • 保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,比如NULL。
  • 动态链接库映射区:用于映射装载的动态链接库。

注:实际的地址可能和图中显示的地址并不相同。

栈在程序运行中具有举足轻重的作用,栈中保存了一个函数调用所需要维护的信息,这常常被称为堆栈帧活动记录

堆栈帧一般包含如下内容:

  • 函数的返回地址与参数。
  • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器(callee save registers)

在i386下,一个函数的调用总是这样调用的:

  1. 把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。
  2. 把当前指令的下一条指令的地址压入栈中。
  3. 跳转到函数体执行。

而i386函数的’标准’开头是这样的(也可以不遵循):

  1. push ebp:把ebp压入栈中(称为old ebp)。
  2. mov ebp, esp:ebp = esp(这时ebp指向栈顶,而此时栈顶就是old ebp)。
  3. [可选] sub esp, XXX:在栈上分配XXX字节的临时空间。
  4. [可选] push XXX:如有必要,保存名为XXX寄存器(可重复多个)。

'标准’结尾则是这样的:

  1. [可选] pop XXX:如有必要,恢复保存过的寄存器(可重复多个)。
  2. mov esp, ebp:恢复ESP同时回收局部变量空间。
  3. pop ebp:从栈中恢复保存的ebp的值。
  4. ret:从栈中取得返回地址,并跳转到该位置。

调用惯例

函数的调用方和被调用方对于函数如何调用须要有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确地调用,这样的约定就称为调用惯例(Calling Convention)。

调用管理一般包含以下几个方面的内容:

  • 函数参数的传递顺序和方式。如通过栈传递,参数从右至左压栈。
  • 栈的维护方式。
  • 名字修饰(Name-mangling)的策略。

在C语言中,有多个调用惯例,默认地调用管理是cdecl

在gcc中可以通过以下形式显式的指明调用惯例:

int __attribute__((cdecl)) foo(int n, float m) { }

一些主要的调用惯例:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7ojKupEq-1628160457035)(https://i.loli.net/2021/06/01/UJx6Rhv5OyuZkqC.png)]

函数返回值传递

以下内容均基于i386。

对于4字节及以下的返回对象,通过eax返回。对于5-8字节的返回对象,通过eax和edx联合返回。

对于更大的返回对象,一种方式会把返回值当成一个参数,进而借助于栈来实现。

堆与内存管理

Linux进程的堆管理主要通过brk()系统调用和malloc()函数来实现。

可能会采用空闲链表或位图或其他方式来作为堆的分配算法。

更多细致的内容可以参考操作系统相关书籍。

你可能感兴趣的:(C++,操作系统,编译,链接)