《UNIX环境高级编程》学习笔记

《Unix环境高级编程》学习笔记

第一章 NUIX基础知识

1.5输入和输出

文件描述符通常是一个非负整数,用以标识一个特定进程正在访问的文件

运行一个新程序,所有shell会为其打开3个文件描述符:标准输入/输出/错误

不带缓冲的I/O

标准I/O

1.6程序和进程

#include "apue.h"
#include 
// 相当于模拟了一个shell的处理过程
int
main(void)
{
	char	buf[MAXLINE];	/* from apue.h */
	pid_t	pid;
	int		status;

	printf("%% ");	/* print prompt (printf requires %% to print %) */
	while (fgets(buf, MAXLINE, stdin) != NULL) {
		if (buf[strlen(buf) - 1] == '\n')
			buf[strlen(buf) - 1] = 0; /* replace newline with null */

		if ((pid = fork()) < 0) {       //创建子进程
			err_sys("fork error");
		} else if (pid == 0) {		/* child */
			execlp(buf, buf, (char *)0);   //并且执行,第八章详述
			err_ret("couldn't execute: %s", buf);
			exit(127);
		}

		/* parent */
		if ((pid = waitpid(pid, &status, 0)) < 0)      //父进程等待子进程终止
			err_sys("waitpid error");
		printf("%% ");
	}
	exit(0);
}

在编译运行这个程序时遇到一些问题:

1.问题描述

gcc -Wall shell1.c显示找不到apue.h,编译终止。

解决方案

于是发现apue.h在随书附带代码的include文件夹中,此外还需要将lib文件夹中的error.c文件一起复制到系统的/usr/include中,然后编辑apue.c在末行添加#include “error.c”.

实际操作时发现复制文件到/usr/include下是需要root权限的,另外编辑apue.c也是需要root权限的。

1.7出错处理

#include "apue.h"
#include 

int
main(int argc, char *argv[])
{
	fprintf(stderr, "EACCES: %s\n", strerror(EACCES));
	errno = ENOENT;
	perror(argv[0]);
	exit(0);
}

strerror 和perror两个函数输出程序运行的错误信息

errno常量记录出错状态,大约15种

1.8用户标识

#include "apue.h"

int
main(void)
{
	printf("uid = %d, gid = %d\n", getuid(), getgid());
	exit(0);
}

每个用户都有一个用户标识(uid)和至少一个组标识(gid)

1.9信号

include "apue.h"
#include 

static void	sig_int(int);		/* our signal-catching function */

int
main(void)
{
	char	buf[MAXLINE];	/* from apue.h */
	pid_t	pid;
	int		status;

	if (signal(SIGINT, sig_int) == SIG_ERR)
		err_sys("signal error");

	printf("%% ");	/* print prompt (printf requires %% to print %) */
	while (fgets(buf, MAXLINE, stdin) != NULL) {
		if (buf[strlen(buf) - 1] == '\n')
			buf[strlen(buf) - 1] = 0; /* replace newline with null */

		if ((pid = fork()) < 0) {
			err_sys("fork error");
		} else if (pid == 0) {		/* child */
			execlp(buf, buf, (char *)0);
			err_ret("couldn't execute: %s", buf);
			exit(127);
		}

		/* parent */
		if ((pid = waitpid(pid, &status, 0)) < 0)
			err_sys("waitpid error");
		printf("%% ");
	}
	exit(0);
}

void
sig_int(int signo)
{
	printf("interrupt\n%% ");
}

信号是用于通知进程发生了某种情况,进程对信号有三种处理方式:

  1. 忽略
  2. 按系统默认方式处理
  3. 提供一个函数分门别类进行处理

1.10时间值

历史上使用过两种时间值:日历时间(time_t)、进程时间(clock_t)

当度量一个进程执行时间时,unix为每个进程维护了3个时间:

  • 时钟时间
  • 用户CPU时间
  • 系统CPU时间

1.11函数调用和库函数

系统调用处于不断发展之中,现在的FreeBSD 8.0中已经有450个函数调用了

Unix的做法是在标准C函数库中设置一个与系统调用具有同样名字的函数,用户进程调用C库函数。 c库函数又用系统要求的方式调用相应的内核服务,要注意:

  • 并不是说一个C库函数一定对应一个内核服务,一个库函数可以对应多个,当然也有的c库函数并没有使用系统服务
  • 这样来看,C库函数并非是内核的入口
  • 在实现者角度看来,系统调用和库函数有根本区别,甚至都可以用系统调用替换库函数。从用户角度看来,这个区别并不重要,似乎二者都以C函数形式出现。
  • 应用程序既可以直接调用系统调用也可以调用库函数

总结起来就是,系统服务都是一些非常基本的模块,C库在此基础上进行了适度的封装,这也就是为什么C语言是接近系统底层的语言。

第二章 Unix标准及其实现

在过去的将近25年时间,人们为了UNIX的标准化做出了种种努力,这使得程序在不同版本的UNIX系统之间的移植相当容易。

ISO C

1989年,C语言首个标准得到批准,其为C89。次年,一个带有小改动的版本标准被批准其为C90。因此,C89和C90通常指同一种语言。在2000年三月,ANSI采纳了ISO/IEC 9899:1999标准。这个标准通常指C99。在2011年12月,ANSI采纳了ISO/IEC 9899:2011标准。这个标准通常即C11,它是C程序语言的现行标准。

按照ISO C标准定义的头文件将C语言公用函数库划分成了24个部分。POSIX.1标准包括这些头文件以及一些额外的头文件。

IEEE POSIX

POSIX是由IEEE制订的一系列标准,其指的是可移植操作系统接口,它说明的是接口而不是实现。

Single UNIX Specification

Single UNIX Specification简称SUS,它是POSIX.1标准的一个超集。

UNIX系统实现

ISO C、IEEE POSIX、Single UNIX Specification是三个不同的组织,第一个组织负责对C语言进行标准化,后两个组织负责对UNIX系统接口进行标准化。这三个组织制定了概念上的规范,但实现是由厂商进行的。几个知名实现发行版如下:

  • SVR4:AT&T实现
  • BSD:加州大学伯克利分校实现
  • FreeBSD:FreeBSD志愿者
  • Linux:Minix改写而来,志愿者维护
  • Mac OS X:Apple公司
  • Solaris:SUN公司

UNIX系统中既有编译器的限制,又有UNIX实现(与编译程序无关)有关的限制。这两种限制有交集部分,也有差集部分,比如int类型的字节长度,和编译器有关,和UNIX系统无关,但通常编译器会参考操作系统的实现来决定。而程序能打开文件描述符的数量和UNIX实现有关,而和编译器无关。对于操作系统的种种限制,UNIX系统提供了3个库函数来查看。其头文件及函数原型如下:

#include 

long int sysconf (int name);
long int pathconf (const char* path, int name);
long int fpathconf (int fd, int name);

对于上述3个函数,成功时返回相应值,失败则返回-1。对于pathconf( )、fpathconf( )函数来说,第一个参数并不是总是有意义的,当需要查询一个文件的链接数时,第一个参数总是需要的,当查询系统的文件名长度限制时,第一个参数是无意义的,只要不为空即可。

第三章 文件I/O

第四章 文件和目录

4.2 四个stat函数

#include
int stat(const char * restrict pathname, struct stat*restrict buf);
int fstat(int fd, struct stat* buf);
int lstat(const char* restrict pathname,struct stat *restrict buf);
int fstatat(int fd,const char*restrict pathname,struct stat*restrict buf,int flag);    //返回文件或者目录的信息结构
  • 参数:

    • pathname:文件或者目录的名字
    • buf:存放信息结构的缓冲区
    • fd:打开的文件描述符
      • 对于fstat,该文件就是待查看信息的文件
      • 对于fstatat,该文件是并不是待查看信息的文件。待查看信息的文件时已该fd对于的目录相对路径定位的
    • flag:控制着fstatat函数是否跟随一个符号链接

    对于fstatat函数:

    • 待查看的文件名是由fdpathname共同决定的。
      • 如果pathname是个绝对路径,则忽略fd参数
      • 如果pathname是个相对路径路径,且 fd=AT_FDCWD,则在当前工作目录的路径下查找pathname
      • 如果pathname是个相对路径路径,且 fd!=AT_FDCWD,则在fd对应的打开目录下查找pathname
    • flag:控制着fstatat函数是否跟随一个符号链接。当!AT_SYMLINK_FOLLOW标志被设置时,查看的是pathname(如果它是个符号链接)本身的信息;否则默认查看的是pathname(如果它是个符号链接)链接引用的文件的信息。
  • 返回值:

    • 成功:返回 0
    • 失败: 返回 -1

注意:

  • lstat类似于stat,但是当pathname是个符号链接时,lstat查看的是该符号链接的有关信息;而stat是查看该符号链接引用的文件的信息。
  • ubuntu 16.04上,虽然有 AT_SYMLINK_NOFOLLOW这个常量,但是不支持。必须用 !AT_SYMLINK_FOLLOW。其常量定义为:
    • AT_SYMLINK_FOLLOW: 1024 (有效)
    • !AT_SYMLINK_FOLLOW: 0(有效)
    • AT_SYMLINK_NOFOLLOW: 256(无效)
    • AT_SYMLINK_FOLLOW: -1025(无效)

stat数据结构:其定义可能与具体操作系统相关,但是基本形式为:

struct stat{
mode_t 			st_mode; 	//文件权限和类型信息
ino_t 			st_ino;		//i-node 号
dev_t 			st_dev;		// 设备号
dev_t 			st_rdev;	// 特殊文件的设备号
nlink_t 		st_nlink;	// 硬链接数量
uid_t 			st_uid;		// owner 的用户ID
gid_t 			st_gid;		// owner 的组ID
off_t 			st_size;	//对普通文件,它是文件字节大小
struct timespec st_atime;	// 上次访问时间
struct timespec st_mtile;	// 上次修改时间
struct timespec st_ctime;	// 上次文件状态改变的时间
blksize_t 		st_blksize;	// 最佳的 I/O block 大小
blkcnt_t 		st_blocks;	//分配的磁盘块数量
}

其中timespec结构与具体操作系统相关,但是至少包括下面两个字段:

struct timespec{
time_t tv_sec;	// 秒
long tv_nsec; 	//纳秒
	}

4.3UNIX 文件类型:

  • 普通文件:最常见的文件类型,这种文件包含了某种形式的数据。至于这种数据是二进制还是文本,对内核无区别。普通文件的内容解释由具体的应用程序进行。

  • 目录文件:这种文件包含了其他文件的名字,以及指向这些文件有关信息的指针。

    • 只有内核可以直接写目录文件(通常用户写目录文件都要通过内核)
    • 对某个目录文件具有读权限的任何一个进程都可以读取该目录的内容
  • 块特殊文件:这种类型的文件提供对设备(如磁盘)带缓冲的访问。每次访问以固定长度为单位进行。

  • 字符特殊文件:这种类型的文件提供对设备不带缓冲的访问,每次访问长度可变。

    系统的所有设备,要么是字符特殊文件,要么是块特殊文件

  • FIFO:这种类型的文件用于进程间通信,有时也称为命名管道

  • 套接字:这种类型的文件用于进程间的网络通信(也可用于单机上进程的非网络通信)

  • 符号链接:这种类型的文件指向另一个文件

