进程及相关API

本文内容来主要来自于unix环境高级编程。

一、main函数

C语言总是从main函数开始执行(java,c++也是从main开始执行)。
main的原型为:
  1. int main(int argc, char *argv[]);  
其中argc是参数个数,argv指向参数的各个指针所组成的数组。ANSI C和POSIX都要求argv[argc]指向NULL。
内核在调用main之前会调用一个特殊的启动例程。可执行文件将此启动例程指定为程序的启动地址(这个由连接器完成,而连接器由编译程序调用,因而我们看不到)。启动例程从内核取得命令的参数和环境变量的值,然后为调用mina做好准备。

二、进程终止

有五种方式使得进程终止:
  1. 从main返回
  2. 调用exit
  3. 调用_exit
  4. 调用abort
  5. 由信号处理程序终止
其中前3种是正常终止,后两种为非正常终止。
exit和_exit的区别是:_exit立即进入内核,而exit则会先执行一些清除处理(包括调用执行各种终止处理程序,关闭所有标准I/O流等),然后再进入内核。
exit和_exit都带有一个整形参数,称为终止状态,该终止状态在进程结束后可以被获得。
如果main执行了一个无返回值的return语句或者main执行隐式返回,则进程的终止状态是未定义的。

ANSI C规定一个进程可以登记至多32个函数,这些函数将由exit自动调用,这些函数称为终止处理程序,并通过atexit来登记。相关头文件及API:

  1. #include <stdlib.h>   
  2. int atexit(void(*func)(void));/*如果成功返回0,出错返回非0*/  

总体上来说,进程资源终止的唯一方式是显式或隐式的(调用exit)调用(_exit),进程也可非自愿的由一个信号使其终止。

三、环境表

每个进程都会收到一张环境表。与传递给main的参数表类似,环境表也是一个字符指针数组,其中每个指针包含一个以null结束的字符串地址。全局变量environ则包含了该指针数组的地址。例如:
environ--------->[0]----------->HOME=/home/roverwang\0
                         [1]----------->PATH=:/bin:/usr/bin\0
                         [2]----------->SHELL=/bin/sh\0
                         [3]----------->USER=roverwang\0
                         [4]----------->LOGNAME=roverwang\0
有几个API可以用于存取环境变量:
  1. #include <stdlib.h>   
  2. char *getenv(const char *name); 返回指向与name关联的value的指针,若未找到返回null  
  3. int *putenv(const char *name); 将形式为name=value的字符串放入环境表,如果name已经存在,则先删除后添加。成功返回0,否则返回非0  
  4. int *setenv(const char *name, const char *value, int rewrite); 将环境表中name的value设置为value。如果name已经存在,则如果rewrite不为0,则先删除其现有的定义,再添加;如果rewrite为0,则不作任何动作(即保留原有设置)  
  5. void unsetenv(const char *name); 删除name的定义。  

四、C程序的存储空间布局

c语言由以下几部分组成:
  1. 正文段(代码段):由CPU执行的机器指令部分。正文段是可共享的,因此即使经常执行的程序在存储器中也只需要一个副本。另外正文段往往设置为只读,以防止由于意外而被修改。
  2. 初始化数据段:通常称为数据段,包含了程序中需要赋初值的变量。
  3. 非初始化数据段:通常称为bss段,在程序开始执行之前,该段被内核初始化为0。
  4. 栈:自动变量以及每次函数调用时所需要保存的信息都存放在此段中。每次函数调用时,其返回地址、以及调用者的环境信息都存放在栈中。
  5. 堆:通常在堆中进行动态存储分配。堆通常位于非初始化数据段顶和栈底之间。
典型的布局如下图:

如果程序使用了共享库,则共享库的代码段和数据段也会进入进入进程的地址空间。
  • 对于静态库,情形很简单,它在编译链接后就变成了可执行程序的一部分了,因而此时可以认为静态连接库就是自己程序的一部分,并无特殊之处。
  • 如果是动态库,情形稍有不同,因为在使用动态库时,为了节省空间,共享库的代码段在内存中只有一个拷贝,该拷贝会被映射到使用它的进程的地址空间中,而共享库中所定义的全局变量、静态变量会在使用该共享库的每一个进程中存在一份。因而使用动态共享库时,共享库的代码段会被映射到进程的地址空间中,共享库的静态数据、全局数据会被拷贝一份到进程的数据段中。

五、存储器分配

