Linux从程序到进程

计算机如何执行进程呢?这可以说是计算机运行的核心问题。即使我们已经编写好程序,但程序是死的文本,只有成为活的进程才能带来产出。我们已经从Linux进程基础中了解了进程的一些概况。现在我们看一下从程序到进程的漫漫征程。

1. 一段程序

我们下面展示一个简单的C语言程序,我们假设该程序已经经过编译,成为可执行的程序文件vamei.exe。

 

(选取哪一个语言或者具体的语法并不是关键,大部分语言都可以写出类似上面的程序。在看python教程的读者也可以利用python的函数结构和print写一个类似的python程序。当然,还可以是C++,Java,Objective-C等等。选用C语言的原因是:它是为UNIX而生的语言。)

在main()函数中,我们调用了inner()函数,并在inner范围内进行了一次printf()以便输出。在从该函数返回之后,我们又在main()的范围之内进行了两次printf()。

我们还要注意各个变量的作用范围。简单地说,变量可以分为全局变量和局部变量。在所有函数之外声明的变量为全局变量,比如glob,在任何时候都可以使用。在函数内定义的变量为局部变量,只能在该函数的作用域(range)内使用,比如说我们在inner()工作的时候不能使用main()函数中声明的main1变量,而在main()中我们无法使用inner()函数中声明的inner2变量。

我们并不在意这个程序的具体功能和结果。我们关心的是这个程序的运行过程。下图为该程序的运行过程,以及各个变量的作用范围:

运行流程

2. 进程空间

为了进一步了解上面的程序的运行,我们还需要知道进程是如何使用内存的。当程序文件运行为进程的时候,进程在内存中得到空间(进程自己的小房间)。每个进程空间按照如下方式分为不同区域:

内存空间

Text区域用来储存指令(instruction),来告诉程序每一步的操作。Global Data用于存放全局变量,stack用于存放局部变量,heap用于存放动态变量 (dynamic variable. 程序利用malloc系统调用,直接从内存中为dynamic variable开辟空间)。Text和Global data在进程一开始的时候就确定了,并在整个进程中保持固定大小。

Stack(栈)以stack frame为单位。当程序调用函数的时候,比如main()函数中调用inner()函数,stack会向下增长一个stack frame。Stack frame中存储该函数的参数和局部变量,以及该函数的返回地址(return address)。此时,计算机将控制权从main()转移到inner(),inner()函数处于激活(active)状态。位于Stack最下方的frame和Global Data就构成了当前的环境(context)。激活函数可以从中调用需要的变量。典型的编程语言都只允许你使用位于stack最下方的frame ,而不允许你调用其它的frame (这也符合stack结构“先进后出”的特征。但也有一些语言允许你调用stack的其它部分,相当于允许你在运行inner()函数的时候调用main()中声明的局部变量,比如Pascal)。当函数又进一步调用另一个函数的时候,一个新的frame会继续增加到stack下方,控制权转移到新的函数中。当激活函数返回的时候,会从stack中弹出(pop,就是读取并删除)该frame,并根据frame中记录的返回地址,将控制权交给返回地址所指向的指令(比如从inner()函数中返回,继续执行main()中赋值给main2的操作)。

下图是stack在运行过程中的变化,箭头表示stack增长的方向,每个方块代表一个stack frame。开始的时候我们有一个为main()服务的frame,随着调用inner(),我们为inner()增加一个frame。在inner()返回时,我们再次只有main()的frame,直到最后main()返回,其返回地址为空,所以进程结束。

stack变化

在进程运行的过程中,通过调用和返回函数,控制权不断在函数间转移。进程可以在调用函数的时候,原函数的stack frame中保存有在我们离开时的状态,并为新的函数开辟所需的stack frame空间。在调用函数返回时,该函数的stack frame所占据的空间随着stack frame的弹出而清空。进程再次回到原函数的stack frame中保存的状态,并根据返回地址所指向的指令继续执行。上面过程不断继续,stack不断增长或减小,直到main()返回的时候,stack完全清空,进程结束。

当程序中使用malloc的时候,heap(堆)会向上增长,其增长的部分就成为malloc从内存中分配的空间。malloc开辟的空间会一直存在,直到我们用free系统调用来释放,或者进程结束。一个经典的错误是内存泄漏(memory leakage), 就是指我们没有释放不再使用的heap空间,导致heap不断增长,而内存可用空间不断减少。

由于stack和heap的大小则会随着进程的运行增大或者变小,当stack和heap增长到两者相遇时候,也就是内存空间图中的蓝色区域(unused area)完全消失的时候,进程会出现栈溢出(stack overflow)的错误,导致进程终止。在现代计算机中,内核一般都会为进程分配足够多的蓝色区域,如果我们即时清理的话,stack overflow是可以避免的。但是,在进行一些矩阵运算的时候,由于所需的内存很大,依然可能出现stack overflow的情况。一种解决方式是增大内核分配给每个进程的内存空间。如果依然不能解决问题的话,我们就需要增加物理内存。

Stack overflow可以说是最出名的计算机错误了,所以才有IT网站(stackoverflow.com)以此为名。

在高级语言中,这些内存管理的细节对于用户来说不透明。在编程的时候,我们只需要记住上一节中的变量作用域就可以了。但在想要写出复杂的程序或者debug的时候,我们就需要相关的知识了。

3. 进程附加信息

除了上面的信息之外,每个进程还要包括一些进程附加信息,包括PID,PPID,PGID(参考Linux进程基础以及Linux进程关系)等,用来说明进程的身份、进程关系以及其它统计信息。这些信息并不保存在进程的内存空间中。内核会为每个进程在内核自己的空间中分配一个变量(task_struct结构体)以保存上述信息。内核可以通过查看自己空间中的各个进程的附加信息就能知道进程的概况,而不用进入到进程自身的空间 (就好像我们可以通过门牌就可以知道房间的主人是谁一样,而不用打开房门)。每个进程的附加信息中有位置专门用于保存接收到的信号(正如我们在Linux信号基础中所说的“信箱”)。

4. fork & exec

现在,我们可以更加深入地了解fork和exec(参考Linux进程基础)的机制了。当一个程序调用fork的时候,实际上就是将上面的内存空间,包括text, global data, heap和stack,又复制出来一个,构成一个新的进程,并在内核中为改进程创建新的附加信息 (比如新的PID,而PPID为原进程的PID)。此后,两个进程分别地继续运行下去。新的进程和原有进程有相同的运行状态(相同的变量值,相同的instructions…)。我们只能通过进程的附加信息来区分两者。

程序调用exec的时候,进程清空自身内存空间的text, global data, heap和stack,并根据新的程序文件重建text, global data, heap和stack (此时heap和stack大小都为0),并开始运行。

(现代操作系统为了更有效率,改进了管理fork和exec的具体机制,但从逻辑上来说并没有差别。具体机制请参看Linux内核相关书籍)

这一篇写了整合了许多东西,所以有些长。这篇文章主要是概念性的,许多细节会根据语言和平台乃至于编译器的不同而有所变化,但大体上,以上的概念适用于所有的计算机进程(无论是Windows还是UNIX)。更加深入的内容,包括线程(thread)、进程间通信(IPC)等,都依赖于这里介绍的内容。

总结:

函数,变量的作用范围,global/local/dynamic variables

global data, text,

stack, stack frame, return address, stack overflow

heap, malloc, free, memory leakage

进程附加信息, task_struct

fork & exec

相关文章参考《Linux进程基础》《对刚刚接触Linux用户非常有用的20个命令

[via@vamei]