程序的一生:从源程序到进程的辛苦历程

更新于 3月17日  约 71 分钟

摘要:一个程序的一生,从源程序到进程的辛苦历程!本文不深入研究编译原理、操作系统原理,主要聚焦于程序的加载和链接。


一、前言

作为计算机专业的人,最遗憾的就是在学习编译原理的那个学期被别的老师拉去干活了,而对一个程序怎么就从源代码变成了一个在内存里活灵活现的进程,一直也心怀好奇。这种好奇驱使我要找个机会深入了解一下,所以便有了本文,来督促自己深入研究程序的一生。不过,本文没有深入研究编译原理、操作系统原理,而是主要聚焦于程序的链接和加载。

学习的过程中主要参考了三本书、一个视频、一个音频(文末有列出),三本书里,最主要的还是《程序员的自我修养 - 链接、装载与库》,里面的代码放到了我的github上,并且配有shell脚本和说明,运行后可以实操理解到更多内容。

南大袁春风老师的计算机原理讲解对我帮助最大,视频是最直接传达知识的方式。另外,为了方便自己的实验,制作了一个ubuntu的环境,并且内置了代码,方便实验:阿里docker镜像

docker pull registry.cn-hangzhou.aliyuncs.com/piginzoo/learn:1.0

二、概述

每天都有无数的程序被编译、部署,不停地跑着,它们干着千奇百怪的事情。如同这个光怪陆离的世界,是由每个人、每个个体组成的,如果我们剖析每个人,会发现他们其实都是一样的结构,都是由细胞、组织组成,再深究便是基因了,DNA里那一个个的“核苷酸基”决定了他们。

同样,通过这个隐喻来认知计算机,我们可以知道,计算机的基因和本质就是冯诺依曼体系。啥是冯诺依曼体系呢?通俗地讲,就是定义了整个硬件体系(CPU、外存、输入输出),以及执行的运行流程等等。可是,一个程序怎么就与硬件亲密无间地运行起来了呢?应该很多人都不了解,甚至包括许多计算机专业的同学们。

本质上来说,这个过程其实就是“从代码编译,然后不同目标文件链接,最终加载到内存中,被操作系统管理起来的一个进程,可能还会动态地再去链接其他的一些程序(如动态链接库)的过程”。看起来似乎很简单,但其实每个部分都隐藏着很多细节,好奇心很强的你一定想知道,到底计算机是怎么做到的。

本文不打算讨论硬件、进程、网络等如此庞大的体系,只聚焦于探索程序的链接和加载这两个主题。

三、基础

探索之前需要交代一些基础知识,不然无法理解链接和加载。

3.1 硬件基础

3.1.1 CPU

1.jpg

CPU由一大堆寄存器、算数逻辑单元(就是做运算的)、控制器组成。每次通过PC(程序计数器,存着指令地址)寄存器去内存里寻址可执行二进制代码,然后加载到指令寄存器里,如果涉及到地址的话,再去内存里加载数据,计算完后写回到内存里。每条指令都会放到指令寄存器(IR)中,等着CPU去取出来运行。

指令是从硬盘加载到内存里,又从内存里加载到IR里面的。指令运行过程中需要一些数据,这又要求从内存里取出一些数据放到通用寄存器中,然后交给ALU去运算,结果出来后又会放到寄存器或者内存中,周而复始。

每一步都是一个时钟周期,现在的CPU一秒钟可以做1G次,是1000000000,几十亿次/秒。目前市场上的CPU主频据说到4GHz就到极限了,限于工艺,上不去了,所以慢慢转为多核,就是把几个CPU封装到一起共享内部缓存。

3.1.2 主板

2.jpg

如图,我们经常听说的“北桥、南桥”是什么?

北桥其实就是一个计算机结构,准确地说是一个芯片,它连接的都是高速设备,通过PCI总线,把cpu、内存、显卡串在一起;而南桥就要慢很多了,连接的都是鼠标、键盘、硬盘等这些“穷慢”亲戚,它们之间用ISA总线串在一起。

3.1.3 硬盘

硬盘硬件上是盘片、磁道、扇区这样的一个结构,太复杂了,所以从头到尾给这些扇区编个号,就是所谓的“LBA(Logical Block Address)”逻辑扇区的概念,方便寻址。

为了隔离,每个进程有一个自己的虚拟地址空间,然后想办法给它映射到物理内存里。如果内存不够怎么办?就想到了再细分,就是分页,分成4k的一个小页,常用的在内存里,不常用的交换到磁盘上。这就要经常用到地址映射计算(从虚拟地址到物理地址),这个工作就是MMU(Memory Management Unit),为了快都集成到CPU里面了。

3.1.4 输入输出设备

还有很多外设负责输入输出,一旦被外界输入或要输出东西,就得去告诉CPU:“我有东西了,来取吧”;“我要输出啦,来帮我输出吧”。这些工作就要靠一个叫“中断”的机制,可以将“中断”理解成一种消息机制,用于通知CPU来帮我干活。不是每个部分都可以直接骚扰CPU的,它们都要通过中断控制器来集中骚扰CPU。

这些外设都有自己的buffer,这些buffer也得有地址,这个地址叫端口

3.jpg

还得给每个设备编个号,这样系统才能识别谁是谁。每次中断,CPU一看,噢,原来是05,05是键盘啊;06,06是鼠标啊。这个号,叫中断编号(IRQ)

每次都必须要骚扰CPU吗?直接把数据从外设的buffer(端口)灌到内存里,不用CPU参与,多好啊!对,这个做法就是DMA。每个DMA设备也得编个号,这个编号就是DMA通道,这些号可不能冲突哦。

4.jpg

3.2 汇编基础

对于汇编,我其实也忘光了,所以得补补汇编知识了,起码要能读懂一些基础的汇编指令。

3.2.1 汇编语法

汇编分门派呢!”AT&T语法” vs “Intel语法”:GUN GCC使用传统的AT&T语法,它在Unix-like操作系统上使用,而不是dos和windows系统上通常使用的Intel语法。

最常见的AT&T语法的指令:movl、%esp、%ebp。movl是一个最常见的汇编指令的名称,百分号表示esp和ebp是寄存器。在AT&T语法中,有两个参数的时候,始终先给出源source,然后再给出目标destination

AT&T语法:

<指令> [源] [目标]

3.2.2 寄存器

寄存器是存放各种给cpu计算用的地址、数据用的,可以认为是为CPU计算准备数据用的。一般分为8类:

5.png

命名上,x86一般是指32位;x86-64一般是指64位。32位寄存器,一般都是e开头,如eax、ebx;64位寄存器约定以r开头,如rax、rbx。

1)32位寄存器

32位CPU一共有8个寄存器。

6.png

详细的介绍:

7.png

2)64位寄存器有:32个

8.png