文件类型信息存放在stat.st_mode成员中,可以用下列的宏测试文件类型:

  • S_ISREG():测试是否普通文件
  • S_ISDIR():测试是否目录文件
  • S_ISCHR():测试是否字符特殊文件
  • S_ISBLK():测试是否块特殊文件
  • S_ISFIFO():测试是否FIFO
  • S_ISLNK():测试是否符号链接文件
  • S_ISSOCK():测试是否套接字

另外 POSIX.1 允许将进程间通信对象说明为文件。但是下面的宏测试的不是stat.st_mode,而是stat*stat指针):

  • S_TYPEISMQ():测试是否消息队列
  • S_TYPEISSEM():测试是否信号量
  • S_TYPEISSHM():测试是否共享存储对象

4.4 设置用户ID和组ID

  1. 与一个进程有关的ID有很多:

    • 实际用户 ID 和实际组 ID: 标志我们究竟是谁。当我们登录进操作系统时,这两个值就确定了!
    • 有效用户 ID、有效组ID、附属组 ID: 用于文件访问权限检查。
    • 保存的设置用户ID、保存的设置组ID:由 exec函数保存

    每个文件都有一个所有者和组所有者,分别有 stat.st_uidstat.st_gid指定。当一个文件为可执行文件时,如果执行这个文件,那么进程的有效用户ID就是实际用户ID,有效组ID就是实际组ID,除了下面的情况:

    • 当在stat.st_mode中设置了一个特殊标志:设置用户ID位时,则将进程的有效用户ID设置为文件所有者的用户ID
    • 当在stat.st_mode中设置了一个特殊标志:设置组ID位时,则将进程的有效组ID设置为文件所有者的组ID

    任何进程都是由可执行文件被执行而得到。因此位于磁盘上的可执行文件的所属的用户ID和组ID会影响到进程的用户ID和组ID

    如果某个可执行文件所有者是root,且该文件的设置用户ID位已经被设置,那么无论谁执行这个可执行文件时,该可执行文件产生的进程就具有超级用户权限。

    设置用户ID位、设置组ID位 都包含在stat.st_mode中,可以通过下列两个宏测试:

    • S_ISUID():测试是否设置了设置用户ID位
    • S_ISGID():测试是否设置了设置组ID位

4.5 文件访问权限

  1. 文件访问权限:所有文件类型(包括目录,字符特别文件等)都有访问权限。每个文件都有9个访问权限位:
  • S_IRUSR:用户读
  • S_IWUSR:用户写
  • S_IXUSR:用户执行
  • S_IRGRP:组读
  • S_IWGRP:组写
  • S_IXGRP:组执行
  • S_IROTH:其他读
  • S_IWOTH:其他写
  • S_IXOTH:其他执行

访问权限规则:

  • 当用名字pathname打开任何一个类型的文件时,对pathname中包含的每一个目录,包括pathname可能隐含的当前工作目录都应该具有执行权限

    因此目录的执行权限位也称之为搜索位

  • 对一个文件的读权限决定了我们能否打开现有文件进行读操作

  • 对一个文件的写权限决定了我们能否打开现有文件进行写操作

  • 如果你在open函数中对一个文件指定了O_TRUNC标志,则必须对该文件具有写权限

  • 为了在一个目录中常见一个新文件,必须对该目录具有写权限和执行权限

  • 为了删除一个现有文件,必须对包含该文件的目录具有写权限和执行权限。对该文件本身没有权限的限制

  • 如果用7个exec函数中的任何一个执行某个文件,则必须对该文件具有执行权限,且该文件必须是个普通文件

进程每次打开、创建、删除一个文件时,内核就进行文件访问权限测试。这种测试如下:

  • 若进程的有效用户ID是0(超级用户),则对该文件的任何访问行为都批准
  • 若进程的有效用户ID等于文件的所有者ID(也就是进程拥有此文件):
    • 如果该文件的用户读权限开放,则内核允许进程读该文件
    • 如果该文件的用户写权限开放,则内核允许进程写该文件
    • 如果该文件的用户执行权限开放,则内核允许进程执行该文件
  • 若进程的有效组ID或者进程的附属组ID之一等于文件的组ID:
    • 如果该文件的组读权限开放,则内核允许进程读该文件
    • 如果该文件的组写权限开放,则内核允许进程写该文件
    • 如果该文件的用户执行权限开放,则内核允许进程执行该文件
  • 否则:
    • 如果该文件的其他读权限开放,则内核允许进程读该文件
    • 如果该文件的其他写权限开放,则内核允许进程写该文件
    • 如果该文件的其他户执行权限开放,则内核允许进程执行该文件

只要有一个权限通过,则不再进行测试。若所有权限都不通过,则不允许访问。

  1. 对一个目录的读权限和可执行权限是不同的:
  • 目录读权限:允许读目录,从而获得在该目录中所有文件名的列表
  • 目录可执行权限:允许搜索该目录,从而寻找一个特定的文件名

4.6 新文件和目录的所有权

  1. 当一个进程通过open或者creat创建一个新文件时:
  • 新文件的用户ID被设置为进程的有效用户ID

  • 新文件的组ID可以有两个值之一:

    • 进程的有效组ID
    • 文件所在目录的组ID

    具体选择哪个,由具体操作系统决定

4.7 函数access 和fasccessat

  • 用途是按实际用户ID和实际组ID进行访问权限测试
#include
int access(const char *pathname ,int mode);
int faccessat(int fd, const chae *pathname, int mode, int flag);
成功返回0,失败返回-1

4.8 函数umask

  • 用途是给进程设置文件模式创建屏蔽字,并返回之前的值(少数几个没有出错返回值的函数之一)
#include
mode_t umask(mode_t cmask);
返回值:之前的文件模式创建屏蔽字

4.9 函数chomd fchmod 和 fchmodat

  • 用途是更改现有文件的访问权限
#include
int chmod(const char *pathname,mode_t mode);
int fchmod(int fd,mode_t mode);
int fchmodat(int fd,mode_t mode);
int fchmodat(int fd,const char *pathname,mode_t mode,int flag);
成功返回0,失败返回-1

4.10黏着位

  • 黏着位的设置首先是为了标记最近调入内存的程序,从而可以在下次运行时快速从交换区调入。
  • 现在的操作系统拓展了黏着位使用范围,如 Single Unix Specification针对目录设置黏着位,则只有对该目录具有写权限并满足下列条件之一,才能删除或者重命名该目录下的文件:拥有该文件;拥有该目录;是超级用户。

4.11 函数chown fchown fchownat lchown

  • 用途是更改文件的用户ID和组ID,如果两个参数中owner或group中任一个为-1,则对应ID不变
#include
int chown(const char*j pathname,uid_t owner,gid_t group);
int fchown(int fd,uid_t owner,gid_t group);
int fchownat(int fd,const char *pathname,uid_t owner,gid_t group,int flag);
int lchown(const char *pathname,uid_t owner,gid_t group);
成功返回0,失败返回-1

基于BSD的系统一直规定只有超级用户才可以更改用户ID,但system V允许任意用户更改他们所拥有的文件的组ID,但只能改到你所属的组。unistd.h中的_POSIX_CHOWM_RESTRICTED常量可以触发这一规定。

4.12 文件长度

stat结构体中的st_size表示以字节为单位的文件长度,此字段只对普通文件,目录文件,和符号链接有意义。

大多数Unix系统提供字段st_blksizest_blocks来表明当前系统文件I/O较合适的块长度和块数。

文件的空洞:文件的物理结构到底是咋整的?

4.13 文件截断

函数truncate和ftruncate可以实现在文件任意偏移处截断文件

将一个文件截断为0,可以有两种方法:在打开文件时使用O_TRUNC标志;

#include
int truncate(const char *pathname,off_t length);
int ftruncate(int fd,off_t length);

4.14 文件系统

首先一块磁盘可以分为几个分区,每个分区内可以建立起不同的文件系统(Solaris支持多种不同类型的磁盘文件,传统基于BSD的unix采用UFS,PCFS,HSFS等)

文件系统是怎样建立起来呢?自举块+超级块+各柱面组,每个柱面组里面分为i节点组,和数据块(目录块和文件块)

i节点在nuix文件系统中地位很重要:

  • i节点数据类型是S_IFLNK(符号链接)
  • i节点中包含文件所有信息,stat结构中的大多信息取自i节点,只有文件名和i节点编号保存在目录项中。
  • i节点编号只能指向同一文件系统中的i节点,因此软连接不能跨越文件系统
  • 文件重命名的实质就是不改动i节点,把原来指向该节点的目录项删除,并新建

目录文件的链接计数:

即便是一个空目录也会有两个计数,一个是子目录.,一个是命名他的目录项;

然后就是目录中的每一个子目录都会使该目录链接计数加1

4.15 函数link、linkat、unlink、unlinkat、remove

  • 用途是创建或者删除指向现有文件的链接
#include
int link(const char *existingpath,const char *newpath);
int linkat(int efd,const char *existingpath,int nfd,const char *newpath,int flag);
int unlink(const char *pathname);
int unlinkat(int fd,const char *pathname,int flag);
成功返回0,失败返回-1

注意:

  1. 创建链接时实质干了这样的事:在你想创建链接的地方创建目录项然后增加i节点的链接计数;删除链接则相反,无论删除或者创建,都要让这两个动作成为原子操作。
  2. 既然你要动目录项,你必须对包含该目录项的目录具有写权限,特别的,当该目录设置了黏着位,除了具有写权限,还要有一下三条件之一:
    • 拥有该文件
    • 拥有该目录
    • 具有超级用户权限

4.16 函数rename和renameat

#include
int rename(const char *oldname,const char *newname);
int rename(int oldfd,const cahr *oldname,int newfd,int newfd,const cahr *newname)

第七章 进程环境

7.2 main函数

  1. 在调用main函数之前先调用一个特殊的启动例程,可执行程序文件将这个启动历程作为起始地址,这是由链接器做的。
  2. 启动例程从内核取得命令行参数和环境变量,然后执行。

7.3 进程终止

8种进程终止方式,其中5种为正常终止:

  1. 从main返回;
  2. 调用exit;
  3. 调用_exit或 _Exit;
  4. 最后一个线程从其启动例程返回
  5. 从最后一个线程调用pthread_exit

异常终止的3种方法:

  1. 调用abort;
  2. 接到一个信号;
  3. 最后一个线程对取消请求做出响应;

1.退出函数

#include 
void exit(int status);
void _Exit(int status);
#include ;
void _exit(int status);

3个函数用于正常终止一个程序,_exit和 _Exit立即进入内核,exit则先执行一些清理处理,然后返回内核。

  1. 函数atexit
#include
int atexit(void (*func)(void));

exit函数不是要进行清理处理嘛,这些清理处理程序称为终止处理程序,每个被调用的终止处理程序都要在atexit处登记。

7.4命令行参数

就是说shell可以在执行程序时将参数以命令行的方式传递给程序

