LYYL' Blog

勿忧拂意,勿喜快心,勿恃久安,勿惮初难。

0%

ELF文件及链接装载过程分析

ELF文件

ELF文件整体概览

首先我们编译一个示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int printf(const char* format, ...);


int global_init_var = 84;
int global_unint_var;

void func1(int i){
printf("%d\n", i);
}

int main(void){
static int static_var = 85;
static int static_var2;
int a=1;
int b;
func1(static_var + static_var2 + a + b);
return a;
}
//gcc -c sectionTest.c 仅编译不链接

我们来看一下示例程序当前存在的段内容

图片无法显示,请设置GitHub代理

我们看到示例程序存在6个段,Size表示的是当前段的长度,file off表示的是在文件中的偏移,从结果来看,其分配的空间也是符合对齐原则的。文件头部的大小为0x40字节。从整体来看一个ELF文件就由文件头和表示各个功能的段组成的。

下面我们简要分析一下示例程序中的各个段的内容

objdump -x -s -d sectionTest.o

  • .text 代码段,存储代码编译之后的数据

  • .data段是数据段,保存了已经初始化的全局变量和局部静态变量,在代码中共有两个变量分别是global_init_var, static_var总共8字节大小,与段大小对应。

    图片无法显示,请设置GitHub代理
  • .rodata只读数据段,存放只读变量(如const修饰的变量),在该程序中存储的是printf的格式化字符串

    图片无法显示,请设置GitHub代理
  • .bss段存储的是未初始化的全局变量和局部静态变量(更加准确地说法是为这两种类型的变量预留空间,我们可以看到bss段不占磁盘空间),在示例程序中应该存储在bss段中的变量为global_unint_var,static_var2,共8字节,这与bss段的长度4字节大小不符。其实我们可以通过符号表看到

    图片无法显示,请设置GitHub代理

    其只存储了static_var2变量,而global_unint_var变量则未存储在任何的段中,仅仅是一个未定义的COMMON符号,这里我们在后面分析。

    这里需要注意的是当全局变量或者局部静态变量被初始化为0的时候,编译器会将其存储在bss段,以优化磁盘空间存储,因为未初始化的变量均为0

  • 其他段

    段名 说明
    .rodata1 只读数据段与.rodata相同
    .comment 存放编译器版本信息
    .debug 调试信息
    .dynamic 动态链接信息
    .hash 符号哈希表
    .line 调试时的行号表,即源代码行号与编译后指令的对应表
    .note 额外的调试信息
    .strtab 字符串表,用来存储ELF文件中的各种字符串
    .symtab 符号表
    .shstrtab 段名表
    .plt,.got 动态链接的跳转表和全局入口函数表
    .init,.fini 程序初始化与终结代码段、
    .note.GNU-stack 堆栈提示段
    .en_frame 包含异常展开和源语言信息,当gcc生成某些处理异常代码时,它将描述如何展开堆栈的表
  • 自定义段

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

EFL文件头

ELF文件头部的定义位于内核代码/include/linux/elf.h文件中,对于3264位的系统存在两种数据结构主要的差别就是成员变量的大小不同。我们主要分析一下32位的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef __u32	Elf32_Addr;
typedef __u16 Elf32_Half;
typedef __u32 Elf32_Off;
typedef __s32 Elf32_Sword;
typedef __u32 Elf32_Word;
#define EI_NIDENT 16
typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
图片无法显示,请设置GitHub代理
  • e_ident,共16字节大小,对应的是readelf输出的magic-ABI Version字段。这十六个字节被ELF文件标准规定用来标识ELF文件的平台的属性。比如ELF文件的字长32/64,字节序,ELF文件版本。

    每一个ELF文件的e_ident的前四个字节必须是0X7F,0x45,0x4C,0x46,前面的0x7F表示的是DEL控制符,后面的三个字节表示的是ELF这三个字符的ASCII码。这四个字节被称为是ELF文件的魔数,用来确认文件的类型。

    接下来的一个字节用来表示ELF文件的类型0x1表示32位,0x2表示的是64位。第6个字节表示的是字节序即表示大小端。第7个字节表示的是ELF文件的主版本号,一般为1,后面的9个字节ELF文件标准没有进行定义

  • e_type表示文件的类型,由ET_开头的常量表示

    1
    2
    3
    4
    5
    6
    7
    #define ET_NONE   0
    #define ET_REL 1//可重定位文件
    #define ET_EXEC 2//可执行文件
    #define ET_DYN 3//共享目标文件
    #define ET_CORE 4
    #define ET_LOPROC 0xff00
    #define ET_HIPROC 0xffff
  • e_machine表示的是CPU平台的类型,由EM_开头的常量表示

  • e_version表示ELF文件的版本号

  • e_entry表示ELF文件的入口的虚拟地址,可重定位文件一般没有这个入口地址

  • e_phoff

  • e_shoff表示段表在文件中的偏移

  • e_word表示ELF文件的标志位

  • e_ehsize表示ELF文件的头部的大小

  • e_phentsize

  • e_phnum

  • e_shentsize段表描述符的大小,也就是Elf32_Shdr结构体的大小

  • e_shnum段表描述符的数量,即该文件拥有的段的数量

  • e_shstrndx段表字符串表所在的段在段表中的下标

段表

前面我们使用objdump -h命令仅仅是将重要的段显示出来了,而省略了其他的辅助性的段,使用readelf即可以显示该文件中的所有的段

图片无法显示,请设置GitHub代理

段表的结构是一个Elf32_Shdr结构体为元素的数组。采用数组保存的方便之处在于我们可以直接通过数组的下标引用这个结构体。下面我们先看一下段表描述符也就是Elf32_Shdr结构体