两者的区别:

  • 64位有16个寄存器,32位只有8个。但32位前8个都有不同的命名,分别是e _ ,而64位前8个使用了r代替e,也就是r 。e开头的寄存器命名依然可以直接运用于相应寄存器的低32位。而剩下的寄存器名则是从r8 - r15,其低位分别用d,w,b指定长度。
  • 32位寄存器使用栈帧作为传递参数的保存位置,而64位寄存器分别用rdi、rsi、rdx、rcx、r8、r9作为第1-6个参数,rax作为返回值。
  • 32位寄存器用ebp作为栈帧指针,64位寄存器取消了这个设定,没有栈帧的指针,rbp作为通用寄存器使用。
  • 64位寄存器支持一些形式以PC相关的寻址,而32位只有在jmp的时候才会用到这种寻址方式。

对了,寄存器可不是L1、L2 cache啊!Cache位于CPU与主内存间,分为一级Cache (L1Cache)和二级Cache (L2Cache),L1 Cache集成在CPU内部,L2 Cache早期在主板上,现在也都集成在CPU内部了,常见的容量有256KB或512KB。寄存器很少的,拿64位的来说,也就是16个,64x16,也就是1024,1K。

总结:大致来说数据是通过内存-Cache-寄存器,Cache缓存是为了弥补CPU与内存之间运算速度的差异而设置的部件。

3.2.3 寻址方式

接下来说说寻址,寻址就是告诉CPU去哪里取指令、数据。比如movl %rax %rbx,这个涉及到寻址,寻址会寻“寄存器”、“内存”,可以是暴力的直接寻址,也可以是委婉的间接寻址。下面是各种寻址方式:

9.png

你可能会看到这种指令movl,movw,mov后面的l、w是什么鬼?

10.png

就是一次搬运的数据数量。

3.2.4 常用的指令

最后说说指令本身,每个CPU类型都有自己的指令集,就是告诉CPU干啥,比如加、减、移动、调用函数等。下面是一些非常常用的指令:

11.png

参考:愿意自虐的同学,可以下载【Intel官方的指令集手册】仔细研读。

3.3 一些工具和玩法

本文还会涉及到一些工具:

  • gcc:超级编译工具,可以做预编译、编译成汇编代码、静态链接、动态链接等,本质上是各种编译过程工具的一个封装器。
  • gdb:太强了,命令行的调试工具,简直是上天入地的利器。
  • readelf:可以把一个可执行文件、目标文件完全展示出来,让你观瞧。
  • objdump:跟readelf功能差不多,不过貌似它依赖一个叫“bfd库”的玩意儿,我也没研究,另外,它有个readelf不具备的功能:反编译。剩下的两者都差不多了。
  • ldd:这个小工具也很酷,可以让你看一个动态链接库文件依赖于哪些其它的动态链接库。
  • cat /proc/<PID>/maps:这个命令很有趣,可以让你看到进程的内存分布。

还有各种利器,自己去探索吧。

3.4 其他

3.4.1 地址编码

假如有个整形变量1234,16进制是0x000004d2,占4个字节,起始地址是0x10000,终止地址是0x10003,那么在外界看来,是它的地址是0x10000还是0x10003呢?答案是0x10000。

那么问题来了,这4个字节里怎么放这个数?高地址放高位,还是低地址放高位?答案是,都可以!

大端方式:高位在低地址,如 IBM360/370,MIPS

12.png

小端方式:高位在高地址,如 Intel 80x86

13.png

四、编译

由于我没学过编译,对词法分析、语法分析也不甚了解,找机会再深入吧,这里只是把大致知识梳理一下。

词法分析->语法分析->语义分析->中间代码生成->目标代码生成

4.1 词法分析

通过FSM(有限状态机)模型,就是按照语法定义好的样子,挨个扫描源代码,把其中的每个单词和符号做个归类,比如是关键字、标识符、字符串还是数字的值等,然后分门别类地放到各个表中(符号表、文字表)。如果不符合语法规则,在词法分析过程中就会给出各类警告,咱们在编译过程中看到的很多语法错误就是它干的。有个开源的lex的程序,可以体会这个过程。

4.2 语法分析

由词法分析的符号表,要形成一个抽象语法树,方法是“上下文无关语法(CFG)”。这过程就是把程序表示成一棵树,叶子节点就是符号和数字,自上而下组合成语句,也就是表达式,层层递归,从而形成整个程序的语法树。同上面的词法分析一样,也有个开源项目可以帮你做这个树的构建,就是yacc(Yet Another Compiler Compiler)。

4.3 语义分析

这个步骤,我理解要比语法分析工作量小一些,主要就是做一些类型匹配、类型转换的工作,然后把这些信息更新到语法树上。

4.4. 中间语言生成

把抽象语法树转成一条条顺序的中间代码,这种中间代码往往采用三地址码或者P-Code的格式,形如x = y op z。长成这个样子:

t1 = 2 + 6
array[index] = t1

不过这些代码是和硬件不相关的,还是“抽象”代码。

4.5 目标代码生成

目标代码生成就是把中间代码转换成目标机器代码,这就需要和真正的硬件以及操作系统打交道了,要按照目标CPU和操作系统把中间代码翻译成符合目标硬件和操作系统的汇编指令,而且,还要给变量们分配寄存器、规定长度,最后得到了一堆汇编指令。

对于整形、浮点、字符串,都可以翻译成把几个bytes的数据初始化到某某寄存器中,但是对于数组等其它的大的数据结构,就要涉及到为它们分配空间了,这样才可以确定数组中某个index的地址。不过,这事儿编译不做,留给链接去做。

编译不是本文重点,这里就不过多讨论了,感兴趣的同学,可以读读这篇:《自己动手写编译器》

五、链接

编译一个c源文件代码,就会对应得到一个目标文件。一个项目中会有一堆的c源代码,编译后会得到一堆的目标文件。这些目标文件是二进制的,就是一堆0、1的集合,到底这一堆0、1是如何排布的呢?接下来,我们得说一说,这些0、1组成的目标文件了。

5.1 目标文件

目标文件是没有链接的文件(一个目标文件可能会依赖其它目标文件,把它们“串”起来的过程,就是链接)。这些目标文件已经和这台电脑的硬件及操作系统相关了,比如寄存器、数据长度,但是,对应的变量的地址没有确定。

目标文件里有数据、机器指令代码、符号表(符号表就是源码里那些函数名、变量名和代码的对应关系,后面会细讲)和一些调试信息。

目标代码的结构依据COFF(Common File Format)规范。Windows和Linux的可执行文件(PE和ELF)就是尊崇这种规范。大家用的都是COFF格式,动态链接库也是。通过linux下的file命令可以参看目标文件、elf可执行文件、shell文件等。

      file /lib/x86_64-linux-gnu/libc-2.27.so
      /lib/x86_64-linux-gnu/libc-2.27.so: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/l, BuildID[sha1]=b417c0ba7cc5cf06d1d1bed6652cedb9253c60d0, for GNU/Linux 3.2.0, stripped

      file run.sh
      run.sh: Bourne-Again shell script, UTF-8 Unicode text executable

      file a.o
      a.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

      file ab
      ab: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

如上可以看到不同文件的区别。

5.2 目标文件的结构

