程序运行概述

Hello world

hello world 的代码非常简单,相信大家都不陌生。

#include <stdio.h>

int main()
{
      printf("Hello world\n");
}

可一个 “hello world” 程序是如何在电脑上跑起来的呢?不妨思考以下问题:

  1. 程序为什么要被编译器编译好了之后才可以运行?
  2. 编译器在把 C 语言程序转换成可以执行的机器码的过程中做了什么?
  3. 最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们是怎么存放的,怎么组织的?
  4. #include <stdio.h> 是什么意思?把 stdio.h 包含进来意味着什么?
  5. 不同编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
  6. Hello world 程序是怎么运行起来的?操作系统是怎么装载它的?它从哪儿开始执行,到哪儿结束? main 函数之前发生了什么? main 函数结束以后又发生了什么?
  7. 如果没有操作系统,Hello World 可以运行吗?如果要在一台没有操作系统的机器上运行 Hello World 需要什么?应该怎么实现?
  8. printf 是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?
  9. Hello World 程序在运行时,它在内存中是什么样子的?

如果清楚的知道上述细节,那么恭喜你,你已经掌握了一名 pwn 手必备的基础知识;不知道也没有关系,接下来的几天课程会让你找到问题的答案。

编译和链接

被隐藏的过程

在 linux 上运行命令可以生成可执行文件。

gcc hello.c -o a.out

a.out 是一个能在终端打印 “Hello World” 的程序。那么在源代码转化为一个可执行的过程中发生了什么呢?可以分为四个步骤。

预编译

在 linux 上运行命令可以生成预编译文件。

gcc -E hello.c -o hello.i

预编译过程主要处理那些源代码文件中以 “#” 开始的预编译指令。比如 “#include”、“#define”等。经过预编译后的 .i 文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到 .i 文件中。

编译

在 linux 上运行命令可以生成汇编代码文件。

gcc -S hello.i -o hello.s

编译过程就是把预编译处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相对应的汇编代码文文件。

汇编

在 linux 上运行命令可以生成目标文件。

gcc -c hello.s -o hello.o

汇编器是将汇编代码转变成机器可以执行的指令,每个汇编语句几乎都对应一条机器指令。

链接

在 linux 上运行命令可以生成可执行文件。

gcc hello.o -o a.out

链接过程就是将许多以 .o 结尾的目标文件链接起来形成可执行文件。

链接

在现代软件开发的过程中,软件的规模往往都很大,动辄数百万行代码,如果都放在一个独立模块肯定无法想象。所以现代的大型软件往往拥有成千上万个模块,这些模块之间相互依赖,又相对独立。一个程序被分割成多个模块后,这些模块之间最后如何形成一个单一的程序是须解决的问题。
我们都知道 C 模块之间有两种通信方式,一种是模块间的函数调用,另一种是模块间的变量访问。函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间的符号引用。这就类似于拼图,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者拼接刚好完美组合,这个拼接的过程就是链接。

静态链接

人们把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接。链接的主要内容就是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确地链接。链接的主要过程包括:

  1. 地址和空间分配
  2. 符号决议
  3. 重定位

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

静态链接

当我们有两个目标文件时,如何将他们链接起来形成一个可执行文件?这个过程发生了什么?这基本上就是链接的核心内容:静态链接。接下来我们将使用两个源代码文件 “a.c” 和 “b.c” 作为例子展开分析:
a.c

#include <stdio.h>

extern int shared;

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

b.c

#include <stdio.h>

int shared = 1;

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

假设我们的程序只有这两个模块 “a.c” 和 “b.c” 。首先哦我们使用 gcc 将 “a.c” 和 “b.c” 分别编译成目标文件 “a.o” 和 “b.o”。

gcc -c a.c b.c -fno-stack-protector

经过编译后我们就得到了 “a.o” 和 “b.o” 这两个目标文件。从代码中可以看到,“b.c” 总共定义了两个全局符号,一个是变量 “shared” ,另一个是函数 “swap” ;“a.c” 里面定义了一个全局符号就是 “main”。模块 “a.c” 里面引用到了 “b.c” 里面的 “swap” 和 “shared”。接下来我们要做的就是把 “a.o” 和 “b.o” 这两个目标文件链接在一起并最终形成一个可执行文件 “ab” 。