ANSI C有三个用于存储空间动态分配的函数。
  1. malloc:分配指定字节数的存储区域。存储区域中的初始值不定。
  2. calloc:为指定长度的对象分配能容纳指定个数的存储空间。分配的空间被初始化为全0
  3. realloc:更改以前分配区的大小,当增加长度时,可能需要将以前分配区域的内容移到一个足够大的区域(因而使用了该函数时,不能有指针指向原有的区域中),而新增区域内的初始值不定。
这三个函数分配的指针都是适当对齐的,使其可以用于任何数据对象。加入系统最苛刻的对其要求是double,则对齐必须在double字长的倍数处。
函数free用于释放这三个函数分配的内存区域。
这些分配函数通常通过sbrk系统调用实现。该系统调用扩充或者缩小进程的堆。
大多数的实现所分配的存储空间比所要求的大一些,额外的空间用于记录管理信息(分配块的长度,指向下一个分配块的指针等等)。

六、setjmp和longjmp

c语言的函数是通过栈帧实现的,每调用一个函数就产生一个栈帧。栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。从逻辑上讲,栈帧就是一个函数执行的环境:函数参数、函数的局部变量、函数执行完后返回到哪里等等。
setjmp和longjmp用于支持跨越函数的跳转(goto语句只能在函数内部使用),也即可以跨越栈帧。
其原型:
  1. #include <setjmp.h>   
  2. int setjmp(jmp_buf env);如果是直接调用则返回0,如果是从longjmp跳转过来则返回非0  
  3. void longjmp(jmp_buf env, int val);其中env应该使用希望从其中返回的setjmp所使用的env,val会称为该setjmp返回时的返回值。  
jmp_buf是一种特殊的数据类型,它包含了可以恢复此处(setjmp处)栈信息的所有信息。调用了longjmp并从setjmp返回后,在该setjmp之后的所有栈帧都被丢弃,setjmp所在的栈帧的状态被恢复到第一次调用它时的状态。显然第一次调用setjmp(也即返回0的那次调用)建立了非0返回时用于恢复栈状态的信息。
另外需要注意:
  • 具有volatile属性的变量在从setjmp非0返回后不会被恢复到第一次调用setjmp时的值,而是保持调用longjmp时的值
  • 全局变量和静态变量在从setjmp非0返回后不会被恢复到第一次调用setjmp时的值,而是保持调用longjmp时的值

七、查看和修改进程资源限制

  1. #include <sys/time.h>   
  2. #include <sys/resource.h>   
  3. int getrlimit(int resource, struct rlimit *rlptr);成功时返回0,否则返回非0  
  4. int setrlimit(int resource, const struct rlimit *rlptr);成功时返回0,否则返回非0  
修改限制时有三个原则:
  1. 任何一个进程都可以将其一个软限制修改为小于等于其硬限制
  2. 任何一个进程都可以降低其硬限制,但它必须大于等于其软限制。这种降低对于普通用于是不可逆的
  3. 只有超级用户可以提高硬限制

八、栈和堆的区别

1.申请方式   

  栈:由系统自动分配。
  堆:需要程序员自己申请,并指明大小。

2.申请后的响应   

  栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。   
  堆:如果堆中的空闲地址可以满足本次申请,则分配程序;否则,可能需要尝试申请扩大进程的堆空间,然后再为程序分配。

3.申请效率的比较

  栈由系统自动分配,速度较快。但程序员是无法控制的。   
  堆是由程序自己发出分配请求,然后由库函数负责分配,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便.   

4.堆和栈中的存储内容   

  栈:函数参数、函数的局部变量、函数执行完后返回到哪里等等。   
  堆:分配的空间有两部分组成,一部分包含了额这部分区域的管理信息,另一部分是可以真正由用户使用的信息。   

九、进程标识

每个进程都有一个非负的整型的唯一的进程ID。
相关的API:
  1. #include <sys/types.h>   
  2. #include <unistd.h>   
  3. pid_t getpid(void);返回:调用进程的进程 ID  
  4. pid_t getppid(void);返回:调用进程的父进程 ID  
  5. uid_t getuid(void); 返回:调用进程的实际用户 ID  
  6. uid_t geteuid(void); 返回:调用进程的有效用户 ID  
  7. gid_t getgid(void); 返回:调用进程的实际组 ID  
  8. gid_t getegid(void); 返回:调用进程的有效组 ID  

十、fork、vforfk

1.创建子进程的API

  1. #include <sys/types.h>   
  2. #include <unistd.h>   
  3. pid_t fork(void);返回:子进程中为 0,父进程中为子进程 ID,出错为- 1  

