csapp之第八章:异常控制流

1 异常控制流

每个指令执行的序列就是CPU控制流,虽然可改变程序控制流,但只适用于程序状态的改变,很难应对系统状态的改变,因此系统需要异常控制流。包括异常、进程切换、信号、非本地跳转。

2 异常

异常是把控制权交给常驻系统的内核以响应某些事件的机制,包括:除零、运算溢出、页错误、IO请求完成等系统级别的事件。异常过程如图:

image-20220119122413641

注意:当发生异常时,系统通过查找异常表(可以理解为函数表)中对应的异常编号确定异常处理代码。

2.1 中断

中断也叫异步异常,由CPU外部事件引起。之所以说是异步的,因为不知道什么时候会发生,因此CPU对其响应也是被动的。

常见中断有:

  • 定时时器中断:每隔几毫秒CPU就会触发中断,进行内核态和用户态切换
  • I/O中断:类型较多:如键盘输入ctrl-c、fd可读可写等

2.2 同步异常

同步异常由执行指令的结果导致的事件,包括三类:

类型说明行为示例
Trap为某事有意设置返回到先前下条指令系统调用、调试断点
Fault潜在可恢复错误返回当前指令或终止页故障(page faults)
Abort不可恢复的错误终止当前执行的程序非法指令、硬件错误

2.3 系统调用

在X86-64系统中,每个系统调用都有唯一的ID号,如:

编号名称说明
0read读取文件
1write写入文件
2open打开文件
3close关闭文件
4stat文件信息
57fork创建进程
59execve执行程序
60_exit关闭进程
62kill发送信号

3 进程

进程给每个程序提供CPU和内存的抽象:

  • CPU:通过上下文切换的机制让每个程序都感觉在独占处理器
  • 内存:通过虚拟内存让每个程序都感觉在独占内存

进程通用结构主要包括:代码区、数据区、堆区、栈区,如下所示:

image-20220119123450788

3.1 多进程

多进程就是计算机同时运行多个进程,分为两大类:

  • 并发:单核CPU交错执行多个进程,因此需保存和恢复上下文
  • 并行:多核CPU并行执行多个进程,共享主要内存和某些缓存
    image-20220119123555006

3.2 用户态模式和内核模

用户态可执行任何指令且访问系统任何内存位置,而用户态则必须通过系统API间接访问内核代码和数据。用户态和内核态通过中断、陷入系统调用、故障这样的异常进行切换。

3.3 上下文切换

上下文切换是建立在异常机制上。内核为每个进程维持寄存器、用户栈、内核栈及各种内核数据结构(如页表、进程表、已打开文件的信息的文件表),内核通过调度器调度进程,主要包括三部分:

  1. 保存当前进程上下文
  2. 恢复被抢占进程保存的上下文
  3. 控制权传递给该新进程

4 进程控制

4.1 系统调用错误处理

出错时,Linux系统函数通常返回-1且设置全局变量errno表示错误原因。因此每个系统调用都应检查返回值,如fork()函数,应检查返回值:

4.2 获取进程ID

getpid返回当前进程PID、getppid返回当前进程的父进程的 PID

4.3 进程生命周期

进程主要包括三种状态:

状态说明
Running进程要么正运行,要么等待被执行或最终将会被内核调度执行
Stopped进程被挂起,且在进一步收到SIGCONT信号前不会被调度执行
Terminated进程被永久停止

4.3.1 终止进程

包括三种情况:

  • 接收到终止信号
  • main函数返回
  • 调用exit函数

4.3.2 创建进程

父进程调用fork创建新进程,该函数执行一次,但返回两次,子进程返回0,父进程返回子进程PID,进程创建前后保存的上下文如图:

image-20220119123702412

4.3.3 COW机制

fork会创建子进程时,并没真正拷贝,而是共享父进程的每个区域,若是只读操作,则什么也不做,后续写操作则拷贝并写入。

4.3.4 解决父子竞争

由于并行执行,无法预计父子进程执行顺序,因此隐藏潜在的竞争关系。如何解决?在不同的分支中插入随机延迟。

/* fork wrapper function */
pid_t fork(void) {
	initialize();
	int parent_delay = choose_delay();
	int child_delay = choose_delay();
	pid_t parent_pid = getpid();
	pid_t child_pid_or_zero = real_fork();
	if (child_pid_or_zero > 0) {
		/* Parent */
		if (verbose) {
			printf("Fork. Child pid=%d, delay = %dms. Parent pid=%d, delay = %dms\n",child_pid_or_zero, child_delay,parent_pid, parent_delay);
			fflush(stdout);
		}
		ms_sleep(parent_delay);
	} else {
		/* Child */
		ms_sleep(child_delay);
	}
	return child_pid_or_zero;
}

