引言:上篇博文中,我们简单的介绍了Linux虚拟存储器的概念及组成情况,下面来分析分析进程的创建和终结及跟进程地址空间的联系。

这里首先介绍一个比较重要的概念:存储器映射

在Linux系统中,通过将一个虚拟存储器区域与一个磁盘上的对象关联起来,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射。存储器映射为共享数据、创建新的进程以及加载程序提供了一种高效的机制。

虚拟存储器区域可以映射到两种类型对象中:

1)普通文件:一个虚拟区域可以映射到普通磁盘文件的连续部分,例如可执行目标文件。虚拟区域分为若干的虚拟页面,这些虚拟页面初始化时并没有实际交换进物理存储器,直到CPU第一次引用页面时才真正的加载进物理内存。如果虚拟区域比映射的文件要大,则剩下的部分用零填充。

2)匿名文件:匿名文件是由内核创建的,包含的全部是二进制零。映射到匿名文件的区域中的页面有时称为 请求二进制零的页。

注意:无论映射到何种文件,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件之间换来换去。这里的交换文件也称为 交换空间。由此可见任何时候,交换空间限制着当前运行着的进程能够分配的虚拟页面的总数。

有了前面的一些概念的基础下面我们开始看进程的创建、执行、退出。

一:进程创建

        Unix的进程创建比较特别。许多其他操作系统提供了产生机制。首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。Unix采用了不同方式:它把上述步骤分为两步,分解到两个单独的函数中执行:fork( ) 和 exec( )。

        fork( )函数被当前进程调用时,内核为新进程创建各种数据结构(例如内核栈、thread_info结构、task_struct结构)并分配给它一个唯一的PID。为了给新进程创建虚拟存储器,它创建了当前进程所有资源的原样拷贝。它将两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为 私有的写时拷贝。

        Linux的fork( )使用写时拷贝页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝。

       只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝,从而为父子进程保持了私有地址空间的抽象概念。资源的复制只有在需要写入的时候才进行,在此之前,只是以只读的方式共享。这中技术使得地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。在页根本不会写入的情况下(例如fork()之后立即调用exec() )它们就无须复制了。

       下面看个实例:

      

linux内核--进程地址空间(三)_可执行文件
 1 #include <stdlib.h>
 2 #include <unistd.h>
 3 #include <stdio.h>
 4 int main( )
 5 {
 6   int pid;
 7   int x = 1;
 8   pid = fork();
 9   if(pid == 0)    /* Child */
10       {
11           printf(" child : x = %d\n",++x);
12           exit(0);
13       }
14   
15   /* Parent */    
16   printf("parent : x = %d\n",--x);
17   exit(0);
18 }
linux内核--进程地址空间(三)_可执行文件

 

输出如下:

linux内核--进程地址空间(三)_初始化_03

由此可以看出:

1)fork()调用一次,返回两次:一次返回到父进程,一次返回到子进程。

2)并发执行:父进程和子进程并发运行,内核能够以任意方式交替运行它们,这里是父进程先运行,然后是子进程。但是在另外一个系统上运行时不一定是这个顺序。

3)父子进程都有自己的私有地址空间,父子进程对x的操作都是独立的,不会反应在另外一个进程的存储器中。

 

函数 int execl(const char *filename,const char *argv[],const char char *envp[]):

下面我们举例看exec 函数是如何加载和执行程序的:

linux内核--进程地址空间(三)_可执行文件
 1 #include <stdlib.h>
 2 #include<stdio.h>
 3 #include <unistd.h>
 4 int main()
 5 {
 6     int pid = fork();
 7     if(pid<0)
 8         {
 9             perror("fork");
10         }
11         
12   else if(pid == 0)
13   {
14     
15    execl("hello",NULL,NULL);
16    /* We can only reach this code when there is an error in execl*/
17    perror("execl");
18   }
19   else
20   {
21       sleep(2);
22       printf("This is parent\n");
23   }
24 
25   exit(0);
26    
27 }
linux内核--进程地址空间(三)_可执行文件

这里的execl调用:execl("hello",NULL,NULL);中的hello是上篇博文中提到的hello.c程序对应的可执行目标文件。

 

linux内核--进程地址空间(三)_可执行文件
1 hello.c
2 #include<stdio.h>
3 int main()
4 {
5     
6     printf("Hello\n");
7     return 0;
8 }
linux内核--进程地址空间(三)_可执行文件

 

 

execl("hello",NULL,NULL)调用后在当前子进程中加载并运行包含在可执行目标文件 hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello的步骤如下:

1)删除已经存在的用户区域,删除当前调用execl的子进程的虚拟地址的用户部分中的已存在的区域结构。

2)将可执行文件hello的连续的片映射到连续的虚拟存储器中。

 段头部表 描述了这种映射关系:

下面我们用readelf 查看可执行文件 hello 的段头部:

linux内核--进程地址空间(三)_#include_08

从图中可以看出:

1:映射私有区域:即为新程序的代码、数据、bss、和栈区域创建和初始化新的区域结构。

Type               Offset       VirtAddr        PhysAddr      FileSiz   MemSiz  Flg  Align

LOAD           0x000000 0x08048000 0x08048000 0x005bc 0x005bc R E 0x1000

代码段:对齐到一个4KB(0x1000=2^12,为X86平台一个页面的大小)的边界,有可读、可执行权限。可执行目标文件中偏移量为0开始的0x005bc字节长的代码段(其中包括了ELF头部、段头部表、以及.init、.text、和 .rodata节)被映射到开始于虚拟地址0x08048000,长度为0x005bc字节的虚拟存储区域中。

LOAD           0x000f14 0x08049f14 0x08049f14 0x00100 0x00108 RW  0x1000

数据段:同样对齐到4KB大小的边界,有可读可写权限。可执行文件偏移0x000f14处开始,长度为0x100个字节的数据段被映射到开始于虚拟地址0x08049f14处,长度为0x00108字节的虚拟存储区域中。

bss段、栈段、堆段被初始化为0,即被映射到匿名文件。

2:映射共享区域:如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间的共享区域内。

Type                 Offset       VirtAddr        PhysAddr      FileSiz   MemSiz  Flg  Align 1

DYNAMIC        0x000f28 0x08049f28  0x08049f28      0x000c8 0x000c8 RW  0x4

linux内核--进程地址空间(三)_加载_09

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

       如上图所示:这里列出了hello可执行文件动态链接依赖的对象。

动态链接共享库的步骤:

    1:加载和运行动态链接器

         动态链接器本身就是一个共享目标,所以要先加载本身,在可执行文件hello中的.interp段包含了动态链接器的路径名:

     linux内核--进程地址空间(三)_加载_10

 2:装载所有需要的共享对象

       启动完动态链接器之后,动态链接器将可执行文件hello和链接器本身的符号表都合并在一起,组成全局符号表。然后链接器开始寻找可执行文件所依赖的共享对象,在之前提到的.dynamic段中,有一种入口的类型是 DT _NEEDED,它所指出的Shared library: [libc.so.6]就是该可执行文件所依赖的共享对象。由此,链接器可以列出可执行文件hello所需要的所有共享对象,并将这些对象名字放入到一个装载集合中。然后链接器根据名字找到相应的文件,并将它相应的代码段、数据段映射到进程地址空间的共享区域中。

3:重定位和初始化   

       当上面步骤完成后,根据进程的全局符号表,对GOT/PLT中的每个需要重定位的位置进行修正,这种技术称为延迟绑定。

      完成重定位和初始化后,所有准备工作结束,所需要的共享对象也都已经装载并且链接完成。最后将进程的控制权转交给hello程序的入口并开始执行。

 可执行elf文件格式,及加载完可执行文件hello后的进程地址空间如下图:

linux内核--进程地址空间(三)_可执行文件_11

linux内核--进程地址空间(三)_加载_12

                                       

在文章中介绍了几个重要的结构体,关于这几个结构体的含义和之间的关系如下:

主要的数据结构有

        task_struct : 进程描述符结构,定义在<linux/sched.h>文件中,描述了该进程打开的文件、进程的地址空间、进程的状态等信息。

        mm_struct :进程虚拟内存描述符,定义在<linux/sched.h>文件中,该结构包含了和进程地址空间相关的全部信息。

        vm_area_struct:虚拟内存区域描述符,定义在<linux/mm.h>文件中,该结构体描述了指定地址空间内连续区间上的一个独立的内存范围(比如进程用户空间栈、代码段、数据段等)。

        三个结构之间的关系

        task_struct 中的一个 字段 mm 指向当前进程的虚拟地址空间描述符 mm_struct , mm_struct中的字段 mmap 指向一个vm_area_struct组成的链表,其中每个vm_area_struct 都描述了当前虚拟地址空间的一个区域。

    除了这三个结构体外,还有几个重要的结构体,在其他临近的博文中有介绍,比如thread_info结构体,这个结构体中包括了task_struct结构体,这几个结构体的内容和含义层次不同。