由fork创建的新进程被称为子进程( child process)。该函数被调用一次,但返回两次。
  1. 一次返回0,顺序执行下面的代码。这是子进程。
  2. 一次返回子进程的pid,也顺序执行下面的代码,这是父进程。
将子进程的进程ID返回给父进程的理由在于一个进程的子进程可以有多个,所以没有一个函数使得一个进程可以获取它所有的子进程的进程ID。子进程返回0的理由在于,一个进程只会有一个父进程,因而它总是可以通过getppid获得其父进程的进程ID。
子进程是父进程的复制品,它复制父进程的数据空间、堆和栈,注意是复制而非共享。
现代操作系统多采用写时复制机制而不是做父进程数据段和堆的完全复制。

写时复制:由于一般 fork后面都接着exec,所以,现在的 fork都在用写时复制的技术,顾名思意,就是,数据段,堆,栈,一开始并不复制,由父,子进程共享,并将这些内存设置为只读。直到父,子进程一方尝试写这些区域,则内核才为需要修改的那片内存拷贝副本。这样做可以提高 fork的效率。

一般来说,在 fork之后是父进程先执行还是子进程先执行是不确定的。这取决于内核所使用的调度算法。如果要求父、子进程之间相互同步,则要求某种形式的进程间通信。
注意fork与I/O函数之间的关系。标准I/O库是带缓存的。如果标准输出连到终端设备,则它是行缓存的,否则它是全缓存的。如果在fork之前调用了标准I/O函数,并且当调用fork时,这些数据仍在缓存中,然后在父进程数据空间复制到子进程中时,该缓存数据也被复制到子进程中。于是父、子进程各自有了该缓存的内容。

fork的另一个特性是所有由父进程打开的描述符都被复制到子进程中。父、子进程每个相同的打开描述符共享一个文件表项,如图:


2.父、子进程的区别

子进程通过复制继承了父进程的大多性质,但是二者也有不同之处,有些性质子进程不继承:
  1. folk的返回值
  2. 进程ID
  3. 不同的父进程ID
  4. 子进程的tms_utime, tms_stime, tms_cutime, tms_ustime设置为0
  5. 子进程不继承父进程设置的锁
  6. 子继承的未决告警被清除
  7. 子进程的未决信号集设置为空

3.vfork

vfork用于创建一个新进程,而该新进程的目的是 exec一个新程序。
vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),因此也就不会存访该地址空间。不过在子进程调用 exec或exit之前,它在父进程的空间中运行
vfork和fork之间的另一个区别是: vfork保证子进程先运行,在它调用 exec或exit之后父进程才可能被调度运行。

十一、exit

无论进程以何种方式终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等等。
对上述任意一种终止情形,我们都希望终止进程能够通知其父进程它是如何终止的。对于exit和_exit,这是依靠传递给它们的退出状态( exit status)参数来实现的。在异常终止情况,内核(不是进程本身)产生一个指示其异常终止原因的终止状态( termination status)。在任意一种情况下,该终止进程的父进程都能用 wait或waitpid函数取得其终止状态。对于正常终止的进程,在最后调用_exit时内核将其退出状态转换成终止状态。如果子进程正常终止,则父进程可以获得子进程的退出状态。
如果父进程在子进程之前终止,则其子进程的父进程都改变为init进程。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止的进程的子进程,如果是,则该进程的父进程 ID就更改为1(init进程的ID)。这种处理方法保证了每个进程有一个父进程。
内核为每个终止子进程保存了一定量的信息,当终止进程的父进程调用 wait或waitpid时,可以得到有关信息。这种信息至少包括进程ID、该进程的终止状态、以及该进程使用的CPU时间总量。内核可以释放终止进程所使用的所有存储器,关闭其所有打开文件。在UNIX术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程.
一个由init进程领养的进程是不会变成一个僵死进程的,因为init被编写成只要有一个子进程终止, init就会调用一个wait函数取得其终止状态。

十二、wait和waitpid

当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHLD信号。因为子进程终止是个异步事件 (这可以在父进程运行的任何时候发生 ),所以这种信号也是内核向父进程发的异步通知。父进程可以忽略该信号,或者提供一个该信号发生时即被调用执行的函数 (信号处理程序)。对于这种信号的系统默认动作是忽略它。调用wait或waitpid的进程可能会:
  • 阻塞(如果其所有子进程都还在运行 )。
  • 带子进程的终止状态立即返回 (如果一个子进程已终止,正等待父进程存取其终止状态 )。
  • 出错立即返回(如果它没有任何子进程)。