4.4 回收子进程

4.4.1 回收子进程

  • 定义:即使进程已终止,但还未被回收的进程还在消耗系统资源称之为僵尸进程
  • 回收:父进程用 waitwaitpid 回收已终止的子进程,kernel 就会回收资源。若父进程不回收,通常会被 init 进程回收(所以一般不必显式回收)

4.4.2 孤儿进程

孤儿进程指子进程正运行,父进程突然退出,消耗资源,若所在进程组没进程收养,就作为init进程的子进程

5 signal

5.1 信号

信号允许进程和内核中断其他进程,提醒进程一个事件已经发生

每个信号都有name和ID,每个信号对应每个处理程序,若信号没被处理,通常要么被忽略要么中断

常用信号的编号及简介:

事件名称ID默认动作
用户按ctrl-cSIGINT2终止
强制中断(不能被处理)SIGKILL9终止
段冲突SIGSEGV11终止且dump
时钟信号SIGALRM14(可变)终止
子进程停止或终止SIGCHLD17(可变)忽略

5.2 信号概念

5.2.1 待处理和阻塞信号

如果信号已被发送但是未被接收,那么处于待处理状态,注意:任何时刻一个类型至多只有一个待处理信号,后续的被发送给进程的信号都被直接丢弃。进程也可以阻塞特定信号的接收,直到信号被解除阻塞。

5.2.2 接收信号

目标进程以某种方式对信号的传递做出响应时,进程接收信号,可能响应的操作:

  • 忽略:忽略信号,不作任何响应
  • 终止:终止进程,可能core dump
  • 捕获:执行用户层的函数

5.2.3 待处理和阻塞位

内核为每个进程的维护等待和阻塞的位集合

  • 等待信号集合:表示等待信号集合,当类型为k的信号被传递,内核将位k置为等待,当接收到类型为k的信号时,内核清除待处理的位k
  • 阻塞信号集合:表示阻塞信号集合,可用sigprocmask函数设置和清除

5.3 进程组

每个进程都只属于一个进程组,如图:

image-20220119141214993

可以通过 kill 来发送信号给进程组或进程(包括自己)

5.3.1 从键盘发送信号

可通过键盘让内核向每个前台进程发送 SIGINT(SIGTSTP) 信号

  • SIGINT - ctrl+c 默认终止进程
  • SIGTSTP - ctrl+z 默认停止(挂起)进程

5.3.2 信号传递

内核控制权传给进程p,会计算进程 p 的 pnb 值:pnb = pending & ~blocked

  • 如果 pnb == 0,那么就把控制交给进程 p 的下一条指令
  • 否则
    • 选择 pnb 中最小的非零位 k,并强制进程 p 接收信号 k
    • 接收到信号之后,进程 p 会执行对应的操作
    • pnb 中所有的非零位进行该操作
    • 最后把控制交给进程 p 的下一条指令

5.4 信号控制

5.4.1 注册信号处理程序

sigaction函数改变与接收信号相关的程序

5.4.2 作为并发流的信号处理程序

信号处理程序是独立与主程序并发运行的逻辑流,此外,信号处理程序也可被其他信号处理程序中断,控制流如下:

image-20220119142052313

5.4.3 阻塞和非阻塞信号

  • 隐式阻塞机制:内核会阻塞与当前在处理的信号同类型的其他正等待的信号,如一个 SIGINT 信号处理器不能被另一个 SIGINT 信号中断。

  • 显式阻塞机制:使用 sigprocmask 等函数:

    • sigemptyset :创建空集

    • sigfillset :把所有的信号都添加到集合中

    • sigaddset :添加指定信号到集合中

    • sigdelset :删除集合中的指定信号

临时阻塞信号示例:

sigset_t mask, prev_mask;

Sigemptyset(&mask); // 创建空集
Sigaddset(&mask, SIGINT); // 把 SIGINT 信号加入屏蔽列表中

// 阻塞对应信号,并保存之前的集合
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
... // 这部分代码不会被 SIGINT 中断
// 取消阻塞信号,恢复原来的状态
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);

5.4.4 安全处理信号

这里提供一些基本的规则帮助避免出现问题:

  • 规则 1:信号处理器越简单越好
  • 规则 2:信号处理器中只调用异步且信号安全(async-signal-safe)的函数
  • 规则 3:在进入和退出的时候保存和恢复 errno
  • 规则 4:临时阻塞所有的信号以保证对于共享数据结构的访问
  • 规则 5:用 volatile关键字声明全局变量
  • 规则 6:用 volatile sig_atomic_t来声明全局标识符(flag)

