进程

基本概念

  • 进程是一个可执行程序的实例,程序代码依托一个进程运行;进程由用户空间和一系列内核数据结构构成。内核数据段通常是只读的,用户代码不能随意修改。
  • 每个进程都有自己的父进程,由此所有进程构成了一棵进程树,顶点是init进程(1号进程);如果一个进程的父进程先行终止,那么该子进程就变为孤儿进程,进而孤儿进程的父进程会变为init,即由init进程收养。
  • 可以通过getpid和getppid查看当前进程的进程号和父进程号,进程号由pid_t类型描述,两个函数原型如下
       #include 
       #include 

       pid_t getpid(void);
       pid_t getppid(void);
  • 每个进程所分配的内存由许多部分组成,可以使用size命令查看一个二进制程序的内存分配情况,如下所示
  1. 文本段:又称为代码段,存放所执行程序代码的机器指令,具有只读属性
  2. 已初始化数据段:存放已经初始化过的全局变量和静态变量
  3. 未初始化数据段:存放未经初始化的全局变量和静态变量
  4. 栈:这是一块可动态变化的内存区,由一个个栈帧组成,系统会为每一个函数调用分配一个栈帧,里面存放该函数的局部变量,实参以及返回值。栈驻留在高地址处并向下增长(也有例外的实现),栈帧中还会保存有一些cpu寄存器的值,比如程序计数器;每当函数嵌套调用另一个函数时,会在被调用函数的栈帧中保存这些寄存器的副本以便函数返回时将寄存器恢复
  5. 堆:可以在运行时进行动态变化的内存区
  • 每一个进程的已初始化数据段都会有该进程的命令行参数和环境变量数据,要想使用环境变量数据,首先得做以下声明extern char** environ,这是一个二维数组,以NULL结尾

虚拟内存简介

  • 进程的地址空间实际上是虚拟内存空间,而地址空间又可以分为有效地址空间和无效地址空间两部分,无效地址空间一般是内核数据段再加上未分配的栈区域,系统只会为有效地址空间分配页表项。由于程序往往展现出空间局部性和时间局部性,所以可以在程序运行时只保留一部分地址空间在实际物理内存中运行。在此基础上,虚拟内存管理所做的工作如下
  1. 把进程的地址空间分割成一个固定大小的页,同时也把实际物理内存空间也分为一片片同样大小的页。在一个程序运行的任意时刻,该进程的有效地址空间可以分为两部分,驻留在物理内存中的内存页以及保留在磁盘上的内存页(一般放置在swap交换分区中)。所以内核会为每个进程都维护一张页表,用来记录有效地址内存页当前是在物理内存中还是在磁盘上
  2. 如果进程在页表中未找到需要的内存页,这时候发生缺页中断,内核挂起当前执行的进程,同时从swap分区将所需要的内存页调入内存
  3. 如果进程所访问的内存页属于无效地址空间,即在页表中找不到,这时候进程会收到SIGSEGV信号,默认动作是终止该进程并转储
  • 一般来说进程不能修改内核代码段,但是堆和栈内存段可以在程序运行时动态修改的,所以进程的有效地址空间在以下几种场景中会发生变化,相应的页表也会随之发生变化
  1. 栈向下增长,可能是因为嵌套函数调用或者在一个栈帧中分配了大量局部变量
  2. 在堆中调用malloc等函数分配了内存
  3. 调用mmap创建内存映射
  • 虚拟内存管理带来的很多好处,如下所示
  1. 提供了多个进程共享内存的方法
  2. 提供了内存保护机制,可以对页表项进行标记,分为可读可写可执行
  3. 可以执行超出大于物理内存的程序
  4. 进程之间相互隔离,不能随意访问各自的地址空间
  5. 对于编译器和连接器以及程序员来说,不需要去关注实际的物理内存

进程的环境变量

  • 除了用声明的方式访问环境变量数组以外,还可以在main函数的参数列表中引用,int main(int argc,char** argv,char** envp),envp就是环境变量数组
  • 下面列举一些与环境变量相关的函数
#include 