空间地址分配

对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合成一个输出文件。在这个例子里,我们的输入就是目标文件 “a.o” 和 “b.o”,输出就是可执行文件 “ab”。通过 ELF 文件的格式我们知道,可执行文件中的代码段和数据段都是由输入的目标文件中合并而来的。那么我们链接过程就很明显产生了第一个问题:对于多个输入目标文件,链接器如何将它们的各段合并到输出文件?或者说,输出文件中的空间如何分配给输入文件?

按序叠加

一个最简单的方案就是将输入的目标文件按次序叠加起来。

Object A            Object B            Object C
File Header         File Header         File Header
.text section       .text section       .text section
.data section       .data section       .data section
.bss section        .bss section        .bss section

直接将各个目标文件一次合并:

Output File
File Header      Object A 
.text section
.data section
.bss section 
File Header      Object B
.text section
.data section
.bss section 
File Header      Object C
.text section
.data section
.bss section 

但是这样做会造成一个问题,在有很多输入文件的情况下,输出文件将会有很多零散的段。比如一个规模稍大的应用程序可能会有数百个目标文件,如果每个目标文件都分别有 .text 段、.data 段和 .bss 段,那最后的输出文件将会有成百上千个零散段。这种做法非常浪费空间,因为每个段都需要有一定的地址和空间的对齐要求,比如对于 x86 的硬件来说,段的装载地址和空间对齐的单位是也,也就是 4096 个字节。那么就是说如果一个段的长度只有 1 个字节,它也要在内存中占用 4096 个字节。这样会照成内存空间大量的内部碎片,所以这并不是一个很好的方案。

相似段合并

一个更实际的方法是将相同性质的段合并到一起,比如将所有输入文件的 “.text” 合并到输出文件的 “.text” 段,接着是 “.data” 段、“.bss” 段等。

Output File
File Header
——————————————————————
.text section Object A
.text section Object B
.text section Object C
——————————————————————
.data section Object A
.data section Object B
.data section Object C
——————————————————————
.bss section  Object A
.bss section  Object B
.bss section  Object C

我们知道,“.bss” 段在目标文件中和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以链接器在合并各段的同时,也将 “.bss” 段合并,并且分配虚拟空间。从 “.bss” 段的空间分配上我们可以思考一个问题,那就是这里所谓的“空间分配”到底是什么空间?
“链接器为目标文件分配地址和空间”这句话中的“地址和空间”其实有两个含义:第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间,对于有实际数据的段,比如 “.text” 和 “.data” 来说,它们在文件中和虚拟空间中的都要分配空间,因为它们在这两者中都存在;而对于 “.bss” 这样的段来说,分配空间的意义只局限于虚拟空间的分配,因为这个关系到链接器后面关于地址计算的步骤,而可执行文件本身的空间分配与链接过程关系并不是很大。
现在链接器空间分配的策略基本上都采用上述方法中的第二种,使用这种方法的链接器一般都是采用一种叫两步链接的方法。也就是说整个链接过程分为两步。

  1. 空间与地址分配:扫描所有的输入目标文件,获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算输出文件中各个段合并后的长度与位置,并建立映射关系。
  2. 符号解析与重定位:使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步时链接过程的核心,特别是重定位过程。

我们使用 ld 链接器将 “a.o” 和 “b.o” 链接起来:

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

让哦我们呢使用 objdump 来查看链接前后地址分配的情况:

pwnki@LAPTOP-KETPO6R7:~/course$ objdump -h a.o

a.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000002c  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  0000006c  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  0000006c  2**0
                  ALLOC
  3 .comment      00000036  0000000000000000  0000000000000000  0000006c  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000a2  2**0
                  CONTENTS, READONLY
  5 .eh_frame     00000038  0000000000000000  0000000000000000  000000a8  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
pwnki@LAPTOP-KETPO6R7:~/course$ objdump -h b.o