ELF是Executable LinkableFormat的缩写,是Linux的链接、可执行、共享库的格式标准,尊从COFF。

Linux下的目标ELF文件(或可执行ELF文件)的结构包括:

  • ELF头部
  • .text
  • .data
  • .bss
  • 其他段
  • 段表
  • 符号表

ELF文件的结构包含ELF的头部说明和各种“段”(section)。段是一个逻辑单元,包含各种各样的信息,比如代码(.text)、数据(.data)、符号等。

5.2.1 文件头(ELF Header)

先说说ELF文件开头部分的ELF头,它是一个总的ELF的说明,里面包含是否可执行、目标硬件、操作系统等信息,还包含一个重要的东西:“段表”,就是用来记录段(section)的信息。

看个例子:

      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:          816 (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

说明:

  • 其中,”7f 45 4c 46”是ELF魔法数,就是DEL字符加上“ELF”3个字母,表明它是一个elf目标或者可执行文件关于elf文件头格式。
  • 还会说明诸如可执行代码起始的入口地址;段表的位置;程序表的位置;….多种信息。细节就不赘述了。

关于更详细的elf文件头的内容,可以参考:

5.2.2 段表(section table)

除了elf文件头,就属段表重要了,各个段的信息都在这里。先看个例子:

命令readelf -S ab可以帮助查看ELF文件的段表。

      There are 9 section headers, starting at offset 0x1208:

      Section Headers:
        [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
        [ 0]                   NULL            00000000 000000 000000 00      0   0  0
        [ 1] .text             PROGBITS        08048094 000094 000091 00  AX  0   0  1
        [ 2] .eh_frame         PROGBITS        08048128 000128 000080 00   A  0   0  4
        [ 3] .got.plt          PROGBITS        0804a000 001000 00000c 04  WA  0   0  4
        [ 4] .data             PROGBITS        0804a00c 00100c 000008 00  WA  0   0  4
        [ 5] .comment          PROGBITS        00000000 001014 00002b 01  MS  0   0  1
        [ 6] .symtab           SYMTAB          00000000 001040 000120 10      7  10  4
        [ 7] .strtab           STRTAB          00000000 001160 000063 00      0   0  1
        [ 8] .shstrtab         STRTAB          00000000 0011c3 000043 00      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),
        p (processor specific)

这个可执行文件里有9个段。常见的3个段:代码段、数据段、BSS段:

  • 代码段:.code或.text;
  • 数据段:.data,放全局变量和局部静态变量;
  • BSS段:.bss,为未初始化的全局变量和局部静态变量预留位置,不占空间。

还有其它段:

  • .strtab : String Table 字符串表,用于存储 ELF 文件中用到的各种字符串;
  • .symtab : Symbol Table 符号表,从这里可以索引文件中的各个符号;
  • .shstrtab : 各个段的名称表,实际上是由各个段的名字组成的一个字符串数组;
  • .hash : 符号哈希表;
  • .line : 调试时的行号表,即源代码行号与编译后指令的对应表;
  • .dynamic : 动态链接信息;
  • .debug : 调试信息;
  • .comment : 存放编译器版本信息,比如 “GCC:GNU4.2.0”;
  • .plt 和 .got : 动态链接的跳转表和全局入口表;
  • .init 和 .fini : 程序初始化和终结代码段;
  • .rodata1 : Read Only Data,只读数据段,存放字符串常量,全局 const 变量,该段和 .rodata 一样。

段表里记录着每个段开始的位置和位移(offset)、长度,毕竟这些段都是紧密的放在二进制文件中,需要段表的描述信息才能把它们每个段分割开。

有了段,我们其实就对可执行文件了然于心了,其中.text代码段里放着可以运行的机器指令;而.data数据段里放着全局变量的初始值;.symtab里放着当初源代码中的函数名、变量名的代表的信息。

目标ELF文件和可执行ELF文件虽然规范是一致的,但还是有很多细微区别。

5.2.3 目标ELF文件的重定位表

在段表中,你会发现这种段:.rel.xxx,这些段就是链接用的!因为你需要把某个目标中出现的函数、变量等的地址,换成其它目标文件中的位置(也就是地址),这样才能正确地引用、调用这些变量。至于链接细节,后面讲链接的时候再说。

一般有text、data两种重定位表:

  • .rel.text:代码段重定位表,描述代码段中出现的函数、变量的引用地址信息等;
  • .rel.data: 数据段重定位表。

5.2.4 字符串表

.strtab、.shstrtab

ELF中很多字符串,比如函数名字、变量名字,都放到一个叫“字符串”表的段中。

5.2.5 符号表

注意:字符串表只是字符串,符号表跟它不一样,符号表更重要,它表示了各个函数、变量的名字对应的代码或者内存地址,在链接的时候,非常有用。因为链接就是要找各个变量和函数的位置,这样才可以更新编译阶段空出来的函数、变量的引用地址。

每个目标文件里都有这么一个符号表,用nm和readelf可以查看:

1)a.o目标文件的符号表

nm a.o


                 U _GLOBAL_OFFSET_TABLE_
                 U __stack_chk_fail
0000000000000000 T main
                 U shared
                 U swap

2)readelf -s a.o 目标文件的符号表:

      Symbol table '.symtab' contains 12 entries:
         Num:    Value  Size Type    Bind   Vis      Ndx Name
           0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
           1: 00000000     0 FILE    LOCAL  DEFAULT  ABS a.c
           2: 00000000     0 SECTION LOCAL  DEFAULT    1
           3: 00000000     0 SECTION LOCAL  DEFAULT    3
           4: 00000000     0 SECTION LOCAL  DEFAULT    4
           5: 00000000     0 SECTION LOCAL  DEFAULT    6
           6: 00000000     0 SECTION LOCAL  DEFAULT    7
           7: 00000000     0 SECTION LOCAL  DEFAULT    5
           8: 00000000    85 FUNC    GLOBAL DEFAULT    1 main
           9: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
          10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
          11: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND __stack_chk_fail

从这个目标ELF文件的符号表可以看到swap函数,Ndx是UND(Undefined的缩写),表明不知道它到底在哪个段,需要被重定位,就是写个1或3之类的数字表明段中的index;对于全局变量shared也是同样的定义。这些内容都会在静态链接的时候,被链接器修改。

为了对比,我们来看可执行文件ab的符号表的样子,看看静态链接后,这些符号的Ndx的变换。

3)可执行文件ab的符号表

nm ab

      0804a000 d _GLOBAL_OFFSET_TABLE_
      0804a014 D __bss_start
      080480d7 T __x86.get_pc_thunk.ax
      0804a014 D _edata
      0804a014 D _end
      080480db T main
      0804a00c D shared
      08048094 T swap
      0804a010 D test

