在学习Linux进程工作原理时,我们应该先了解一下Linux进程的基本环境是怎么样的,首先从main函数开始。
int main(int argc, char *argv[]);
相信main函数是我们非常熟悉的一个函数,它是C程序执行的入口函数。其中,argc是命令行参数的数目,agrv是指向参数的各个指针所构成的数组,而ISO/C和POSIX.1都要求argv[argc]是一个空指针。当内核使用某个exec函数执行C程序时,在调用main函数前先调用一个特殊的启动例程,可执行程序文件将此启动例程指定为程序的起始地址,启动例程从内核取得命令行参数和环境变量值,为调用main函数做好准备。启动例程常常用汇编语言编写,从main函数返回后立即调用exit函数,如果以C代码表示,形式如下:
exit(main(argc, argv));
进程正常终止方式有5种:
(1)从main函数返回,return指定返回状态。
(2)调用exit函数。先执行一些清理工作,包括执行各终止处理程序,关闭所有标准I/O流等,然后进入内核。按照ISO/C的规定,一个进程可以通过atexit函数注册多达32个终止处理程序,这些程序将由exit自动调用。atexit函数的参数是一个函数指针,exit函数调用这些函数的顺序与它们注册的顺序相反,同一函数若注册多次,也会被调用多次。
#include
void exit(int status);
int atexit(void (*function)(void));
(3)调用_exit或_Exit函数。立即进入内核,而不执行清理工作。
#include
void _Exit(int status);
#include
void _exit(int status);
上面提到的三个exit函数,都带有一个整型参数,称之为终止状态或退出状态。如果调用这些函数时不带终止状态,或main函数执行了一个无返回值的return语句,或main没有声明返回类型为整型,则该进程的终止状态是未定义的。但是,若main函数的返回类型是整型,并且main函数执行到最后一条语句时返回,可能是隐式返回,那么该进程的终止状态是0。
(4)最后一个线程从其启动例程返回。
(5)最后一个线程调用pthread_exit。
#include
void pthread_exit(void *retval);
进程异常终止方式有3种:
(1)调用abort。
#include
void abort(void);
(2)接到一个信号并终止。
(3)最后一个线程对取消请求做出响应。
每个程序都会收到一张环境表,与参数表一样,环境表也是一个字符指针数组,其中每个指针包含一个以null结束的C字符串的地址。全局变量environ则包含了该指针数组的地址,按照惯例,环境变量由“name=value”这样的字符串组成。在历史上,大多数Linux系统支持main函数带有三个参数,其中第三个参数就是环境表的地址。因为ISO/C规定main函数只有两个参数,而且第三个参数与全局变量environ相比也没有带来更多益处,所以POSIX.1也规定应使用environ而不使用第三个参数。通常用getenv和putenv函数来访问特定的环境变量,而不是用environ变量,但是,如果要查看整个环境表,则必须使用environ指针。
/* NULL-terminated array of "NAME=VALUE" environment variables. */
extern char **environ;
int main(int argc, char *argv[], char *envp[]);
#include
char *getenv(const char *name);
int putenv(char *string);
int setenv(const char *name, const char *value, int overwrite);
int unsetenv(const char *name);
int clearenv(void);
getenv取环境变量值。putenv参数形式为“name=value”,如果name存在,先删除原来的定义然后更新value。setenv设置name为value,如果name存在,overwrite非0时先删除原来的定义然后更新value,overwrite为0时不做任何事情。unsetenv和clearenv用于清除环境变量。这些函数在修改环境表时值得考究,因为环境表和环境字符串通常占用的是进程地址空间的顶部,所以它不能再向高地址方向扩展,同时也不能移动在它之下的各栈帧,所以它也不能向低地址方向扩展,如果确实要增加一个新的环境变量,就要为新的环境变量分配存储空间以维护环境表了。
C程序的存储空间布局由下面几部分组成:
(1)正文段。这是由CPU执行的机器指令部分,通常,正文段是可共享的,所以即使是频繁执行的程序在存储器中也只需有一个副本,另外,正文段一般是只读的,以防止程序由于意外而修改其自身的指令。
(2)初始化数据段或者叫数据段。数据段包含了程序中需明确地赋初值的变量,如C程序中出现在任何函数之外的声明。
(3)非初始化数据段或者叫bss(block started by symbol)段。在程序开始执行之前,内核将此段中的数据初始化为0或空指针。
(4)堆。通常在堆中进行动态存储分配。
(5)栈。自动变量以及每次函数调用时所需保存的信息都存放在此段上。
另外,可执行文件中还有若干其它类型的段,例如,包含符号表的段、包含调试信息的段以及包含动态共享库链接表的段等等,这些部分并不装载到进程执行的程序映像中。需要存放在程序文件中的段只有正文段和数据段,bss段的内容并不存放在磁盘上的程序文件中。一种典型的存储器安排方式是,从低地址到高地址依次为正文段、数据段、bss段、堆、栈,对于X86处理器上的Linux,正文段从0x08040000单元开始向高地址增长,栈底从0xc0000000单元开始向低地址增长。在shell中,使用size命令可查看程序正文段、数据段和bss段的大小。
在Linux上共享库文件的后缀为“.so”,是相对于以“.a”结尾的静态库而言的。共享库使得可执行文件中不再需要包含公用的库例程,而只需在所有进程都可引用的存储区中维护这种库例程的一个副本。程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接,这减少了每个可执行文件的长度(可用上面提到的size命令查看程序各数据段的大小),但增加了一些运行时间开销,这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。共享库的另一个优点是可以用库函数的新版本代替老版本,而无需对使用该库的程序重新编译链接。在gcc编译时,可通过命令行参数设置是否使用共享库。查看一个共享库所依赖的其它东西,可使用ldd命令。
ISO/C说明了三个用于存储空间动态分配的allloc函数:
#include
void *malloc(size_t size);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
void free(void *ptr);
malloc分配指定字节数的存储区,此存储区中的初始值不确定。
calloc为指定数量具有指定长度的对象分配存储空间,该空间中的每一位都初始化为0。
realloc更改以前分配区的长度,增加或减少,当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,以便在尾端提供增加的存储区,而新增区域内的初始值则不确定。
free函数释放ptr指向的存储空间,被释放的空间通常被送入可用存储区池,以后可在调用上述三个分配函数时再分配。
malloc、calloc、realloc这三个分配函数所返回的指针一定是适当对齐的,使其可用于任何数据对象。这些分配例程通常调用sbrk系统调用实现,该系统调用扩充或缩小进程的堆,但是大多数malloc和free的实现都不减小进程的存储空间,释放的空间可供以后再分配,通常将它们保持在malloc池中而不返回给内核。
应当注意的是,大多数实现所分配的存储空间比所要求的要稍大一些,额外的空间用来记录管理信息,如分配块的长度、指向下一个分配块的指针等等。这就意味着如果超过一个已分配区的尾端进行写操作,则会重写后一个块的管理记录,这种类型的错误是灾难行的,但是因为这种错误不会很快暴露出来,所以也就很难发现,同样在已分配区起始位置之前进行写操作会重写本块的管理记录。
如若一个进程调用malloc函数,但却忘记调用free函数,那么该进程占用的存储器就会连续增加,造成内存泄漏,分页开销过度,进而使性能下降。如果释放一个已经释放了的块,或者调用free时所用的指针不是三个alloc函数的返回值等也将产生致命性的错误。
此外,alloca函数在当前函数的栈帧上分配存储空间而不是在堆中,当函数返回时自动释放它所使用的栈帧,但是某些系统在函数已被调用后不能增加栈帧长度,于是也就不能支持alloca函数。glibc中,mallopt函数可以控制内存分配策略,mallinfo函数可以统计内存分配数据。
#include
void *alloca(size_t size);
#include
int mallopt(int param, int value);
struct mallinfo mallinfo(void);
struct mallinfo {
int arena; /* Non-mmapped space allocated (bytes) */
int ordblks; /* Number of free chunks */
int smblks; /* Number of free fastbin blocks */
int hblks; /* Number of mmapped regions */
int hblkhd; /* Space allocated in mmapped regions (bytes) */
int usmblks; /* Maximum total allocated space (bytes) */
int fsmblks; /* Space in freed fastbin blocks (bytes) */
int uordblks; /* Total allocated space (bytes) */
int fordblks; /* Total free space (bytes) */
int keepcost; /* Top-most, releasable space (bytes) */
};
在C中,goto语句是不能跨越函数的,即局部的,而执行非局部的可以跨越函数跳转功能的是函数setjmp和longjmp,这两个函数对于处理发生在深层嵌套函数调用中的出错情况是非常有用的。
#include
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);
先在某个地方掉用setjmp,即希望跳转返回的地方,然后在另一个地方使用同一个env变量调用longjmp,栈帧就会跳转到setjmp的地方。需要注意的是,栈帧跳转之后,变量能否回滚到以前调用setjmp时的值,答案是不确定的。以自动变量(函数内变量默认类型)、register变量、volatile变量、static变量、全局变量(函数外声明的变量)为例,如果GCC编译不带任何优化选项,所有类型的变量都是不会回滚的,如果GCC编译带优化选项,如“-O”选项,自动变量和寄存器变量回滚,其它类型的变量不回滚。换句话说,存放在存储器中的变量将具有longjmp时的值而不回滚,而在CPU和浮点寄存器中的变量则恢复为调用setjmp时的值,不进行优化时,所有类型的变量都存放在存储器中,而进行了优化后,自动变量和register变量则存放在寄存器中,其它变量仍存放在存储器中。关于自动变量,当声明自动变量的函数已经返回后,将不能再引用这些自动变量,想要使用这些变量时,应在全局存储空间静态地(如static或extern)或者动态地(使用一种alloc函数)为变量分配空间。
每个进程都有一组资源限制,可以使用如下函数查询和修改:
#include
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);
struct rlimit {
rlim_t rlim_cur; /* Soft limit */
rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
};
进程的资源限制通常是在系统初始化时由进程0建立的,然后由每个后续进程继承,每种实现都可以用自己的方法对各种限制作出调整。在更改资源限制时,有下列三条规则:
(1)任何一个进程都可将一个软限制值更改为小于或等于其硬限制值。
(2)任何一个进程都可降低其硬限制值,但它必须大于或等于其软限制值,这种降低对普通用户而言是不可逆的。
(3)只有超级用户进程可以提高硬限制值。
说起Linux进程,其标识符便是我们熟悉的PID,即进程ID,一个非负整数,这个ID是唯一的,但却可以被重用,当一个进程终止后,其进程ID就可以再次被使用了了,正因为如此,大多数Linux系统实现延迟重用算法,使得赋予新建进程的ID不同于最近终止进程所使用的ID,这防止了将新进程误认为是使用同一ID的某个已终止的先前进程。
系统中有一些专用的进程,但具体细节因实现而异。ID为0的进程通常是调度进程,常常被称为交换进程swapper,该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被成为系统进程。ID为1的进程通常是init进程,在自举过程结束时由内核调用,负责启动Linux系统,读取与系统有关的初始化文件,并将系统引导到一个状态,init进程不会终止,它虽是一个普通的用户进程,但却以超级用户特权运行,是所有孤儿进程的父进程。
除了进程ID之外,每个进程还有一些其它的标识符,如父进程ID、实际与有效用户ID、实际与有效组ID,它们可以通过下列函数获取:
#include
pid_t getpid(void);
pid_t getppid(void);
uid_t getuid(void);
uid_t geteuid(void);
gid_t getgid(void);
gid_t getegid(void);
#include
pid_t fork(void);
一个现有进程可以调用fork函数创建一个新进程,新进程即子进程,需要注意的是调用fork成功时返回两次,子进程中返回0(因为一个进程只有一个父进程,所以子进程总是可以调用getppid来获得父进程的进程ID),父进程中返回子进程ID(因为一个进程可以有多个子进程,却没有一个函数使一个进程可以获得其所有子进程的进程ID),出错返回-1。fork失败时,可能是系统中已经有了太多的进程,或者实际用户ID的进程总数超过了系统限制。
子进程和父进程继续执行fork调用之后的指令。子进程是父进程发副本,例如,子进程获得父进程数据空间、堆和栈的副本,注意,这是子进程所拥有的副本,父、子进程并不共享这些存储空间部分。父、子进程共享正文段。由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全复制,作为替代,使用了写时复制技术,这些区域由父、子进程共享,而且内核将它们的访问权限改变为只读的,如果父、子进程中的任何一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储器系统中的一页。
fork之后,子进程共享父进程的文件描述符,处理文件描述符有两种常见的情况,一是父进程等待子进程完成,在这种情况下,父进程无需对其描述符做任何处理,当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已执行了相应更新;二是父、子进程各自执行不同的程序段,在这种情况下,在fork之后,父、子进程各自关闭它们不再使用的文件描述符,这样就不会干扰对方使用的文件描述符,这种方法是网络服务进程中经常使用的。
除了文件描述符之外,子进程从父进程继承的内容还包括实际与有效用户ID、实际与有效组ID、附加与进程组ID、会话ID、控制终端、设置用户ID标志和设置组ID标志、当前工作目录、根目录、文件模式创建屏蔽字、信号屏蔽和安排、针对任一打开文件描述符的在执行时关闭标志close-on-exec、环境、连接的共享存储段、存储映射、资源限制等。当然,父、子进程也有不同之处,包括fork的返回值,进程ID不同,两个进程具有不同的父进程ID,子进程的tms_utime、tms_stime、tms_cutime以及tms_ustime均被设置为0,父进程设置的文件锁不会被子进程继承,子进程的未处理的闹钟alarm被清除,子进程的未处理信号集被设置为空集。
一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父、子进程之间相互同步,则要求某种形式的进程间通信。当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,则认为发生了竞争条件,哪一个进程先运行是无法预料的。即使知道哪一个进程先运行,那么在该进程开始运行后,所发生的事情也依赖于系统负载和内核调度算法。在子进程等待父进程终止时,有时候我们会用轮询方式来等待测试,判断其父进程ID是否是init进程ID,但这又浪费了CPU时间,通常的做法是使用Linxu的信号机制或者IPC通信。
在很多场合下,fork之后跟随一个exec函数,某些操作系统将这两个操作组合成一个接口spawn,还有个类似的创建子进程的clone函数,可以设置父、子进共享的数据。
/* Prototype for the glibc wrapper function */
#include
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
/* Prototype for the raw system call */
long clone(unsigned long flags, void *child_stack, void *ptid, void *ctid,
struct pt_regs *regs);
#include
pid_t vfork(void);
vfork函数的调用序列和返回值与fork相同,但两者的语义不同。vfork用于创建一个新进程,而该新进程的目的是exec一个新程序。vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程立即调用exec或exit,于是也就不会存放该地址空间。相反,在子进程调用exec或exit之前,它在父进程的空间中运行。vfork和fork之间的另一个区别是vfork保证子进程先运行,在它调用exec或exit只有父进程才可能被调度运行,如果在调用这两个函数之前子进程依赖与父进程的进一步动作,则会导致死锁。
不管进程如何终止,最后都会执行内核中的同一段代码,这段代码为相应进程关闭所有打开的描述符,释放它所使用的存储器等。子进程终止时,终止状态要通知到其父进程,该终止进程的父进程可以用wait或waitpid函数取得其终止状态。但是,如果父进程在子进程之前终止呢?对于父进程提前终止的所有子进程,它们的父进程都将改变为init进程,由init进程领养。在Linux术语中,一个已经终止、但是其父进程尚未对其进行善后处理的进程被称为僵尸进程。如果编写一个长期运行的程序,它调用fork产生了很多子进程,那么除非父进程等待取得子进程的终止状态,否则这些子进程终止后就会变为僵尸进程。exit函数执行atexit注册的终止处理程序,关闭所有标准IO流,但不处理文件描述符、父子进程和作业控制;_Exit和_exit和函数则不执行atexit注册的终止处理程序,是否冲洗标准IO流由实现而定,在Linux上不冲洗标准IO流。
#include
void exit(int status);
void _Exit(int status);
#include
void _exit(int status);
5、wait
当一个进程正常终止或异常终止时,内核就向其父进程发送SIGCHILD信号,因为子进程是个异步事件,所以这种信号也是内核向父进程发的异步通知。父进程可以忽略该信号,或者提供一个该信号发生时的信号处理程序,对于这种信号的系统默认动作是忽略它。wait等待子进程终止,会阻塞。waitpid等待指定的子进程,其参数可控制阻塞与否。检查wait和waitpid所返回的终止状态可使用四个互斥的宏,这些宏以WIF开头,如WIFEXITED,以查看终止原因,然后使用另外的宏获取更多的退出信息,如WEXITSTATUS。waitid类似于waitpid,但提供了更多的灵活性,它使用单独的参数表示要等待的子进程的类型,而不是将此与进程ID或进程组ID组合成一个参数。wait3和wait4则多提供了另一个功能,rusage参数要求内核返回由终止进程及其所有子进程使用的资源汇总,如用户CPU时间总量、系统CPU时间总量、页面出错次数、接收到信号的次数等,还有一个相关的函数为getrusage。
#include
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
#include
#include
#include
#include
pid_t wait3(int *status, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);
int getrusage(int who, struct rusage *usage);
struct rusage {
struct timeval ru_utime; /* user CPU time used */
struct timeval ru_stime; /* system CPU time used */
long ru_maxrss; /* maximum resident set size */
long ru_ixrss; /* integral shared memory size */
long ru_idrss; /* integral unshared data size */
long ru_isrss; /* integral unshared stack size */
long ru_minflt; /* page reclaims (soft page faults) */
long ru_majflt; /* page faults (hard page faults) */
long ru_nswap; /* swaps */
long ru_inblock; /* block input operations */
long ru_oublock; /* block output operations */
long ru_msgsnd; /* IPC messages sent */
long ru_msgrcv; /* IPC messages received */
long ru_nsignals; /* signals received */
long ru_nvcsw; /* voluntary context switches */
long ru_nivcsw; /* involuntary context switches */
};
当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变,exec只是用一个全新的程序替换了当前进程的正文、数据、堆和栈段。除了进程ID未改变之外,新进程还保持了原有进程的其它特征,包括父进程ID、实际用户ID和实际组ID、附加组ID、进程组ID、会话ID、控制终端、闹钟尚余留的时间、当前工作目录、根目录、文件创建模式屏蔽字、文件锁、进程信号屏蔽、未处理信号、资源限制、tms_utime、tms_stime、tms_cutime以及tms_ustime。每个文件都有一个close-on-exec标志,若此标志设置,则在执行exec时关闭该描述符,否则该描述符仍打开,除非特别地使用了fcntl函数设置了该标志,否则系统的默认操作是执行了exec后仍保持文件描述符打开,而POSIX.1要求在执行了exec后要关闭目录流。在很多Linux实现中,如下的6个exec函数中只有execve是内核的系统调用,另外5个只是库函数,它们最终都要调用该系统调用,参数中path表示路径名,file表示文件名,而函数名中l表示list,即多参函数中分别给出各个参数,最后一个参数为NULL,p表示第一个参数为文件名,e表示使用给定的环境变量指针数组,v表示vector,各参数置于一个指针数组中。
#include
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
#include
int fcntl(int fd, int cmd, ... /* arg */ );
在Linux系统中,特权是基于用户ID和组ID的。当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新ID具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源访问时,也需要更换用户ID或组ID,从而使新ID不具有相应特权或访问这些资源的能力。一般而言,在设计应用程序时,我们总是试图使用最小特权模型。依照此模型,我们的程序应当只具有为完成给定任务所需的最小特权。这减少了安全性受到损害的可能性,这种安全性损害是由恶意用户试图哄骗我们的程序以未预料的方式使用特权所造成的。下面以用户ID为例说明,组ID用法类似(附加组ID例外)。
setuid函数可以设置实际用户ID和有效用户ID。若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID,以及保存的设置用户ID设置为uid;若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid,不改变实际用户ID和保存的设置用户ID;如果前面两个条件都不满足,则将errno设置为EPERM,并返回-1。保存的设置用户ID有效的前提是_POSIX_SAVED_IDS为真。setreuid可以交换实际用户ID和有效用户ID,seteuid只更改有效用户ID。任一进程都可以得到其实际和有效用户ID及组ID,但是有时希望找到运行该程序的用户登录名。我们可以调用getpwuid(getuid()),但是如果一个用户有多个登录,这些登录名又对应着同一个用户ID,那么又将如何呢?这时,可以调用getlogin函数获取用户登录名,然后用返回值作为参数调用getpwnam函数即可。
关于内核所维护的三个用户ID,还要注意下列几点:
(1)只有超级用户进程可以更改实际用户ID,通常实际用户ID是在用户登录时,由login程序设置的,而且永远不会改变它,因为login是一个超级用户进程,当它调用setuid时,会设置所有三个用户ID。
(2)仅当对程序文件设置了设置用户ID位时,exec函数才会设置有效用户ID,如果设置用户ID位没有设置,则exec函数不会改变有效用户ID,而将其维持为原先值,任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID,自然不能将有效用户ID设置为任意随机值。
(3)保存的设置用户ID是由exec复制有效用户ID而得来的,如果设置了文件的设置用户ID位,则在exec根据文件的用户ID设置了进程的有效用户ID以后,就将这个副本保存起来。
#include
int setuid(uid_t uid);
int setgid(gid_t gid);
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);
int seteuid(uid_t euid);
int setegid(gid_t egid);
#include
#include
struct passwd *getpwnam(const char *name);
struct passwd *getpwuid(uid_t uid);
#include
char *getlogin(void);
所有现今的Linux系统都支持解释器文件,这种文件是文本文件,其起始行的形式是:
#! pathname [optional-argument]
感叹号和pathname之间的空格是可选的,最常见的python脚本启动行格式如下:
#!/usr/bin/env python
pathname通常是绝对路径名,对它不进行什么特殊的处理,即不使用PATH进行路径搜索。对这种文件的识别是由内核作为exec系统调用处理的一部分来完成的,内核使调用exec函数的进程实际执行的并不是该解释器文件,而是解释器文件第一行pathname所指定的文件。
#include
int system(const char *command);
在程序中执行一个命令字符串很方便,调用system函数即可,其实现要调用fork、exec、waitpid,下面例子是system的一种实现,它对信号没有进行处理。
#include
#include
#include
int system(const char *command)
{
pid_t pid;
int status;
if (command == NULL) {
return(1);
}
if ((pid = fork()) < 0) {
status = -1;
}
else if (0 == pid) {
execl("/bin/sh", "sh", "-c", command, (char*)0);
_exit(127);
}
else {
while (waitpid(pid, &status, 0) < 0) {
if (errno != EINTR) {
status = -1;
break;
}
}
}
return status;
}
#include
int acct(const char *filename);
大多数Linux系统提供了一个选项以进行进程会计处理,启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包含较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。acct可以把会计记录写到指定文件filename中,filename为空指针时关闭会计记录。会计记录定义在sys/acct.h中,结构如下:
#define ACCT_COMM 16
/*
comp_t is a 16-bit "floating" point number with a 3-bit base 8
exponent and a 13-bit fraction. See linux/kernel/acct.c for the
specific encoding system used.
*/
typedef u_int16_t comp_t;
struct acct
{
char ac_flag; /* Flags. */
u_int16_t ac_uid; /* Real user ID. */
u_int16_t ac_gid; /* Real group ID. */
u_int16_t ac_tty; /* Controlling terminal. */
u_int32_t ac_btime; /* Beginning time. */
comp_t ac_utime; /* User time. */
comp_t ac_stime; /* System time. */
comp_t ac_etime; /* Elapsed time. */
comp_t ac_mem; /* Average memory usage. */
comp_t ac_io; /* Chars transferred. */
comp_t ac_rw; /* Blocks read or written. */
comp_t ac_minflt; /* Minor pagefaults. */
comp_t ac_majflt; /* Major pagefaults. */
comp_t ac_swaps; /* Number of swaps. */
u_int32_t ac_exitcode; /* Process exitcode. */
char ac_comm[ACCT_COMM+1]; /* Command name. */
char ac_pad[10]; /* Padding bytes. */
};
进程时间包括墙上时钟时间、用户CPU时间和系统CPU时间,任一进程都可以调用times函数以获得这些时间及已终止子进程的时间。tms结构没有包含墙上时钟时间,可以用times函数的返回值求取,返回值是个相对时间,墙上时钟时间可使用两次调用times函数的返回值之差来计算。
#include
clock_t times(struct tms *buf);
struct tms {
clock_t tms_utime; /* user time */
clock_t tms_stime; /* system time */
clock_t tms_cutime; /* user time of children */
clock_t tms_cstime; /* system time of children */
};
每个进程除了有一个进程ID之外,还属于一个进程组。进程组是一个或多个进程的集合,通常它们与同一作业相关联,可以接收来自同一终端的各种信号。每个进程组有一个唯一的进程组ID,进程组ID类似与进程ID,是一个正整数,并可存放在pid_t数据类型中。从进程组创建开始到其中最后一个进程离开为止的时间区间称为进程组的生存期,进程组中的最后一个进程可以终止,或者转移到另一个进程组,且一个进程只能为它自己或它自己的子进程设置进程组ID。
每个进程组都可以有一个组长进程,组长进程的标识是其进程组ID等于其进程ID。组长进程可以创建一个进程组,创建该组中的进程,然后终止。只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。
会话是一个或多个进程组的集合,相关的操作函数是setsid和getsid。
#include
pid_t getpgrp(void); /* POSIX.1 version */
pid_t getpgrp(pid_t pid); /* BSD version */
int setpgrp(void); /* System V version */
int setpgrp(pid_t pid, pid_t pgid); /* BSD version */
pid_t getpgid(pid_t pid);
int setpgid(pid_t pid, pid_t pgid);
pid_t getsid(pid_t pid);
pid_t setsid(void);
进程组与会话有如下特性:
(1)一个会话可以有一个控制终端,这通常是登录到其上的终端设备或伪终端。
(2)建立与控制终端连接的会话首进程被称为控制进程。
(3)一个会话中的几个进程组可被分成一个前台进程组以及一个或多个后台进程组。与前台进程组相关的函数有tcgetpgrp、tcsetpgrp、tcgetsid。
(4)如果一个会话有一个控制终端,则它有一个前台进程组,会话中的其它进程组则为后台进程组。
(5)无论何时键入终端的中断键,就会将中断信号发送给前台进程组的所有进程。
(6)无论何时键入终端的退出键,就会将退出信号发送给前台进程组中的所有进程。
(7)如果终端接口检测到调制解调器或网络已经断开链接,则将挂断信号发送给控制进程。
作业控制允许一个终端上启动多个作业,即多个进程组,并控制哪一个作业可以访问该终端,以及哪些作业在后台运行。从shell使用作业控制功能角度讲,用户可以在前台或后台启动一个作业,一个作业只是几个进程的集合,通常是一个进程的管道。当启动一个后台作业时,shell赋予它一个作业标识号码,并显示一个或几个进程ID。
前面提到了孤儿进程,一个进程的父进程提前终止时,这个进程将被init进程收养,同样,进程组也可能是一个孤儿进程组。POSIX.1将孤儿进程组定义为:该组中每个成员的父进程要么是该组的一个成员,要么不是该组所属会话的成员。对孤儿进程组的另一种描述是:一个进程组不是孤儿进程组的条件是,该进程组中有一个进程,其父进程在属于同一会话的另一个组中。如果进程组不是孤儿进程组,那么在属于同一会话的另一个组中的父进程就有机会重新启动该组中停止的进程。
信号是软件中断,它提供了一种处理异步事件的方法。每个信号都有一个名字,这些名字都以三个字符SIG开头。在头文件“signal.h”中,这些信号都被宏定义为正整数,不存在编号为0的信号,这是因为kill函数对信号0有特殊的应用,POSIX.1将此种信号称为空信号。
很多条件可以产生信号,如下:
(1)当用户按某些终端键时,引发终端产生信号。例如,在终端上按Ctrl+C组合键,通常产生中断信号SIGINT,这是停止一个已失去控制程序的方法。
(2)硬件异常产生信号。例如,除数为0或者引用无效内存,这些条件通常由硬件检测到,并将其通知内核,然后内核为该条件发生时正在运行的进程产生适当的信号,如对执行一个无效内存引用的进程产生SIGSEGV信号。
(3)进程调用kill函数将信号发送给另一个进程或进程组。这种情况下,接收信号进程和发送信号进程的所有者必须相同,或者发送信号进程的所有者必须是超级用户。
(4)用户用kill命令将信号发送给其它进程。常用此命令终止一个失控的后台进程。
(5)当检测到某种软件条件已经发生,并应将其通知有关进程时也产生信号。
信号处理一般有下列三种方法:
(1)忽略信号。SIGKILL和SIGSTOP信号是不能被忽略的,因为它们向超级用户提供了终止或停止进程的方法。
(2)捕捉信号。在某种信号发生时,要通知内核调用一个用户函数,但不能捕捉SIGKILL和SIGSTOP信号。
(3)执行系统默认动作。大多数信号的系统默认动作是终止进程。
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal函数是Linux系统的信号机制中最简单的接口,其参数包含一个函数指针,返回值类型也是一个函数指针。在早期的Unix版本中,信号是不可靠的,可能会丢失,而且对信号的控制能力也很差。
早期Linux系统的一个特性是:如果进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用就被中断不再继续执行,该系统调用返回出错,其errno被设置为EINTR。这样处理的理由是:因为一个信号发生了,进程捕捉到了它,这意味这已经发生了某种事情,所以是个应当唤醒阻塞的系统调用的好机会。为了支持这种特性,将系统调用分成两类:低速系统调用和其它系统调用,低速系统调用是可能会使进程永远阻塞的一类系统调用。为了帮助应用程序使其不必处理被中断的系统调用,4.2BSD引入了某些被中断系统调用的自动重启动功能,包括ioctl、read、readv、write、writev、wait和waitpid。
什么是可重入函数?进程捕捉到信号并对其进行处理时,进程正在执行的指令序列就被信号处理程序临时中断,它首先执行该信号处理程序中的指令。如果从信号处理程序返回,则继续执行在捕捉到信号时进程正在执行的正常指令序列。但在信号处理程序中,不能判断捕捉到信号时进程在何处执行,这时会发生什么;如果进程正在执行malloc,在其堆中分配另外的存储空间,而此时由于捕捉到信号而插入执行该信号处理程序,其中又调用malloc,这时就可能会对进程造成破坏,因为malloc通常为它所分配的存储区维护一个链接表,而插入执行信号处理程序时,进程可能正在更改次链接表。所以,Linux定义了一些可重入函数,保证不会出现上述问题。有些函数,如果使用了静态或全局数据结构,调用malloc或free,或者是标准I/O函数,通常是不可重入函数。
当引发信号的事件发生时,为进程产生一个信号。事件可以是硬件异常、软件条件、终端产生的信号或调用kill函数。在产生了信号时,内核通常在进程表中设置一个某种形式的标志。当对信号采取了这种动作时,我们说向进程递送了一个信号,在信号产生和递送的时间间隔内,称信号是未决的。
#include
int kill(pid_t pid, int sig);
int raise(int sig);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigpending(sigset_t *set);
int sigsuspend(const sigset_t *mask);
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
#include
unsigned int alarm(unsigned int seconds);
int pause(void);
unsigned int sleep(unsigned int seconds);
#include
void abort(void);
#include
int sigsetjmp(sigjmp_buf env, int savesigs);
void siglongjmp(sigjmp_buf env, int val);
kill函数将信号发送给进程或进程组,raise函数则允许进程向自身发送信号。sigemptyset、sigfillset、sigaddset、sigdelset、sigismember是与信号集相关的几个函数。调用sigprocmask函数可以检测或更改进程的信号屏蔽字。sigpending函数通过set参数获得信号集。sigsuspend函数是sigprocmask和pause函数的集合,但sigsuspend是个原子操作。sigaction函数的功能是检查或修改与指定信号相关联的处理动作。使用alarm函数可以设置一个计时器,在将来某个指定的时间该计时器会超时,当计时器超时时,产生SIGALRM信号,如果不忽略或不捕捉此信号,则其默认动作是终止调用该alarm函数的进程。pause函数使调用进程挂起直到捕捉到一个信号。sleep函数让进程挂起,休眠指定时间或者捕捉到信号并从信号处理程序中返回时结束。abort函数的功能是使异常程序终止。sigsetjmp和siglongjmp函数在信号处理程序中进行非局部转移。