1
2
3
4
5
6
7
8
9
10
11
12
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;
  • sh_name表示段名,即在段名表.shstrtab中的偏移

  • sh_type表示段的类型,段的名字并不能直接表示段的类型。该字段通常用SHT_开头的常量表示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #define SHT_NULL	0
    #define SHT_PROGBITS 1//程序段,代码段和数据段都是该类型
    #define SHT_SYMTAB 2//该段的内容为符号表
    #define SHT_STRTAB 3//该段的内容为字符串表
    #define SHT_RELA 4//重定位表,包含了重定位信息
    #define SHT_HASH 5//符号表的哈希表
    #define SHT_DYNAMIC 6//动态链接信息
    #define SHT_NOTE 7//提示性信息
    #define SHT_NOBITS 8//该段在文件中没有内容,如bss段
    #define SHT_REL 9//该段包含了重定位信息
    #define SHT_SHLIB 10//保留段
    #define SHT_DYNSYM 11//动态链接的符号表
    #define SHT_NUM 12//该段表示段类型的数目
    #define SHT_LOPROC 0x70000000
    #define SHT_HIPROC 0x7fffffff
    #define SHT_LOUSER 0x80000000
    #define SHT_HIUSER 0xffffffff
  • sh_flags表示该段在进程虚拟地址空间中的属性,用SHF_开头的常量表示

    1
    2
    3
    4
    5
    6

    /* sh_flags */
    #define SHF_WRITE 0x1//可写
    #define SHF_ALLOC 0x2//表示该段在进程空间中需要分配空间
    #define SHF_EXECINSTR 0x4//可执行
    #define SHF_MASKPROC 0xf0000000
  • sh_addr若该段可以被加载则表示加载后在进程虚拟地址空间中的虚拟地址,否则为0

  • sh_offset如果该段在文件中则表示该段在文件中的偏移

  • sh_size段长度

  • sh_linksh_info。如果段的类型是链接相关的则表示意义如下

    sh_type sh_link sh_info
    SHT_DYNAMIC 该段使用的字符串表在段表中的下标 0
    SHT_HASH 该段使用的符号表在段表中的下标 0
    SHT_REL 该段使用的相应的符号表在段表中的下标 该重定位表所作用的段在段表中的下标
    SHT_RELA 该段使用的相应的符号表在段表中的下标 该重定位表所作用的段在段表中的下标
    SHT_SYSTAB 操作系统相关 操作系统相关
    SHT_DYNSYM 操作系统相关 操作系统相关
  • sh_addralign表示该段有没有对齐要求

  • sh_entsize表示段中包含的固定大小的项的大小,若为0则表示不包含固定大小的项、(如符号表包含的每个符号的所占的大小都是相同的)

重定位表

我们的示例程序中包含了一个rel.text的段,其段类型是SHT_REL即重定位表。因为代码段中至少存在一个绝对地址的引用也就是printf函数的调用,但是data段中就不存在这种绝对引用,因此不存在rel.data段。

由于该段的类型是SHT_REL,因此其结构体中的sh_link的内容就表示符号表中的下标,sh_info表示的是作用的是哪个段,由于text是第1个段,因此sh_info的内容是1,这一部分将在后面详细的分析

字符串表

由于字符串的长度不定,因此将所有的字符串存储在一个表中,使用字符串在表中的偏移来引用这个字符串,每个字符串都是以\x00结尾的。字符串表共分为两种一种是普通的字符串表.strtab,另一种是段表字符串表shstrtab

符号

在程序中我们经常使用到函数调用或者变量的引用,这些在可执行文件中都是以地址的形式体现的(相对或者绝对),地址的解析就涉及到了符号。在链接中每一个变量或者函数必须有自己独特的名字,才能够避免在链接过程中不同变量和函数直接的混淆。在链接中将函数和变量统称为符号,函数名或者变量名就是符号名。每个目标文件都会有一个相应的符号表,这个表里面记录了目标文件中所用到的所有的符号。每个定义的符号都有一个对应的值,叫做符号值,对于变量和函数来说,符号值就是他们的地址。

在链接中最为关注的就是全局符号(即可以被其他文件引用的符号)和外部符号(即在本文件引用的全局符号但是并没有定义在本目标文件中),可以通过readelf,objdump,nm等工具来查看目标文件的符号表

符号表结构

1
2
3
4
5
6
7
8
typedef struct elf32_sym{
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;//未在使用
Elf32_Half st_shndx;
} Elf32_Sym;

符号表同段表一样采用数组存储,即每一个元素都是一个elf32_sym结构体表示一个符号。

  • st_name表示符号名,即在字符串表中的下标

  • st_value表示符号值

    • 若改符号不为SHN_COMMON类型,则st_value表示的是改符号在段中的偏移。即符号所对应的函数或者变量在st_shndx指定的段中偏移st_value的位置
    • 若符号类型为SHN_COMMON则表示符号的对齐属性
    • 在可执行文件中st_value表示的是符号的虚拟地址
  • st_size表示符号大小

  • st_info表示符号类型和绑定信息,第4位表示的是符号类型,高28位表示的是符号的绑定信息

    符号绑定信息

    1
    2
    3
    #define STB_LOCAL  0//局部符号对目标文件的外部不可见
    #define STB_GLOBAL 1//全局符号,外部可见
    #define STB_WEAK 2//弱引用

    符号类型

    1
    2
    3
    4
    5
    #define STT_NOTYPE  0
    #define STT_OBJECT 1//该符号是个数据对象如变量或者数组
    #define STT_FUNC 2//该符号是个函数或者其他的可执行代码
    #define STT_SECTION 3//该符号是一个段,这种符号的类型必须是STB_LOCAL
    #define STT_FILE 4//该符号表示文件名,一般是目标文件所对应的源文件的文件名
  • st_shndx表示符号所在的段,如果符号定义在本目标文件中,则表示符号所在的段在段表的下标。否则表示特殊意义如

    1
    2
    3
    #define SHN_ABS		0xfff1//表示包含了一个绝对的值,比如文件名
    #define SHN_COMMON 0xfff2//表示是一个COMMON块类型的符号,一般未初始化的全局变量符号为该类型
    ...

我们看一下示例程序的符号表