readelf -s ab

      Symbol table '.symtab' contains 18 entries:
         Num:    Value  Size Type    Bind   Vis      Ndx Name
           0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
           1: 08048094     0 SECTION LOCAL  DEFAULT    1
           2: 08048128     0 SECTION LOCAL  DEFAULT    2
           3: 0804a000     0 SECTION LOCAL  DEFAULT    3
           4: 0804a00c     0 SECTION LOCAL  DEFAULT    4
           5: 00000000     0 SECTION LOCAL  DEFAULT    5
           6: 00000000     0 FILE    LOCAL  DEFAULT  ABS b.c
           7: 00000000     0 FILE    LOCAL  DEFAULT  ABS a.c
           8: 00000000     0 FILE    LOCAL  DEFAULT  ABS
           9: 0804a000     0 OBJECT  LOCAL  DEFAULT    3 _GLOBAL_OFFSET_TABLE_
          10: 08048094    67 FUNC    GLOBAL DEFAULT    1 swap
          11: 080480d7     0 FUNC    GLOBAL HIDDEN     1 __x86.get_pc_thunk.ax
          12: 0804a010     4 OBJECT  GLOBAL DEFAULT    4 test
          13: 0804a00c     4 OBJECT  GLOBAL DEFAULT    4 shared
          14: 0804a014     0 NOTYPE  GLOBAL DEFAULT    4 __bss_start
          15: 080480db    74 FUNC    GLOBAL DEFAULT    1 main
          16: 0804a014     0 NOTYPE  GLOBAL DEFAULT    4 _edata
          17: 0804a014     0 NOTYPE  GLOBAL DEFAULT    4 _end

可以看到,现在shared的Ndx是4,而swap的Ndx是1,对应的就是:4-数据段、1-代码段。

    上面曾经显示过的段的编号
      。。。。
        [ 1] .text             PROGBITS        08048094 000094 000091 00  AX  0   0  1
        [ 2] .eh_frame         PROGBITS        08048128 000128 000080 00   A  0   0  4
        [ 3] .got.plt          PROGBITS        0804a000 001000 00000c 04  WA  0   0  4
        [ 4] .data             PROGBITS        0804a00c 00100c 000008 00  WA  0   0  4
        [ 5] .comment          PROGBITS        00000000 001014 00002b 01  MS  0   0  1
      。。。

如上,对应的第一列的序号就标明了代码段是1,数据段是4。

另外,第二列Type也挺有用的:Object表示数据的符号,而Func是函数符号。

六、静态链接

目标文件介绍得差不多了,我们得到了一大堆零散的目标ELF文件,是时候把它们“合体”了,这就需要链接过程了,就是要把这些目标文件“凑”到一起,也就是把各个段合并到一起。

14.jpg

合并开始!读每个目标文件的文件头,获得各个段的信息,然后做符号重定位。

  • 读每个目标文件,收集各个段的信息,然后合并到一起,其实我理解就是压缩到一起,你的代码段挨着我的代码段,合并成一个新的,因为每个ELF目标文件都有文件头,是可以很严格合并到一起的;
  • 符号重定位,简单来说就是把之前调用某个函数的地址给重新调整一下,或者某个变量在data段中的地址重新调整一下。因为合并的时候,各个代码段都合并了,对应代码中的地址都变了,所以要调整。这是链接最核心的一步!

ld a.o b.o ab

详细介绍a.o+b.o=> ab的变化,特别是虚拟地址的变化。

先看链接前的目标ELF文件:a.o,b.o。

a.o的段属性(objdump -h a.o)
------------------------------------------------------------------------
      Idx Name          Size      VMA               LMA               File off  Algn
        0 .text         00000051  0000000000000000  0000000000000000  00000040  2**0
                        CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
        1 .data         00000000  0000000000000000  0000000000000000  00000091  2**0
                        CONTENTS, ALLOC, LOAD, DATA
        2 .bss          00000000  0000000000000000  0000000000000000  00000091  2**0
                        ALLOC

b.o的段属性(objdump -h b.o)
------------------------------------------------------------------------
      Idx Name          Size      VMA               LMA               File off  Algn
        0 .text         0000004b  0000000000000000  0000000000000000  00000040  2**0
                        CONTENTS, ALLOC, LOAD, READONLY, CODE
        1 .data         00000008  0000000000000000  0000000000000000  0000008c  2**2
                        CONTENTS, ALLOC, LOAD, DATA
        2 .bss          00000000  0000000000000000  0000000000000000  00000094  2**0
                        ALLOC

接下来是a.o + b.o,链接合体后的可执行ELF文件:ab。

ab的段属性(objdump -h ab)
------------------------------------------------------------------------
      Idx Name          Size      VMA       LMA       File off  Algn
        0 .text         00000091  08048094  08048094  00000094  2**0
                        CONTENTS, ALLOC, LOAD, READONLY, CODE
        1 .eh_frame     00000080  08048128  08048128  00000128  2**2
                        CONTENTS, ALLOC, LOAD, READONLY, DATA
        2 .got.plt      0000000c  0804a000  0804a000  00001000  2**2
                        CONTENTS, ALLOC, LOAD, DATA
        3 .data         00000008  0804a00c  0804a00c  0000100c  2**2
                        CONTENTS, ALLOC, LOAD, DATA

我们来玩一玩“找不同”!可执行ELF文件ab的VMA填充了。VMA是啥?为何需要调整?看来是时候说一说可执行ELF文件了。

6.1 目标ELF文件和可执行ELF文件

上面一直刻意不区分目标ELF文件和可执行ELF文件,原因是想先介绍它们共同的ELF规范部分,但其实两者是有区别的,这一小节忍不住想介绍一下,希望不会打断看官的思路。

目标ELF文件和可执行ELF文件,其实是两个目的、两个视角:

15.jpg

  • 目标文件是为了进一步链接用的,我们可以用“链接视角”来看待它,它有各个sections,用段表section head table(SHT)来记录、归档不同的内容,还有重要的重定位表,用于链接;
  • 可执行文件是为“进程视角”存在的,不需要重定位表,但它多了一个 “program header table(PHT)”,用来告诉操作系统如何把各个section加到进程空间的segment中。进程里专门有个“segment”的概念,定义出“虚拟内存区域”(VMA,Virtual Memory Area),每个VMA就是一个segement。这些segment是操作系统为了装载需要,专门又对sections们做了一次合并,定义出不同用途的VMA(如代码VMA、数据VMA、堆VMA、栈VMA)。
  • 在目标文件中,你会看到地址都是从0开始的,但是在可执行文件中是0x8048000开始的,因为操作系统进程虚拟地址的开始地址就是这个数。关于虚拟地址空间,这里不展开了,后面讲装载的部分再详细讨论。

虽然两者有区别,但大体的规范是一样的,都有ELF头、段表(section table)、节(section)等基本的组成部分。

可以参考这篇文章《ELF可执行文件的理解》,加深理解。

6.2 合体的ELF可执行文件

回来看合体(链接)后的可执行ELF文件ab。

