茫无头绪的瞎侃(一)

前者稍微解除了一下PE文件格式,这次简要说一下PE的装载,PE文件中,所有段的起始地址都是页的整数倍,段的长度如果不是

页的整数倍,那就会映射时向上补齐到页的整数倍,PE文件中,连接器在生产可执行文件时,往往将所有的段尽可能的合并,所以一般

只有代码段,数据段,只读数据段和BSS等为数不多的几个段。

PE的术语中,有个相对虚拟地址的概念,其实当当与文件中的偏移量。它是相对于PE文件的装载基地址的一个偏移地址。如果一个pe文件

被装载到虚拟地址0x00400000,那么虚拟偏移地址为0x1000的地址就是0x00401000,每个pe文件在装载时都会有

一个装载目标地址,即所谓的基地址。装载一个PE可执行文件的过程如下:

先读取文件的第一个页,此页包含了DOS头,PE文件头和段表

检查进程地址空间中,目标地址是否可用

使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置

如果装载地址不是目标地址,则进行Rebasing

装载所有PE文件所需要的DLL文件

对PE文件中的所有导入符号进行解析

根据PE头中指定的参数,简历初始化栈和堆

建立主线程并且启动进程

PE文件中,与装载有关的信息都包含在PE扩展头和段表,具体结构如下,只分析32位

typedef struct _IMAGE_OPTIONAL_HEADER32{

WORD Magic;

BYTE MajorLinkerVersion,MinorLinkerVersion;

DWORD SizeOfCode;

DWORD SizeOfInitializedData; ///初始化了的数据段长度

DWORD SizeofUninitializedData; ///未初始化的数据段长度

DWORD AddressOfEntryPoint; PE装载器准备运行的PE文件的第一个指令的RVA

DWORD BaseOfCode; 代码段起始RVA

DWORD BaseOfData; 数据段起始RVA

DWORD ImageBase; PE文件的优先装载地址

DWORD SectionAlignment; 内存中段对齐的粒度,一般为4096

DWORD FileAlignment; 文件中段对齐的粒度,一般为512字节

WORD MajorOperatingSystemVersion;

WORD MinorOperatingSystemVersion;

WORD MajorImageVersion;

WORD MinorImageVersion;

WORD MajorSubsystemVersion; 程序运行所需要的子系统版本

WORD MinorSubsystemVersion;

DWORD Win32VersionValue;

DWORD SizeofImage; 内存中整个PE映像体的尺寸

DWORD SizeofHeaders; 所有头+节表的大小,等于文件尺寸减去文件中所有节的尺寸

DWORD CheckSum;

WORD subsystem; NT用来识别PE文件属于哪个子系统,GUI和CUI

DWORD sizeofStackReserve;

DWORD sizeofStackCommit;

DWORD sizeofHeapReserve;

DWORD sizeofHeapCommit;

DWORD LoaderFlags;

IMAGE_DATA_DIRECTORY DataDirectory[16];

}IMAGE_OPTIONAL_HEADER32,*PIMAGE_OPTIONAL_HEADER32


typedef struct _IMAGE_DATA_DIRECTORY{

DWORD VirtualAddress;

DWORD Size;

}IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;


关于动态链接

要解决空间浪费和更新困难的简单办法就是把程序的模块相互分割开来,形成独立的文件,而不再将他们静态的连接在一起,

简单来说,就是不对那些组成程序的目标文件进行连接,等到程序要运行时才进行连接,也就是说,把连接过程推迟到运行时

在进行,这就是动态连接


动态连接的思想是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将他们连接在一起形成一个完整的程序,而不是

像静态链接那样把所有的程序模块都连接在一个单独的可执行文件。换句话说,动态链接把连接过程从本来的程序装载前推迟到了

装载的时候

在静态链接时,整个程序最终只有一个可执行文件,它是一个不可以分割的整体,但是在动态连接下,一个程序被分成了若干个文件

有程序的主要部分,即可执行文件和程序所依赖的共享对象,很多时候称为模块。


/* Program1.c */

#include "lib.h"