图片无法显示,请设置GitHub代理

可以看到输出的信息和结构体的成员变量是一一对应的。可以看到func1,main都是定义在代码段中的函数,因此其Ndx的值为1,也就是text段在段表的下标,他们是全局可见的。printf符号则没有在本文件中定义,因此其Ndx的类型为SHN_UNDEF。而类型为STT_SECTION即表示段类型的符号,他们的符号名没有显示,但是其实其Ndx指向的段就表示了段名

特殊符号

在链接的过程中,链接器会定义很多的特殊符号,虽然我们没有在目标文件中声明,但是可以在目标文件中直接引用这些符号如

  • __executable_start即程序的起始地址,注意这里不是入口地址,是程序最开始的地址
  • __etext,_etext,etext代码段的结束地址,即代码段最末尾的地址
  • _edata,edata数据段的结束地址
  • _end,end程序结束地址

这些地址都是程序被装载之后的虚拟地址。

弱符号和强符号

编译器默认函数和已经初始化的全局变量为强符号,未初始化的全局变量为弱符号。可以通过__attribute__((weak))来定义一个弱符号。注意的是强弱符号的概念是针对定义来说的,并不针对符号的引用。

链接器针对多次定义的符号有如下的处理(多个文件中包含相同名字的全局符号定义)

  • 强符号不允许多次定义即多个文件中不能存在名称相同的强符号
  • 如果一个符号在一个文件中为强符号,在其他文件中均为弱符号则选择强符号
  • 若均为弱符号则选择占用空间最大的那个弱符号

由此引申出弱引用和强引用的概念。可以通过__attribute__((weakref))定义一个弱引用。在链接器进行链接的过程中,若未找到强引用的定义则报错,针对弱引用则不会报错。(链接器不认为未定义的弱引用是个错误)。

弱符号和弱引用的产生对于库来说非常重要。比如库中定义的弱符号可以被用户定义的强符号覆盖从而使得程序执行自定义版本的库函数。在Linux设计中如果一个程序被设计为可以支持单线程也可以支持多线程就可以通过弱引用的方法来判断当前的程序是链接到了多线程GLIBC还是单线程GLIBC(即是否在编译的时候加入-lpthread的选项)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<stdio.h>
#include<pthread.h>

int pthread_create(
pthread_t*,
const pthread_attr_t*,
void*(*)(void *),
void *
)__attribute__((weak));

int main(){
if (pthread_create){
printf("This is multi-thread version\n");
}else{
printf("This is single-thread version\n");
}
}

静态链接

对于链接器来说,整个链接过程就是将几个输入目标文件加工合并成一个输出文件,一般常用的方法就是相似段合并,即将相同的段合并到一个段中。使用这种方法的链接器一般都采用一种叫做两步链接的方法,也就是整个链接过程分为两步。

  1. 空间与地址分配:扫描所有的输入文件,根据各个文件的中各个段的长度,属性和位置计算输出文件各个段合并后的长度和位置,并建立映射关系。在这个过程中会将各个文件的符号表中所有的符号定义和引用收集起来,形成一个全局符号表。
  2. 符号解析与重定位:读取第一步收集到的所有的信息,读取输入文件中的段数据,重定位信息,并进行符号解析与重定位,调整代码中的地址等。

空间与地址分配

示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//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 -m32 -fno-stack-protector -c a.c b.c
//ld a.o b.o -m elf_i386 -e main -o ab

我们主要关注的是text,data这两个段的变化

图片无法显示,请设置GitHub代理 图片无法显示,请设置GitHub代理

列表中的VMA表示的是虚拟地址,LMA表示的是加载地址,正常情况下这两个数值应该是相同的。我们可以看到在链接之前两个目标文件的虚拟地址均为0,因为此时的虚拟空间还没有被分配。在链接之后可执行文件ab的各个段都被分配到了相应的虚拟地址。并且我们观察到ab.text,.data段的大小正是a.out,b.out.text,.data段大小之和。这正是相似段合并的体现。至于为什么.text的起始地址是0x8048094,是因为对32位程序来说,ELF可执行文件默认从0x804800开始分配。

当空间与地址分配完成之后就开始计算各个符号的虚拟地址。因为符号在段内的相对位置是固定的,因此只需要在每个符号的偏移值上面加上段的起始地址就得到该符号的虚拟地址了。

符号解析与重定位

重定位之前与之后的对比

首先我们先看一下在没有链接前,也就是没有重定位前a.c中对于shared,swap的引用

图片无法显示,请设置GitHub代理

可以看到在没有进行空间分配之前main函数的起始地址为0x0。函数中对于shared的引用是第一个,push指令后面应该是shared的地址,因为此时并不知道shared的地址,因此这里临时采用0x0代替。对于swap函数的调用时第二个,由于call指令是一个近址位移相对指令,后面跟的是调用地址相对于下一条指令的偏移,这是0xfffffffc也就是-c调用的是0x29-0x4也就是0x27,没有实际意义。

图片无法显示,请设置GitHub代理

可以看到经过链接已经完成了空间地址分配的过程,并且我们可以看到经过修正之后,shared,swap的地址分别为0x804916c,0x10。由于call指令后存储的是调用函数的地址距离下一条指令的偏移,也就是调用的是0x89480bd+0x10=0x80480cd恰好是swap函数的起始地址。

重定位表

那么链接程序是如何得知重定位的相关信息呢。 之前在ELF文件头部有一个叫做重定位表的结构存储着与重定位相关的信息,重定位表往往是一个或者几个段。如果text段中需要重定位,那么就会产生一个.rel.text的重定位表,data段中需要重定位,则会产生一个.rel.data的重定位表。

每一个被重定位的地方由一个重定位入口的结构体表示