ab的段属性(objdump -h ab):

      Idx Name          Size      VMA       LMA       File off  Algn
        0 .text         00000091  08048094  08048094  00000094  2**0
                        CONTENTS, ALLOC, LOAD, READONLY, CODE
        1 .eh_frame     00000080  08048128  08048128  00000128  2**2
                        CONTENTS, ALLOC, LOAD, READONLY, DATA
        2 .got.plt      0000000c  0804a000  0804a000  00001000  2**2
                        CONTENTS, ALLOC, LOAD, DATA
        3 .data         00000008  0804a00c  0804a00c  0000100c  2**2
                        CONTENTS, ALLOC, LOAD, DATA

可以看到,ab的代码段.text是从0x8048094开始的,长度是0x91,也就是145个字节长度的代码段。

段的开头地址确定了,接下来段里符号对应的地址就好找了(也就是.text段中的函数和.data段中的变量)。

回过头去看几个符号:swap函数、main函数、test变量、shared变量:

        Num:    Value     Size Type    Bind   Vis      Ndx Name
          10:   08048094    67 FUNC    GLOBAL DEFAULT    1 swap
          12:   0804a010     4 OBJECT  GLOBAL DEFAULT    4 test
          13:   0804a00c     4 OBJECT  GLOBAL DEFAULT    4 shared
          15:   080480db    74 FUNC    GLOBAL DEFAULT    1 main
  • main函数:地址是080480db,Ndx=1,Type=FUNC,也就是说,main这个符号对应的是一个函数,在代码段.text,起始地址是080480db;
  • test变量:地址是0804a010,Ndx=4,Type=OBJECT,也就是说,test这个符号对应的是一个变量,在数据段,起始地址是0804a010。

问题来了,这些地址是如何确定的呢?要知道目标ELF文件a.o、b.o里的地址还都是0作为基地址的,到合体后的可执行文件ab怎么就填充了这些东西呢?这就要引出“符号重定位”了。

6.3 符号重定位

既然链接是把大家的代码段、数据段都合并到一起,那就需要修改对应的调用的地址,比如a.o要调用b.o中的函数,合并到一起成为ab的时候,就需要修改之前a.o中的调用的地址为一个新的ab中的地址,也就是之前b.o中的那个函数swap的地址。

链接器通过“重定位 + 符号解析”完成上述工作。

最开始编译完的目标文件,变量地址、函数地址的基准地址都是0。一旦链接,就不能从0开始了,而要从操作系统和应用进程规定的虚拟起始地址开始作为基准地址,这个规定是0x08048094。别问我为什么,真心不知~

另外,还有这几个目标文件的各个段,它们的函数、变量等的地址原本都是基于0,现在合体了,都要开始逐一调整!之前每个函数、变量的地址都是相对于0的,也就是说,你知道它们的偏移offset,这样的话,你只需要告诉它们新的基地址的调整值,就可以加上之前的offset算出新的地址,把所有涉及到被调用的地方都改一遍,就完成了这个重定位的过程。

具体怎么做呢?通过重定位表来完成。

6.4 重定位表

就是一个表,记着之前每个object目标文件中哪些函数、变量需要被重定位。这是一个单独的段,命名还有规律呢!就是.rel.xxx,比如.rel.data、.rel.text。

看个栗子:

      RELOCATION RECORDS FOR [.text]:
      OFFSET           TYPE              VALUE
      0000000000000025 R_X86_64_PC32     shared-0x0000000000000004
      0000000000000032 R_X86_64_PLT32    swap-0x0000000000000004

shared变量和swap函数都在a.o的重定位表中被记录下来,说明它们的地址后期会被调整。offset中的25,就是shared变量对于数据段的起始位置的位移offset是25个字节;同样,swap函数相对于代码段开始的offset是32个字节。另外,VALUE这列的“shared、swap”会对应到符号表里面的shared、swap符号。

重定位表只记录哪些符号需要重定位,而关于这个函数、变量更详细的信息都在符号表中。

接下来精彩的事情发生了,也就是链接中最关键的一步:修改链接完成的文件中调用函数和变量引用的地址。

6.5 指令修改

修改函数和数据的应用地址有很多方法,这涉及到各个平台的寻址指令差异,比如R_X86_64_PC32。但本质来讲就需要一种计算方法,计算出链接后的代码中对函数的调用地址、变量的应用地址、进行链接后的修改地址。

对于32位的程序来说,一共有10种重定位的类型。

举个例子可能更容易理解:文件a.c,b.c,链接成ab,我们来看链接过程中是如何做指令地址修改的。

先看看源代码:

a.c

      extern int shared;

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

b.c

      int shared = 1;
      int test = 3;

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

a.c的汇编文件

00000000 <main>:
  ....
  31: 89 c3                 mov    %eax,%ebx
  33: e8 fc ff ff ff        call   34 <main+0x34> <------------- 调用swap函数
  38: 83 c4 10              add    $0x10,%esp
  ....
Relocation section '.rel.text' at offset 0x24c contains 4 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
 ....
00000034  00000e04 R_386_PLT32       00000000   swap

可以看到目标文件a.o中的汇编指令和重定位表中为R_386_PLT32的重定位方式。然后,链接后得到ab的代码。

链接后的 ab ELF可执行文件:

08048094 <swap>:
 8048094: 55                    push   %ebp
 8048095: 89 e5                 mov    %esp,%ebp
 ....


080480db <main>:
 ....
 804810c: 89 c3                 mov    %eax,%ebx
 804810e: e8 81 ff ff ff        call   8048094 <swap>
 8048113: 83 c4 10              add    $0x10,%esp 
 ....

分析

1)修正后的swap地址是:0x08048094

2)修正后的代码地址是: 0x804810e

3)原来的调用代码: 33: e8 fc ff ff ff call 34 <main+0x34>,其实是0xfffffffc,补码表示的-4

4)先看修改完成的:ab中,804810e: e8 81 ff ff ff call 8048094 <swap>。e8 fc ff ff ff 修改成了=> e8 81 ff ff ff,补码表示是-127

5)这个值是怎么算的?

a.o的重定位表中的信息是:00000034 00000e04 R_386_PLT32 00000000 swap

所谓R_386_PLT32,是:L+A-P

  • L:重定项中VALUE成员所指符号@plt的内存地址 => 8048094,就是修正后的swap函数地址;
  • A:被重定位处原值,表示”被重定位处”相对于”下一条指令”的偏移 => fcffffff,就是源代码上的地址,固定的,补码表示的,实际值是-4;
  • P:被重定位处的内存地址 => 804810e,就是修正后的main中调用swap的代码地址。

按照这个公式计算修正后的调用地址:

L+A-P:8048094 + −4 - 804810e = - 127 = -0x7f,补码表示是 ffffff81,由于是小端表示,所以最终替换完的指令为:

804810e: e8 81 ff ff ff call 8048094 <swap>

代码在执行的时候,会用当前地址的下一条指令的地址,加上偏移(-127),正好就是swap修正后的地址0x08048094。

6.6 静态链接库

我们自己写的程序可以编译成目标代码,然后等着链接。但是,我们可能会用到别的库,它们也是一个个的xxx.o文件么?链接的时候需要挨个都把它们指定链接进来么?