如果进程由于接收到SIGCHLD信号而调用wait,则可期望wait会立即返回。但是如果在一个任一时刻调用 wait,则进程可能会阻塞。

1.API

  1. #include <sys/types.h>   
  2. #include <sys/wait.h>   
  3. pid_t wait(int * statloc) ; 若成功则为进程ID,若出错则为-1  
  4. pid_t waitpid (pid_t pid, int *statloc, int options) ; 若成功则为进程ID,若出错则为-1  
这两个函数的区别是:
  • 在一个子进程终止前, wait使其调用者阻塞,而 waitpid 有一选择项,可使调用者不阻塞。
  • waitpid并不等待第一个终止的子进程—它有若干个选择项,可以控制它所等待的进程。如果一个子进程已经终止,是一个僵死进程,则 wait立即返回并取得该子进程的状态,否则wait使其调用者阻塞直到一个子进程终止。如调用者阻塞而且它有多个子进程,则在其一个子进程终止时, wait就立即返回。因为wait返回终止子进程的进程 ID,所以它总能了解是哪一个子进程终止了。
这两个函数的参数statloc是一个整型指针。如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可将该参数指定为空指针。

2.获取进程终止状态

POSIX.1规定终止状态用定义在 <sys/wait.h>中的各个宏来查看。有三个互斥的宏可用来取得进程终止的原因,它们的名字都以WIF开始。基于这三个宏中哪一个值是真,就可选用其他宏来取得终止状态、信号编号等。
  • WIFEXITED(status) 若为正常终止子进程返回的状态,则为真。对于这种情况可执行WEXITSTATUS(status)取子进程传送给exit或_exit参数的低8位
  • WIFSIGNALED(status) 若为异常终止子进程返回的状态,则为真(接到一个不捕捉的信号)。对于这种情况,可执行WTERMSIG(status)取使子进程终止的信号编号。
  • WIFSTOPPED(status) 若为当前暂停子进程的返回的状态,则为真。对于这种情况,可执行WSTOPSIG(status)取使子进程暂停的信号编号

3.waitpid

对于waitpid的pid参数的解释与其值有关:

  • pid == -1 等待任一子进程。于是在这一功能方面 waitpid与wait等效。
  • pid > 0 等待其进程ID与pid相等的子进程。
  • pid == 0 等待其组ID等于调用进程的组ID的任一子进程。
  • pid < -1 等待其组ID等于pid的绝对值的任一子进程。
options参数使我们能进一步控制 waitpid的操作。此参数或者是 0,或者是下列常数的逐位或运算。
  • WNOHANG 若由pid指定的子进程并不立即可用,则waitpid不阻塞,此时其返回值为0
  • WUNTRACED 若某实现支持作业控制,由pid指定的任一子进程状态已暂停,且其状态自暂停以来还未报告过,则返回其状态。 WIFSTOPPED宏确定返回值是否对应于一个暂停子进程
waitpid函数提供了wait函数没有提供的三个功能:
  1.  waitpid等待一个特定的进程 (而wait则返回任一终止子进程的状态 )。
  2. waitpid提供了一个wait的非阻塞版本。有时希望取得一个子进程的状态,但不想阻塞。
  3.  waitpid支持作业控制(以 WUNTRACED选择项)

十三、exec函数

用fork函数创建子进程后,子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程完全由新程序代换,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程 ID并未改变。exec只是用另一个新程序替换了当前进程的正文、数据、堆和栈段。

1.API

有六种不同的exec函数可供使用:

  1. #include <unistd.h>   
  2. extern char **environ;  
  3. int execl(const char *path, const char *arg, ...);  
  4. int execv(const char *path, char *const argv[]);  
  5. int execle(const char *path, const char *arg, ..., char * const envp[]);  
  6. int execve(const char *path, char *const argv[], char *const envp[]);  
  7. int execlp(const char *file, const char *arg, ...);  
  8. int execvp(const char *file, char *const argv[]);  

2.API之间的区别:

1. 这些函数之间的第一个区别是前四个取路径名作为参数,后两个则取文件名作为参数。当指定filename作为参数时:
  • 如果filename中包含/,则就将其视为路径名。
  • 否则就按PATH环境变量,在有关目录中搜寻可执行文件。