1
2
3
4
typedef struct elf32_rel {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
  • r_offset表示重定位入口的偏移,表示的是需要重定位的第一个字节距离段起始位置的偏移值。

    对于可执行文件或者共享对象文件来说,该变量表示的是需要重定位的第一个字节的虚拟地址

  • r_info表示重定位入口的类型和符号。低8位表示的是重定位入口的类型,高24位表示的是重定位入口的符号在符号表中的下标

查看一下a.o的重定位信息

图片无法显示,请设置GitHub代理

其中R_386_32代表了绝对寻址修正,是一个立即数,即shared的绝对地址(符号的实际地址+保存在被修正位置的值),R_386_PC32代表的则是相对寻址修正(符号的实际地址+保存在被修正位置的值-被修正的位置的地址(该地址可以通过r_offset计算得到))。这也符合call指令的相对位移调用指令的类型。

COMMON块

之前我们在sectionTest.c的例子中发现未初始化的全局变量global_unint_var的符号类型是COMMON。这是为了解决符号类型冲突的问题。未初始化的全局变量被定义为global_unint_var的类型是COMMON是一个典型的弱符号,当我们在其他文件中定义相同名称但是不同类型的变量(如double类型,大小为8字节)那么最终根据弱符号的处理规则,global_unint_var变量的最终大小为8字节。这也是为什么在链接之前没有将global_unint_var变量置入bss段中的原因,因为无法确定最终global_unint_var变量的大小,只有在链接结束之后才能最终确定global_unint_var变量的大小,并将其存储在bss段中。

需要注意的是如果链接过程中弱符号的大小大于强符号的大小,那么编译器会给出警告。

静态库链接

很多程序在执行的时候都会利用到系统的API,比如在使用printf函数输出一个字符串,在经过一定的处理之后就会调用系统提供的APILinux下是一个write函数的调用,在Windows下则是WriteConsole系统API。这些都利用到了系统的静态库.lib文件,我们可以把一个静态库简单的看做是一组目标文件的集合,即多个目标文件经过压缩打包形成的一个文件(使用ar压缩程序)如linux下的libc.a。我们可以使用ar -t /home/pwn/Desktop/glibc/x32/glibc2.23/lib/libc.a来查看libc.a中包含的目标文件的信息。

整个libc.a中包含了上千个目标文件,那么链接程序是如何找到对应的目标文件的呢。我们以printf函数为例,我们使用objdump查看libc.a的符号表

图片无法显示,请设置GitHub代理

目标函数被定义在了printf.o文件中,那么我们仅仅将printf.o与只调用printf的目标文件链接起来就可以调用printf函数了嘛,不是的,因为printf.o中存在对其他符号的依赖,从符号表中也可以看出其存在UND符号。

理论上我们按照文件依次找全所有的依赖文件并将它们链接在一起就可以成功的调用printf函数了,但是这样做的人工代价太大了,链接器会帮我们处理这一切,我们简单的看一下程序链接过程的中间步骤

图片无法显示,请设置GitHub代理

其中重要的有三步,一个是调用ccl程序,将hello.c编译为一个临时文件/tmp/ccleoQg3.s

然后调用as汇编器,将/tmp/ccleoQg3.s汇编为临时文件/tmp/cc2G2jTy.o这个就是hello.o,然后调用collect2程序(ld链接器的一个包装,主要是调用ld完成链接,再对链接结果做一些处理,主要收集所有的与程序初始化现骨干的信息并构造初始化的结构)最终编译完成。

当然可以使用更加复杂或者精细的连接脚本来控制链接的过程,尤其是对于编译内核程序,或者一些脱离操作系统运行的程序(如磁盘分区软件)等,他们往往具有特殊的段存储要求,需要特殊指定,这时就可以使用链接脚本对 链接过程进行控制,这里可以参考《程序员的自我修养》4.6章节。

可执行文件的装载

当我们编译完成,生成的可执行文件只有被装载到内存中才能够启动。

进程的虚拟地址空间

当每个程序在内存中运行的时候,我们知道它都是运行在自己的虚拟地址空间中,对32位的平台来说其大小为4GB。在Linux平台中,这4GB中位于高地址处的1GB(0xC0000000-0xFFFFFFFF)大小的空间被划分为操作系统的空间,低地址处的3GB大小的空间才是进程所能够利用的空间。但是进程并不能完全利用这3GB大小的空间,其中的一部分会预留给其他的用途,这个之后会提到。在WINDOWS操作系统中操作系统所占的空间则是2GB当然在Boot.ini中可以进行更改。

32位的CPU只能访问4GB大小的物理空间嘛。不是的,在1995年之后也就是Pentium Pro CPU之后,Intel开始采用了36位地址线,并修改了页的映射方式,使得最高支持64GB大小的内存。但是进程的虚拟空间的大小还是4GB,其通过AWE即窗口映射的方法访问超过4GB大小的物理内存,在Linux操作系统中通过mmap系统调用实现。(简单来说就是多个高地址处的相同大小内存空间(页面)映射到虚拟空间的特定地址范围,并根据访问的需要不断地轮换)

页映射

可执行文件的内存装入方式分为两种,一种是覆盖装入,一种是页映射。覆盖装入的方法已经被放弃,主要思想就是子模块公用一块物理空间,主模块使用一块物理空间。

而页映射是虚拟存储的一部分,与操作系统对于内存的管理类似,页映射方法是一种动态加载的方法即在程序执行的时候需要用到哪个页就将哪个页装入内存,内存已满时采用一定的置换策略(FIFO,LUR等)置换页。由于不确定页会加载到内存的哪个位置,因此需要地址转换和页映射机制的支持。

可执行文件的创建和运行过程可以简单地描述如下:

  • 首先操作系统建立虚拟地址空间,即建立虚拟地址空间到物理地址空间的映射关系,这里实际上只是分配了一块内存创建了一个页目录项,里面并没有实际的物理地址的内容。通常这里由MMU进行管理。
  • 接着读取可执行文件的头部,建立虚拟地址空间和可执行文件之间的映射关系,这种映射关系保存在操作系统内部的一个数据结构中,我们以只有一个.text段的程序加载为例,其虚拟地址空间的起始位0x8048094大小为0x72,由于页映射都是以页为单位的,因此该段在虚拟空间中的地址假设为0x8048000-0x8049000。那么操作系统内部的数据结构中就会设置一个.text的项,其虚拟地址空间为0x8048000-0x8049000,对应ELF文件的偏移值为0.text段,该段的属性是可读可执行,还有一些其他的属性等。这就完成了虚拟地址空间到可执行文件之间的映射关系。当完成这种映射关系的时候,操作系统在缺页的时候就知道所需页在物理磁盘中的位置,进而可以加载到内存中。
  • CPU指令寄存器设置为可执行文件的入口,开始执行。这一步涉及到了内核堆栈和用户堆栈的切换,CPU运行权限的切换,最终跳转到了可执行文件的入口地址也就是ELF文件头部保存的可执行文件的入口地址处开始执行。当然由于最开始我们并没有装载任何的页,因此其会触发页错误,此时控制权转换到了操作系统位置,操作系统将查询第二步设置的数据结构找到在可执行文件中的偏移,然后在物理内存分配空间,设置第一步中的虚拟地址空间与物理内存之间的映射关系,然后将控制权重新交还给进程,重新开始执行。内存已满时就采用一定的置换策略(FIFO,LUR等)置换页(其实为虚拟存储的管理)。这样进程就可以一直执行下去了。

进程虚拟空间分布

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

这里只是举了一个只有一个.text段的例子,实际上在进行转载的时候装载程序会将多个相同属性的section合并为一个segment(节),以减少每个段都要映射一个或者多个页而造成的空间浪费。我们可以用readelf -l来查看可执行文件节区的分配

1
2
3
4
5
6
7
8
9
//示例程序
#include<stdlib.h>

int main(){
while(1){
sleep(1000);
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
❯ readelf -l sectionMapping                          

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

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0xa05f1 0xa05f1 R E 0x1000
LOAD 0x0a0f5c 0x080e9f5c 0x080e9f5c 0x01024 0x01e48 RW 0x1000
NOTE 0x0000f4 0x080480f4 0x080480f4 0x00044 0x00044 R 0x4
TLS 0x0a0f5c 0x080e9f5c 0x080e9f5c 0x00010 0x00028 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
GNU_RELRO 0x0a0f5c 0x080e9f5c 0x080e9f5c 0x000a4 0x000a4 R 0x1

Section to Segment mapping:
Segment Sections...
00 .note.ABI-tag .note.gnu.build-id .rel.plt .init .plt .text __libc_freeres_fn __libc_thread_freeres_fn .fini .rodata __libc_subfreeres __libc_atexit __libc_thread_subfreeres .eh_frame .gcc_except_table
01 .tdata .init_array .fini_array .jcr .data.rel.ro .got .got.plt .data .bss __libc_freeres_ptrs
02 .note.ABI-tag .note.gnu.build-id
03 .tdata .tbss
04
05 .tdata .init_array .fini_array .jcr .data.rel.ro .got

这个可执行文件存在六个segment,在加载过程中最为重要的则是LOAD类型的节区,因为这是需要加载的,其他的只是起辅助性的作用的节区。

section角度来看ELF文件时链接视图,segment角度来看则是执行视图。

ELF文件中用来保存segment信息的数据结构叫做程序头表可以用下面的结构来表示,由于ELF目标文件不需要被装载因此其没有程序头,ELF可执行文件和共享库文件都存在程序头

1
2
3
4
5
6
7
8
9
10
typedef struct elf32_phdr{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
  • p_type表示节区的类型

  • p_offset表示节区在文件中的偏移

  • p_vaddr该节区的第一个字节在进程虚拟空间中的起始地址,在整个程序头中所有的LOAD类型的按照p_vaddr从小到大排序

  • p_paddr节区的物理装载地址LMA,一般与p_paddr相同

  • p_fileszELF文件中所占空间的大小

  • p_memsz在进程虚拟地址空间的中大小

    正常情况下p_memsz的大小要大于p_filesz,等于和小于好理解,大于的意思是指的是在内存中分配的空间大于实际的大小,那么这些多出来的空间则全部填充为0用来作为BSS段,(和data段相比BSS段中的变量全部初始化为0,而data中的变量则从文件中获取数据进行初始化)这样就不用了再增加BSS的节区了。

  • p_flags节区的权限属性,如可读可写等

  • p_align节区的对齐属性,实际的对齐字节是2p_align次方

那么进程在装载segment的时候也会遇到和之前可执行文件映射section的情况,就是segment可能不足一个页的大小,因此会造成空间的浪费,有些UNIX系统采取的方法是将两个相邻的segment共享一个物理页,并将该物理页映射两次,从而减少空间浪费。

PE文件则与ELF文件不同,其在链接过程中会将所有的段尽可能的合并,所以一般只有代码段,数据段,只读数据段和BSS段等几个少数的段,因此其在映射过程中就没有必要采用segment的形式,因此其段的起始地址都是页的整数倍

栈初始化

在装载完成之后需要对程序栈进行初始化,即将用户参数,环境变量,辅助变量等压栈。从低地址到高地址分别是argc,argv,NULL,envp

动态链接

由于采用静态链接,每个程序都会保存其所引用的系统库,这些系统库是相同的,这就会造成空间的浪费,并且静态链接还会导致更新困难等问题。动态链接则是将程序的模块相互分割开来,形成独立的文件,在加载的时候根据需要选择需要加载的模块,而对于已经加载的模块,在其他程序引用的时候就不想要重复进行加载了,因为该模块已经加载到内存中。相对于静态链接在装载之前就需要完成符号解析,地址重定位来说,动态链接则是在加载过程中完成符号解析和地址重定位,并采用延迟绑定的方法减小动态链接所造成的性能损失。

Linux系统中的动态链接文件为共享对象也就是.so结尾的一些文件,而在windows系统中则被称为是动态链接库通常是以.dll结尾的一些文件。

动态链接程序运行时地址分布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//program1.c
#include "Lib.h"

int main(){
foobar(1);
}
//program2.c
#include "Lib.h"

int main(){
foobar(2);
}
//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
//gcc -m32 -fPIC -shared -o Lib.so Lib.c
//gcc -m32 -o program1 program1.c ./Lib.so
//gcc -m32 -o program1 program2.c ./Lib.so

我们编译得到了一个共享库文件Lib.so,该共享对象中包含了foobar函数。但是我们知道动态链接是在装载时完成和共享对象的连接过程,那么我们在对program1.c进行编译链接的过程中为什么还加入了Lib.so共享对象文件呢。是因为链接器在将program1.c链接成可执行文件时候,链接器必须确定program1.c文件中所引用的foobar函数的性质,如果foobar函数是定义在其他静态目标文件中的函数,链接器则会完成符号解析和地址重定位,如果foobar是定义在一个动态共享对象中的函数,链接器则会将该函数标记为一个动态链接的符号,等到装载的时候在进行处理。

这里的fPIC选项是指的采用地址无关的方式进行共享对象的编译,之后会详细分析

我们看一下程序的地址空间分布

我们查看一下program1进程的虚拟地址空间分布

图片无法显示,请设置GitHub代理

我们可以看到program1除了用到了Lib.so之外,还用到了动态链接的C语言库libc-2.23.so。并且动态链接文件ld-2.23.so和普通文件一样也被映射到了进程的虚拟地址空间,在系统开始运行program1之前,首先会将控制权交给动态链接器,由它完成动态链接工作之后再把控制权交给program1,然后开始执行。我们来看一下Lib.so共享对象的装载属性

图片无法显示,请设置GitHub代理我们注意到动态链接模块的装载地址是从0x0开始的,这与之前我们看到的地址并不相同,也就睡说共享对象的最终装载地址在编译的时候是不确定的,而是在装载的时候装载器根据当前地址空间的空闲情况动态分配一块足够大小的虚拟地址空间给相应的共享对象。

地址无关代码

动态链接中不同目标的装载地址是不一样的,但是程序中的一些模块中的指令或者数据会包含一些绝对地址的引用,这就造成了共享对象的地址冲突问题。为了使得共享对象能够在任意的地址装载,首先想到的是装载时重定位,一旦模块的装载地址确定,那么系统就对程序中所有的绝对地址的引用进行重定位(装载时重定位)。但是共享对象的代码部分是需要在多个进程之间进行共享的,而在装载时需要对绝对地址进行重定位,需要修改指令,因此无法完成共享。

于是人们提出了一种解决方案就是地址无关代码,即将指令中需要修改的部分分离出来,跟数据部分存放在一起,这样指令部分就可以保持不变,而数据部分在每一个进程中都拥有一个副本,这样就做到了共享对象代码在多个进程中共享。我们首先来看代码中存在的四种类型的地址引用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
static int a;
extern int b;
extern void ext();

void bar(){
a = 1;//模块内部的数据访问
b = 2;//模块间的数据访问
}

void foo(){
bar();//模块内部的函数调用
ext();//模块间的函数调用
}

对于模块内部的函数调用来说,因为代码之间的相对地址是固定的,而call,jmp是相对地址的调用,因为不需要进行额外的操作,其本身就是位置无关的。

对于模块内部的数据访问。我们知道一个模块装入内存之后其前面一半是若干个页的代码,紧跟着后面是若干个页的数据,因此数据与代码之间的相对地址是固定的。相对地址的选择是要访问的数据与下一条指令之间的偏移。由于当前的体系中数据寻址的方式不存在基于PC的寻址方式,因此需要先获取下一条指令的地址,这里使用call指令将下一条指令压入栈中之后,栈顶的地址就是下一条指令的地址。

模块间的数据访问。由于只有当模块装载时才能够确定数据的地址,为了做到位置无关,ELF在数据段中建立了一个指向这些全局变量的指针数据即全局偏移表,也就是GOT表,链接器在装载模块的时候会查找每个变量所在的地址,并更新GOT表,由于每个进程的都有独立的数据副本,因此对其他的进程没有影响。访问GOT表时与访问模块内部的数据相同。

但是这里需要注意的是,如果模块内部定义了主模块中需要访问的全局变量,因为主模块并不是位置无关代码,并且在装载的过程中并不会进行重定位,那么在主模块进行编译连接的时候就需要确定该变量的地址,会将该全局变量加入到bss段中,而此时GOT表中也会存在一个表示该变量的项,这就很不合理了,因此GOT表中的项相应的地址指向的是可执行文件中该变量的副本。

也就是说如果某个全局变量在可执行文件中存在副本,那么动态链接器就会将GOT表中的项指向该副本并拷贝模块内部的初始化值

模块间的函数调用。这里也是采用GOT表的思想

而对于数据段中的对于绝对地址的引用,则采取装载时重定位的方法,对于共享对象来说,如果数据段中存在绝对地址的引用,那么编译器和链接器就会产生一个重定位表项R_386_RELATIVE,之后就会在装载时进行重定位。

在对共享对象进行编译链接的时候需要添加-fPIC的选项以开启地址无关,此时数据段的地址可以采取相对于下一条地址的偏移的方式,此时无需重定位。在非PIC的方式中对于数据的引用就需要绝对地址寻址,此时需要进行重定位。

延迟绑定

由于动态链接需要在可执行文件装载过程中进行符号解析和地址重定位,并且其针对外部模块的变量访问和函数调用都是通过GOT表间接实现,这相对于静态链接来说,其性能损失要大。并且实际上在装载时并不需要对所有的外部函数进行绑定(符号解析和重定位),因为有些函数(如错误函数,不常用功能函数)在整个可执行文件执行过程中并不会被调用,因此全部进行绑定是浪费资源的。

ELF采用了一种延迟绑定的策略来解决上述的问题,总的来说就是在函数第一次被调用的时候才进行绑定。该功能通过PLT的两层间接跳转实现。函数调用的时候首先跳转到plt对应的表项,然后跳转到对应的got表项。实际的PTL代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
PLT0:
push *(GOT+4)
jmp *(GOT+8)
...
bar@plt:
jmp *(bar@GOT)
push n
jump PLT0

function@plt:
jmp *(function@GOT)
push n
jmp PLT0

实际的GOT表如下

1
2
3
4
5
6
.dynamic address
Moudle ID
_dl_runtime_resolve()
import function1
import function2
bar function

当函数还未绑定的时候调用bar函数,首先会跳转到bar@plt处执行,也就是jmp *(bar@GOT),跳转到barGOT表项中执行,由于函数还未进行绑定,bar函数的GOT表项中存储的是bar@PLT的第二行代码的地址也就是push n的地址,程序又跳转回PLT表项中执行

push n中的n表示的是在bar这个符号引用在重定位表.re.plt中的下标,接着跳转到PLT0的开头的两行代码执行,这两行代码从GOT表中可以得知执行的是push module_id;jmp _dl_runtime_resolve,可以明显的看出两个push在为调用_dl_runtime_resolve函数进行参数传递,传递的两个参数分别是function符号的重定位信息和模块ID_dl_runtime_resolve函数就是进行符号解析和地址绑定的函数。

当完成对bar函数的符号解析和地址绑定之后,GOT表中的相应的项就会被修改为函数的真实地址,并开始执行bar函数。当我们下一次执行bar函数调用的时候,GOT表中保存的已经是函数的真实地址了,就可以直接跳转到bar函数中。

动态链接相关结构

.interp

我们知道静态链接在完成可执行文件装载之后就将控制权交给可执行文件的入口函数了,但是动态链接在可执行文件装载完成之后需要先将控制权交给动态链接器,动态链接器完成动态链接之后再将控制权交给可执行文件的入口函数,那么可执行文件要执行哪个动态链接器呢

可执行文件的动态链接器既不是由系统指定,也不是由环境参数执行,而是.interp段中。我们可以看一下

图片无法显示,请设置GitHub代理

Linux系统中/lib/ld-linux.so.2是一个软连接指向与glibc版本相同的动态链接器,这样做的好处就是当glibc版本发生变化的时候,只需要修改系统的软链接就可以了,而不用修改可执行文件。

Linux提供了一个工具叫做ldconfig,当系统中安装或者更新一个共享库的时候,就需要运行这个工具,遍历所有的默认共享库目录,更新软链接

.dynamic

动态链接中最为重要的结构就是.dynamic段了,他保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象,动态链接符号表的位置,动态链接重定位表的位置,共享对象初始化代码的位置等等,其结构如下

1
2
3
4
5
6
7
typedef struct dynamic{
Elf32_Sword d_tag;
union{
Elf32_Sword d_val;//数值
Elf32_Addr d_ptr;//地址
} d_un;
} Elf32_Dyn;

其中d_tag用来表示该结构所表示的类型,例如DT_SYMTAB表示动态链接符号表的地址等。d_val,d_ptr则根据类型的不同表示不同的含义,DT_SYMTAB条件下,d_ptr表示的是.dynamic段的地址。

该段可以看做是动态链接下ELF的文件头,我们可以看一下

图片无法显示,请设置GitHub代理

.dynsym

动态链接符号表,与符号表类似,用来保存动态链接相关的符号。很多时候动态链接的模块同时拥有符号表.symtab和动态链接符号表.dynsym,符号表中保存了包含动态链接符号表在内的所有的符号。

当然还存在动态符号字符串表和辅助的hash

动态链接重定位表

与重定位表类似,表示函数的动态链接重定位表为rel.plt,表示数据的重定位表为rel.data

图片无法显示,请设置GitHub代理

我们在静态链接的时候遇到了R_386_32,R386_PC32两种重定位的类型。这里我们看一下R_386_JUMP_SLOT的重定位类型,该类型针对.got.plt即全局函数的重定位,只需要将函数的真实地址填充到.got.plt中对应的表项中即可。R_386_GLOBAL_GOT则是针对.got即全局数据的重定位,与R_386_JUMP_SLOT重定位的方法相同。

R_386_RELATIVE则是基址重置类型的重定位,如我们之前看到的共享对象中对数据的绝对地址引用,这种重定位需要在装载时进行,即当模块的装载地址确定之后,将地址重定位为之前的保存的偏移值加上模块装载的基址。

动态链接的实现步骤

动态链接基本上分为三个步骤首先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化

动态链接器自举

对于需要进行动态链接的共享对象来说,其动态链接由动态链接器完成,那么对于动态链接器来说其动态链接由谁完成呢。实际上动态链接器的动态链接由其本身完成,那么这就需要动态链接器不依赖于其他任何的共享对象,并且其所需要的全局和静态变量的重定位工作由它本身完成。

第一个条件可以认为的指定,第二个条件就需要在动态链接器的起始部分添加一段对其本身的全局和静态变量进行动态链接的代码,这段代码不能用到全局和静态变量,我们称这段代码为自举代码。实际上在自举代码中即使链接器本身的函数也不能调用,因为在fPIC方式对共享对象编译的时候,其对模块内的调用和对模块间的调用采用的是同一种方式也就是GOT/PLT表的方式,因此自举代码不能使用全局变量也不能调用函数。

当动态链接器完成自举的时候,其才可以开始调用函数和访问全局变量。

装载共享对象

动态链接器和可执行文件的符号表合并为一个全局符号表,然后链接器就会到.dynamic段中找到DT_NEEDED类型的共享对象,这就是可执行文件所依赖的共享对象。动态链接器会将这些共享对象加入到一个集合中,在依次从集合中选择一个共享对象进行装载,若该共享对象存在其依赖的共享对象那么便将其加入到集合中。可执行文件对于共享对象的依赖关系可以看做是一个图,动态链接器可以采用广度优先的搜索方法加载共享对象。

在加载时则会出现一个问题就是两个或者两个以上的共享对象包含相同的符号,即出现全局符号介入。动态链接器对此的处理方法就是当一个符号加入到全局符号表的时候,若相同的符号已经存在,则后加入的符号被忽略。

这也是为什么fPIC将模块内的调用也采用GOT/PLT的方式的原因。因为如果采用相对地址调用的话,如果存在全局符号介入那么其对应的相对便宜就需要重定位,这与地址无关相矛盾

重定位和初始化

动态链接器将共享对象全部加载完成之后,动态链接器就拥有了进程的全局符号表,就开始遍历可执行文件和共享对象的重定位表,按照重定位的类型对需要重定位的地址进行重定位。重定位完成之后,如果某个对象拥有.init段,那么就开始执行.init段中的代码,实现共享对象特有的初始化的过程。这里需要注意的是并不会执行可执行文件的.init段的代码,该部分的代码有程序初始化代码执行。

Linux下的动态链接器是Glibc的一部分,源代码位于sysdeps/i386/dl-machine.h中的_start()函数,函数会调用定义在elf/rtld.c文件中的dl_start()函数

显式运行时加载

支持动态链接的系统往往支持显式运行时链接或者运行时加载的方式,也就是当用到该模块是才加载该模块,并且可以重新加载。

从文件本身的格式上面看动态链接库与一般的共享对象没有区别,主要区别就是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一过程主要是由动态链接器完成的,对程序本身是透明的。该过程主要涉及到四个函数dlopen,dlsym,dlerror,dlclose分别是打开动态库,查找符号,错误处理,关闭动态库。

dlopen

其函数定义的原型是void *dlopen(const char *filename, int flag),用来打开动态链接库,其中filename表示的是被加载的动态链接库的路径,如果该路径是绝对路径即以/开头,那么函数会尝试直接打开,否则则按照一定的顺序查找相对路径

  • 查找环境变量指定的一系列路径LOAD_LIBRARY_PATH
  • 查找由/etc/ld.so.cache中指定的共享库路径
  • /lib, /usr/lib

如果filename的参数为0,那么dlopen返回的是全局符号表的句柄

第二个参数flag表示的是函数符号解析的方式,RTLD_LAZY表示使用的是延迟绑定,RTLD_NOW表示的模块加载的时候绑定所有的函数,这两种方式二选其一。RTLD_GLOBAL表示加载模块的全局符号合并到进程的全局符号表中,可以和上面的进行组合。

dlopen在加载模块的时候会执行模块中的初始化部分的代码,但是不会根据模块之间的依赖关系进行递归加载,需要手动安排加载顺序

dlsym

其函数定义void *dlsym(void *handle, char *symbol),第一个参数是dlopen返回的动态库的句柄,第二个即需要查找的符号的名字。如果查找的是一个函数或者变量的符号,则返回函数或者变量的地址,如果这个符号是一个常量则返回的是该常量的值。如果该常量的值是NULL或者0就需要查看dlerror的错误信息,如果返回是NULL则表示的是加载成功,否则dlerror返回的是错误信息(字符串)

符号的查找顺序:如果是在全局符号表中查找则使用的是装载序列,也就是先装入的符号优先,如果直接对某个dlopen打开的共享对象进行符号查找的话则是按照其依赖的共享对象进行广度优先遍历。

dlerror

加载成功返回的是NULL,加载失败返回的是错误字符串信息

dlclose

在执行dlopen的时候相应的计数器加一,此时计数器减一,执行.finit段的代码,关闭模块文件

Linux共享库

Linux等开源的系统遵循FHS标准,该标准规定了共享库的存储方式

  • /lib库中存储了系统最为关键和基础的共享库,如动态链接器,C语言运行库,这些库主要为/bin,/sbin目录下面的程序所使用的,还包含系统启动时需要的库
  • /usr/lib保存的是非系统运行时所需要的关键性库,主要是开发时用到的共享库
  • /usr/local/lib目录中存储跟操作系统本身并不十分相关的库,主要是第三方应用程序的库

共享库的查找过程

如果.dynamicDT_NEED类型表示的所依赖的对象的路径是绝对路径则直接在该路径下面查找,若是相对路径则在/lib,/usr/lib和由/etc/ld.so.conf配置文件所指定的目录中查找共享库

为了加快速度,ldconfig会将所有的执行共享库的软链接即SO-NAME收集起来,加入到/etc/ld.so.cache中,动态链接需要查找的时候直接在该文件中查找

如果设置了环境变量LD_LIBRARY_PATH的话,那么动态链接器的查找顺序就变为如下

  1. LD_LIBRARY_PATH中的目录下
  2. /etc/ld.so.cache缓存文件指定的路径
  3. 默认的共享目录,先/usr/lib/lib

如果设置了LD_PRELOAD环境变量,那么该变量里面指定的共享库或者目标文件都会被装载,由于全局符号介入的存在,我们可以更换可执行文件加载的函数,方便调试。

还有一个就是LD_DEBUG环境变量,设置该环境变量之后,就可以打开动态链接器的调试功能,动态链接器就会在运行时打印出各种有用的信息

  • files打印整个装载过程,显示程序依赖的共享库及嘉爱过程,bindings显示动态链接的符号绑定过程,libs显示共享库的查找过程,versions显示符号的版本依赖关系,reloc显示重定位过程,symbols显示符号表的查找过程,statistics显示动态链接过程中的各种统计信息,all显示以上的所有的信息,help显示帮助信息

内存

程序的内存布局

Linux进程的典型的内存布局如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-----------------------<<0xfffffff
Kernek space
-----------------------<<0xc0000000
stack
-----------------------
unused
-----------------------
dynamic libraries
-----------------------<<0x40000000(0xbfxxxxxx)
unused
-----------------------
heap
-----------------------
read/write section
(.data, .bss)
-----------------------
readonly section
(.init, .rodata
.text)
-----------------------<<0x08048000
reserved
-----------------------<<0

reserved保留区,系统中一般不允许访问极低的地址,stack向低地址增长,heap向高地址增长