b.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000004b  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  0000008c  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000090  2**0
                  ALLOC
  3 .comment      00000036  0000000000000000  0000000000000000  00000090  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000c6  2**0
                  CONTENTS, READONLY
  5 .eh_frame     00000038  0000000000000000  0000000000000000  000000c8  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
pwnki@LAPTOP-KETPO6R7:~/course$ objdump -h ab

ab:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000077  00000000004000e8  00000000004000e8  000000e8  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .eh_frame     00000058  0000000000400160  0000000000400160  00000160  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .data         00000004  00000000006001b8  00000000006001b8  000001b8  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  3 .comment      00000035  0000000000000000  0000000000000000  000001bc  2**0
                  CONTENTS, READONLY

链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,这里我们关心各段中的 VMA 和 Size。我们可以看到,在链接之前,目标文件中的所有段的 VMA 都是 0,因为虚拟空间还没有被分配,所以它们默认都是 0。等到链接后,可执行文件 “ab” 中的各个段都被分配到了相应的虚拟地址。

符号地址的确定

在第一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了。
当第一步完成后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内相对位置时固定的,所以这时候其实 “main”、“shared”、“swap” 的地址已经是确定的了,只不过链接器需要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。

符号解析与重定位

重定位

在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤,这也是静态链接的核心内容。在分析符号解析和重定位之前,首先让我们来看看 “a.o” 里面是怎么使用这两个外部符号的,也就是说我们在 “a.c” 的源程序里面使用了 “shared” 变量 和 “swap” 函数,那么编译器将在 “a.c” 编译成指令时,它如何访问 “shared” 变量?如何调用 “swap” 函数?
使用 objdump 的 “-d” 参数可以看到 “a.o” 的代码反汇编结果:

pwnki@LAPTOP-KETPO6R7:~/course$ objdump -d a.o

a.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 10             sub    $0x10,%rsp
   8:   c7 45 fc 64 00 00 00    movl   $0x64,-0x4(%rbp)
   f:   48 8d 45 fc             lea    -0x4(%rbp),%rax
  13:   be 00 00 00 00          mov    $0x0,%esi
  18:   48 89 c7                mov    %rax,%rdi
  1b:   b8 00 00 00 00          mov    $0x0,%eax
  20:   e8 00 00 00 00          callq  25 <main+0x25>
  25:   b8 00 00 00 00          mov    $0x0,%eax
  2a:   c9                      leaveq
  2b:   c3                      retq

当源代码 “a.c” 在被编译成目标文件时,编译器并不知道 “shared” 和 “swap” 的地址,因为它们定义在其他目标文件中。
通过前面我们可以得知,链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址,那么链接器就可以根据符号对每个需要重定位的指令进行修正。

重定位

链接器是怎么知道哪些指令是要被调整的呢?这些指令的哪些部分要被调整?怎么调整?比如上面这个例子中 “mov” 指令和 “call” 指令的调整方式就有所不同。事实上在 ELF 文件中,有一个叫做重定位表(Relocation Table)的结构专门用来保存这些与重定位相关的信息。
对于可重定位的 ELF 文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的 ELF 段都有一个对应的重定位表,而一个定位表往往就是 ELF 文件中的一个段,所以其实重定位表也可以叫做重定位段。比如代码段 “.text” 如有要重定位的地方,那么就会有一个相对应叫 “.rel.text” 的段保存了代码段的重定位表;如果代码段 “.data” 有要被重定位的地方,就会有一个相对应叫 “.rel.data” 的段保存了数据段的重定位表。
我们可以使用 objdump 来查看目标文件的重定位表:

pwnki@LAPTOP-KETPO6R7:~/course$ objdump -r a.o

a.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE
0000000000000014 R_X86_64_32       shared
0000000000000021 R_X86_64_PC32     swap-0x0000000000000004


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE
0000000000000020 R_X86_64_PC32     .text

每个要被重定位的地方叫一个重定位入口(Relocation Entry),我们可以看到 “a.o” 里面有两个重定位入口。重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置,“RELOCATION RECORDS FOR [.text]” 表示这个重定位表是代码段的重定位表,所以偏移表示代码段中需要被调整的位置。

