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

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


1、前言

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

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

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

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

2、概述

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

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

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

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

3、基础

探索以前须要交代一些基础知识,否则没法理解连接和加载。ubuntu

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、r八、r9做为第1-6个参数,rax做为返回值。
  • 32位寄存器用ebp做为栈帧指针,64位寄存器取消了这个设定,没有栈帧的指针,rbp做为通用寄存器使用。
  • 64位寄存器支持一些形式以PC相关的寻址,而32位只有在jmp的时候才会用到这种寻址方式。

对了,寄存器可不是L一、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、编译

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

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

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的地址。不过,这事儿编译不作,留给连接去作。

编译不是本文重点,这里就不过多讨论了,感兴趣的同窗,能够读读这篇:《本身动手写编译器》

5、连接

编译一个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是函数符号。

6、静态连接

目标文件介绍得差很少了,咱们获得了一大堆零散的目标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、装载

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

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、动态连接

静态连接大体清楚了,接下来介绍动态连接。

动态连接的好处不少:

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

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 等等。

9、后记

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

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

参考

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

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

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

相关文章
相关标签/搜索