//检索单个名字为name的环境变量值,返回value
char* getenv(const char* name);
//向当前进程的环境变量表中添加一个新的环境变量,string必须是"name=value"的格式
int putenv(char* string);
//添加一个新的环境变量,只需填入name和value,不需要"=";并且overwrite非0时覆写原变量
int setenv(const char *name, const char *value, int overwrite);
//删除名字为name的变量
int unsetenv(const char *name);
//清除所有环境变量
int clearenv(void);

setjmp和longjmp

#include 

int setjmp(jmp_buf env);

void longjmp(jmp_buf env,int val);
  • 这两个函数用来执行非局部的语句跳转,这是相对于goto来说,因为goto只能在同一个函数中跳转;setjmp函数为后续调用的longjmp确立了跳转目标。通过查看setjmp的返回值可以区分是不是第一次返回,初次调用setjmp设计跳转目标的返回值为0,env保存了一些执行跳转时必要的参数信息,比如栈指针,程序计数器等;在标记完成后,应该调用longjmp返回标记处执行,第一个env参数应当对应于调用setjmp所传递的env参数,所以一般将其设置为全局变量,第二个参数val是setjmp用来区分是不是初次返回的值,一般将其设置为非0值,则setjmp返回时可以进行判断。示例代码如下
  1 #include 
  2 #include "sysHeader.h"
  3 
  4 static jmp_buf env;
  5 void f2();
  6 
  7 void f1()
  8 {
  9     if(setjmp(env) != 0)
 10         printf("经过f2跳转,第二次调用setjump\n");
 11     else
 12         f2();
 13 }
 14 
 15 void f2()
 16 {
 17     printf("调用f2,执行longjump\n");
 18     longjmp(env,1);
 19 }
 20 
 21 int main(void)
 22 {
 23     f1();
 24     return 0;
 25 }
  • 如果开启编译优化,优化器对代码的重组会受到longjmp的干扰从而导致错误的逻辑;如果采用volatile声明的变量则不会受到此类影响,因为volatile变量禁止编译优化。同时,尽量避免使用setjmp和longjmp

内存分配

  • 一个进程中的堆内存是向上增长的,与栈相反。而堆内存的界限值是由program break来定义的(下面简称为pb),最初的时候pb位于未初始化数据段之后,如果调用分配函数是的pb位置抬升,那么内核会在进程初次访问该部分虚拟地址空间时自动分配内存页。相当于上面说的改变了有效地址空间。
  • 在linux下,malloc函数依赖于底层的brk和sbrk;这两个函数都是改变pb的位置,调用sbrk(0)会返回当前pb的位置,原型如下
       #include 

       int brk(void *addr);

       void *sbrk(intptr_t increment);
  • 一般再分配堆内存时,都调用malloc,calloc等函数,相关函数如下
       #include 

       void *malloc(size_t size);
       void free(void *ptr);
       void *calloc(size_t nmemb, size_t size);
       void *realloc(void *ptr, size_t size);
       void *reallocarray(void *ptr, size_t nmemb, size_t size);
  • malloc函数在堆上分配size大小的内存并返回指向新分配内存块的起始地址;如果堆内存不够,函数调用失败返回NULL
  • free函数用来释放ptr所指向的内存块,该内存块必须是由以上的分配函数中的一种所分配的;因为free函数的实现通常不会调用sbrk来降低pb,这是为了后续的分配更方便,减少了系统调用次数,提高效率;只有当释放的内存块处于堆的顶部时,free才会去调用sbrk来减少pb
  • calloc用于给一组相同对象来分配内存,nmemb表示一共要分配几个这样的对象,size表示单个对象的大小;若分配成功,返回指向第一块内存的指针,并将所有内存块初始化为0
  • realloc用来调整一块已分配的内存块的大小,ptr指向内存块的指针,size表示更改的大小
  • 不仅可以在堆上分配内存,还可以通过alloca函数在栈上分配内存
        #include 

        void* alloca(size_t size);
  • 使用alloca在栈上分配内存和在堆上分配有两个优点:1.分配的速度更快,因为alloca不需要维护空闲内存列表并且作为内联函数实现;2.不需要主动释放内存,因为由alloca分配的内存是处于调用alloca函数的栈帧上的,那么随着该函数返回,该栈帧被释放,分配的内存也就释放了

