1. 进程和程序
进程(process)是一个可执行程序(program)的实例。
程序是包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程,所包括的内容如下所示。
- 二进制格式标志:每个程序文件都包含用于描述可执行文件格式的元信息(metainformation,描述信息的结构、语义、用途的信息,即关于信息的信息)。内核(kernel)利用此信息来解释文件中的其他信息。
- 机器语言指令:对程序的算法进行编码。
- 程序入口地址:标识程序开始执行时的起始指令位置。
- 数据:程序文件包含的变量初始化值和程序使用的字面值常量。
- 符号表及重定位表:描述程序中函数和变量的位置及名称。
- 共享库和动态连接信息:列出了程序运行时需要使用的共享库,以及加载共享库的动态链接器的路径名。
- 其他信息:当然还有很多其他没有列出的信息。用以描述如何创建进程。
可以用一个程序来创建许多进程,或者反过来说,许多进程运行的可以是同一个程序。
从内核角度来看,进程由用户空间(user-space memory)和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多进程相关的标识号(IDs)、虚拟内存表、打开文件的描述符表(struct files_struct)、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量其他信息。
可以用一个程序来创建许多进程,或者反过来说,许多进程运行的可以是同一个程序。
从内核角度来看,进程由用户空间(user-space memory)和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多进程相关的标识号(IDs)、虚拟内存表、打开文件的描述符表(struct files_struct)、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量其他信息。
2. 进程号和父进程号
每个进程都有一个进程号(PID),进程号是一个正数,用以唯一标识系统中的某个进程。
系统调用 getpid()
返回调用进程的进程号。
#include <unistd.h>
pid_t getpid(void);
getpid()
的返回值类型是pid_t,该类型是由SUSv3所规定的整数类型,专门用于存储进程号。
每个进程都有一个创建自己的父进程。使用系统调用 getppid()
可以检索到父进程的进程号。
#include <unistd.h>
pid_t getppid(void);
实际上,每个进程的父进程号属性反映了系统上所有进程间的树状关系。每个进程的父进程又有自己的父进程,以此类推,回溯到1号进程——init进程,即所有进程的始祖。使用pstree(1)命令可以查看到这一“进程树”。
如果子进程的父进程中止,则子进程就会变成“孤儿”,init进程随即将收养该进程。
通过查看Linux系统所特有的 /proc/PID/status 文件中所提供的PPid字段,可以获知每个进程的父进程。
3. init进程
系统引导时,内核会创建一个名为init的特殊进程,即“所有进程之父”,该进程的相应程序文件为 /sbin/init。系统的所有进程不是由init亲手创建(fork),就是由init的子孙进程创建。init的进程号总为1,且总是以超级用户权限运行。谁(哪怕是超级用户)也不能杀死init进程,只有关闭系统才能中止该进程。init的主要任务是创建并监控系统运行所需的一系列进程。
4. 守护进程(daemon)
守护进程指的是具有特殊用途的进程,系统创建和处理此类进程的方式与其他进程相同,但以下特征是其所独有的:
- “长生不老”。守护进程通常在系统引导时启动,直至系统关闭前,会一直“健在”。
- 守护进程在后台运行,且无控制终端供其读取或写入数据。
守护进程的例子有syslogd(在系统日志中记录消息)和httpd(利用HTTP分发Web页面)。
5. 进程内存布局
每个进程所分配的内存由很多部分组成,通常称之为“段(segment)”。如下所示:
- 文本段,包含了进程运行的程序机器语言指令以及字符串常量。文本段具有只读属性,以防止程序通过错误指针意外修改自身指令。因为多个进程可同时运行同一程序(以及父子进程),所以又将文本段设为可共享,这样,一份程序代码的拷贝可以映射到所有这些进程的虚拟地址空间中。
- 初始化数据段,包含显式初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
- 未初始化数据段,包含了未进行显式初始化的全局变量和静态变量。程序启动之前,系统将本段内所有内存初始化为0。将初始化的全局变量和静态变量与未初始化的全局变量和静态变量分开存放,因为这两个部分的数据的初始化逻辑本身就不一样,而且在可执行文件中也没必要为未初始化的数据分配存储空间,所以就自然而然的分开了。
- 栈(stack),是一个动态增长和收缩的段,由栈帧(stack frame)组成。系统会为每个当前调用的函数分配一个栈帧。栈帧中存储了函数的局部变量、实参、和返回值。
- 堆(heap),是可在运行时动态进行内存分配的一块区域。堆顶端称作program break。
size(1)
命令可现实二进制可执行文件的文本段、初始化数据段、非初始化数据段的段大小。
注意这里的术语“段”和硬件体系架构里的硬件分段不是一回事。
下图展示了各种内存段在x86-32 体系结构中的布局。图中灰色的区域表示这些范围在进程的虚拟地址空间中不可用,也就是说,没有为这些区域创建页表。
6. 栈和栈帧
函数的调用和返回使栈的增长和收缩呈线性。专用寄存器——栈指针(stack pinter),用于跟踪当前栈顶。每次调用函数时,会在栈上重新分配一帧,每当函数返回时,再从栈上将此帧移去。
有时用用户栈(user stack)来表示此处所讨论的栈,以便与内核栈区分开来。
每个用户栈帧包括如下信息:
- 函数实参和局部变量:由于这些变量都是在调用函数时自动创建的,因此在C语言中称其为自动变量。函数返回时将自动销毁这些变量(因为栈帧将被释放)。
- 函数调用的链接信息:每个函数都会用到一些CPU寄存器,比如程序计数器,其指向下一条要执行的机器语言指令。每当一个函数调用另一个函数时,会在被调用函数的栈帧中保存这些寄存器的副本,以便函数返回的时候能为函数调用者将寄存器恢复原状。
7. 命令行参数
每个C语言程序都必须有一个称为main() 的函数,作为程序启动的起点。当执行程序时,命令行参数(command-line argument)(由shell逐一解析)通过两个入参提供给main() 函数,第一个参数 int argc,表示命令行参数的个数。第二个参数 char* argv[],是一个指向命令行参数的指针数组,每一个参数以'\0'结尾,第一个字符串即argv[0] 通常指向的是该程序的名称。argv中的阵阵列表以NULL指针结尾(即argv[argc] 是NULL)。
正如前面虚拟内存空间的图上所示,argv和environ数组,都驻留在进程栈上的一个单一、连续的内存区域。此区域可存储的字节数有上限要求。
8. 环境列表
每个进程都有与之相关的称之为环境列表(environment list)的字符串数组,或简称为环境(environment)。其中每个字符串都以名称=值(name=value)形式定义。因此,环境是“名称-值”的成对集合,可存储任何信息。常将列表中的name称为环境变量(environrment variables)。
环境变量的传递
新进程在创建之时,会继承其父进程的环境副本。这是一种原始的进程间通信方式,却颇为常用(但只能用于有亲缘关系的进程间通信,而且这种传递是单向的、一次性的)。
环境变量的常见用途之一是在shell中。通过在当前shell中设置环境变量,shell就可以确保把这些环境变量值传递给其所创建的进程(因为所有在shell中运行的程序的父进程都是当前的shell进程)。
大多数shell使用export命令向环境中添加变量值。
SHELL=/bin/bash
export SHELL
第一行创建了一个Shell变量,第二行将这个变量设置成当前shell的环境变量。
可以通过Linux专有的 /proc/PID/environ 文件检查任一进程的环境列表。
程序中访问环境变量
获取环境变量
C程序中可以使用全局变量 char **environ
访问环境变量列表。environ与argv参数类似,指向一个以NULL结尾的指针列表,每个指针又指向一个以'\0'结尾的字符串。
extern char **environ;
int main(int argc, char* argv[]) {
char **ep;
for(ep = environ; *ep != NULL; ep++) {
puts(*ep);
}
return 0;
}
另外还可以通过main函数第三个参数来访问环境列表:
int main(int argc, char* argv[], char* envp[])
但这种方法的局限性在于环境变量只能在main函数内使用。
第三种方法是通过系统调用获取某个特定的环境变量
#include <stdlib.h>
char *getenv(const char *name);
修改环境变量
putenv() 函数向调用进程的环境中添加一个新变量,或者修改一个已经存在的变量值。
#include <stdlib.h>
int putenv(char* string);
参数string是一个形如 name=value的字符串,若当前的环境中name已经存在,则将value替换,如果name不存在则新建一条环境变量。这个函数最大的限制是,会直接修改环境中对应指针的指向,也就是如果原来存在name,在函数调用后name这条环境变量指针将指向string,所以要注意string最好用字符串常量而不是字符数组,因为函数返回后栈区数据很可能被修改。
setenv() 函数可以代替putenv() 函数,向环境中添加一个变量。
#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);
setenv() 函数将name和value的值复制到内存中一块缓冲区并保存为形如name=value的形式,并在环境列表中加上这一条,如果name已经存在且overwrite为0则什么都不做,overwrite为1则覆盖。因为这个函数会把name=value拷贝到内存上的缓冲区,所以不用担心修改原name和value会对环境变量产生什么影响。
unsetenv() 用来移除由name标识的环境变量。
#include <stdlib.h>
int unsetenv(const char *name);
清空环境变量可以直接让 environ = NULL,这也是清空环境变量的库函数 clearenv 的工作内容。但用setenv设置环境变量然后用这种方法清空环境变量可能会导致内存泄漏,因为并没有释放setenv申请的缓冲区,因为clearenv根本就不知道有缓冲区。
- 进程与进程、进程与内核相互隔离,所以一个进程不能读取或修改另一个内存或者内核的内存。
- 适当情况下,两个或者更多进程能够共享内存。因为页表可以让不同进程的虚拟内存页单元映射到同一块物理内存上。
1 条评论
不错,讲的很深入