int main()

{

show(1);

return 0;

}


/* Program2.c */

#include "lib.h"

int main()

{

show(2);

return 0;

}

/* lib.c */

#include <stdio.h>

void show(int i)

{

printf("Printing from lib.so %d\n",i);

}


/**  lib.h **/

#ifndef LIB_H

#define LIB_H

void show(int i);

#endif


下面是进程运行时的虚拟地址空间分布

$cat /proc/12985/maps


08048000-08049000 r-xp 00000000 08:01 1343422 ./Program1

08049000-0804a000 rwxp 00000000 08:01 1343432 ./Pragram1

b7e83000-b7e84000 rwxp b7e83000 00:00 0

b7e84000-b7fc8000 r-xp 00000000 08:01 1488993 /lib/tls/i686/cmov/libc-2.6.1.so

b7fc80000-b7fc9000 r-xp 00143000 08:01 1488993 /lib/tls/i686/cmov/libc-2.6.1.so

b7fc9000-b7fce000 r-xp 00144000 08:01 1488993 /lib/tls/i686/cmov/libc-2.6.1.so 

b7fcb000-b7fce000 rwxp b7fcb000 00:00 0

b7fd8000-b7fd9000 rwxp b7fd8000 00:00 0

b7fd9000-b7fda000 r-xp 00000000 08:01 1343290 ./lib.so

b7fda000-b7fdb000 rwxp 00000000 08:01 1343290 ./lib.so

b7fdb000-b7fdd000 rwxp b7fdb000 00:00 0

b7fdd000-b7ff7000 r-xp 00000000 08:01 1455332 /lib/ld-2.6.1.so

b7ff7000-b7ff9000 rwxp 00019000 08:01 1455332 /lib/ld-2.6.1.so

bf965000-bf97b000 we-p bf965000 00:00 0 [stack]

ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]


可以看到,整个进程虚拟地址空间中,多出了几个文件的映射。lib.so与program1一样,都是被操作系统用同样的方法映射到进程的虚拟地址空间。

关于ld-2.6.so,实际删格式linux下的动态连接器,动态连接器与普通共享对象一样被映射到了进程的地址空间,在系统开始运行program1之前,

首先会把控制权交给动态连接器,由他完成所有的动态链接工作以后再把控制权交给program1,然后开始执行。

共享对象的最终链接装在地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块大小的虚拟地址空间给

相应的共享对象。

其实程序模块的指令和数据中可能会包含一些绝对地址的引用,在连接产生输出文件的时候,就要假设模块被装载 的目标地址。但是共享对象在

编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定在进程虚拟空间中的起始位置,因为可执行文件往往

是第一个被加载的文件,它可以选择一个固定空闲的地址。在连接时的重定位称为连接时重定位,而此时,装载时也需要对模块地址进行重定位

,我们称为装载时重定位。而windows下又称为基址重置。

装载时重定位虽然解决了动态模块中绝对地址引用,但是使得指令部分无法再多个进程之间共享,此时我们希望程序模块中共享的指令部分在

装载时不需要根据装载地址的改变而改变,所以需要把指令中那些需要被修改的部分分离出来,和数据部分放在一起,这样指令部分就可以保持

不变,而数据部分可以在每个进程中拥有一个副本,这种方法被称为地址无关代码PIC。

其实产生地址无关代码并不麻烦,先将模块中各种类型的地址引用方式是否跨模块分为两类:模块内部引用和模块外部引用:按照不同的引用

方式又可以分为指令引用和数据访问。此时分为四种情况

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

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

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

模块外部的数据访问,比如其它模块中定义的全局变量。

static int a;

extern int b;

extern void ext();

void bar()

{

a = 1;                       ///模块内部数据访问

b = 2; ///模块外部数据访问

}

void foo()

{

bar(); ///模块内部函数访问

ext(); ///模块外部函数访问

}

当编译器在编译此文件时,实际上不能确定变量b和函数ext() 是模块外部的还是模块内部的,因为它们有可能定义在同一共享对象的其它目标文件

