异常控制和进程
异常控制流
- ak是某个指令Ik的地址,每次从ak到ak+1的过渡称为控制转移,这样的控制转移序列叫做处理器的控制流
- Ik和Ik+1在内存中是相邻的,这种最简单的控制流是”平滑的序列”,平滑流的突变通常是跳转、调用和返回造成的
- 现代系统通过使控制流发生突变来应对系统的突变,将这些突变称为异常控制流(Exceptional Control Flow,ECF)。异常控制流发生在计算机系统的各个层次,ECF是操作系统用来实现I/O、进程和虚拟内存的基本机制
- 应用程序通过使用陷阱或者系统调用的ECF形式,向操作系统请求服务,操作系统为应用程序提供了强大的ECF机制,用来创建新进程、等待进程终止、通知其他进程系统的异常事件以及检测和响应这些事件
- ECF是计算机实现并发的基本机制
- 异常发生于硬件和操作系统交界的部分,进程和信号位于应用和操作系统的交界处,非本地跳转是ECF应用层形式
异常
- 处理器检测到有事情发生时,通过一张交错异常表的跳转表,进行间接过程调用,到一个专门用来处理这类程序的操作系统子程序(异常处理程序),异常处理程序完成后根据引起异常的数据类型,发生以下3中情况的一种:
- 处理程序将控制返回给当前指令
- 处理程序将控制返回给当前指令的下一条指令
- 处理程序终止
- 异常处理
- 系统为每个类型的异常都分配一个唯一的非负整数异常号,系统启动后操作系统分配和初始化一张称为异常表的跳转表,条目k包含异常k的处理程序的地址,异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器中
- 异常程序处理程序运行在内核模式下,它们对所有系统资源都有访问权限
- 异常的类别
异常可分为四类:终端、陷阱、故障和终止
- 硬件中断的异常处理程序称为中断处理程序。剩下三种异常是同步的,是执行当前指令的结果,我们把这类指令叫做故障指令
- 陷阱最重要的用途是在用户程序和内核之间提供一种像过程一样接口,称为系统调用。用户请求服务n时,执行
syscall n
,导致一个到异常处理程序的陷阱。 - 普通函数运行在用户模式中,用户模式限制了函数可执行的指令的类型,只能访问和调用函数相同的栈
- 系统调用运行在内核模式中,允许调用执行特权指令,访问内核中的栈
- 故障由错误的情况引起,可能被故障处理程序修正。故障发生时,处理器将控制转移给故障处理程序。如果能够处理和这个错误,将控制返回到引起故障的指令,重新执行。否则,处理程序返回内核的例程,终止引起故障的程序。
- 终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,DRAM或者SRAM位损坏发生的奇偶错误,处理程序将控制返回给一个abort例程,终止程序
- Linux/x86-64系统中的异常
- x86-64有256中不同的异常类型,0-31是Intel架构师定义的异常,32-255对应的是操作系统定义的终端和陷阱
- C程序syscall函数可以直接调用任何系统调用,但是一般使用系统调用和相关联的包装函数统称为系统级函数
- Linux系统调用的参数一般都通过寄存器而不是栈传递。%rax包含系统调用,%rdi、%rsi、%rdx、%r10、%r9、%r8分别包含第1-6个参数,从系统调用返回时,寄存器%rcx和%r11会被破坏,%rax包含返回值(-4095到-1表示发生错误,对应负值的errno)
进程
- 异常是操作系统允许内核提供进程概念的基本构造块
- 进程是执行中的程序的实例,系统中的每个程序都运行在某个进程的上下文中,上下文由程序正确运行所需的状态组成。这个状态包括内存中程序的代码和数据,栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合
- 进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流
- 一个私有的地址空间
- PC值序列称为逻辑控制流(逻辑流),一个逻辑流的执行在时间上与另一个流重叠,称为并发流,一个进程和其他进程轮流运行的概念称为多任务,一个进程执行它的控制流一部分的每一个时间段称为时间片,多任务也叫时间分片,两个流并发的运行在不同的处理器核或者计算机上,称它们为并行流
- 进程为每个程序提供私有地址空间,空间中某个地址相关联的内存字节不能被其他进程读或者写
- 处理器通常使用控制寄存器的一个模式位来限制进程当前享有的特权
- 设置了模式位时,进程运行在内核模式,可以执行指令集中的任何指令,可以访问系统中的任何内存位置
- 没有设置模式位,进程运行在用户模式中,进程不允许执行特权指令,必须通过系统调用接口间接地访问内核代码和数据
- Linux提供了/proc文件系统,允许用户模式进程访问内核数据结构的内容。/proc文件系统将内核数据结构的内容输出为用户可读的文本层次结构,2.6版本的Linux引入了/sys文件系统,输出关系系统总线和设备的低层信息
- 上下文切换是一种较高层形式的异常控制流,可以实现多任务。内核为每个内存维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(如,地址空间的页表、包含当前进程信息的进程表、进程已打开文件的信息的文件表)
- 上下文切换:
- 保存当前进程的上下文
- 恢复先前被抢占的进程的上下文
- 将控制转移给这个新恢复的进程
进程控制
- 进程有唯一的进程ID(整数),getpid函数返回进程的PID
- 进程的三个状态:
- 运行,在CPU上执行或者等待被执行且最终被调度
- 停止,进程被挂起,且不会被调度,停止信号SIGSTOP/SIGTSTP/SIGTTIN/SIGTTOU,再次开始信号SIGCONT
- 终止,进程永远终止,收到信号的默认行为是终止进程、从主程序返回、调用exit函数(exit是以status退出状态来终止进程的)
- 父进程调用fork函数来创建一个新的运行的子进程,两者的PID不同
- 子进程得到与父进程用户级虚拟地址空间相同的但独立的一个副本,包括代码和数据段、堆、共享库以及用户栈
- 子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程打开的任何文件
- 每个进程都只属于一个进程组,进程组由一个正整数进程组ID来标识,
getpgrp
函数返回当前进程的进程组ID
- 子进程和父进程同属于一个进程组
- 一个进程可以通过使用
setpgid
函数来改变自己或其他进程的进程组ID1
2
3
4
5
6#include<unistd.h>
int setpgid(pid_t pid,pid_t pgid);
//将进程组pid改成pgid
//如果pid为0,则使用当前进程的PID
//如果pgid为0,用pid指定的进程PID作为进程组ID
setpgid(0,0);//创建一个新的进程组ID15213(调用进程),并且进程15213加入这个新的进程组中
- 父进程和子进程的异同点
- fork被调用一次会返回两次,一次在父进程中返回子进程的PID,另一次在子进程中返回0
- 调用fork,父进程和子进程是并发运行的独立进程
- 在两个进程均没有发生改变之前,父进程和子进程有相同但独立的地址空间
- 子进程和父进程共享文件
- 回收子进程
- 子进程退出但未被回收的状态被称为僵死过程
- 父进程终止子进程的过程:内核将子进程的退出状态传递给父进程,抛弃已终止的进程
- 如果父进程终止了,内存会安排init进程成为它的孤儿进程的养父,init进程的PID为1,是所有进程的祖先。进程可以通过waitpid函数来等待子进程终止或停止
1
2
3
4
5
6
7
8
9#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid,int *statusp,int options);
/*默认情况下(options=0),waitpid函数挂起调用进程的执行,直到等待集合中的一个子进程终止,返回子进程的PID*/
/*判断等待集合的成员:1)pid>0等待集合就是一个单独的子进程,进程ID=pid;2)pid=-1,等待集合是由父进程的所有子进程组成的*/
/*通过将option设为常量WNOHANG/WUNTRACED/WCONTINUED的各种组合来修改默认行为*/
/*如果statusp参数是非空的,waitpid就会在status中放上关于导致返回子进程的状态信息,status就是指向statusp的值*/
/*wait.h头文件定义了status参数的几个宏*/
options常量组合
status参数的几个宏
- 错误条件
- 如果调用进程没有子进程,waitpid返回-1,并设置errno为ECHILD
- 如果waitpid函数被一个信号中断,则返回-1,设置errno为EINTR
让进程休眠
1
2
3
4
5
6
7#include <unistd.h>
unsigned int sleep(unsigned int secs);
/*如果请求的时间量到了,sleep返回0,否则返回剩下的秒数*/
/*系统让调用函数休眠,直到该进程收到一个信号*/
int pause(void);加载并运行程序
1
2
3
4
5
6int execve(const char *filename,const char *argv[],const char *envp[]);
/*execve函数在当前进程的上下文加载并运行一个新程序,如果成功则不返回,如果错误则返回-1*/
/*参数变量表argv和环境变量表envp
argv指向一个以null为结尾的指针数组,每个指针都指向一个参数字符串,argv[0]是可执行目标文件的名字
envp变量指向一个以null为结尾的指针数组,每个指针指向一个环境变量,每个串形如"name=value"的名字值对
*/
1
2
3
4
5
6
7
8
9
10
11
12int main(argc,char **argv,char **envp);
//main函数的三个参数
//argc给出argv[]数组中非空指针的数量,argv指向argc[]数组中的第一个条目,envp指向encp[]数组中的第一个条目
#include <stdlib.h>
char *getenv(const char *name);
//getenv函数在环境数组中搜索字符串,如果找到就返回value的指针,否则返回NULL
//如果环境数组包含一个形如“name=oldvalue”的字符串
int setenv(const char *name,const char *newvalue,int overwrite);
//用newvalue代替oldvalue,只有overwrite非零时才会这样,如果name不存在,将“name=oldvalue”添加到数组中
void unsetenv(const char *char);//删除字符串
- shell执行一系列读/求值,然后终止,读步骤读取来自用户的一个命令行,求值步骤解析命令行,代表用户运行程序
信号
- Linux信号是更高层的软件形式的异常,允许进程和内核中断其他进程
- 每种信号类型都对应于某种系统事件,低层的硬件异常是由内核处理程序处理的,通常对用户进程是不可见的,系统提供了一种机制通知用户发生了这些异常
- 传送信号到目的进程是由两个不同步骤组成的:
- 发送信号,内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程,发送信号的两个原因,内核检测到一个系统事件或是一个进程调用了kill函数
- 接收信号,目的进程被内核强迫以某种方式对信号的发送做出反应,进程可以忽略这个信号,终止或通过执行一个称为信号处理程序的用户层函数捕获这个信号
4.一个发出而没有被接收的信号叫做待处理信号,在任何时刻,一种类型至多有一个待处理信号,如果进程有一个待处理信号k,那么接下来所有发送到这个进程的信号k都会被丢弃
- 一个进程可以有选择的阻塞接收某种信号,当一种信号被阻塞时,仍可以被发送,但是产生的待处理信号不会被接收。一个待处理信号只能被接收一次,内核为每个进程在pending位向量中维护待处理信号的集合
- blocked位中维护者被阻塞的信号集合
发送信号
- 用/bin/kill程序发送信号
/bin/kill -9 15213 #发送信号9到进程15213
/bin/kill -9 -15213 #一个为负的PID会导致信号被发送到进程组PID中的每个进程
- 从键盘发送信号
ls | sort
#常见由两个进程组成的前台作业,两个进程通过Unix管道连接,一个进程运行ls程序,另一个运行sort程序 - shell为每个作业创建一个独立的进程组,进程组ID通常取自作业中父进程中的一个
用kill函数发送信号
1
2
3
4
5
6#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid,int sig);
//若pid>0,kill函数发送信号sig给进程pid
//若pid=0,kill函数发送信号sig给调用进程所在进程组的每个进程,包括调动进程自己
//若pid<0,kill函数发送信号sig给进程|pid|中的每个进程用alarm函数发送信号
1
2
3
4
5
6#include <unistd.h>
unsinged int alarm(unsigned int secs);
//安排内核在secs秒后发送一个信号给调用进程,如果secs=0,调度不会安排新的闹钟
//在任何情况下,对alarm的调用都会取消任何待处理的(pending)闹钟
//返回任何待处理闹钟在发送前还剩下的秒数
//如果没有任何待处理的闹钟,就返回0
接收信号
- 每个信号都一个预定义的默认行为
- 进程终止
- 进程终止并转储内存
- 进程停止知道被SIGCONT信号重启
- 进程忽略该信号
signal信号
1
2
3
4
5
6
7
8#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighander_t handler);
//signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为
//如果handler是SIG_ING,忽略类型为signum的信号
//如果handler是SIG_DFL,类型为signum的信号行为恢复为默认行为
//否则handler就是用户定义的函数的地址,这个函数被称为信号处理程序
//通过处理程序把地址传递到signal函数而改变默认行为,叫做设置信号处理程序调用信号处理程序被称为捕获信号,执行信号处理程序被称为处理信号
- 进程捕获一个类型为k的信号,会调用信号k设置的信号处理程序,一个整数参数被设置为k,这个参数允许同一个处理函数捕获不同类型的信号
阻塞和解除阻塞信号
- 隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号
显式阻塞机制:应用程序使用sigprocmask函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号
1
2
3
4
5
6
7
8
9
10
11
12
13
14#include <signal.h>
int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
//sigprocmask会改变当前阻塞的信号集合,以来与how值
//SIG_BLOCK:把set中的信号添加到blocked中(block=block|set)
//SIG_UNBLOCK:从block中删除set中的信号(block=block&~set)
//SIG_SETMASK:block=set
//如果oldset非空,那么blocked位向量之前的值保存在oldset中
int sigemptyset(sigset_t *set);//初始化set为空集合
int sigfillset(sigset_t *set);//把每个信号都添加到set中
int sigaddset(sigset_t *set,int signum);//把signum添加到set中
int sigdelset(sigset_t *set,int signum);//把signum从set中删除
int sigismember(const sigset_t *set,int signum);//如果signum是set成员返回1,否则返回0通过阻塞信号和取消阻塞信号来避免并发错误
编写信号处理程序
- 主程序和信号处理程序并发运行,相互干扰
- 信号处理中产生输出唯一安全的方法是使用write函数,为了绕开这个限制,我们开发了一些安全的函数,称为SIO包,用来在信号处理程序中打印简单的信息
1
2
3
4
5#include "csapp.h"
ssize_t sio_putl(long v);//向标准输出传送long类型数
ssize_t sio_puts(char s[]);//向标准输出传送字符串
void sio_error(char s[]);//打印一条错误信息并终止
- 处理程序尽可能的简单
- 在处理程序中只调用异步信号安全的函数,是可重入的或者不能被信号处理程序终端
- 保存和恢复errno,在进入处理程序时将errno保存在一个局部变量中,在处理程序返回时恢复它
- 阻塞所有信号,保护对共享全局数据结构的访问,保证处理程序不会中断指令
- 用volatile生声明全局变量,强迫编译器每次在代码中引用g时都要从内存中读取g的值
- 用sig_atomic_t声明标志,保证读和写是原子的(不可中断的),
volatile sig_atomic_t flag;
- 正确的信号处理,不可以用信号对其他进程中发生的之间计数,相同类型的信号可能会丢失
- 可移植的信号处理:1)不同的系统有不同的信号处理语义;2)系统调用可以被中断
- Posix标准定义了sigaction函数,允许用户设置信号处理时明确指定他们想要的信号处理语义
1
2
3#include <signal.h>
int sigaction(int signum,struct sigaction *act,struct sigaction *oldact);
//一个更简洁的方式,用Signal包装函数调用sigaction
Signal包装函数设置了一个信号处理程序,其信号处理语义如下:
- 只有这个处理程序当前正在处理的那种类型的信号被阻塞
- 信号不会排队的等待
- 中断的系统调用会自动重启
- 一旦设置了信号处理程序,会一直被保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用
- 显式的等待信号
- 父进程设置SIGINT和SIGCHLD的处理程序,然后进入一个无限循环
- 阻塞了SIGCHLD信号,避免了竞争
- 创建了子进程之后,将pid设为0,取消阻塞SIGCHLD,然后以循环的方式等待pid变为非零
- 子进程终止后,处理程序回收它,以非零的PID赋值给全局pid变量,终止循环,父进程开始其他工作,然后开始下一次迭代
- 使用sigsuspend,修补循环造成的浪费资源
1
2
3
4
5#include <signal.h>
int sigsuspend(const sigset_t *mask);
//暂时用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号
//如果信号的行为是终止的,那么sigsuspend从处理程序返回
//如果信号的行为是运行一个处理程序,sigsuspend从处理程序返回,恢复调用sigsuspend时原有的阻塞集合
非本地跳转
- 将控制直接从一个函数转移到当前正在执行的函数,而不需要经过正常调用-返回序列,通过setjmp和longjmp函数来提供的
- 非本地跳转允许从一个深层嵌套的函数调用中立即返回,通常是检测到某个错误情况引起的
1
2
3
4
5
6
7
8
9
10#include <setjmp.h>
int setjmp(jmp_buf env);
//在env缓冲区中保存当前调用环境,供后面的longjmp使用,返回0,stejmp返回的值不能被赋值给变量
//调用环境包括程序计数器、栈指针和通用目的寄存器
int sigsetjmp(sigjmp_buf env,int savesigs);
void longjmp(jmp_buf env,int retval);
void siglongjmp(sigjmp_buf env,int retval);
//longjmp函数从env缓冲区中恢复调用环境,触发从最近一次初始化env的setjmp调用的返回,然后setjmp返回并带有非零的返回值retval
//setjmp调用一次,但是返回多次;longjmp调用一次,但是从不返回
操作进程的工具
- STRACE:打印正在运行的程序和子进程调用的每个系统调用的轨迹,用
-strace
编译程序 - PS:列出当前系统中的进程(包括僵死程序)
- TOP:打印出关于当前进程资源使用的信息
- PMAP:显示进程的内存映射
- /proc:虚拟文件系统,以ASCII文本格式输出大量内核数据