我们可能会用到c语言的核心库、操作系统提供的各种api的库,以及很多第三方的库。比如c的核心库,比较有名的是glibc,原始的glibc源代码很多,可以完成各种功能,如输入输出、日期、文件等等,它们其实就是一个个的xxx.o,如fread.o,time.o,printf.o,就是你想象的样子。

可是,它们被压缩到了一个大的zip文件里,叫libc.a:./usr/lib/x86_64-linux-gnu/libc.a,就是个大zip包,把各种*.o都压缩进去了,据说libc.a包含了1400多个目标文件。

      objdump -t ./usr/lib/x86_64-linux-gnu/libc.a|more
      In archive ./usr/lib/x86_64-linux-gnu/libc.a:

      init-first.o:     file format elf64-x86-64

      SYMBOL TABLE:
      0000000000000000 l    d  .text  0000000000000000 .text
      0000000000000000 l    d  .data  0000000000000000 .data
      0000000000000000 l    d  .bss 0000000000000000 .bss
      .......

我好奇地统计了一下,其实不止1400,我的这台ubuntu18.04上,有1690个!

      objdump -t ./usr/lib/x86_64-linux-gnu/libc.a|grep 'file format'|wc -l
      1690

如果以–verbose方式运行编译命令,你能看到整个细节过程:

gcc -static --verbose -fno-builtin a.c b.c -o ab

       ....
        /usr/lib/gcc/x86_64-linux-gnu/7/cc1 -quiet -v -imultiarch x86_64-linux-gnu b.c -quiet -dumpbase b.c -mtune=generic -march=x86-64 -auxbase b -version -fno-builtin -fstack-protector-strong -Wformat -Wformat-security -o /tmp/cciXoNcB.s
       ....
       as -v --64 -o /tmp/ccMLSHnt.o /tmp/cciXoNcB.s
       .....
        /usr/lib/gcc/x86_64-linux-gnu/7/collect2 -o ab /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginT.o ...

整个过程分为3步:

  • cc1做编译:编译成临时的汇编程序/tmp/cciXoNcB.s
  • as汇编器:生成目标二进制代码;
  • collect2:实际上是一个ld的包装器,完成最后的链接。

还会链接各类的静态库,其实它们都在libc.a这类静态库中。

七、装载

终于把一个程序编译、链接完,变成了一个可执行文件,接下来就要聊聊如何把它加载到内存,这就是“装载”的过程。

7.1 虚拟地址空间

在谈加载到内存之前,先了解进程虚拟地址空间。

进程虚拟地址空间,在我看来是一个非常重要的概念,它的意义在于,让每个程序,甚至后面的进程,都变得独立起来,不需要考虑物理内存、硬盘、在文件中的绝对位置等。它关心的只是自己在一个虚拟空间的地址位置。这样链接器就好安排每个代码、数据的位置,装载器也好安排指令、数据、栈、堆的位置,与硬件无关。

这个地址编码也很简单,就是你总线多大,我就能编码多大。比如8位总线,地址就256个;到了32位,地址就可以是4G大小了;64位的话,地址就很大了...这么大的一个地址空间都给一个程序和进程用了!可是,真实内存可能也就16G、32G,还有那么多进程怎么办?怎么装载进来?别急,后面会介绍。

7.2 如何载入内存

一个可执行文件地址空间硕大无比,怎么把这头大象装入只有16G大小的“冰箱”—-内存?!答案是映射。

16.jpeg

这样就可以把可执行文件中一块一块地装进内存里面了,前提是进程需要的块,比如正在或马上要执行的代码、数据等。那剩下的怎么办?如果内存满了怎么办?这些不用担心,操作系统负责调度,会判断是否用到,用到的就会加载;如果满了,就按照LRU算法替换旧的。

7.3 进程视角

切换到进程视角,进程也要有一个虚拟空间,叫“进程虚拟空间(Process Virtual Space)”。注意:我们又提到了虚拟空间,前面聊起过这个话题,链接器需要、进程加载也需要,链接的时候要给每段代码、数据编个地址,现在进程也需要一个虚拟地址。我的学习认知告诉我这俩不是一回事,但应该差不了多少,都是总线位数编码出来的空间大小,各个内容存放的位置也不会有太大变换。

但毕竟是不一样的,所以它们之间也需要映射。有了这个映射,进程发现自己所需要的可执行代码缺了,才能知道到可执行文件中的第几行加载。这个映射关系就存在可执行ELF的PHT(程序映射表 - Program Header Table)中,前面介绍过,就是个映射表。

我们再将PHT映射表细化一下。

如果能直接把可执行文件原封不动地映射到进程空间多好啊,这样映射多简单啊。事实不是这样的。

为了空间布局上的效率,链接器会把很多段(section)合并,规整成可执行的段(segment)、可读写的段、只读段等,合并后,空间利用率就高了。否则,即便是很小的一段,未来物理内存页浪费太大(物理内存页分配一般都是整数倍一块给你,比如4k)。所以链接器趁着链接就把小块们都合并了,这个合并信息就在可执行文件头的VMA信息里。

这里有2个段:section和segment,中文都叫段,但有很大区别:section是目标文件中的单元;而segement是可执行文件中的概念,是一个section的组合或集合,是为了将来加载到进程空间里用的。在我理解,segement和VMA是一个意思。

readelf -l ab 可以查看程序映射表 - Program Header Table:

      Elf file type is EXEC (Executable file)
      Entry point 0x80480db
      There are 3 program headers, starting at offset 52

      Program Headers:
        Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
        LOAD           0x000000 0x08048000 0x08048000 0x001a8 0x001a8 R E 0x1000
        LOAD           0x001000 0x0804a000 0x0804a000 0x00014 0x00014 RW  0x1000
        GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

       Section to Segment mapping:
        Segment Sections...
         00     .text .eh_frame
         01     .got.plt .data

“Segment Sections”就告诉你如何合并这些sections了。

上述示例有3个段(Segment),其中2个type是LOAD的Segment,一个是可执行的Segment,一个是只读的Segment。第一个可执行Segment到底合并哪些Section呢? 答案是:00 .text .eh_frame

这个信息是存在可执行文件的“程序头表(Program Header Table - PHT)”里面的,就是用readelf -f看到的内容,告诉你sections如何合并成segments。

总结:

  • 目标文件有自己的sections,可执行文件也一样;
  • 只不过可执行文件又创造了一个概念:segment,就是把sections做了一个合并;
  • 真正装载放到内存里的时候,还要段地址对齐。

7.4 段(Segment)地址对齐

内存都是一个一个4k的小页,便于分配,这涉及到内存管理,不展开详述。

操作系统就给你一摞4k小页,问题是即使将sections们压缩成了segment,也不正好就4k大小,就算多一点点,操作系统也得额外再分配一页,多浪费啊。

办法来了:段地址对齐

17.jpg

一个物理页(4k)上不再是放一个segment,而是还放着别的,物理页和进程中的页是1:2的映射关系,浪费就浪费了,反正也是虚拟的。物理上就被“压缩”到了一起,过去需要5个才能放下的内容,现在只需要3个物理页了。