符号解析

在我们通常的观念里,之所以要链接时因为我们目标文件中用到的符号被定义为在其他目标文件,所以要将它们链接起来。比如我们直接使用 ld 来链接 “a.o”,而不将 “b.o” 作为输入:

pwnki@LAPTOP-KETPO6R7:~/course$ ld a.o
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000b0
a.o: In function `main':
a.c:(.text+0x14): undefined reference to `shared'
a.c:(.text+0x21): undefined reference to `swap'

这也是我们平时在编写程序的时候最常碰到的问题之一,就是链接时符号未定义。
通过前面的介绍,我们可以更加深层次地理解为什么缺少符号的定义会导致链接错误。其实重定位过程中也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会区查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
比如我们查看 “a.o” 的符号表。

pwnki@LAPTOP-KETPO6R7:~/course$ readelf -s a.o

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

指令修正方式

不同的处理器指令对于地址的格式和方式都不一样。比如对于 32 位 Intel x86 处理器来说,跳转指令(jmp 指令)、子程序调用指令(call 指令)和数据传送指令(mov 指令)寻址方式千差万别。截至到 2006 年,Intel x86 系列 CPU 的 jmp 指令有 11 种寻址模式;call 指令有 10种;mov 指令则有多达 34 种寻址模式。这些寻址方式有如下几方面的区别:

  1. 近址寻址或者原址寻址。
  2. 绝对寻址或者相对寻址。
  3. 寻址长度为 8 位、16 位、32 位或 64 位。

可执行文件的装载与进程

可执行文件只有装载到内存以后才能被 CPU 执行。早期的程序装载十分简陋,装载的基本过程就是把程序从外部存储器读取到内存中的某个位置。随着硬件 MMU 的诞生,多进程、多用户、虚拟存储的操作系统出现以后,可执行文件的装载过程变得非常复杂。

进程虚拟地址空间

我们知道每个程序运行起来以后,它将拥有自己独立的虚拟地址空间,这个虚拟地址空间的大小由计算机的硬件平台决定的,具体地说是由 CPU 的位数决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址大小,比如 32 位的硬件平台决定了虚拟地址空间的地址位 0 到 2^32-1,即 0x00000000~0xFFFFFFFF,也就是我们常说的 4GB 虚拟空间大小;而 64 位硬件平台具有 64 位 寻址能力,它的虚拟地址空间达到了 2^64 字节,即 0x0000000000000000~0xFFFFFFFFFFFFFFFF,总共 17179869184GB 。
从程序的角度看,我们可以通过判断 C 语言程序中的指针所占的空间来计算虚拟地址空间的大小。一般来说,C 语言指针大小的位数与虚拟空间的位数相同,如 32 位平台下的指针为 32 位,即 4 字节;64 位平台下的指针位 64 位,即 8 字节。
那么 32 位平台下的 4GB 虚拟空间,我们的程序是否可以任意使用呢?很遗憾,不行。因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控运行等一一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那些操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。我们经常在 Windows 下碰到令人讨厌的 “进程因非法操作需要关闭” 或 Linux 下的 “Segmentation fault” 很多时候是因为进程访问了未经允许的地址。
默认情况下,Linux 操作胸痛将进程的虚拟地址空间做了如下分配:

0xFFFFFFFF
            Openrating System
0xC0000000
            User Process
0x00000000

整个 4GB 被划分成两部分,其中操作系统本身用去了一部分;从地址 0xC0000000 到 0xFFFFFFF,共 1GB。剩下的从 0x00000000 地址开始到 0xBFFFFFFF 共 3GB 空间都是留给进程使用的。那么原则上讲,我们的进程最多可以使用 3GB 的虚拟空间,也就是说整个进程在执行的时候,所有的代码、数据包括通过 C 语言 malloc() 等方法申请的虚拟空间之和不可以超过 3GB。

装载的方式

程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的方法就是将程序运行所需的指令和数据全部装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的办法。但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存数量不够时,根本的解决方法就是添加内存。相对于磁盘来说,内存时昂贵且稀有的,这种情况自计算机磁盘诞生依赖一直如此。所以人们想尽各种办法,希望能够在不添加内存的情况下让更多的程序运行起来,尽可能有效地利用内存。后来经研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
覆盖装入和页映射时两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。

覆盖装入

覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了。
覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块合适应该驻留内存而何时应该被替换掉。

页映射

页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。与覆盖装入的原理类似,页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就页。一般来说划分 4096 字节为一个页。
假设我们的 32 位机器有 16KB 的内存,每个页大小为 4096 字节,则共有 4 个页。

页编号 地址
F0 0x00000000-0x00000FFF
F1 0x00001000-0x00001FFF
F2 0x00002000-0x00002FFF
F3 0x00003000-0x00003FFF

假设程序所有的指令和数据总和为 32KB ,那么程序总共被分为 8 个页。我们将它们编号为 P0~P7。很明显,16KB 无法同时将 32KB 的程序装入,那么我们将按照动态装入的原理来进行整个装入的过程。如果程序刚开始执行时的入口地址在 P0,这时装载管理器发现程序的 P0 不在内存中,于是将内存 F0 分配给 P0,并且将 P0 的内容装入 F0;运行一段时间以后,发现程序需要用到 P5,于是装载管理器将 P5 装入 F1;就这样,当程序用到 P3 和 P6 的时候,它们分别被装入到了 F2 和 F3。

Executable            Physical Memory
----------            ---------------
   P7                    F3:P6
----------            ---------------
   P6                    F2:P3
----------            ---------------
   P5                    F1:P5
----------            ---------------
   P4                    F0:P0      
----------            ---------------
   P3
----------
   P2
----------
   P1
----------
   P0
----------

很明显,如果这时候程序只需要 P0、P3、P5 和 P6 者 4 个页,那么程序就能一直运行下去。但是问题很明显,如果这时候程序需要访问 P4 ,那么装载管理器必须做出抉择,他必须放弃目前正在使用的 4 个内存页中的其中一个来装载 P4。页的选择根据算法的选择不同而不同。

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

从上面的页映射的动态装入方式可以看到,可执行文件中的页可能被装入内存中的任意页。比如程序需要 P4 的时候,它可能就会被装入 F0~F3 这 4 个页中的任意一个。很明显,如果程序使用物理地址直接操作,那么每次页被装入时都需要进行重定位。在虚拟存储中,现代的硬件 MMU 都是体统地址转换功能。所以虚拟存储中操作系统加载可执行文件的方式跟静态加载有了很大的区别。

进程的建立

进程是操作系统对于一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个程序,而每个进程都好像在独占地使用硬件。
事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这是得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么我们就来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述情况最开始只需要做三件事情:

  1. 创建一个独立的虚拟地址空间。我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构。
  2. 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。第一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。
  3. 将 CPU 的指令寄存器设置成可执行文件的入口地址,操作系统通过设置 CPU 的指令寄存器间控制权转交给进程,由此程序开始执行。

页错误

上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有装入内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚拟内存之间的映射关系而已。当 CPU 开始打算执行某个地址的指令时,发现地址所在的页面为空,就会认为这是一个页错误。CPU 将控制权交给操作系统,操作系统在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页建立映射关系,然后把控制权再还会给进程,进程从刚才页错误的位置重新开始执行.
随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。当然进程所需要的内存可能会超过可用内存的数量,特别是在由多个进程同时执行的时候,这是后操作系统就需要精心组织和分配物理内存,甚至有时候应将分配给进程的物理内存暂时收回等。

进程虚拟空间分布

Segment

ELF 文件中,段的权限往往只有为数不多的几种组合,基本上是三种:

  1. 以代码段为代表的权限为可读可执行段。
  2. 以数据段和 BSS 段为代表的权限为可读可写段。
  3. 以只读数据段为代表的权限为只读段。

ELF 可执文件引入了一个概念叫做 “Segment”,一个 “Segment” 包含一个或多个属性类似的 “Section”。“Segment” 的概念实际上是从装载的角度重新划分了 ELF 各段。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。

posted @ 2021-01-26 14:35  PwnKi  阅读(406)  评论(0编辑  收藏  举报