进程的创建和终止

  • 通过fork调用可以创建一个子进程,该进程相当于调用进程的一个复制品;两个进程共享相同的程序文本段(即执行相同的代码),但是对于栈,堆以及数据段都是各自私有的(写时复制,只有在真正发生写操作时,才会真正申请新的内存空间)。由于开始时共享相同的代码段,所以需要根据fork的返回值来区分是父进程还是子进程,fork返回值为0表示是子进程,大于0表示父进程(返回的是新创建子进程的进程号),返回-1创建进程失败。一般,在fork还没返回,新的进程就已经创建完成了,并且父进程和子进程的执行先后顺序是不确定的。其函数原型为pid_t fork(void)
  • 父进程的文件描述符会在创建的子进程中得到复制,即相当于调用dup函数;两个进程的文件描述符指向相同的文件表项
  • 一般,进程有两种终止方式,其一是异常终止,通过信号的接受与处理;其二是正常终止,通过_exit或者exit调用。status参数表示退出状态,0表示正常退出,可由父进程通过wait调用获取退出值。exit函数的执行过程是1.调用退出处理程序2.刷新stdio缓冲区3.调用_exit退出。函数原型如下
       #include 
       void _exit(int status);

       #include 
       void exit(int status);
  • 通过wait调用可以等待一任意一个子进程退出。如果有之前没有被回收的子进程存在,那么wait立即返回;反之,阻塞直到一个子进程退出。如果调用成功,status表示子进程的退出值并且函数返回值为终止子进程的进程ID;如果调用失败,返回-1并设置相关errno。函数原型为pid_t wait(int* status)
  • 由于wait函数存在一些限制,比如如果没有进程退出,需要阻塞以及不能等待特定的进程。waitpid函数就是为了解决这些问题而出现的。waitpid函数的status参数以及返回值和wait函数相同。pid参数如果是大于0的值,表示等待进程ID为pid的子进程;如果pid等于0等待和父进程同一个进程组的所有子进程;如果pid小于-1,等待进程组ID等于pid的绝对值的所有子进程;如果pid等于-1,等待任意子进程。options参数可以指定一些附加选项,WNOHANG可以取消wait函数的阻塞特性,当pid所指定的进程的进程状态未发生改变时,立即返回0;如果不存在与pid匹配的进程时,返回-1并设置errno为ECHILD;WUNTRACED选项可以额外返回因信号而停止的子进程;WCONTINUED可以返回因为收到SIGCONT信号而恢复执行的子进程。函数原型为pid_t waitpid(pid_t pid,int* status,int options)
  • 根据进程状态返回值status可以获得许多附加信息。首先status是一个int类型,但是只使用后两个字节。如果子进程正常终止,那么status的第二个字节表示退出状态值,第一个字节填充为0;如果子进程被信号所杀status的第一个字节的高一位用来填充内核转储标志,剩下的bit用来表示终止信号,第二个字节不用;如果子进程被信号所停止,status的第一个字节是一个固定值0x7F,第二个字节是终止信号;如果子进程通过信号恢复执行,status的两个有效字节被填充为0xFFFF。一般都会使用系统所提供的宏去检测而不是自己检测,系统头文件sys/wait.h中提供的常用的宏如下
  • WIFEXITED(status),如果子进程正常结束则返回true,并可以使用WEXITSTATUS(status)取得进程的退出值
  • WIFSIGNALED(status),如果通过信号杀掉子进程则返回true,这时可以使用WTERMSIG(status)返回导致该进程终止的信号编号
  • WIFSTOPPED(status),如果子进程因为信号停止则返回true,这时可以使用WSTOPSIG(status)返回导致该进程停止的信号编号
  • WIFCONTINUED(status),如果子进程因为收到SIGCONT信号而恢复执行则返回true
  • 对于不回收的子进程来说,该进程会变为僵尸进程占用系统资源,直到由init进程调用wait。但是我们更希望由父进程去及时的回收,这时候通常会使用SIGCHLD信号的处理函数来及时的回收。当一个子进程终止的时候会向父进程发送一个SIGCHLD信号,对于该信号的默认动作是忽略,我们可以捕获该信号并设计处理程序来回收。需要注意的是,默认情况下当子进程因为信号处于停止或者恢复执行的状态时,也会向父进程发送SIGCHLD信号。如果要屏蔽这种情况,需要在为SIGCHLD信号设置处理函数,即调用sigaction时需要指定SA_NOCLDSTOP标志