7.5 环境表

环境表是一个字符指针数组,全局变量environ包含了该指针数组的地址

7.5 C程序的存储空间布局

正文段(.text) 数据段(.date) 未初始化数据(.bss)堆 栈

32位x86处理器一般是这样的,正文从0x08048000开始,栈底在0xC0000000之下

当然一个可执行文件还包括符号表,调试信息段,动态库段等,但是这些不装载到进程执行的程序映像中。

7.7共享库

共享库的优点是可以节省可执行文件体积,共性库新老版本升级时只要接口不变就不用重新连接,缺点是增加运行时的时间开销

7.8 存储空间分配

#include
void *malloc(size_t size);
void *calloc(size_t nobj,size_t size);
void *realloc(void *ptr,size_t newsize);

这些函数都是基于sbrk(2)系统调用实现,而且都返回通用指针 *void

替代的存储分配程序

各个环境下都有实现

7.9 环境变量

环境变量的获取函数

#include 
char *getenv(const char *name);

环境变量的设置函数

#include
int putnev(char *str);
成功返回0,出错返回非0
int setenv(const char *name,const char *value,int rewrite);
int unsetenv(const char *name);
成功返回0,出错返回-1

环境变量存在进程地址空间顶部,也就是栈空间之上,这里的空间使用需要很谨慎。

7.10 函数setjmp和longjmp

#include
int setjmp(jmp_buf env);
void longjmp(jmp_buf env,int val);
配合使用的,当直接调用时返回0,从longjmp返回时为val

setjmp函数的参数是一个数组,其中存放在调用longjmp时能用来回复栈状态的所有信息(setjmp执行在前怎么能记下以后调用的对战情况呢?)

当执行longjmp跳转后,存储器中的变量将具有longjmp时的值,但是CPU和浮点寄存器的变量则恢复为调用setjmp时的值。

自动变量的潜在问题:
就是一个函数一旦返回,那么他原来的栈帧将不能为他所用,但是考虑这样一种情况,如果这个函数在栈帧中开辟了一个空间当作流缓冲,他退出后,标准I/o仍然在使用这块流缓冲区,就会产生冲突,

如何避免?想这样的缓冲区就不应该建在栈空间,而是应该在全局存储空间。

7.11 函数getlimit和setrlimit

#include
int getrlimit(int resource,struct rlimit *rlptr);
int setrlimit(int resource,const struct rlimit *rlptr)
成功返回0,出错返回非0
struct rlimit{
	rlim_t rlim_cur;      //软限制
	rlim_t rlim_max;	 //硬限制
	};

两个函数指向资源和结构体,

资源的限制子进程继承父进程

8 进程控制

8.2进程标识

每个进程都有一个非负整数表示的唯一进程 ID

  • 所谓的唯一,即当前正在系统中运行的所有进程的ID各不相同
  • 当一个进程A终止后,它的进程 ID 可以复用
    • 大多数UNIX系统实现的是延迟复用算法,使得新进程BID不同于最近终止的进程AID
  • 系统中有一些专用的进程
    • ID为0的进程通常是调度进程,也称作交换进程。该进程是操作系统内核的一部分,并不执行任何磁盘上的程序,因此也称作是系统进程
    • ID为1的进程通常是init进程,在自举过程结束时由内核调用。
      • 该进程对应的程序文件为/etc/init,在较新的版本中是/sbin/init文件
      • 该进程负责在自举内核后启动一个UNIX系统
      • 该进程通常读取与系统有关的初始化文件(/etc/rc*文件,/etc/inittab文件以及/etc/init.d中的文件),并经系统引导到一个状态
      • 该进程永远不会终止
      • 该进程是一个普通的用户进程(不是内核中的系统进程),但是它以超级用户特权运行H

获取进程标识

#include
pid_t getpid(void);  // 返回值:调用进程的进程ID
pid_t getppid(void); // 返回值:调用进程的父进程ID
uid_t getuid(void);  // 返回值:返回进程的实际用户ID
uid_t geteuid(void); // 返回值:返回进程的有效用户ID
gid_t getgid(void);  // 返回值:返回进程的实际组ID
gid_t getegid(void); // 返回值:返回进程的有效组ID

这些函数都没有出错返回

8.3 函数fork

#include
pid_t fork(void);
子进程返回0,父进程返回子进程ID