5.4.5 异步信号安全

异步信号安全函数指:

  • 所有变量都保存在帧栈中
  • 不会被信号中断的函数

Posix 标准指定了 117 个异步信号安全的函数(可通过 man 7 signal-safety 查看),常用的printf、sprintf、malloc、exit都不是。

5.4.6 信号安全代码示例

  • 同步避免父子竞争:
int main(int argc, char **argv)
{
	int pid;
	sigset_t mask_all, mask_one, prev_one;
	int n = N; /* N = 5 */
	Sigfillset(&mask_all);
	Sigemptyset(&mask_one);
	Sigaddset(&mask_one, SIGCHLD);
	Signal(SIGCHLD, handler);
	initjobs(); /* Initialize the job list */
    
	while (n--) {
		Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
		if ((pid = Fork()) == 0) { /* Child process */
			Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
			Execve("/bin/date", argv, NULL);
		}
		Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
		addjob(pid); /* Add the child to the job list */
		Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
	}
	exit(0);
}
  • 显式等待信号:
volatile sig_atomic_t pid;

void sigchld_handler(int s)
{
	int olderrno = errno;
	pid = Waitpid(-1, NULL, 0); /* Main is waiting for nonzero pid */
	errno = olderrno;
}

void sigint_handler(int s)
{
}
int main(int argc, char **argv) {
	sigset_t mask, prev;
	int n = N; /* N = 10 */
	Signal(SIGCHLD, sigchld_handler);
	Signal(SIGINT, sigint_handler);
	Sigemptyset(&mask);
	Sigaddset(&mask, SIGCHLD);
	//Similar to a shell waiting for a foreground job to terminate.
	while (n--) {
		Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
		if (Fork() == 0) /* Child */
			exit(0);
		/* Parent */
		pid = 0;
		Sigprocmask(SIG_SETMASK, &prev, NULL); /* Unblock SIGCHLD */

        /* Wait for SIGCHLD to be received (wasteful!) */
		while (!pid)
			;
		/* Do some work after receiving SIGCHLD */
		printf(".");
	}
	printf("\n");
	exit(0);
}

5.5 非本地跳转

5.5.1 非本地跳转

本地跳转的限制在于不能从一个函数跳转到另一个函数中。因此C语言提供非本地跳转,使用 setjmplongjmp 用于将控制权转移到任意位置,打破call/return调用机制。

setjmp 必须在longjmp前调用,为后续longjmp标识返回地址,调用一次返回一次或多次,保存当前程序的寄存器上下文,注意,保存的堆栈上下文环境仅在调用 setjmp 的函数内有效,调用 setjmp 的函数返回,保存的上下文环境就失效。直接返回值为 0。

longjmp 调用一次但永不返回,将会从缓存j中恢复由 setjmp 保存的程序堆栈上下文,跳转到j中保存的地址,设置%eax(返回值)为i,而不是setjmp的0

/* Deeply nested function foo */
void foo(void)
{
	if (error1)
		longjmp(buf, 1);
	bar();
}
void bar(void)
{
	if (error2)
		longjmp(buf,2);
}
jmp_buf buf;
int error1 = 0;
int error2 = 1;
void foo(void), bar(void);
int main()
{
	switch(setjmp(buf)) {
	case 0:
		foo();
		break;
	case 1:
		printf("Detected an error1 condition in foo\n");
		break;
	case 2:
		printf("Detected an error2 condition in foo\n");
		break;
	default:
		printf("Unknown error condition in foo\n");
	}
	exit(0);
}

5.5.2 非本地跳转的限制

只能跳转到已经调用但尚未完成(函数还在栈中)的函数环境

image-20220119142859909

image-20220119142911613

P2在跳转的时候已返回,栈帧在内存中已被清理,所以P3中的 longjmp 并不能实现期望的操作

6 操作进程的工具

  • STRACE:打印进程和子进程的每个系统调用
  • PS:列出当前系统中所有进程(包括僵尸进程)
  • TOP:打印当前进程资源使用信息
  • PMAP:进程内存映射
  • /proc:虚拟文件系统,以ASCII码输出大量内核数据结构内容

7 总结

异常控制流(ECF)发生在系统各层次,是系统提供并发的基本机制:

  • 硬件层:四种类型异常
  • 操作系统层:内核用ECF提供进程基本概念,进程提供CPU和内存的抽象
  • 系统调用接口:程序可创建子进程,等进程停止或终止,运行新程序以及捕获信号
  • 应用层:可用setjmp和longjmp转规避正常调用/返回的栈规则,直接从一个函数跳转到另一个函数
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值