程序的执行

  • 系统调用execve可以将新程序加载到一个进程的地址空间,替换该进程的文本段,栈,堆以及数据段,从而开始新程序的执行。该系统调用是其他exec函数的基础,其原型为int execve(const char* pathname,char* const argv[],char* const envp[]),参数pathname是准备载入的程序的路径名,可以是绝对路径也可以是相对于当前进程工作目录的相对路径;argv是传给新程序的命令行参数,是一个字符串数组,以NULL结尾;envp是新程序的环境变量,也是一个字符串数组,以NULL结尾,字符串格式为"name=value"。如果pathname所指向的可执行程序设置了"set-user-ID"或者"set-group-ID"位,那么调用execve会使得进程的有效用户(组)ID变为对应执行文件的属主(组)ID;并且无论是否设置了设置ID位,调用execve都会将保存的设置用户(组)ID设置为有效用户(组)ID。如果该调用成功,那么返回值将没有意义;如果调用失败,即该函数返回,可以通过errno查看具体错误
  • 构建在execve基础上的库函数如下表所示
函数 对程序文件的描述 对参数的描述 环境变量来源
execle 路径名 列表 指定envp参数
execlp 文件名+path 列表 调用者的envp参数
execvp 文件名+path 数组 调用者的envp参数
execv 路径名 数组 调用者的envp参数
execl 路径名 列表 调用者的envp参数
  • 对于名字中带p的函数来说,对程序文件的定位可以只给出可执行文件的名字,系统会自动的在环境变量的PATH中查找目录;对于名字中带l的函数来说,命令行参数是通过可变长参数的方式给出的;对于名字中带v的函数来说,命令行参数是通过数组非方式来给出的;对于名字中带e的函数来说,环境变量可以由envp参数指定,而不是使用默认的环境变量
  • 默认情况下,由exec函数的调用程序打开的所有文件描述符在exec后都依然保持打开状态,且在新程序中依然有效。可以设置文件描述符的close-on-exec标志位来改变这一特性(可以通过fcntl或者ioctl函数来实现)
  • 由于exec函数族会将调用进程的文本段丢弃,在调用exec之前所建立的信号处理程序都会被丢弃,所以在调用exec之后内核会将所有被设置信号处理的信号都设置为默认处理方式(SIG_DFL)
  • 下表说明了exec和fork对部分进程属性的影响,exec列表示在调用exec后哪些属性得到了保存,fork列表示在调用fork后子进程继承或者共享的属性
进程属性 exec() fork() 说明
文本段 共享 -
栈段 -
数据段和堆段 -
环境变量 见说明 除了带有e的exec函数会修改环境变量,其他都是继承原来的
内存映射 -
内存锁 -
进程ID -
父进程ID -
进程组ID -
会话ID -
实际ID(用户和组) -
有效和保存设置ID 见说明 根据待执行的可执行文件是否设置了设置用户或者组ID位来决定
打开的文件描述符 见说明 如果fd设置了close-on-exec位,则不保存
close-on-exec标志 -
文件偏移 共享 -
打开文件状态标志 共享 -
信号处理程序 见说明 如果信号的处理动作是默认的或者忽略的则exec后不变,只有那些被设置过处理过程的信号才会被设置为默认处置
信号掩码 -
等待信号集 -
alarm设置的定时器 -
线程 见说明 fork之后只会将调用fork的线程复制过来,其他线程丢失并且不会调用线程清理函数
互斥量和条件变量 -

进程组、会话与作业控制

你可能感兴趣的:(进程)