CSAPP Note chap8
CSAPP 读书笔记系列chap8
chap 8 异常控制流
这一章谈的会是异常控制等,也就是控制流的突变。控制流指的是处理器的处理序列,而其突变一般是由跳转,调用(之前已经说过)和返回等造成的。
一般把这些突变称为异常控制流ECF(Exception Control Flow),这个概念将会在本书下半部分占十分重要的角色。
8.1 异常
异常控制流存在于系统的每个层级,最底层的机制称为异常(Exception),用以改变控制流以响应系统事件,通常是由硬件的操作系统共同实现的。
计算机运行的程序(也就是进程)一般分为:
内核模式:如果当前运行的是用户程序(用户代码),没有设置模式位时,那么对应进程就处于用户模式(用户态),不允许执行特权指令
用户模式: 设置了模式位,进程就会运行在内核模式。
进程由用户模式进入内核模式的* 唯一方法 是异常 *,诸如中断,故障或陷入系统调用。
异常的具体过程为:
图片出自 http://wdxtub.com/2016/04/16/thin-csapp-5/
这里有一个关键的概念-事件event,经常在网络编程中碰到
事件:处理器状态的变化,处理器中,状态被编码成不同的位和信号.
异常的类别和处理:
异常的处理
而异常表是一个跳转表,其为每一个异常分配了* 唯一的异常号 *
类似 LC-3 的 TRAP rontine
异常的类别
分为异步异常和同步异常;
- 异步异常(Asynchronous Exception)称之为中断(Interrupt),是由处理器外面发生的事情引起的。对于执行程序来说,这种“中断”的发生完全是异步的,因为不知道什么时候会发生。CPU对其的响应也完全是被动的,但是可以屏蔽掉。
常见的中断有两种:
- 计时器中断:计时器中断是由计时器芯片每隔几毫秒触发的,内核用计时器终端来从用户程序手上拿回控制权。
和 I/O 中断: I/O 中断类型比较多样,比方说键盘输入了 ctrl-c,网络中一个包接收完毕,都会触发这样的中断。
同步异常(Synchronous Exception)
因为执行某条指令所导致的事件,可以知道发生的原因和时刻,有以下三种
-
- 陷阱(trap): 有意的异常,如
-
故障(fault): 潜在可恢复的异常
终止(abort): 不可恢复的异常
具体为:
8.2 进程
计算机科学中最深刻、最成功的概念之一:
一个经典的定义为:
* 进程是操作系统对一个执行中程序的实例的一个抽象 *
进程给每个应用提供了两个非常关键的抽象:
- 逻辑控制流:逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。
- 私有地址空间: 私有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。
私有的意思指的是:和这个空间某个地址相关联的那个内存字节是不能被别的其他进程读或者写的。
一个进程的地址空间如下所示:
上下文指的是: 内核重新启动一个被抢占的进程所需的状态。
一般上下文的调度如下图:
注意并发和并行的区别
计算机虽然只有一个 CPU,但操作系统能够将程序的执行单位细化,然后分开执行,从而实现伪并行执行。这种伪并行执行称为并发(concurrent)。使用多个 CPU 真的同时执行称为并行(parallel)
这里会利用到栈的概念:
- 1. 保存上一个进程的上下文
- 2. 恢复下一个进程的已保存的上下文
- 3. 将控制传递给新恢复的的进程
所以一个具体的单核的进程具体空间就是:
看起来就像是内核独占一个进程在运行。
而双核的是:
8.3 系统调用错误处理
这里说的是一些实际工作中常遇到的包装函数概念:
stevens 大神先提出的,厉害啦厉害啦
例如,对于 fork() 函数,程序可以应该这么写:
if ((pid = fork()) < 0) {
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(0);
}
如果觉得这样写太麻烦,可以利用一个辅助函数:
void unix_error(char *msg) /* Unix-style error */
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
// 上面的片段可以写为
if ((pid = fork()) < 0)
unix_error("fork error");
我们甚至可以更进一步,把整个 fork() 包装起来,就可以自带错误处理,比如
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
进程的状态和一些函数:
从程序员的角度,一个进程的状态为以下三个之一:
- 运行Running:
- 正在被执行、正在等待执行或者最终将会被执行
停止 Stopped:
- 进程被挂起Suppended,在进一步通知前不会计划执行
终止 Terminated:
- 进程被永久停止,一般为以下原因
- 接收到一个终止信号
- 返回到 main
- 调用了 exit 函数
另外的两个状态称为新建(new)和就绪(ready).
获取进程ID
pid_t getpid(void) - 返回当前进程的 PID
pid_t getppid(void) - 返回当前进程的父进程的 PID
pid_t 在 type.h 中定义为 int
终止进程
exit 函数会被调用一次,但从不返回,具体的函数原型是
// 以 status 状态终止进程,0 表示正常结束,非零则是出现了错误
void exit(int status)
创建进程
调用 fork 来创造新进程。这个函数执行一次,但是会返回两次,具体的函数原型为
// 对于子进程,返回 0
// 对于父进程,返回子进程的 PID
int fork(void)
对于新创建的进程,和原来的函数是并行执行的,也就是不能预计父进程和子进程的执行顺序
需要的注意下面:
- 子进程拥有自己独立的地址空间(也就是变量都是独立的),除此之外和父进程的其他都相同
- 共享一些内存,例如:在父进程和子进程中 stdout 是一样的(都会发送到标准输出)
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0)
{ // Child
printf("I'm the child! x = %d\n", ++x);
exit(0);
}
// Parent
printf("I'm the parent! x = %d\n", --x);
exit(0);
}
输出是
linux> ./forkdemo
I'm the parent! x = 0
I'm the child! x = 2
或:
linux> ./forkdemo
I'm the child! x = 2
I'm the parent! x = 0
具体的多少个结果全排序可以根据拓扑排序 来检测
回收子进程
一般来说,当一个进程结束(Terminated)时,内核不会马上将他从系统删除。而是保持已终止状态,等其父进程回收(reaped).父进程利用 wait 或 waitpid 回收已终止的子进程,然后给系统提供相关信息,kernel 就会把 zombie child process 给删除
一个主进程已经终止,子进程也还在消耗系统资源,我们称之为『僵尸』
如果父进程不回收子进程的话,通常来说会被 init 进程(pid == 1)回收,所以一般不必显式回收。但是在长期运行的进程中,就需要显式回收(例如 shell 和 server)。
函数waitpid:
pid_t waitpid( pid_t pid, int * statusp,int options);
函数wait
pid_t wait(int statusp);
// 等价于
pid_t waitpid(-1,&status, 0)
参数的定义可以具体查看 p518页
例子如下:
int main()
{
if (Fork() == 0) {
printf("a"); fflush(stdout);
}
else {
printf("b"); fflush(stdout);
waitpid(-1, NULL, 0);
}
printf("c"); fflush(stdout);
exit(0);
}
// fflush 擦除 stream stdout 的缓冲区
// 其拓扑输出的一个如下
➜ ecf ./waitprob0
bacc
进程休眠
网络编程经常遇到,都在
unsigned int sleep(unsigned int sec);
- pause函数
让调用函数休眠,直到其收到一个信号才继续
int pause (void);
加载并运行程序execve函数
他会覆盖当前进程的地址空间,但没有创建一个新的进程。也就是新程序有一个原来一样的PID,并继承和调用已打开的的文件描述符。
注意,其一次调用若成功则没有返回,否则返回-1.
其函数定义如下:
int execve(char *filename, char *argv[], char *envp[])
注意
- filename 是一个目标文件或一个以#!解释器开头的脚本(e.g., #!/bin/bash)
- argv是以前说的参数列表
- envp 是环境变量,
例子如下:
int main(int argc, char *argv[], char *envp[]) {
int i;
printf("Command-line arguments:\n");
for (i = 0; argv[i] != NULL; i++)
printf(" argv[%2d]: %s\n", i, argv[i]);
printf("\n");
printf("Environment variables:\n");
for (i = 0; envp[i] != NULL; i++)
printf(" envp[%2d]: %s\n", i, envp[i]);
exit(0);
}
// 其输出为:
➜ ecf ./myecho arg1 arg2
Command-line arguments:
argv[ 0]: ./myecho
argv[ 1]: arg1
argv[ 2]: arg2
Environment variables:
envp[ 0]: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/
games:/usr/local/games:/snap/bin
envp[ 1]: XDG_VTNR=7
envp[ 2]: LC_PAPER=zh_CN.UTF-8
envp[ 3]: XDG_SESSION_ID=c4
envp[ 4]: LC_ADDRESS=zh_CN.UTF-8
envp[ 5]: CLUTTER_IM_MODULE=xim
envp[ 6]: XDG_GREETER_DATA_DIR=/var/lib/lightdm-data/ferris
。。。。。。下面还有很多
一个shell和web服务器的程序会大量使用 fork() 和 execve() 函数。具体可以看shelllab
信号
进程通信的方式有如下几种:
管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
- 有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
信号量( semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
共享内存( shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
套接字( socket ) : 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机间的进程通信。
信号 ( sinal ) : 用于通知接收进程某个事件已经发生
这一次谈谈信号:
信号是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都将被中断。如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。
常见的信号有:
信号的处理有两个步骤:
发送信号
发送信号的原因有两个:
内核检测到了如除以零(SIGFPE)或子进程终止(SIGCHLD)的系统事件
另一个进程调用了 kill 指令来请求内核发送信号给指定的进程(一个进程可以发送信号给自己)
发送信号的方式:
- kill 指令
- 例如 kill -9 12345
发送SIGKILL杀死程序信号终结PID为12345的程序
- 例如 kill -9 12345
- 键盘输入
- 如Ctrl + C 发送SIGINT给当前程序
- 使用alarm函数给自己发送SIGALRM信号
unsigned int alarm(unsigned int secs);
- 使用kill 函数
int kill ( pid_t pid,int sig);
若pid参数小于0,其为发送sig信号给该进程组的所有进程
进程组
每个进程都只属于一个进程组,可以使用pstree查看
➜ chap8 pstree
systemd─┬─ModemManager─┬─{gdbus}
│ └─{gmain}
├─NetworkManager─┬─dhclient
│ ├─dnsmasq
│ ├─{gdbus}
│ └─{gmain}
├─accounts-daemon─┬─{gdbus}
│ └─{gmain}
├─acpid
├─agetty
├─avahi-daemon───avahi-daemon
├─bluetoothd
├─colord─┬─{gdbus}
│ └─{gmain}
一般使用如下函数:
- getpgrp() - 返回当前进程的进程组
- setpgid() - 设置一个进程的进程组
查看进程的状态可以使用ps指令
接受信号
其发生在: 当内核把进程p从内核模式切换为用户模式时,其检测进程p的未被阻塞的待处理信号的集合(pending & ~blocked)。当该集合为非空时,其选择集合中的最小信号k使得进程p接受信号k。
并且,内核会强制要求进程对于信号做出响应,可以有几种不同的操作:
- 忽略这个型号
- 终止进程
- 捕获信号,执行信号处理器(signal handler),类似于异步中断中的异常处理器(exception handler)
未被阻塞的待处理信号的集合(pending & ~blocked)中的值为pnb 值:
pnb = pending & ~blocked
其规则如下:
- 如果 pnb == 0,那么就把控制交给进程 p 的逻辑流中的下一条指令
- 如果 pnb != 0(有一定的优先级)
- 选择 pnb 中最小的非零位 k,并强制进程 p 接收信号 k
- 接收到信号之后,进程 p 会执行对应的动作
- 对 pnb 中所有的非零位进行这个操作
- 最后把控制交给进程 p 的逻辑流中的下一条指令
每个信号类型都有一个预定义的『默认动作』,可能是以下的情况:
- 终止进程
- 终止进程并 dump core
- 停止进程,收到 SIGCONT 信号之后重启
- 忽略信号
可以通过signal 函数修改默认的动作,函数原型为
handler_t *signal(int signum, handler_t *handler)。
handler有三种方法来改变signum的行为:
handle = SIG_IGN; 忽略该信号
handle = SIG_DFL; 将信号恢复原来的行为
自己定义的* 信号处理程序 *
- 调用信号处理程序为 * 捕获信号*
- 执行信号处理程序为 * 处理信号*
一个例子如下:
void sigint_handler(int sig) // SIGINT 处理器
{
printf("想通过 ctrl+c 来关闭我?\n");
sleep(2);
fflush(stdout);
sleep(1);
printf("OK. :-)\n");
exit(0);
}
int main()
{
// 设定 SIGINT 处理器
if (signal(SIGINT, sigint_handler) == SIG_ERR)
unix_error("signal error");
// 等待接收信号
pause();
return 0;
}
还有一个需要注意的是,信号是有优先级的;
信号处理器也可以被其他的信号处理器中断,控制流如下图所示:
阻塞信号
内核会阻塞与当前在处理的信号同类型的其他正待等待的信号,也就是说,一个 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);
安全处理信号
信号处理器的设计是一个棘手的问题,因为:
信号处理器和主程序并发运行且共享相同的全局数据结构,尤其要注意因为并行访问可能导致的数据损坏的问题
信号接受的方式和时间 的规则有时候事与人违
不同系统的信号处理方式不同
这里给出一些基本的原则
- 规则 1:信号处理器越简单越好
- 例如:设置一个全局的标记,并返回
- 规则 2:信号处理器中只调用异步且信号安全(async-signal-safe)的函数,具体见man 7 signal
- 异步且信号安全(async-signal-safe)函数指的是能被信号处理器安全地调用,
- 要么是可重入的,所有的变量都保存在栈帧中的函数
- 要么不能被其他信号打断
- 一些安全的函数:_exit, write, wait, waitpid, sleep, kill
- 诸如 printf, sprintf, malloc 和 exit 都是不安全的!
- 唯一的写安全的函数是write
- 规则 3:在进入和退出的时候保存和恢复 errno
- 这样信号处理器就不会覆盖原有的 errno 值
- 规则 4:临时阻塞所有的信号以保证对于共享数据结构的访问
- 防止可能出现的数据损坏
- 规则 5:用 volatile 关键字声明全局变量(仅仅是全局变量)
- volatile 告诉编译器不用缓存这个变量,每次都从内存中读。
- 这样编译器就不会把它们保存在寄存器中,保证一致性
规则 6:用 si sig_atomic_t 来声明全局标识符(flag)
- 这样可以防止出现访问异常
- 原子性指的是不可中断的
非本地跳转 Non local Jump
本地跳转指的是在一个程序中通过 goto 语句进行流程跳转,本地跳转的限制在于,我们不能从一个函数跳转到另一个函数中。
如果想突破函数的限制,就要使用 setjmp 或 longjmp 来进行非本地跳转了。
setjmp
保存当前程序的堆栈上下文环境(stack context),注意,这个保存的堆栈上下文环境仅在调用 setjmp 的函数内有效,如果调用 setjmp 的函数返回了,这个保存的堆栈上下文环境就失效了。调用 setjmp 的直接返回值为 0。
int setjmp(jmp_buf env); 或 int sigsetjmp(sigjmp_buf env, int savesigs);
longjmp
- 将会恢复由 setjmp 保存的程序堆栈上下文,即程序从调用 setjmp 处重新开始执行,不过此时的 setjmp 的返回值将是由 longjmp 指定的值。注意longjmp 不能指定0为返回值,即使指定了 0,longjmp 也会使 setjmp 返回 1。
void longjmp(jmp_buf env,int retval);
void siglongjmp()sigjmp_buf env,int etval);
注:
C++中的异常处理机构是较高层次的,是C语音setjmp和longjmp函数的高级版本
其结构如下:
try{
// 异常处理的代码
} catch ( 异常的类型// 类似setjmp函数 ){
// throw 类似longjmp 函数
}
总结
这一章贯彻的东西很多,从基本的异常开始说起,说的了用户态和内核态。谈到进程以及相应的状态及处理;并发和并行,信号的发送以及接受,不同进程的竞争race,如何安全处理信号,最后谈到了非局部跳转也就是高级语音的异常处理。耗费的精力比较多,但值得。