7.5 堆和栈

可执行文件加载到进程空间里之后,进程空间还有两个特殊的VMA区域,分别是堆和栈

18.jpg

通过查看linux中的进程内存映射也可以看到这个信息:cat /proc/555/maps

      55bddb42d000-55bddb4f5000 rw-p 00000000 00:00 0                          [heap]
      ...
      7ffeb1c1a000-7ffeb1c3b000 rw-p 00000000 00:00 0                          [stack]

参考:Anatomy of a Program in Memory Gcc 编译的背后

八、动态链接

静态链接大致清楚了,接下来介绍动态链接。

动态链接的好处很多:

  • 代码段可以不用重复静态链接到需要它的可执行文件里面去了,省了磁盘空间;
  • 运行期还可以共享动态链接库的代码段,也省了内存。

8.1 一个栗子

先举个例子,看看动态链接库怎么写。

lib.c,动态链接库代码:

#include <stdio.h>
void foobar(int i) {
    printf("Printing from lib.so --> %d\n", i);
    sleep(-1);
}

为了让其他程序引用它,需要为它编写一个头文件:lib.h

  #ifndef LIB_H_
  #define LIB_H_
    void foobar(int i);
  #endif // LIB_H_

最后是调用代码:program1.c

#include "lib.h"
int main() {
    foobar(1);
    return 0;
}

编译这个动态链接库:gcc -fPIC -shared -o lib.so lib.c可以得到lib.so。然后编译引用它的程序的program1.c: gcc -o program1 program1.c ./lib.so,这样就可以顺利地引用这个动态链接库了。

19.jpg

这背后到底发生了什么?

编译program1.c时,引用了函数foobar,可这个函数在哪里呢?要在编译,也就是链接的时候,告诉这个program1程序,所需要的那个foobar在lib.so里面,也就是需要在编译参数中加入./lib.so这个文件的路径。据说链接器要拷贝so的符号表信息到可执行文件中。

在过去静态链接的时候,我们要在program1中对函数foobar的引用进行重定位,也就是修改program1中对函数foobar引用的地址。动态链接不需要做这件事,因为链接的时候,根本就没有foobar这个函数的代码在代码段中。

那什么时候再告诉program1 foobar的调用地址到底是多少呢?答案是运行的时候,也就是运行期,加载lib.so的时候,再告诉program1,你该去调用哪个地址上的lib.so中的函数。

我们可以通过/proc/$id/maps,查看运行期program1的样子:

cat /proc/690/maps

      55d35c6f0000-55d35c6f1000 r-xp 00000000 08:01 3539248                    /root/link/chapter7/program1
      55d35c8f0000-55d35c8f1000 r--p 00000000 08:01 3539248                    /root/link/chapter7/program1
      55d35c8f1000-55d35c8f2000 rw-p 00001000 08:01 3539248                    /root/link/chapter7/program1
      55d35dc53000-55d35dc74000 rw-p 00000000 00:00 0                          [heap]
      7ff68e48e000-7ff68e675000 r-xp 00000000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e675000-7ff68e875000 ---p 001e7000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e875000-7ff68e879000 r--p 001e7000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e879000-7ff68e87b000 rw-p 001eb000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e87f000-7ff68e880000 r-xp 00000000 08:01 3539246                    /root/link/chapter7/lib.so
      7ff68ea81000-7ff68eaa8000 r-xp 00000000 08:01 3671308                    /lib/x86_64-linux-gnu/ld-2.27.so
      7ffc2a646000-7ffc2a667000 rw-p 00000000 00:00 0                          [stack]
      7ffc2a66c000-7ffc2a66e000 r--p 00000000 00:00 0                          [vvar]
      7ffc2a66e000-7ffc2a670000 r-xp 00000000 00:00 0                          [vdso]
      ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

如上可以看到“ld-2.27.so”,动态连接器。系统开始的时候,它先接管控制权,加载完lib.so后,再把控制权返还给program1。凡是有动态链接库的程序,都会把它动态链接到程序的进程中,由它首先加载动态链接库。

8.2 GOT和PLT

20.jpg

GOT和PLT很复杂,细节很多,不太好理解,我也只是把大致的过程搞明白了,所以这里只是说一说我的理解,如果感兴趣可以看南大袁春风老师关于PLT的讲解。

GOT放在数据段里,而PLT在代码段里,所以GOT是可以改的,放的跳转用的函数地址;而PLT里面放的是告诉怎么调用动态链接库里函数的代码(不是函数的代码,是怎么调用的代码)。

假如主程序需要调用动态链接库lib.so里的1个函数:ext,那么在GOT表里和PLT表里都有1个条目,GOT表里是未来这个函数加载后的地址;而PLT里放的是如何调用这个函数的代码,这些代码是在链接期链接器生成的。

GOT里还有3个特殊的条目,PLT里还有1个特殊的条目。

GOT里的3个特殊条目:

  • GOT[0]: .dynamic section的首地址,里面放着动态链接库的符号表的信息。
  • GOT[1]: 动态链接器的标识信息,link_map的数据结构,这个不是很明白,我理解就是链接库的so文件的信息,用于加载。
  • GOT[2]: 这个是调用动态库延迟绑定的代码的入口地址,延迟绑定的代码是一个特殊程序的入口,实际是一个叫“_dl_runtime_resolve”的函数的地址。

PLT里的特殊条目:

  • PLT[0]: 就是去调动“_dl_runtime_resolve”函数的代码,是链接器自动生成的。

整个过程开始了:因为是延迟绑定,所以动态重定位这个过程就需要在第一次调用函数的时候触发。什么是动态重定位?就是要告诉进程加载程序,修改新载入的动态链接库被调用处的地址,谁知道你把so文件加载到进程空间的哪个位置了,你得把加载后的地址告诉我,我才能调用啊~这个过程就是动态重定位。

.text的主程序开始调用ext函数,ext函数的调用指令:

804845b: e8 ec fe ff ff call 804834c<ext>

804834c是谁?原来是PLT[1]的地址,就是ext函数对应的PLT表里的代理函数,每个函数都会在PLT、GOT里对应一个条目。

现在跳转到这个函数(PLT[1])去。

PLT[1]:

804834c: ff 25 90 95 04 08  jmp   *0x8049590 
8048352: 68 00 00 00 00     pushl $0x0 
8048357: e9 e0 ff ff ff     jmp   804833c

这个函数首先跳到0x8049590里写的那个地址去了(jmp *xxx,不是跳到xxx,而是跳到xxx里面写的地址上去)。

这里有2个细节:

  • 0x8049590这个地址就是GOT[3],GOT[3]是ext函数对应的GOT条目;
  • 0x8049590里写的那个地址就是PLT[1](ext对应的plt条目)的下一条。

what?PLT[1]代码绕这么个圈子(用GOT[3]里的地址跳)jmp,其实就是跳到了自己的下一条?是,这次是可笑,但未来这个值会改的,改成真正的动态库的函数地址,直接去执行函数。