中。由于没法确定,编译器只能把它们都当做外部函数和变量来处理。msvc编译器提供了__declspec(dllimport)扩展来标识一个符号是模块内部

还是模块外部的。

第一种情况中,对于被调用函数和调用者都处于同一个模块,他们之间的相对位置是固定的,因此模块内部的跳转、函数调用都可以是相对地址

调用,或者基于寄存器的相对调用,所以对于这种指令是不需要重定位的。

<bar>:

8048344: 55 push %ebp

8048345: 89  e5 mov %esp,%ebp

8048347: 5d pop %ebp

8048348: c3 ret

8048349: <foo>:

......

8048357: e8  e8  ff  ff  ff call 8048344 <bar>

804835c: b8  00  00  00 00 mov $0x0,%eax

......


foo中对bar的调用那条指令实际上就是一条相对地址调用指令,此条指令中后4个字节是目的地址相对于当前指令的下一条指令的偏移,即

0xffffffe8,0xffffffe8是-24的补码形式,即bar的地址为0x804835c-24 = 0x8048344 只要bar和foo的相对位置不变,这条指令是地址无关的,

这种相对地址的方式对于jmp指令也是有效的。

很明显,指令中不能直接包含数据的绝对地址,唯一的办法就是使用相对地址,一个模块前面一般是若干个页的代码,后面紧跟若干个页的数据

这些页之间的相对位置是固定的,如此,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,只需要相对于当前指令加上固定

偏移量就可以访问模块内部的数据了。

00000044c <bar>:

44c: 55 push %ebp

44d: 89 e5 mov %esp,%ebp

44f: e8  40  00  00 00   call 494 <__i686.get_pc_thunk.cx>

454: 81  c1  8c  11  00  00 add  $0x118c,%ecx

45a: c7  81   28  00  00  00  01 movl $0x1,0x28(%ecx)

461: 00  00  00

464: 8b  81  fb  ff  ff  ff mov 0xfffffff8(%ecx),%eax

46a: c7  00  02  00 00 00 movl $0x2,(%eax)

470: 5d pop %ebp

471: c3 ret

00000494 <__i686.get_pc_thunk.cx>

494: 8b  0c  24 mov (%esp),%ecx

497: c3 ret


当处理器执行call指令以后,下一条指令的地址就会被压到栈顶,而esp寄存器始终指向栈顶,当"__i686.get_pc_thunk.cx"执行"mov (%esp),%ecx"

时,返回地址就被赋值到ecx寄存器了。

接着执行一条add和一条mov,就可以看到遍历a地址是add指令地址(保存在ecx寄存器)加上另个偏移量0x118c和0x28,即如果模块被装载到

0x10000000这个地址,那么变量a的实际地址是0x100000000 + 0x454 +0x118c + 0x28 = 0x10001608 如图



|--------------------------------------------------------------------------------0x00000000 

|

|

|

|

|---------------------------------------------------------------------------------0x10000000

|--------- |  44f: e8  40  00  00 00    call 494 <__i686.get_pc_thunk.cx>

| | 454: 81  c1  8c  11  00  00 add  $0x118c,%ecx

| |  45a: c7  81   28  00  00  00  01 movl $0x1,0x28(%ecx)

| |  461: 00  00  00

| | .text

0x118c  +  0x28   |

| |

| |--------------------------------------------------------------------------------------

| |

|--------- |static int a;

|

|

| .data

|

|----------------------------------------------------------------------------------------


而模块间的数据访问,需要等到装载时才决定,例如变量b,被定义在其它模块中,并且改地址在装载时才能确定,使得代码地址无关,基本

思想就是把跟地址相关的部分放到数据段中,很明显,这些其它模块的全局变量的地址是跟模块装载地址有关的,此时在数据段建立一个

指向这些变量的指针数组,也称为全局偏移表GOT,当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。当指令中需要访问

变量b时,程序先找到GOT,此时根据GOT中变量所对应的项找到变量的木匾地址,每个变量对应一个4字节的地址,连接器在装载模块的时候