如果execlp和execvp中的任意一个使用路径前缀中的一个找到了一个可执行文件,但是该文件不是由连接编辑程序产生的机器可执行代码文件,则就认为该文件是一个shell脚本,于是试着调用/bin/sh,并以该filename作为shell的输入。
2. 第二个区别与参数表的传递有关(l表示表(list),v表示矢量(vector))。函数execl、execlp和execle要求将新程序的每个命令行参数都说明为一个单独的参数。这种参数表以空指针结尾。对于另外三个函(execv,execvp和execve),则应先构造一个指向各参数的指针数组,然后将该数组地址作为这三个函数的参数。
3. 最后一个区别与向新程序传递环境表相关。以e结尾的两个函数(execle和execve)可以传递一个指向环境字符串指针数组的指针。其他四个函数则使用调用进程中的environ变量为新程序复制现存的环境。
这六个exec函数的参数很难记忆。函数名中的字符会给我们一些帮助。字母p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件。字母l表示该函数取一个参数表,它与字母v互斥。v表示该函数取一个argv[ ]。最后,字母e表示该函数取envp[ ] 数组,而不使用当前环境。
其中只有execve是真正意义上的系统调用,其它都是在此基础上经过包装的库函数。

十四、更改用户ID和组ID

可以用setuid函数设置实际用户ID和有效用户ID。与此类似,可以用 setgid函数设置实际组ID和有效组ID。

1.API

  1. #include <sys/types.h>   
  2. #include <unistd.h>   
  3. int setuid(uid_t uid) ; 若成功则为 0,若出错则为- 1  
  4. int setgid(gid_t gid) ; 若成功则为 0,若出错则为- 1  

seteuid和setegid更改有效用户ID和有效组ID。

  1. int seteuid(uid_t uid) ; 若成功则为 0,若出错则为- 1  
  2. int setegid(gid_t gid) ; 若成功则为 0,若出错则为- 1  

2.改变用户ID的规则(也适用于组ID)

  1. 若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID,以及保存的设置-用户-ID设置为uid。
  2. 若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置-用户-ID,则setuid只将有效用户ID设置为uid。不改变实际用户ID和保存的设置-用户-ID。
  3. 如果上面两个条件都不满足,则 errno设置为EPERM,并返回出错。
在这里假定 _POSIX_SAVED_IDS为真。如果没有提供这种功能,则上面所说的关于保存的设置-用户-ID部分都无效。
关于内核所维护的三个用户 ID,还要注意下列几点:
  1. 只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登录时,由login程序设置的,而且决不会改变它。因为login是一个超级用户进程,当它调用setuid时,设置所有三个用户ID。
  2. 仅当对程序文件设置了设置-用户-ID位时, exec函数设置有效用户ID。如果设置-用户-ID位没有设置,则exec函数不会改变有效用户ID,而将其维持为原先值。任何时候都可以调用setuid,将有效用户ID设置为实际用户 ID或保存的设置-用户-ID。自然,不能将有效用户ID设置为任一随机值。
  3. 保存的设置-用户-ID是由exec从有效用户ID复制的。在exec按文件用户ID设置了有效用户ID后,即进行这种复制,并将此副本保存起来。

改变用户ID的方法总结如下:


如果某个可执行文件的设置-用户-ID位打开,且其用户ID为A,当用户B执行该程序时:
实际用户ID=B
有效用户ID=A
设置-用户-ID=A
此时该可执行文件可以以A的权限做某些只允许A做的事情。
另外在该程序文件的执行过程中,它可以随时用setuid获得用户B的权限,并以B的该权限运行。

十五、用户标识

  1. #include <unistd.h>   
  2. char *getlogin(void);返回:若成功则为指向登录名字符串的指针,若出错则为null  
通过该函数可以获得用户的登录名。

十六、进程时间

进程可以通过times获得进程的相关时间。
  1. #include <sys/times.h>   
  2. clock_t times(struct tms *buf);返回:若成功则为经过的墙上时钟时间(单位:滴答),若出错则为- 1  
所有由此函数返回的 clock_t值都用_SC_CLK_TCK(由sysconf函数返回的每秒时钟滴答数)变换成秒数。
  1. struct tms{  
  2.         clock_t  tms_utime;   /* user cpu time */  
  3.         clock_t  tms_stime;   /* system cpu time */  
  4.         clock_t  tms_cutime;  /* user cpu time of children */  
  5.         clock_t  tms_cstime;  /* system cpu time of children */  
  6. };  

posted on 2013-05-03 13:48  疯子123  阅读(251)  评论(0编辑  收藏  举报

导航