跳回来之后(PLT[1]),接下来是压栈了一个0,0表示是第一个函数,也就是ext的索引。

继续跳0x804833c,这是PLT[0],PLT[0]是去调用“_dl_runtime_resolve”函数。在调用之前还要干一件事:push 0x8049588,0x8049588是GOT[2]。GOT[2]里放着so的信息(我理解的不一定完全正确)。

至此,可以调用“_dl_runtime_resolve”函数去加载整个so了。

参数包括2个:一个是压栈的那个0,就是ext函数的索引,后续通过这个索引可以找到GOT表的位置,把真正的函数的地址回填回去;第二个参数是压栈的GOT[1],就是动态链接器的标识信息,我理解就是告诉加载器so名字叫啥,它好去加载。

加载完成,立刻回调安放到位置的so里,索引为0的ext函数的地址,到GOT[3]中,也就是索引0。

下次再调用这个函数的时候,还是先调用PLT[1](ext的代理代码),但里面的jmp \*0x8049590 (jmp *GOT[3])可以直接跳转到真正的ext里去了。

终于捋完了,必须总结一下。

  • 动态链接库,动态把so加载到虚拟地址空间,因为地址是不定的,所以跟静态链接的思路一样,需要做重定位,也就是要修改调用的代码地址。
  • 因为是动态链接,都已经是运行期了,不能修改内存代码段(.text)(只读),只能加载完之后,把加载的函数地址写到GOT表里。这就是在加载时修改GOT表的方法。
  • 还有一种方法是:在主程序启动时不加载so,等第一次调用某个动态链接库的函数时再加载so,再更新GOT表。思路是:主程序调用某个动态链接库函数时,其实是先调用了一个代理代码(PLT[x]),它会记录自己的序号(确定是调哪个函数)和动态链接库的文件名这2个参数,然后转去调用“_dl_runtime_resolve”函数,这个函数负责把so加载到进程虚拟空间去,并回填加载后的函数地址到GOT表,以后再调用就可以直接去调用那个函数了。

8.3参考

这个是一篇很赞的文章讲的PLT的内容,引用过来:

动态链接库中的函数动态解析过程如下:

1)从调用该函数的指令跳转到该函数对应的PLT处;

2)该函数对应的PLT第一条指令执行它对应的.GOT.PLT里的指令。第一次调用时,该函数的.GOT.PLT里保存的是它对应的PLT里第二条指令的地址;

3)继续执行PLT第二条、第三条指令,其中第三条指令作用是跳转到公共的PLT(.PLT[0]);

4)公共的PLT(.PLT[0])执行.GOT.PLT[2]指向的代码,也就是执行动态链接器的代码;

5)动态链接器里的_dl_runtime_resolve_avx函数修改被调函数对应的.GOT.PLT里保存的地址,使之指向链接后的动态链接库里该函数的实际地址;

6)再次调用该函数对应的PLT第一条指令,跳转到它对应的.GOT.PLT里的指令(此时已经是该函数在动态链接库中的真正地址),从而实现该函数的调用。

8.4 Linux的共享库组织

Linux为了管理动态链接库的各种版本,定义了一个so的版本共享方案。

libname.so.x.y.z

  • x是主版本号:重大升级才会变,不向前兼容,之前引用的程序都要重新编译;
  • y是次版本号:原有的不变,增加了一些东西而已,向前兼容;
  • z是发布版本号:任何接口都没变,只是修复了bug,改进了性能而已。

1)SO-NAME

Linux有个命名机制,用来管理so之间的关系,这个机制叫SO-NAME。任何一个so都对应一个SO-NAME,就是libname.so.x

一般系统的so,不管它的次版本号和发布版本号是多少,都会给它建立一个SO-NAME的软链接,例如 libfoo.so.2.6.1,系统就会给它建立一个叫libfoo.so.2的软链。

这个软链接会指向这个so的最新版本,比如我有2个libfoo,一个是libfoo.so.2.6.1,一个是libfoo.so.2.5.5,软链接默认指向版本最新的libfoo.so.2.6.1。

在编译的时候,我们往往需要引入依赖的链接库,这时依赖的so使用软链接的SO-NAME,而不使用详细的版本号。

在编译的ELF可执行文件中会存在.dynamic段,用来保存自己所依赖的so的SO-NAME。

编译时有个更简洁指定lib的方式,就是gcc -lxxx,xxx是libname中的name,比如gcc -lfoo是指链接的时候去链接一个叫libfoo.so的最新的库,当然这个是动态链接。如果加上-static: gcc -static -lfoo就会去默认静态链接libfoo.a的静态链接库,规则是一样的。

2)ldconfig

Linux提供了一个工具“ldconfig”,运行它,linux就会遍历所有的共享库目录,然后更新所有的so的软链,指向它们的最新版,所以一般安装了新的so,都会运行一遍ldconfig。

8.5 系统的共享库路径

Linux尊崇FHS(File Hierarchy Standard)标准,来规定系统文件是如何存放的。

  • /lib:存放最关键的基础共享库,比如动态链接器、C语言运行库、数学库,都是/bin,/sbin里系统程序用到的库;
  • /usr/lib: 一般都是一些开发用到的 devel库;
  • /usr/local/lib:一般都是一些第三方库,GNU标准推荐第三方的库安装到这个目录下。

另外/usr目录不是user的意思,而是“unix system resources”的缩写。

/usr:/usr 是系统核心所在,包含了所有的共享文件。它是 unix 系统中最重要的目录之一,涵盖了二进制文件、各种文档、头文件、库文件;还有诸多程序,例如 ftp,telnet 等等。

九、后记

研究这个话题,前前后后经历了一个月,文章只是把过程中的体会记录下来,同时在单位给同事们做了一次分享。虽然也只是浮光掠影,但终究是了结了多年的心愿,对可执行文件的格式、加载等基础知识做了一次梳理,还是收获满满的。这些知识对实际的工作有什么帮助吗?可能会有帮助,但可能也非常有限。“行无用之事,做时间的朋友”,做一些有意思的事情,过程本身就充满了乐趣。

文章可能会有纰漏和错误,能看到这里的同学,也请留言指出来,一起讨论学习,共同进步!

参考

  • 南京大学-袁春风老师-计算机系统基础
  • 深入浅出计算机组成原理-极客时间
  • 《程序是怎样跑起来的》
  • 《程序员的自我修养》
  • 《深入理解计算机系统》
  • readlf、nm、ld、objdump、ldconfig、gcc命令
文章来源:宜信技术学院 & 宜信支付结算团队技术分享第14期-支付结算机器学习技术团队负责人 刘创 分享《程序的一生:从源程序到进程的辛苦历程》

分享者:宜信支付结算机器学习技术团队负责人 刘创

原文发布于个人博客:动物园的猪(www.piginzoo.com)

阅读 5.3k更新于 3月17日

推荐阅读
宜信技术学院
用户专栏

宜信技术学院是宜信旗下的金融科技平台。专注分享金融科技深度文章。

11362 人关注
201 篇文章
专栏主页
目录