注意:

  • 如果fork调用成功,则它被调用一次,但是返回两次。 两次返回的区别是:子进程的返回值是0,父进程的返回值是新建子进程的进程ID

    • 子进程返回值是 0 的理由:一个进程总可以通过getpid知道它的进程ID,通过getppid知道它的父进程的ID
    • 父进程返回值是子进程的进程ID的理由:一个进程的子进程可以有多个,但是并没有函数可以获取它的子进程的ID
  • 子进程是父进程的一份一模一样的拷贝,如子进程获取了父进程数据空间、堆、栈的副本。

    • 父子进程共享正文段(因为正文段是只读的)
    • 父子进程并不共享这些数据空间、堆、栈
  • 子进程和父进程都从fork调用之后的指令开始执行。也就是子进程从出生开始,就跟父进程处于同样的状态

  • 由于创建子进程的目的通常是为了完成某个任务,因此fork之后经常跟随exec,所以很多操作系统的实现并不执行一个父进程数据段、堆和栈的完全拷贝,而是使用写时赋值技术(copy-on-write:COW

    • 这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读
    • 如果父子进程中有一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本
  • 通常fork之后,是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的进程调度算法

  • 注意标准IO库的跨fork行为。由于标准IO库是带缓冲的,因此在fork调用之后,这些缓冲的数据也被拷贝到子进程中

  • 父进程的所有打开的文件描述符都被复制到子进程中。父进程和子进程每个相同的打开描述符共享同一个文件表项

    • 更重要的是:父进程和子进程共享同一个文件偏移量
    • 如果父进程和子进程写同一个描述符指向的文件,但是又没有任何形式的同步,则它们的输出会相互混合
      • 如果父进程fork之后的任务就是等待子进程完成,而不作任何其他的事情,则父进程和子进程无需对打开的文件描述符做任何处理。因为此时只有子进程处理文件
      • 如果父进程fork之后,父进程与子进程都有自己的任务要处理,则此时父进程和子进程需要各自关闭它们不需要使用的文件描述符,从而避免干扰对方的文件操作
  • 除了打开的文件描述符之外,子进程还继承了父进程的下列属性:实际用户ID、实际组ID、有效用户ID、有效组ID、附属组ID、进程组ID、会话ID、控制终端、设置用户ID标志和设置组ID标志、当前工作目录、根目录、文件模式创建屏蔽字、信号屏蔽和信号处理、对任一打开文件描述符的执行时关闭标志、环境、连接的共享存储段、存储映像、资源限制

  • 父进程和子进程的区别为:

    • fork返回值不同
    • 进程ID不同
    • 进程父进程ID不同
    • 子进程的tms_utime,tms_stime,tms_cutime,tms_ustime的值设置为0
    • 子进程不继承父进程设置的文件锁
    • 子进程的未处理闹钟被清除
    • 子进程的未处理信号集设置为空集
  • fork失败的零个主要原因:

    • 系统已经有了太多的进程
    • 实际用户ID的进程总数超过了系统的限制(CHILD_MAX规定了每个实际用户ID在任何时刻拥有的最大进程数)

示例: 在main函数中调用test_fork 函数:

void test_fork()
{
 M_TRACE("---------  Begin test_fork()  ---------\n");
 assert(prepare_file("test","abc",3,S_IRWXU)==0);
 int fd=My_open("test",O_RDWR);
 if(-1==fd)
 {
     un_prepare_file("test");
     M_TRACE("---------  End test_fork()  ---------\n\n");
     return;
 }
 //****** 打开文件成功 *************//
 pid_t id=fork();
 if(0==id)
 { // child 1
     prgress_func(fd,"**********In Child 1***********");
     _exit(0);
 }
 sleep(2); // 确保父进程在子进程之后执行
 id=fork();
 printf("This is in the second fork\n");
 if(0==id)
 {// child 2
     prgress_func(fd,"**********In Child 2***********");
     _exit(0);
 }
 sleep(2);  // 确保父进程在子进程之后执行
 prgress_func(fd,"**********In Parent***********");

 close(fd);
 un_prepare_file("test");
 M_TRACE("---------  End test_fork()  ---------\n\n");
}

可以看出:

  • 子进程和父进程的执行顺序是不确定的
  • 由于标准IO库是带缓冲的,因此在fork调用之后,这些缓冲的数据也被拷贝到子进程中,因此"This is in the second fork"被输出两次

fork有两种用法:

  • 父进程希望复制自己,使父进程和子进程同时执行不同的代码段。在网络服务中很常见:父进程等待请求,然后调用fork并使子进程处理请求
  • 父进程要执行一个不同的程序。在shell是很常见。此时子进程从fork返回之后立即调用exec

8.4 函数vfork

vfork函数的调用序列和返回值与fork相同,但是二者语义不同:

  • vfork用于创建一个新进程,该新进程的目的是exec一个新程序,所以vfork并不将父进程的地址空间拷贝到子进程中。

    • vfork的做法是:在调用exec或者exit之前,子进程在父进程的空间中运行

    所以在exec或者exit之前,子进程可以篡改父进程的数据空间

  • vfork保证子进程优先运行,在子进程调用exec或者exit之后父进程才可能被调度运行

当子进程调用exec或者exit中的任何一个时,父进程会恢复运行,在此之前内核会使父进程处于休眠状态

main函数中调用test_vfork函数:

void test_vfork()
{
 M_TRACE("---------  Begin test_vfork()  ---------\n");
 assert(prepare_file("test","abc",3,S_IRWXU)==0);
 int fd=My_open("test",O_RDWR);
 if(-1==fd)
 {
     un_prepare_file("test");
     M_TRACE("---------  End test_fork()  ---------\n\n");
     return;
 }
 //****** 打开文件成功 *************//

 int i=0; // 用于测试父子进程是否共享同一个子空间
 int id=vfork();
 if(0==id)
 {//child
//        fcntl_lock(fd);  // 加锁
     printf("*********** In Child ***********\n");
     print_pid_ppid();
     printf("i=%d\n",i);
     i=999;
     printf("*********** In Child ***********\n");
//        fcntl_unlock(fd); // 解锁
     _exit(0);
 }else
 {//parent
//        fcntl_lock(fd);  // 加锁
     printf("*********** In Parent ***********\n");
     print_pid_ppid();
     printf("i=%d\n",i);
     printf("*********** In Parent ***********\n");
//        fcntl_unlock(fd); // 解锁
 }

 close(fd);
 un_prepare_file("test");
 M_TRACE("---------  End test_vfork()  ---------\n\n");
}

可以看出:

  • 子进程调用_exit(0)之前,父进程被阻塞;当子进程调用_exit(0)之后,父进程才开始执行
  • 子进程共享了父进程的进程空间,且可以访问修改父进程的进程空间

如果我们通过加锁让父进程先获得锁,则结果如下:

可以看出:虽然我们期望父进程先执行(因为父进程先获得锁?),但是实际上仍然是子进程先执行。vfork直接让父进程处于未就绪的状态,从而不会去获取记录锁。只有当子进程执行完_exit(0)时,父进程才就绪。

8.5 函数exit

进程有 8 种方式使得进程终止,其中 5 种为正常终止,3 种异常终止:

  • 正常终止方式:
    • main函数返回,等效于exit
    • 调用exit函数。exit会调用各终止处理程序,然后关闭所有标准IO流
    • 调用_exit函数或者_Exit函数。它们不运行终止处理程序,也不冲洗标志IO流
    • 多线程的程序中,最后一个线程从其启动例程返回。但是该线程的返回值并不用做进程的返回值,进程是以终止状态 0 返回的
    • 多线程的程序中,从最后一个线程调用pthread_exit函数。进程也是以终止状态 0 返回的
  • 异常终止方式:
    • 调用abort函数。它产生SIGABRT信号
    • 接收到一个信号
    • 多线程的程序中,最后一个线程对取消请求作出响应

更进一步的:

  • 不管进程如何终止,最后都会执行内核中的同一段代码:这段代码为相应进程关闭所有打开的描述符(不仅仅是文件描述符),释放它所使用的内存。

  • 不管进程如何终止,我们需要有一种方法来通知父进程,本进程是如何终止的。

    • 对于exit,_exit,_Exit这三种情况:将本进程的退出状态作为参数传给函数,并且在最后调用_exit时,内核将退出状态转换成终止状态

    exit函数和_Exit函数最终调用的是_exit函数

    • 对于异常终止情况,内核产生一个指示异常终止原因的终止状态

    在任意一种情况下,终止进程的父进程都能够用wait或者waitpid函数取得终止状态。然后父进程能够检测终止状态。如果发现子进程是正常终止,则可以从终止状态中提取出退出状态

  1. 如果父进程在子进程之前终止,那么内核会将该子进程的父进程改变为init进程,称作由init进程收养。其原理为:

    • 在一个进程终止时,内核逐个检查所有活动进程,以判断这些活动进程是否是正要终止的进程的子进程
    • 如果是,则该活动进程的父进程ID就改为 1

    这种方式确保了每个进程都有一个父进程

  2. 内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait函数或者waitpid函数时,可以得到这些信息。

    • 这些信息至少包括:终止进程的进程ID、终止进程的终止状态、终止进程的使用的CPU时间总量
    • 内核此时可以释放终止进程使用的所有内存,关闭它所有的打开文件。但是该终止进程还残留了上述信息等待父进程处理
    • 我们称一个已经终止、但是等待父进程对它进行善后处理的进程称作僵死进程,在ps命令中显示为Z
      • 所谓善后处理,就是父进程调用wait函数或者waitpid函数读取终止进程的残留信息
      • 一旦父进程进行了善后处理,则终止进程的所有占用资源(包括残留信息)都得到释放,该进程被彻底销毁
    • 对于init超级进程,它被设计成:任何时候只要有一个子进程终止,就立即调用wait函数取得其终止状态。这种做法防止系统中塞满了僵死进程
  3. 当一个进程终止时,内核就向其父进程发送SIGCHLD信号。这种信号是一个异步信号,因为该信号可能在任何时间发出

    • 父进程可以选择忽略此信号。这是系统的默认行为
    • 父进程也可以针对此信号注册一个信号处理程序,从而当接收到该信号时调用相应的信号处理程序

8.6 函数 wait和waitpid

#include
pid_t wait(int *staloc);
pid_t waitpid(pid_t pid,int *staloc,int options);
成功返回进程ID,失败返回0-1

参数:

  • staloc:存放子进程终止状态的缓冲区的地址。如果你不关心子进程的终止状态,则可以设它为空指针NULL

对于waitpid函数:

  • pid

    • 如果pid==-1:则等待任意一个子进程终止
    • 如果pid>0:则等待进程ID等于pid的那个子进程终止
    • 如果pid==0:则等待组ID等于调用进程组ID的任一子进程终止
    • 如果pid<0:等待组ID等于pid绝对值的任一子进程终止
  • options:或者是0,或者是下列常量按位或的结果:

    • WNOHANG:没有指定的子进程终止时,并不阻塞程序的执行
    • WUNTRACED:执行作业控制。若操作系统支持作业控制,则由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态
    • WCONTINUED:执行作业控制。若操作系统支持作业控制,则由pid指定的任一子进程已处于停止状态,并且其状态自停止以来尚未报告过,则返回其状态

    进程的停止状态:类似于暂停。它不同于终止状态

这两个函数返回值的整形状态字(pid_t)是由实现定义,其中某些位表示退出状态(正常退出),其他位标识信号编号(异常返回),有一位指示是否产生了core文件。根据这个状态字再调用不同的宏来实现对子进程状态的解析。

注意:

  • wait的语义是等待任何一个子进程终止:

    • 如果当前进程的所有子进程都还在运行,则阻塞
    • 如果有一个子进程已终止,正在等待父进程获取其终止状态,则当前进程取得该子进程的终止状态并立即返回
    • 如果当前进程没有任何子进程,则立即出错返回
  • waitpid的语义是等待指定的子进程终止:

    • 如果当前进程的所有子进程都在运行:
      • 如果options指定为WNOHANG,则waitpid并不阻塞,而是立即返回 0
      • 如果options未指定为WNOHANG,则waitpid阻塞
    • 如果指定pid的子进程已终止,正在等待父进程获取其终止状态,则当前进程取得该子进程的终止状态并立即返回
    • 如果指定的pid有问题(如不存在,或者不是当前进程的子进程),则立即出错返回
  • 对于出错的情况:

    • wait出错的原因是:
      • 调用进程没有子进程
      • 函数调用(正在阻塞中)被一个信号中断
    • waitpid出错的原因是:
      • 指定的进程或者进程组不存在
      • pid指定的进程不是调用进程的子进程
      • 函数调用(正在阻塞中)被一个信号中断
  • 可以通过宏从终止状态中取得退出状态以及终止原因等:

    • WIFEXITED(status):如果子进程正常终止,则为真。此时可以执行WEXITSTATUS(status)获取子进程的退出状态的低 8 位
    • WIFSIGNALED(status):如果子进程异常终止,则为真。此时可以执行WTERMSIG(status)获取使得子进程终止的信号编号
    • WIFSTOPPED(status):如果子进程的当前状态为暂停,则为真。此时可执行WSTOPSIG(status)获取使得子进程暂停的信号编号
    • WIFCONTINUED(status):如果子进程在暂停后已经继续执行了,则为真。

main函数中调用test_wait_waitpid函数

void test_wait_waitpid()
{
 M_TRACE("---------  Begin test_wait_waitpid()  ---------\n");
 assert(prepare_file("test","abc",3,S_IRWXU)==0);
 int fd=My_open("test",O_RDWR);
 if(-1==fd)
 {
     un_prepare_file("test");
     M_TRACE("---------  End test_fork()  ---------\n\n");
     return;
 }
 //****** 打开文件成功 *************//

 prgress_func(fd,"**********Parent***********");
 if(0!=child_exit(fd,100))
 {// parent
     sleep(1); //确保父进程稍后执行
     if(0!=child_abort(fd))
     {//parent
         sleep(1); //确保父进程稍后执行
         if(0!=child_signal(fd))
         {
              sleep(1); //确保父进程稍后执行
              check_wait();   //only wait at parent (二选一)
              // check_waitpid(); // only wait at parent  (二选一)

              close(fd);
              un_prepare_file("test");
              M_TRACE("---------  End test_wait_waitpid()  ---------\n\n");
         }
     }
 }
}
  • 子进程的结束顺序跟它们派生的顺序没有什么关系。wait只会处理最先结束的子进程

  • 调用了_exit的子进程,属于正常终止;调用了abort和被信号终止的子进程属于异常终止

  • 通过waitpid可以严格控制取得终止子进程状态的顺序

  • 通过waitpid依次等待所有的子进程,可以确保父进程是最后一个结束的

8.7 函数waitid

waitid函数:它类似waitpid,但是提供了更灵活的参数

#include
int waitid(idtype_t idtype,id_t id,siginfo_t *infop,int options);
//成功返回0,失败-1
  • 参数:
    • idtype:指定了id类型,可以为下列常量
      • P_PID:等待特定进程。此时id表示要等待的子进程的进程ID
      • P_GID:等待属于特定进程组的任一子进程。此时id表示要等待的进程组ID
      • P_ALL:等待任一子进程。此时忽略id
    • id:指定的进程id或者进程组id
    • infop:一个缓冲区的地址。该缓冲区由waitid填写,存放了造成子进程状态改变的有关信号的详细信息
    • options:指示调用者关心哪些状态变化。可以是下列常量的按位或:
      • WCONTINUED:等待这样的子进程:它以前曾被停止过,此后又继续执行,但是其状态尚未报告
      • WEXITED:等待已经终止的子进程
      • WNOHANG:如无可用的子进程终止状态,立即返回而不是阻塞
      • WNOWAIT:不破坏子进程的终止状态,该子进程的终止状态可以由后续的wait,waitid,waitpid调用取得
      • WSTOPPED:等待这样的子进程:它已经停止,但是其状态尚未报告

8.8 函数wait3和wait4

wait3/wait4函数:可以返回终止子进程及其子子进程的资源使用情况

#include
#include
#include
#include
pid_t wait3(int *staloc,int options,struct rusage *rusage);
pid_t wait4(pid_t pid,int *staloc,int options,struct rusage *rusage);
//成功返回子进程ID,失败返回-1
  • 参数:

    • staloc:存放子进程终止状态的缓冲区的地址。如果你不关心子进程的终止状态,则可以设它为空指针NULL
    • rusage:一个缓冲区的地址,该缓冲区存放由wait3,wait4返回的终止子进程的资源统计信息,包括:用户CPU时间总量、系统CPU时间总量、缺页次数、接收到的信号的次数等

    pidoptions参数与waitpid相同

8.9 竞争条件

就是说父子进程之间会因为共享文件而造成竞争,可采用信号量,管道加以解决

8.10 函数exec

  1. 当进程调用一种exec函数时,该进程执行的程序完全替换成新程序,而新程序则从main函数开始执行

    • 调用exec前后,进程ID并未改变。因为exec并不创建新进程
    • exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段
  2. 有7种不同的exec函数可以供使用,它们被统称称作exec函数:

    #include
    int execl(const char *pathname,const char *arg0,.../*(char *) 0 */);
    int execv(const char *pathname,char *const argv[]);
    int execle(const char *pathname,const char *arg0,.../*(char *) 0
    		,char *const envp[] */);
    int execve(const char *pathname,char *const argv[],char *const envp[]);
    int execlp(const char *filename,const char*arg0,.../*(char *) 0*/);
    int execvp(const char *filename, char *const argv[]);
    int fexecve(int fd,char *const argv[],char *const evnp[]);
    //成功不返回,失败-1
    

    这几个函数的区别:

    • 前四个函数取路径名作为参数;后两个函数取文件名作为参数;最后一个取文件描述符做参数
      • filename中包含/,则视为路径名
      • filename不包含/,则按照PATH环境变量指定的各个目录中搜寻可执行文件
    • 函数execl,execlp,execle要求将新程序的每个命令行参数都说明为一个单独的参数,这种参数表以空指针结尾;函数execv,execvp,execve,fexecve应先构造一个指向各参数的指针数组,然后将该指针数组的地址作为参数
      • l表示列表list
      • v表示矢量vector
      • l形式中,必须以空指针结尾,否则新程序根本不知道要读取多少个参数。空指针就是命令行参数序列终止的标记
      • v形式中,数组的最后一个元素必须是空指针,否则报错。
    • e结尾的execle,execve,fexecve可以传递一个指向环境字符串指针数组的指针。注意这个数组的最后一个元素必须是空指针,否则报错。其他四个函数则使用调用进程的environ变量为新程序复制现有的环境

    注意:

    • 操作系统对参数表和环境表的总长度有一个限制。在POSIX中,这个值至少是 4096 字节
    • 执行exec之后,新程序的进程ID不变,进程的大多数属性不变。但是对打开文件的处理要注意:
      • 进程中每个打开的文件描述符都有一个执行时关闭标志。若设置了此标志,则执行exec时会关闭该文件描述符;否则该文件描述符仍然保持打开。系统默认行为是不设置执行时关闭标志
    • 执行exec之后,进程的实际用户 ID 和实际组 ID不变,但是进程的有效用户 ID 要注意:
      • 进程的有效用户 ID 和有效组 ID 是否改变取决于所执行程序文件的设置用户 ID 和设置组 ID 位是否设置。
        • 若程序文件的设置用户 ID 位已设置,则进程的有效用户 ID 变成程序文件所有者的 ID;否则有效用户 ID 不变
        • 若程序文件的设置组 ID 位已设置,则进程的有效组 ID 变成程序文件所有组的 ID;否则有效组 ID 不变
    • 在很多UNIX操作系统中,这7个函数只有execve是内核的系统调用。另外 6 个只是库函数。它们最终都要调用该系统调用
  3. main函数中调用test_exec 函数

    void test_exec()
    {
     M_TRACE("---------  Begin test_exec()  ---------\n");
     char buffer[1024];
     getcwd(buffer,1024);
     char *pathname=abs_path(buffer,"print_arg");
    
     _test_execl(pathname); // 绝对路径名
     _test_execv(pathname); // 绝对路径名
     _test_execle(pathname); // 绝对路径名
     _test_execve(pathname); // 绝对路 径名
     _test_execlp("print_arg"); //相对文件名
     _test_execvp("print_arg"); //相对文件名
     M_TRACE("---------  End test_exec()  ---------\n\n");
    }
    

    这里调用的print_arg程序非常简单,就是打印环境变量和命令行参数。其程序如下:

    //************* print_arg 程序的源代码 **********//
    //                                             //
    // 该程序的功能是打印环境变量以及参数列表            //
    //                                            //
    //********************************************//
    #include 
    #include
    #include
    extern char **environ;
    void print_environ()
    {
     printf("\t************Environment***************\n");
     char **ptr=environ;
     while(*ptr!=NULL)
     {
         printf("\t'%s'",*ptr);
         ptr++;
     }
     printf("\n");
    }
    int main(int argc, char *argv[])
    {
     printf("\t************Argument List***************\n");
     for(int i=0;i<argc;i++)
     {
         printf("\t'%s'",argv[i]);
     }
     printf("\n");
     print_environ();
     return 0;
    }
    

    编译print_arg程序后,将它放置在目录build-APUE-Desktop_Qt_5_5_1_GCC_64bit-Debug下,并且向PATH中添加主目录路径。在shell中输入命令:

    PATH=$PATH:/home/huaxz1986/build-APUE-Desktop_Qt_5_5_1_GCC_64bit-Debug
    

    这是因为execlpexecvp需要PATH环境变量中寻找filename。如果未添加合适的路径,则程序提示指定的文件不存在。

    最终调用结果输出到文件,内容为(省略号中为环境变量内容,因太长所以这里只截取一部分):

    ---------  Begin test_exec()  ---------
    ---------  End test_exec()  ---------
    
    ************Argument List***************
    'execle_arg1'	'execle_arg2'
    ************Environment***************
    'execle_env1=1'	'execle_env2=2'
    ************Argument List***************
    'execv_arg1'	'execv_arg2'
    ************Environment***************
    'XDG_VTNR=7'	'LC_PAPER=zh_CN.UTF-8'	'LC_ADDRESS=zh_CN.UTF-8'	
    ...	
    'LC_NAME=zh_CN.UTF-8'	'XAUTHORITY=/home/huaxz1986/.Xauthority'	'_=./APUE'
    ************Argument List***************
    'execve_arg1'	'execve_arg2'
    ************Environment***************
    'execve_env1=1'	'execve_env2=2'
    ************Argument List***************
    'execvp_arg1'	'execvp_arg2'
    ************Environment***************
    'XDG_VTNR=7'	'LC_PAPER=zh_CN.UTF-8'	'LC_ADDRESS=zh_CN.UTF-8'	
    ...	
    'LC_NAME=zh_CN.UTF-8'	'XAUTHORITY=/home/huaxz1986/.Xauthority'	'_=./APUE'
    ************Argument List***************
    'execlp_arg1'	'execlp_arg2'
    ************Environment***************
    'XDG_VTNR=7'	'LC_PAPER=zh_CN.UTF-8'	'LC_ADDRESS=zh_CN.UTF-8'	
    ...	
    'LC_NAME=zh_CN.UTF-8'	'XAUTHORITY=/home/huaxz1986/.Xauthority'	'_=./APUE'
    ************Argument List***************
    'execl_arg1'	'execl_arg2'
    ************Environment***************
    'XDG_VTNR=7'	'LC_PAPER=zh_CN.UTF-8'	'LC_ADDRESS=zh_CN.UTF-8'	
    ...	
    'LC_NAME=zh_CN.UTF-8'	'XAUTHORITY=/home/huaxz1986/.Xauthority'	'_=./APUE'
    

    可以发现:

    • execl/execv/execvp/execlp继承了父进程的环境变量;execle/execve指定了环境变量
    • execvp/execlpPATH中正确搜索到了可执行文件
    • execv/execvp/execve指定的参数表数组必须以空指针结尾,否则exec失败
    • execle/execve指定的环境变量数组必须以空指针结尾,否则exec失败
  4. PATH环境变量包含了一张目录表,称作路径前缀。目录之间用冒号:分隔。如PATH=/bin:/usr/bin:.

    • .表示当前目录
    • 零长前缀也表示当前目录。在起始处,零长前缀为:xxx,在中间,零长前缀为xxx::xxx,在行尾,零长前缀为xxx:
  5. 基本的进程控制原语:

    • fork创建进程
    • exec初始化执行新的程序
    • exit终止进程
    • wait等待子进程终止

8.11 更改用户ID和更改组ID

  1. 在设计应用程序时,应该使用最小特权模型:程序应当只具有为完成给定认为所需的最小的特权

    • 当进程需要增加特权或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或者组ID,使得新ID具有合适的特权或者访问权限
    • 当前进程需要降低其特权或者阻止对某些资源的访问时,也需要更换用户ID或者组ID,新ID不具有相应的特权
    • 进程在大部分时候都是最低特权运行。只有到必要的时候提升特权访问资源,一旦资源访问完毕立即降低特权
  2. setuid/setgid函数:设置实际用户ID和有效用户ID/ 实际组ID和有效组ID

    #include
    int setuid(uid_t uid);
    int setgid(gid_t gid);
    
    • 参数:
      • uid:待设置的用户ID
      • gid:待设置的组ID
    • 返回值:
      • 成功: 返回 0
      • 失败: 返回 -1

    设置的规则为:

    • 如果进程具有超级用户特权,则setuid函数将实际用户ID,有效用户ID以及保存的设置用户ID(saved set-user-ID) 全部设置为uid(此时uid没有限制)
    • 如果进程没有超级用户特权,但是uid等于实际用户ID或者保存的设置用户ID,则setuid只会将有效用户ID设置为uid,不改变实际用户ID和保存的设置用户ID
    • 如果上面两个条件都不满足,则errno设置为EPERM并返回 -1
    • 上述讨论中,假设_POSIX_SAVED_IDS为真。如果未提供此功能,则对于保存的设置用户ID部分都无效
    • 针对setgid的讨论类似setuid
  3. 操作系统内核为每个进程维护3个用户ID:实际用户ID、有效用户ID、保存的设置用户ID

    • 只有超级用户进程可以更改实际用户ID
      • 通常是基用户ID是在用户登录时,由login程序设置的,而且绝不会改变它。login是一个超级用户进程,当它调用setuid时,设置所有的3个用户ID
    • 仅当对程序文件设置了设置用户ID时,exec函数才设置有效用户ID。如果程序文件的设置用户ID位没有设置,则exec函数不会改变有效用户ID,而是维持其现有值
      • 任何时候都可以调用setuid将有效用户ID设置为实际用户ID或者保存的设置用户ID
      • 调用setuid时,有效用户ID不能随意取值,只能从实际用户ID或者保存的设置用户ID中取得
    • 保存的设置用户ID是由exec复制有效用户ID而得到。如果设置了程序文件的设置用户ID位,则exec根据文件的用户ID设置了进程的有效用户ID之后,这个副本就保存起来
    • 目前可以通过getuid获取进程的当前实际用户ID,可以通过geteuid获取进程的当前有效用户ID,但是没有函数获取进程当前的保存的设置用户ID
  4. POSIX提供了两个函数:

    #include
    int seteuid(uid_t uid);
    int setegid(gid_t gid);
    
    • 参数:
      • uid:待设置的有效用户ID
      • gid:待设置的有效组ID
    • 返回值:
      • 成功: 返回 0
      • 失败: 返回 -1

    seteuid只修改进程的有效用户IDsetegid只修改进程的有效组ID

    • 如果进程具有超级用户权限,则seteuid将设置进程的有效用户IDuid(此时uid没有限制)
    • 如果进程没有超级用户权限,则seteuid只能将进程的有效用户ID设置为它的实际用户ID或者保存的设置用户ID
    • 针对setegid的讨论类似seteuid
  5. getlogin:获取运行该程序的用户的登录名

    #include
    char *getlogin(void);
    
    • 返回值:
      • 成功:返回指向登录名字符串的指针
      • 失败:返回NULL

    通常失败的原因是:进程的用户并没有登录到系统。比如守护进程。

  6. 示例:在main函数中调用test_setuid_seteuid函数:

    void test_setuid_seteuid()
    {
     M_TRACE("---------  Begin test_setuid_seteuid()  ---------\n");
     struct passwd* result=My_getpwnam("huaxz1986");
     if(NULL==result)
     {
         M_TRACE("---------  End test_setuid_seteuid()  ---------\n\n");
         return;
     }
    
     My_getlogin();
     printf("\n********** Before set id **********\n");
     print_uid();
     print_gid();
     print_euid();
     print_egid();
     printf("\n********** After set id **********\n");
     My_setuid(result->pw_uid); // 二选一
     My_setgid(result->pw_gid); // 二选一
     My_seteuid(result->pw_uid); // 二选一
     My_setegid(result->pw_gid); // 二选一
    //    My_setuid(0); // 二选一
    //    My_setgid(0); // 二选一
    //    My_seteuid(0); // 二选一
    //    My_setegid(0); // 二选一
     print_uid();
     print_gid();
     print_euid();
     print_egid();
     M_TRACE("---------  End test_setuid_seteuid()  ---------\n\n");
    }
    

    我们首先在普通用户状态下,将那些 id都设置成超级用户所属的用户ID和组ID

    然后,我们在超级用户状态下,将那些id都设置成普通用户的用户ID和组ID

    可以看到:

    • 普通进程无法将自己的用户ID和有效用户ID设置为超级用户root
    • 超级进程可以设置自己的用户ID和有效用户ID为任意值,但是无法修改组ID和有效组ID
    • 另外这里发现,无论在普通用户下还是超级用户下, getlogin都调用失败

    另外没有给出的截图是:超级进程一旦将自己的用户ID和有效用户ID设置为普通用户之后,该进程退化为普通进程

8.12 解释器文件

exec不仅可以执行二进制可执行文件,也可以执行解释器可执行文件。

  • 解释器可执行文件时文本文件,其首行格式为:

    #! /bin/sh
    

    其中/bin/sh(或者其他路径)通常是绝对路径名,对它不进行任何特殊的处理

  • 实际上exec不仅可以执行二进制可执行文件,也可以执行解释器可执行文件。

    • 解释器可执行文件时文本文件,其首行格式为:

      #! /bin/sh
      

      其中/bin/sh(或者其他路径)通常是绝对路径名,对它不进行任何特殊的处理

    • 实际上**exec执行的并不是解释器文件(它是个文本),而是由/bin/sh指定的二进制可执行文件**,然后/bin/sh以该解释器文件作为参数

    • 对解释器可执行文件的识别是由操作系统内核来完成的。该识别步骤是作为exec系统调用处理的一部分来完成的

    • 注意该解释器文件必须要有可执行权限。可以通过chmod a+x添加任意用户的可执行权限

  • 然后/bin/sh以该解释器文件作为参数

  • 对解释器可执行文件的识别是由操作系统内核来完成的。该识别步骤是作为exec系统调用处理的一部分来完成的

  • 注意该解释器文件必须要有可执行权限。可以通过chmod a+x添加任意用户的可执行权限

8.13 函数system

system`函数:在程序中执行一个命令字符串

#include
int system(const char *cmdstring);
  • 参数:
    • cmdstring:命令字符串(在shell中执行),如 "ps -aux"
  • 返回值:
    • 有三种返回值。见下面描述

system用于将一个字符作为命令来执行。它等同于同时调用了fork、exec、waitpid。有三种返回值:

  • fork失败或者waitpid返回除了EINTR之外的错误,则system返回 -1,并且设置errno以指示错误类型
  • 如果exec失败(表示不能执行shell),则其返回值如同shell执行了exit(127)一样
  • 如果三个函数都执行成功,则system返回值是shell的终止状态,其格式在waitpid中说明

system对操作系统依赖性很强。目前在UNIX操作系统上,system总是可用的。如果cmdstring为空指针,则如果system返回 0 表示该操作系统不支持system函数;否则支持。

system相较于fork+exec的优点是:system进行了所需的各种出错处理以及各种信号处理。缺点是:一旦调用system的进程具有超级用户权限,则system执行的命令也具有超级用户权限。

因为system的实现过程中并没有更改有效用户ID和实际用户ID的操作。

  • 因此如果一个进程以特殊的权限运行,而它又想生成另一个进程执行另外一个程序,则它应该直接使用fork_exec并且在fork之后,exec之前改回普通权限。
  • 设置用户ID和设置组ID程序绝不应该调用system函数

main函数中调用test_system函数:

void test_system()
{
 M_TRACE("---------  Begin test_system()  ---------\n");
 My_system("ls /home"); //命令存在
 My_system("ttttt"); // 不存在命令
 M_TRACE("---------  End test_system()  ---------\n\n");
}

注意:调用system后不再需要调用wait等进程控制原语了。这一切控制由system打包

8.14 进程会计

  1. 大多数UNIX系统提供了一个选项以进行进程会计处理

    • 启用该选项后,每当进程结束时内核就会写一个会计记录
    • 超级用户执行命令accton pathname则会启用会计处理,会计记录会写到pathname指定的文件中
      • 如果不带文件名参数,会停止会计处理
    • 会计记录文件是个二进制文件,包含的会计记录是二进制数据
  2. 会计记录结构定义在头文件中。虽然各个操作系统的实现可能有差别,但是基本数据如下:

    typedef u_short comp_t;
    struct acct
    {
    	char ac_flag;  	//标记
    	char ac_stat; 	//终止状态
    	uid_t ac_uid; 	//真实用户ID
    	gid_t ac_gid;	//真实组ID
    	dev_t ac_tty;	// 控制终端
    	time_t ac_btime;// 起始的日历时间
    	comp_t ac_utime;// 用户 CPU 时间
    	comp_t ac_stime;// 系统 CPU 时间
    	comp_t ac_etime;// 流逝时间
    	comp_t ac_mem;	// 平均内存使用
    	comp_t ac_io;	// `read`和`write`字节数量
    	comp_t ac_rw;	// `read`和`write`的块数
    	char ac_comm[8];//命令名。对于LINUX ,则是 ac_comm[17]
    };
    
    • ac_flag记录了进程执行期间的某些事件:
      • AFORK:进程是由fork产生的,但从未调用exec
      • ASU:进程使用超级用户特区
      • ACORE:进程转储core(转储core的字节并不计算在会计记录内)
      • AXSIG:进程由一个信号杀死
    • 在大多数平台上,时间是以时钟滴答数来记录的
    • 会计记录所需的所有数据都由内核保存在进程表中,并在一个新进程被创建时初始化
    • 进程终止时,会写一个会计记录。这产生两个后果:
      • 我们不能获取永远不终止的进程的会计记录。因此init进程以及内核守护进程不会产生会计记录
      • 在会计文件中记录的顺序对应的是进程终止的顺序,而不是他们启动的顺序
    • 会计记录对应的是进程而不是程序。因此如果一个进程顺序的执行了3个程序 : A exec B, B exec C,则只会写一个会计记录。在该记录中的命令名对应于程序C,但是CPU时间是程序A,B,C之和

    times`函数:任何进程都可以用该函数获取它自己以及已经终止子进程的运行时间

    #include
    clock_t times(struct tms *buf);
    
    • 参数:
      • buf:执行tms结构的指针。该结构由times填写并返回
    • 返回值:
      • 成功:返回流逝的墙上始终时间(以时钟滴答数为单位)
      • 失败:返回 -1

    一个进程可以度量的有3个时间:

    • 墙上时钟流逝的时间。从进程从开始运行到结束时钟走过的时间,这其中包含了进程在阻塞和等待状态的时间

    • 用户 CPU 时间:用户进程获得了CPU资源以后,在用户态执行的时间

      与用户进程对应的是内核进程

    • 系统 CPU 时间:用户进程获得了CPU资源以后,在内核态的执行时间

      进程的三种状态为阻塞、就绪、运行

      • 墙上时钟流逝的时间 = 阻塞时间 + 就绪时间 +运行时间
      • 用户CPU时间 = 运行状态下用户空间的时间
      • 系统CPU时间 = 运行状态下系统空间的时间
      • 用户CPU时间+系统CPU时间=运行时间

    times函数就是获取进程的这几个时间的。这里的tms结构定义为:

    struct tms{
    	clock_t tms_utime;  //用户 CPU 时间
    	clock_t tms_stime;  //系统 CPU 时间
    	clock_t tms_cutime; //终止的子进程的用户 CPU 时间的累加值
    	clock_t tms_cstime; //终止的子进程的系统 CPU 时间的累加值
    

    注意:

    • 墙上时钟是相对于过去某个时刻度量的,所以不能用其绝对值而必须用相对值。通常的用法是:调用两次times,然后取两次墙上时钟的差值
    • tms_cutimetms_cstime包含了wait函数族已经等待到的各个子进程的值
    • clock_t可以使用_SC_CLK_TCK(用sysconf函数)转换成秒数

8.15 用户标识

任意进程都可以得到其实际用户ID和有效用户ID及组ID

一个用户ID可以有多个登录名

#include
char *getlogin(void);
成功返回登录名字符串指针,失败返回NULL;

8.16 进程调度

  1. UNIX系统的调度策略和调度优先级是内核确定的。进程可以通过调整nice值选择以更低优先级运行

    • 只有特权进程运行提高调度权限
    • POSIX实时扩展增加了进一步细调的行为
  2. nice值的在UBUNTU 16.04中范围是 -20~19 之间。

    • nice值越小,优先级越高(该进程抢占能力更强,更霸道);nice值越大,优先级越低(从而该进程是“友好的”)
    • 0 是系统默认的nice
  3. nice函数:进程通过它来获取自己的nice值或者修改自己的nice值:

    #include
    int nice(int incr);
    
    • 参数:
      • incrnice值的增量
    • 返回值:
      • 成功:返回新的nice
      • 失败:返回 -1

    incr会被增加到调用进程的nice值上。

    • 如果incr值太大,系统会直接将它降到最大合法值,不会出错(UBUNTU 16.04中是 19)
    • 如果incr值太小,系统会直接将它提高到最小合法值,不会出错(UBUNTU 16.04中是 -20)
      • 由于 -1 是合法的成功返回值。因此在nice返回 -1 时,需要综合errno才能判断是否出错
  4. getpriority/setpriority函数:获取/设置进程的nice值:

    #include
    int getpriority(int which,id_t who);
    int setpriority(int which,id_t who,int value);
    
    • 参数:

      • which:控制who参数是如何解释的。可以取三个值之一:
        • PRIO_PROCESS:表示进程
        • PRIO_PGRP:表示进程组
        • PRIO_USER表示用户ID
      • who:选择感兴趣的一个或者多个进程。
        • 如果who为0,whichPRIO_PROCESS,返回当前进程的nice
        • 如果who为0,whichPRIO_PGRP,则返回进程组中最小的nice
        • 如果who为0,whichPRIO_USER,则返回调用进程的实际用户ID拥有的那些进程中最小的nice
      • valuenice的增量
    • 返回值:

      • getpriority:成功返回-20~19之间的nice值;失败返回 -1

    getpriority不仅可以获得本进程的nice值,还可以获取一组相关进程的nice值。而setpriority可以为本进程、进程组、属于特定用户ID的所有进程设置优先级

  5. 示例:在main函数中调用test_getpriority_setpriority函数:

    void test_getpriority_setpriority()
    {
     M_TRACE("---------  Begin test_getpriority_setpriority()  ---------\n");
     create_child();
     // 只有父进程能到此处
     check_waitpid();
     My_getpriority(PRIO_PROCESS,0); // 父进程自己的 nice 值
     M_TRACE("---------  End test_getpriority_setpriority()  --------\n\n");
    }
    

    可以看到,如果为普通用户,则没有权限降低nice值。因为普通进程没有权限提升其优先级(即降低nice值)。在超级用户权限下,结果如下:

8.17 进程时间

main函数中调用test_progress_times函数:

void test_progress_times()
{
 M_TRACE("---------  Begin test_progress_times()  ---------\n");
 assert(prepare_file("test","abc",3,S_IRWXU)==0);
 int fd=My_open("test",O_RDWR);
 if(-1==fd)
 {
     un_prepare_file("test");
     M_TRACE("---------  End test_fork()  ---------\n\n");
     return;
 }
 //****** 打开文件成功 *************//
 clock_t t1,t2;
 struct tms buf;
 t1=times(&buf);
 create_child(fd,1000000000);// 子进程直接 _exit
 create_child(fd,2000000000);// 子进程直接 _exit
 sleep(5);// 让子进程获得锁,否则父进程持有锁,然后等待子进程结束,最后死锁
 fcntl_lock(fd);  // 加锁
 busy_work(1000000000);// 只有父进程能到达这里
 check_waitpid();
 t2=My_times(&buf);
 printf("Parent elapsed time is %d s\n",clock_2_second(t2-t1));\
 fcntl_unlock(fd); // 解锁

 close(fd);
 un_prepare_file("test");
 M_TRACE("---------  End test_progress_times()  ---------\n\n");
}

该示例的父进程派生了两个子进程。每个子进程都睡眠了 2秒

  • 为了便于观察结果,父进程和子进程都进行了十亿次级别的循环(busy_work实现的)。如果没有busy_work,则进程的用户CPU时间为0
  • 流逝时间转换成了秒
  • 父进程必须wait子进程才能收集子进程的信息。否则父进程的cstimecutime均为0
  • 为了防止发生竞争条件,这里使用记录锁。

可以看到:

  • 当进程睡眠时,只会影响进程的流逝时间,不会影响进程的utimestime
  • 进程的流逝时间必须用两次墙上时钟的差值
  • 父进程想要获得子进程的运行时间,必须调用wait
  • 父进程获取的子进程的user cpu time等于279+559=838个时钟滴答,约等于 839个时钟滴答。等于 8 秒
  • 父进程获取的子进程的system cpu time等于0+6个时钟滴答,等于0 秒。
  • 第一个子进程的运行时间,除了user cpu time(等于2秒)之外,还加上睡眠时间( 2 秒)等于4秒
  • 第二个子进程的运行时间,除了user cpu time(等于5秒)之外,还加上睡眠时间( 2 秒)等于7秒
  • 父进程的运行时间 = 4秒(子进程一运行时间)+7秒(子进程二运行时间)+2秒(父进程的user cpu time),一共是 13秒。但是注意到:子进程一的user cpu time为 279个时钟滴答, 子进程二的user cpu time为 559 个时钟滴答,父进程的user cpu time为 280个时钟滴答。这三个时间加在一起应该是 1118 个时钟滴答,约 11秒。而我们前面计算是,为 2+5+2=9秒。因此父进程运行时间为 13秒+ 2秒=15秒

父进程sleep时,正好子进程在运行。由于记录锁的存在,时间可以这样累加。

第九章 进程关系

9.2 终端登录

  • BSD系统:

    • 当系统自举时,内核创建进程ID为 1 的进程,即init进程

    • init进程读取文件/etc/ttys,对每个允许登录的终端设备,init调用一次fork,其所生成的子进程则exec getty程序(以一个空的环境)

    • getty对终端设备调用open函数,以读、写方式将终端打开。

      • 一旦设备被打开则文件描述符0、1、2被设置到该设备

      • 然后getty输出login:之类的信息,并等待用户键入用户名

      • 当用户键入了用户名后,getty的工作就完成了,它以类似下列的方式调用login程序:

        execle("/bin/login","login","-p",username,(char *)0,envp);

        其中envpgetty以终端名和在gettytab中说明的环境字符串为login创建的环境。-p标志通知login保留传递给它的环境,也可以将其他环境字符串添加到该环境中,但是不能替换它

    • login能处理多项工作

      • login得到了用户名,所以能够调用getpwnam获取相应用户的口令文件登录项,然后调用getpass以显示Password:
      • 接着login读取用户键入的口令,它调用crypt将用户键入的口令加密,并且与该用户在阴影口令文件中登录的pw_passwd字段比较
      • 如果用户自己键入的口令都无效,则login以参数1调用exit表示登录过程失败
        • 父进程init了解了子进程的终止情况后,再次调用fork其后又调用了getty,对此终端重复上述过程
      • 如果用户键入的口令正确,则login完成下列工作:
        • 将当前工作目录更改为用户的起始目录
        • 调用chown更改该终端的所有权,使得登录用户成为它的所有者
        • 将对该终端设备的访问权限变成”用户读和写“
        • 调用setgidinitgroups设置进程组ID
        • login得到的所有信息初始化环境:起始目录(HOME)、shell(SHELL)、用户名(USERLOGNAME)以及一个系统默认路径(PATH)
        • login进程调用setuid,将进程的用户ID更改登录用户的用户ID,并调用该用户的登录shell,其方式类似于:execl("/bin/sh","-sh",(char*)0);
        • 至此,登录用户的登录shell开始运行。登录shell读取其启动文件(如.profile)。这些启动文件通常是更改某些环境变量并增加很多环境变量。当执行完启动文件后,用户最后得到shell提示符,并能键入命令
  • MAC OS X系统:它部分地给予Free BSD,因此启动步骤与FreeBSD几乎相同,除了:

    • init工作是由launchd完成的
    • 一开始提供的就是图形终端
  • Linux:步骤几乎与Free BSD相同。但是init读取的是/etc/inittab文件而不是/etc/ttys文件

9.3 网络登录

网络登录:对于网络登录,所有登录都是经由内核的网络接口驱动程序。

  • BSD系统中,由init执行shell脚本/etc/rc,此shell脚本启动inetd守护进程。由inetd负责处理网络登录
  • Linux系统中,使用xinetd代替inetd进程

9.4 进程组

  1. 进程组:每个进程除了有一个进程ID之外,还属于一个进程组。进程组是一个或者多个进程的集合。

    • 通常进程组是在同一个作业中结合起来的
    • 同一个进程组中的各进程接收来自同一个终端的信号
    • 每个进程中都有一个唯一的进程组ID标志
      • 进程组ID类似于进程ID,是一个整数并且可以存放在pid_t数据类型中
    • 每个进程组都有一个组长进程。进程组ID就等于组长进程的进程ID
      • 进程组的组长进程可以创建该组中的进程
      • 进程组的组长进程也可以终止。只要进程组中至少有一个进程存在,则该进程中就存在,这与组长进程是否终止无关
    • 进程组的生命周期:从组长进程创建进程中开始,到组内最后一个进程离开为止的时间
      • 这里用离开,是因为进程可以从一个进程组转移到另一个进程组
  2. getpgrp/getpgid函数:获取进程所属的进程组:

    #include
    pid_t getpgrp(void);
    pid_t getpgid(pid_t pid);
    
    • 对于getpgrp函数:其返回值是调用进程的进程组ID(没有失败值)

    • 对于getpgid函数:

      • 参数:pid为待查看进程的进程ID。如果pid=0,则返回调用进程的进程组ID

      这里没有要求pid和本进程的关系

      • 返回值:成功,则返回进程组ID;失败返回 -1
  3. setpgid函数:加入一个现有的进程组或者创建一个新进程组

    #include
    int setpgid(pid_t pid,pid_t pgid);
    
    • 参数:
      • pid:待处理的进程的进程ID
      • pgid:进程组的组ID
    • 返回值:
      • 成功:返回 0
      • 失败: 返回 -1

    setpgid函数将pid进程的进程组ID设置为pgid

    • 如果pid等于pgid,则由pid指定的进程变成进程组组长
    • 如果pid等于0,则使用调用者的进程ID
    • 如果pgid等于0,则使用pid指定的进程ID用作进程组ID

    注意:一个进程只能为它自己或者他的子进程设置进程组ID,且进程组ID只能为父进程进程组ID、父进程的进程ID或者子进程的进程ID。

    • 在它的子进程调用exec之后,它就不再更改子进程的进程组ID
    • 在大多数作业控制shell中,fork之后立即调用此函数,使得父进程设置其子进程的进程组ID,同时也使子进程设置其自己的进程组ID(这两个调用是冗余的,但是是个双保险)

9.5 会话

  1. 会话session是一个或者多个进程组的集合。

  2. setsid函数:创建一个新会话

    #include
    pid_t setsid(void);
    
    • 返回值:
      • 成功:返回进程组ID
      • 失败:返回 -1

    进程调用setsid建立一个新会话。如果调用此函数的进程不是一个进程组的组长进程,则此函数创建一个新会话并且发生下面三件事:

    • 该进程会变成新会话的会话首进程session leader。此时该进程是新会话中的唯一进程

      会话首进程是创建该会话的进程

    • 该进程成为一个新进程组的组长进程。新进程组ID就是该调用进程的进程ID

    • 该进程没有控制终端。即使调用setsid之前该进程有一个控制终端,该联系也被切断

    如果调用此函数的进程是个进程组的组长,则此函数返回出错。

    通常是进程首先fork,然后父进程终止,子进程调用setsid继续执行。这确保了子进程不是一个进程组的组长

  3. getsid函数:返回进程所在的会话ID(会话ID等于会话首进程的进程组ID,会话首进程总是进程组的组长进程,因此它也等于会话首进程的进程ID

    #include
    pid_t getsid(pid_t pid);
    
    • 参数:
      • pid:待查看进程的进程ID
    • 返回值:
      • 成功:会话ID
      • 失败:返回 -1

    如果pid为0,则getsid返回调用进程的会话ID。如果pid并不属于调用者所在的会话,则调用进程就不能得到该会话ID

  4. main函数中调用test_getsid_setsid函数:

    void test_getsid_setsid()
    {
     M_TRACE("---------  Begin test_getsid_setsid()  ---------\n");
     create_child();
     // 只有父进程能到达此处
     check_waitpid();
     print_pid();
     print_parent_pid();
     My_getpgid(0);
     My_getsid(0);
     My_setsid();
     My_getsid(0);
     M_TRACE("---------  End test_getsid_setsid()  ---------\n\n");
    }
    

    9.6 控制终端

    会话和进程组还有一些特性:

    • 一个会话可以有一个控制终端controlling terminal
      • 这可以是终端设备(在终端登录的情况下)
      • 也可以是伪终端设备(在网络登录的情况下)
    • 建立与控制终端连接的会话首进程称作控制进程controlling process
    • 一个会话中的进程组可以分成一个前台进程组,以及一个或者多个后台进程组
    • 如果一个会话有一个控制终端,则它有一个前台进程组,其他进程组为后台进程组
    • 无论何时键入终端的中断键(通常是Ctrl+C),都会将中断信号发送至前台进程组的所有进程
    • 无论何时键入终端的退出键(通常是Ctrl+\),都会将退出信号发送至前台进程组的所有进程

9.7 函数tcgetpgrp、tcsetpgrp、和tcgetsid

  1. tcgetpgrp/tcsetpgrp函数:获取/设置当前进程所在会话的前台进程组ID

    #include
    pid_t tcgetpgrp(int fd);
    int tcsetpgrp(int fd,pid_t pgrpid);
    
    • 参数:

      • fd:进程在fd这个描述符上打开的终端
      • pgrpid:待设置的前台进程组ID
    • 返回值:

      • 对于tcgetpgrp:成功则返回前台进程组ID,失败返回 -1
      • 对于 tcsetpgrp:成功返回 0;失败返回 -1

    如果进程有一个控制终端,则该进程可以调用tcsetpgrp将前台进程组ID设置为pgrpid,其中:

    • pgrpid必须是同一个会话的一个进程组的ID
    • fd必须引用该会话的控制终端

    注意:大多数应用程序并不直接使用这两个函数,它们通常是由作业控制shell调用

  2. tcgetsid函数:获取会话首进程的进程组ID(也就是会话ID)

    #include
    pid_t tcgetsid(int fd);
    
    • 参数:
      • fd:进程在fd这个描述符上打开的终端
    • 返回值:
      • 成功则返回前台进程组ID
      • 失败返回 -1

    注意会话ID不一定等于前台进程组的组ID。对于一个会话,会话ID通常不变(前提是没有函数主动设置它);但是前台进程组进程由于作业调度会经常发生变化

9.8 作业控制

运行在一个终端上启动多个作业,它控制哪个作业可以访问终端以及那些作业在后台运行

  • 一个作业是一个进程组。该进程组的进程协同完成一个任务
  • Linux中,当执行ls > a.out &等命令以&时,就启动了一个后台作业
    • shell会赋予它一个作业标识符,并打印作业标识符以及一个或者多个进程ID
  • Linux中,当执行ls > a.out等命令时,就启动了一个前台作业
  • 当我们键入以下三个字符之一时,会使终端产生信号,并将它们发送到所有的前台进程组(后台进程组不受影响):
    • Ctrl+C中断字符:产生SIGINT信号
    • Ctrl+\退出字符:产生SIGQUIT信号
    • Ctrl+Z挂起字符:产生SIGTSTP信号
  • 只有前台作业能够接收来自终端的输入。如果后台作业试图读取终端,则这并不是个错误。终端驱动程序会检测到这种情况,并向后台作业发送一个特定的信号SIGTTIN,该信号通常会停止此后台作业,而shell会向用户发出这种情况的通知
    • 用户此时可以通过shell命令将该后台作业转换为前台作业运行
  • 通过stty命令可以禁止或者允许后台作业输出到终端。
    • 用户禁止后台作业向控制终端写入,则当后台作业试图写到标准输出时,终端驱动程序识别出该写操作来自于后台进程,于是向该作业发送SGITTOU信号,该信号通常会停止此后台作业,而shell会向用户发出这种情况的通知
    • 用户此时可以通过shell命令将该后台作业转换为前台作业运行
  • 作业控制是在窗口终端得到广泛应用之前设计和实现的

9.9 shell执行程序

shell如何执行一个进程,以及这与进程组、控制终端、和会话概念的关系。

9.10 孤儿进程组

一个进程组不是孤儿进程组的条件是:该进程组中存在一个进程,其父进程在属于同一个会话的另一个组中

  • 如果进程组不是孤儿进程组,则属于同一个会话的另一个组中的父进程就有机会重启该组中停止的进程

如果一个进程组中的所有进程:

  • 要么其父进程不再同一个会话中
  • 要么其父进程就在同一个组中

则该进程组是个孤儿进程组

当孤儿进程组产生的时候,如果孤儿进程组中有TASK_STOP的进程,那么就发送SIGHUPSIGCONT信号给这个进程组

  • 这个顺序是不能变的。我们知道进程在进程在TASK_STOP的时候是不能响应信号的,只有当进程继续运行的时候,才能响应之前的信号。

    • 如果先发送SIGCONT信号再发送SIGHUP信号,那么SIGCONT信号后,进程就开始重新进入运行态,这个和马上响应SIGHUP信号的用意相悖
    • 所以这个时候需要在进程TASK_STOP的过程中首先发送SIGHUP信号,为的是让进程运行之后马上执行SIGHUP信号。
  • 这两个信号是发送给有处于TASK_STOP状态的进程的进程组的所有进程的。所以进程组中正在运行的进程,如果没有建立SIGHUP信号处理函数,那么运行的进程就会因为SIGHUP退出。

9.11 FreeBSD实现

这一届梳理了各种结构体之间的相互作用

9.12 小结

本章主要就是说明了进程之间的关系——会话

第十章 信号

10.2 信号概念

  1. 信号是软中断,它提供了一种处理异步事件的方法

    • 产生信号的事件对于进程而言是随机出现的
    • 进程不能简单的测试一个变量来判断是否发生了一个信号,而是必须告诉内核当某个信号发生时,执行哪些操作。
  2. 每个信号都有一个名字,这些名字都以SIG开头:

    • 所有的信号名都被定义为正整数常量(信号编号),定义在头文件
    • 不存在编号为 0 的信号,POSIX将 0 号编号值称作空信号
    • Mac OS X 10.6.8以及Linux 3.2.0都支持31种信号
  3. 很多条件可以产生信号:

    • 当用户按某些终端键时,引发终端产生信号。如当用户在终端上按Delete键(通常是Ctrl+C)时,产生中断信号SIGINT

    • 硬件异常信号:除数为0、无效的内存引用等等

      这些条件通常由硬件检测到,并通知内核。然后内核为该条件发生时正在运行的进程产生适当的信号。如对执行一个无效内存引用的进程产生SIGSEGV信号

    • 进程调用kill()函数可将任意信号发送给另一个进程或者进程组

      要求接收信号的进程和发送信号的进程的所有者必须相同,或者发送信号的进程的所有者是超级用户

    • 用户可以用kill命令将任意信号发送给其他进程

      此命令只是kill()函数的接口。通常用于终止一个失控的后台进程

    • 当检测到某种软件条件已经发生并应将其通知有关进程时,也产生信号。如定时器超时的时候产生SIGALRM信号

  4. 进程可以告诉内核当某个信号发生时,执行下列三种操作之一(我们称作信号处理):

    • 忽略此信号。大多数信号都可以使用这种方式进行处理,但是SIGKILLSIGSTOP信号决不能被忽略
      • SIGKILLSIGSTOP向内核和超级用户提供了使进程终止或者停止的可靠方法
      • 如果忽略某些由硬件异常产生的信号(如非法内存引用或除以0),则进程的运行行为是未定义的
    • 捕捉信号。为了做到这一点,进程需要通知内核当某种信号发生时,调用一个用户函数。
      • 在用户函数中,内核执行进程希望对这种事件进行的处理
      • 注意无法捕捉SIGKILLSIGSTOP信号
    • 执行系统默认动作。对大多数信号的系统默认动作是终止该进程。默认动作是忽略的常见的信号有:SIGCANCEL(线程库内部使用)、SIGCHILD(子进程状态改变)
  5. 进程执行时,如果没有显式设定,则所有的信号的处理都为默认动作

    • 调用fork之后,子进程继承父进程的信号处理方式,因为子进程在开始时复制了父进程的进程空间
    • 对于子进程,exec函数会将原先设置为要捕捉的信号都改为默认动作,非捕捉的信号则不变。这是因为信号捕捉函数的地址很可能在新程序中没有任何意义

10.3 函数signal

unix系统信号机制最简单的接口是signal函数,

#inclide<signal.h>
void(*signal(int signo, void (*func)(int))) (int);
//成功则返回信号处置配置,出错返回SIG_ERR

参数:

  • signo 是31种信号名
  • func的值是以下几种情况
    • 常量SIG_IGN 向内核表示忽略此信号
    • 常量SIG_DFL 表示接到此信号的动作是系统默认
    • 为指定函数地址 接到信号调用该函数

10.4 不可靠的信号

早期信号机制存在各种问题

10.5 中断的系统调用

  1. 如果进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用就会被中断而不再继续执行。此时该系统调用返回出错,其errno设置为EINTR

    • 这么做的意义是因为一个信号发生了,进程捕捉到了它,意味着已经发生了某种事情,所以是个好机会应当唤醒阻塞的系统调用
  2. 为了支持中断的系统调用,我们将系统调用分成两类:低速系统调用和非低速系统调用

    • 低速系统调用是可能使进程永远阻塞的一类系统调用,包括:
      • 如果某些类型文件(如读管道、终端设备、网络设备)的数据不存在,则读操作可能会使调用者永远阻塞
      • 如果写某些类型文件(如写管道、终端设备、网络设备),可能会使得调用者永远阻塞
      • pause函数(根据定义,它使调用进程休眠直到捕捉一个信号)和wait函数
      • 某些ioctl函数
      • 某些进程间通信函数
  3. 为了帮助应用程序使其不必处理被中断的系统调用(即不需要人工来重新启动被中断的系统调用),4.2BSD引进了某些被中断的系统调用自动重启动

    • 自动重启动的系统调用包括ioctl、read、readv、write、writev、wait、waitpid
    • 有些情况下,可能我们并不希望这些函数被中断后重启动,因此4.3BSD运行进程基于每个信号禁用重启动功能
    • 需要自动重启动的原因是:有时候用户根本不知道所使用的输入、输出设备是否是低速设备。如果不提供重启动功能,则对每次read、write系统调用就要进行是否出错返回的测试;如果是被中断的,则需要再调用read、write系统调用
  4. POSIX要求:只有中断信号的SA_RESTART标志有效时,才重启动被该信号中断的系统调用

10.6 可重入函数

  1. 进程捕捉到信号并对其进行处理时,进程正在执行的正常指令序列就被信号处理程序临时中断

    • CPU 首先执行该信号处理程序中的指令

    • 如果从信号处理程序返回,则继续执行进程正在执行的正常指令序列

      有可能无法从信号处理程序返回,如在信号处理程序中调用_exit()或者longjmp

    • 但是有个问题:在信号处理程序中,无法判断捕捉到信号的时候,进程执行到何处。

      • 如果在捕捉到信号的时候,进程正在执行malloc,那么在信号处理程序中,绝不应该再调用malloc。否则会破坏malloc维护的存储区链表
    • 对于某一类函数,如果在捕捉到信号的时候,进程正在执行这些函数,那么在信号处理程序中,可以安全的重复调用这些函数。这一类函数称作可重入函数

  2. SUS规范说明了在信号处理程序中保证调用安全的函数。这些函数有以下特点:

    • 没有使用静态数据结构。使用了静态数据结构的函数不是可重入的
    • 没有调用malloc或者free。调用malloc或者free的函数不是可重入的
    • 没有使用标准IO函数。使用标准IO函数的函数不是可重入的。因为标准IO库很多都是用了全局数据结构
  3. 当在信号处理函数中调用可重入函数时,应当在调用前保存errno,然后在调用后恢复errno

    • 因为可重入函数执行失败的时候,可能会修改全局的errno值。而这种改变并不属于进程的正常执行逻辑。

可靠信号术语

你可能感兴趣的:(linux,unix,学习,服务器)