会查找每个变量所在的地址,填充GOT中各个项,以确保每个指针指向地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改

,并且每个进程都可以有独立的副本。

模块在编译时可以确定模块内部变量相对与当前指令的偏移,那么我们也可以在编译时确定GOT相对于当前指令的偏移即确定GOT的位置,然后根据

变量地址在GOT中的偏移就可以得到变量地址。

但是定义在模块内部的全局变量该如何处理呢?比如一个共享对象定义了一个全局变量global,在模块module.c中是这么引用的

extern int global;

int foo()

{

global = 1;

}

此时编译器编译module.c时,无法根据上下文判断global是定义在同一个模块的其它目标文件还是定义在另外一个共享对象之中,即无法判断

是否跨模块调用,也就是无法判断是通过GOT方式引用还是在本地可执行文件.bss中。此时我们把所有的使用这个变量的指令都指向位于可执行文件

中的那个副本。elf共享库在编译时,默认把定义在模块内部的全局变量当做定义在其他模块的全局变量,通过GOT来实现变量的访问。当共享模块

被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中相应地址指向该副本,这样该变量在运行时实际上最终只有

一个实例。如果变量在共享模块中被初始化,俺么动态连接器还需要将该初始化值复制到程序主模块中的变量副本。如果该全局变量在程序主模块中

没有副本,那么GOT的相应地址就指向模块内部的该变量副本。

如果lib.so中定义了一个全局变量G,而进程A和进程B都是用了lib.so,那么当进程A改变G时,进程B会受到影响吗?

不会,当lib.so被两个进程加载时,它的数据段部分在每个进程中都有独立的副本,此时,共享对象中的全局变量实际上和定义在程序内部的全局变量

没什么区别,任何一个进程访问的只是那个副本,而不会影响到其它进程,但是如果是同一个进程的线程A和线程B,此时是会影响到的。此时windows

上有个专门的术语线程私有存储(Thread Local Storage).


此时我们该对比一下静态链接和动态连接的区别了,动态连接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行负载的GOT

定位,并进行间接寻址;对于模块间的调用也要先进性GOT,然后间接跳转。如此一来,必然使程序的运行速度收到影响。同时,动态链接的连接工作

在运行时完成,即程序执行时,还要进行一次连接工作,装载所需要的共享对象,然后进行符号查找地址重定位等工作。针对第二种情况,基于共享对象

中的很多函数不会被用到,如果一开始就把所有函数都连接实际上也是一种浪费。所以此时采用一种延迟绑定的做法,基本思想就是,函数第一次

用到时才进行绑定(符号查找、定位)

elf使用PLT(Procedure Linkage Table)方法实现。假设liba.so需要调用libc.so中的bar函数,那么当liba.so第一次调用bar函数时,需要动态连接器

中的某个函数来完成地址绑定工作,我们假设lookup()来查询bar地址,此时lookup需要知道地址绑定发生在哪个模块,哪个函数。

lookup(module,function),当调用外部模块的函数时,通常用GOT中相应项进行间接跳转,PLT为了实现延迟绑定,又增加了一层间接跳转。

此时,每个外部函数在PLT中都有一个相应的项,比如bar的项地址为bar@plt。实现如下

bar@plt:

jmp *(bar@GOT)

push n

push moduleID

jump _dl_runtime_resolve

很明显,第一条指令的效果就是跳转到第二条指令,而第二条指令将n压入堆栈,这个数字是bar这个符号引用在重定位表的.rel.plt的下标,接着

又是将moduleid压入堆栈,然后跳转到_dl_module_resolve

这实际就是lookup(module,function)的调用。

其实PLT真正实现起来要复杂一些,elf将GOT拆分为.got和.got.plt,其中.got用来保存全局变量引用地址,而.got.plt用来保存函数的地址,对于外部函数

的引用部分被分离出来放入.got.plt中,另外.got.plt的前三项如下:

第一项保存的是.dynamic段的地址,此段描述了本模块动态连接相关的信息

第二项保存本模块的ID,第三项保存的是_dl_runtime_resolve的地址。







评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值