【摘自《Linux/Unix系统编程手册》】
进程和程序
进程(process)是一个可执行程序(program)的实例。
程序是包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程,包含如下内容:
- 二进制格式标识:每个程序文件都包含用于描述可执行文件格式的元信息(metainformation)。
- 机器语言指令:对程序算法进行编码。
- 程序入口地址:标识程序开始执行时的起始指令位置。
- 数据:程序文件包含的变量初始值和程序使用的字面常量值(比如字符串)。
- 符号表及重定位表:描述程序中函数和变量的位置及名称。这些表格有多种用途,其中包含调试和运行时的符号解析(动态链接)。
- 共享库和动态链接信息:程序文件所包含的一些字段,列出了程序运行时需要使用的共享库,以及加载共享库的动态链接器的路径名。
- 其它信息:程序文件还包含许多其它信息,用以描述如何创建进程。
从内核角度看,进程由用户内存空间和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。
记录在内核数据结构中的信息包括许多与进程相关的标识号、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其它信息。
每个进程都有一个进程号(PID),进程号是一个正数,用以唯一标识系统中的某个进程。
Linux内核限制进程号需小于等于32767。新进程创建时,内核会按顺序将下一个可用的进程号分配给其使用。每当进程号达到32767的限制时,内核将重置进程号计数器,以便从小整数开始分配。
(实际上,一旦进程号达到32767,会将进程号计数器重置为300,而不是1。因为低数值的进程号为系统进程和守护进程所长期占用。)
在Linux2.4版本及更早的版本,进程号的上限是32767,有内核常量PID_MAX所定义。在Linux2.6中,情况有所改变,尽管进程号的默认上限仍为32767,但可以通过Linux系统中特有的/proc/sys/kernel/pid_max文件来进行调整(其值=最大进程号+1)。在32位平台中,pid_max文件的最大值为32768,但是在64位平台中,该文件的最大值可以高达222(约400万),系统可能容纳的进程数量会非常庞大。
每个进程都有一个创建自己的进程,每个进程的父进程号属性反应了系统上所有进程间的树状关系。每个进程的父进程又有自己的父进程,以此类推,回溯到1号进程--init进程,即所有进程的始祖。
如果子进程的父进程终止,则子进程就会变成“孤儿”,init进程随即将收养该进程。
进程内存布局
每个进程所分配的内存由很多部分组成,通常称之为“段(segment)”:
- 文本段:包含了进程运行的程序机器语言指令。文本段具有只读属性,以防止进程通过错误指针意外修改自身指令。因为多个进程可同时运行同一程序,所以又将文本段设为可共享,这样,一份程序代码的拷贝可以映射到所有这些进程的虚拟地址空间中。
- 初始化数据段:包含显示初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
- 未初始化数据段:包含了未进行显示初始化的全局变量和静态变量。程序启动之前,系统将本段内所有内存初始化为0。出于历史原因,此段常被称为BSS段,这源于老版本的汇编语言助记符“block started by symbol”。将经过初始化的全局变量和静态变量与未初始化的全局变量和静态变量分开存放,其主要原因在于程序在磁盘上存储时,没有必要为未经初始化的变量分配存储空间。相反,可执行文件只需记录未初始化数据段的位置及所需大小,直到运行时再由程序加载器来分配空间。
- 栈(stack):是一个动态增长和收缩的段,有栈帧(stack frames)组成。系统会为每个当前调用的函数分配一个栈帧。栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。
- 堆(heap):是可在运行时(为变量)动态进行内存分配的一块区域。堆顶端称为program break。
对于初始化和未初始化的数据段而言,不太常用、但表达更清晰的称为分别是用户初始化数据段(user-initialized data segment)和零初始化数据段(zero-initialized data segment)。
在大多数Unix(包括Linux)中的C语言编程环境提供了3个全局符号(symbol):etext、edata、end,可以在程序中使用这些符号以获取相应程序文本段、初始化数据段和非初始化数据段结尾处下一字节的地址。
使用这些符号,必须显式声明如下:
extern char etext, edata, end; // For example, &etext gives the address of the end of the program text / start of initialized data
图中标灰的区域表示这些范围在进程虚拟地址空间中不可用,也就是说,没有为这些区域创建页表(page table)。
虚拟内存管理
Linux采用了虚拟内存管理技术。该技术利用了大多数程序的一个典型特征,即访问局部性(locality of reference),以求高效使用CPU和RAM(物理内存)资源。
大多数程序都展现了两种类型的局部性:
- 空间局部性(Spatial locality):是指程序倾向于访问在最近访问过的内存地址附近的内存(由于指令是顺序执行的,且有时会按顺序处理数据结构)。
- 时间局部性(Temporal locality):是指程序倾向于在不久的将来再次访问最近刚访问过的内存地址(由于循环)。
正是由于访问局部性特征,使得程序即便仅有部分地址空间存在于RAM中,依然可能得以执行。
虚拟内存的规划之一是将每个程序使用的内存分割成小型的、固定大小的“页(page)”单元。相应地,将RAM划分成一系列与虚存页尺寸相同的页帧。任一时刻,每个程序仅有部分页需要驻留在物理内存页帧中。这些页构成了所谓的驻留集(resident set)。程序未使用的页拷贝保存在交换区(swap area)内--这是磁盘空间中的保留区域,作为计算机RAM的补充--仅在需要时才会载入物理内存。若进程欲访问的页面目前并未驻留在内存中,将会发生页面错误(page fault),内核即刻挂起进程的执行,同时从磁盘中将该页面载入内存。(程序可调用sysconf(_SC_PAGESIZE)来获取系统虚拟内存的页面大小)
为支持这一组织方式,内核需要为每个进程维护一张页表(page table)。该页表描述了每页在进程虚拟地址空间(virtual address space)中的位置(可为进程所用的所有虚拟内存页面的集合)。页表中的每个条目要么指出一个虚拟页面在RAM中的所在位置,要么表明其当前驻留在磁盘上。
在进程虚拟地址空间中,并非所有的地址范围都需要页表条目。通常情况下,由于可能存在大段的虚拟地址空间并未投入使用,故而也无必要为其维护相应的页表条目。若进程试图访问的地址并无页表条目与之对应,那么进程将受到一个SIGSEGV信号。
由于内核能够为进程分配和释放页(和页表条目),所以进程的有效虚拟地址范围在其生命周期中可以发生变化。这可能会发生于如下场景:
- 由于栈向下增长超出之前曾达到的位置。
- 当在堆中分配或释放内存时,通过调用brk()、sbrk()或malloc函数族来提升program break的位置。
- 当调用shmat()连接System V共享内存区时,或者当调用shmdt()脱离共享内存区时。
- 当调用mmap()创建内存映射时,或者当调用munmap()解除内存映射时。
虚拟内存管理是使进程的虚拟地址空间与RAM物理地址空间隔离开来,这带来许多优点:
- 进程与进程、进程与内核相互隔离,所以一个进程不能读取或修改另一个进程或内核的内存。这是因为每个进程的页表条目指向RAM(或交换区)中截然不同的物理页面集合。
- 适当情况下,两个或更多进程能够共享内存。这是由于内核可以使不同进程的页表条目指向相同的RAM页。内存共享常发生于如下两种场景:
- 执行同一程序的多个进程,可共享一份(只读的)程序代码副本。当多个程序执行相同的程序文件(或加载相同的共享库)时,会隐式地实现这一类型的共享。
- 进程可以使用shmget()和mmap()系统调用显示地请求与其他进程共享内存区。这么做是出于进程间通信的目的。
- 便于实现内存保护机制:也就是说,可以对页表条目进行标记,以表示相关页面内容是可读、可写、可执行亦或是这些保护措施的组合。多个进程共享RAM页面时,允许每个进程对内存采取不同的保护措施。例如:一个进程可能以只读方式访问某页面,而另一进程则以读写方式访问同一页面。
- 程序员和编译器、链接器之类的工具无需关注程序在RAM中的物理布局。
- 因为需要驻留在内存中的仅是程序的一部分,所以程序的加载和运行都很快。而且,一个进程所占用的内存(即虚拟内存大小)能够超出RAM的容量。
虚拟内存管理的最后一个优点是:由于每个进程使用的RAM减少了,RAM中同时可以容纳的进程数量就增多了。这增大了如下事件的概率:在任一时刻,CPU都可执行至少一个进程,因而往往也会提高CPU的利用率。