8.1 异常

  • 异常就是控制流中的突变,用来响应处理器状态中的某些变化。

image.png

当处理器检测到有事件发生时,就通过异常表的跳转表,进行一个间接过程调用,到一个专门处理这类事件的OS子程序。

8.1.1 异常处理

系统中可能的每种类型异常都分配了一个唯一的非负整数:异常号。其中一些由处理器设计者分配,另一些由OS内核(OS常驻内存部分)设计者分配。前者包括被零除、缺页、内存方文伟里、断点以及算数溢出;后者包括系统调用、外部I/O信号。

在系统启动时,OS分配和初始化异常表。异常号到是到异常表的索引,异常表的起始地址放在异常表基址寄存器中。

image.png

异常调用类似过程调用,但也有不同:

  1. 过程调用过程调用时, 在跳转到处理程序之前,处理器将返回地址压入栈中。然而根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一条指令。
  2. 处理器也会把一些额外的处理器状态压入栈中,当处理程序返回,重新开始执行被中断程序会需要这些状态。
  3. 如果控制从用户程序转移到内核,这些项目都被压到内核栈中,而不是用户栈中。
  4. 异常处理程序运行在内核模式下,所以它们对所有系统资源都有完全访问权限。

8.1.2 异常的类别

类别 原因 异步/同步 返回行为
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

异步:异常不是由任何专门指令造成的。

同步:异常是执行当前指令的结果。


  • 中断

中断是来自CPU外部的I/O设备的信号的结果。它不由任何一条专门的指令造成,所以它是异步的。

  • 陷阱和系统调用

陷阱是有意的异常,是一条指令的结果。陷阱最重要的用途就是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用

用户经常需要向内核请求服务,如读文件(read)、创建新的进程(fork)、加载新程序(execve),终止当前进程(exit)。为了允许对这些内核服务的受控的访问,CPU提供了”syscall n”指令。执行syscall指令会导致一个陷阱的异常处理程序,它可以解析参数并调用适当的内核程序。

普通函数运行在用户模式,限制了函数可以执行的指令的类型,而且只能访问与调用函数相同的栈。系统调用运行在内核模式,允许系统调用执行特权指令,并访问定义在内核中的栈。

  • 故障

故障由错误情况引起,可能可以被故障处理程序修正。如果CPU能修正,就将控制返回到引起故障的指令,重新执行;否则处理程序返回内核中的abort例程,终止程序。

image.png

一个经典的故障就是缺页异常。当指令引用一个虚拟地址,而与改地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,就可以正常运行了。

  • 终止

通常是一些硬件错误,不可恢复。处理程序直接交给abort例程,终止程序。


8.1.3 Linux/x86-64系统中的异常

所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递的。

image.png

  1. int main(){
  2. write(1,"hello, world\n",13);
  3. _exit(0);
  4. }

write函数的第一个参数将输出发送到stdout。第二个参数是要写的字节序列,第三个参数是要写的字节数。

8.2 进程

  • 进程的经典定义:一个执行中程序的实例。

系统中的每个程序都运行在某个进程上下文中。

8.2.2 并发流

  • 一个逻辑流的执行在时间上与另一个流重叠,称为并发流。多个流并发的执行被称为并发

image.png

8.2.3 私有地址空间

进程为每个程序提供一种假象,好像它独占地使用系统地址空间。

image.png

8.2.4 用户模式和内核模式

CPU通过某个控制寄存器中的一个模式位描述进程当前享有的特权:内核模式和用户模式。

image.png

Linux提供/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容的内容输出位一个用户程序可以读的文本文件的层次结构。比如CPU类型(/proc/cpuinfo)

8.2.5 上下文切换

  • 内核为每个进程维持一个上下文

image.png

8.4 进程控制

8.4.1 获取进程ID

  • 每个进程都有一个唯一的正数(非零)进程ID(PID)。
  1. #include<sys/types.h>
  2. #include<unistd.h>
  3. pid_t getpid(void); //getpid函数返回进程PID
  4. pid_t getppid(void); //getppid函数返回它的父进程的PID

Linux系统上pid_t在types.h被定义为int。

8.4.2 创建和终止进程

image.png

  1. #include<stdlib.h>
  2. void exit(int status);

exit函数以status退出状态来终止进程


  1. #include<sys/types.h>
  2. #include<unistd.h>
  3. pid_t fork(void);

新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还活的与父进程任何打开文件描述符相同的副本。子进程可以读写父进程中打开的任何文件

8.4.3 回收子进程

  • 当一个进程终止时,内核并不是立刻把它清除。而是把它保持在一种终止状态,直到被它的父进程回收。

当父进程回收已终止子进程的时候,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。

一个终止但还未被回收的进程称为僵死进程


image.png


一个进程可以通过waitpid等待他的子进程终止:

  1. #include<sys/types.h>
  2. #include<sys/wait.h>
  3. pid_t waitpid(pid_t pid, int *startusp, int options);

默认情况下(当options=0时),waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。此时该函数返回已终止子进程的PID。

  • 判定等待集合的成员

等待集合的成员是由参数pid确定的

如果pid>0,那么等待集合就是一个单独的子进程,他的进程ID等于pid;

如果pid=-1,那么等待集合就是由父进程所有的子进程组成的。

  • 修改默认行为

通过将options设置为常量:WNOHANG、WUNTRACED和WCONTINUED的各种组合来修改默认行为

image.png

  • 检查已回收子进程的退出状态

如果statusp参数是非空的,那么waipid就会在status中放入导致返回的子进程的状态信息。

image.png

  • 错误条件

如果调用进程没有子进程,那么waitpid返回-1,并且设置errnoECHILD。如果waitpid函数被一个信号中断,那么返回-1,设置errnoEINTR。(为了检查ECHILDEINTR必须包含errno.h头文件)


wait函数:是waitpid的简单版本

  1. #include<sys/types.h>
  2. #include<sys/wait.h>
  3. pid_t wait(int *statusp);

调用wait(&status)等价于调用waitpid(-1.&status,0)

8.4.4 让进程休眠

sleep函数将一个进程挂起一段指定时间;pause函数让调用进程休眠,直到该进程收到一个信号:

  1. #include<unistd.h>
  2. unsigned int sleep(unsigned int secs);
  3. int pause(void);

如果请求的时间量到了,sleep返回0,否则返回还剩下要休眠的秒数(因为sleep函数被一个信号中断而过早返回)。

8.4.5 加载并运行程序

execve函数在当前进程的上下文中加载并运行一个新程序:

  1. #include<unistd.h>
  2. int execve(const char * filename, const char *argv[],const char *envp[]);

加载并运行可执行目标文件filename,带参数列表argv和环境变量列表envp,只有出现错误才会返回到调用程序。execve从不返回

Hint:参数argv[0]是可执行文件的名字,所以更多参数从argv[1]开始。

image.png

当main开始执行的时候,用户栈的组织结构:

image.png


fork()execve()的区别(即进程和程序的区别):

  • fork在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。
  • execve在当前进程的上下文中加载并运行新的程序。会覆盖当前进程的地址空间,但并没有创建新进程,而保持了PID,并且继承了调用**execve**时候的所有已打开的文件描述符

8.5 信号

  • Linux信号,允许进程和内核中断其它进程。

8.5.1 信号术语

传送一个信号到目的进程由两个步骤组成:

  • 发送信号:内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程上下文。发送信号有两个二原因:
  1. 内核检测到一个系统事件,比如除0错误或者子进程终止。
  2. 一个进程调用了kill函数显式要求内核发送一个信号给目的进程。
  • 接收信号:当目的进程被内核强迫以某种方式对信号做出反应时,它就接收了信号。进程可以忽略、终止或者执行信号处理函数的用户层函数捕获该信号。

一个发出而没有被接收的信号叫待处理信号。一种类型至多只有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么接下来发送给该进程类型为k的信号都不会排队等待,而是直接丢弃。 一个进程可以有选择地阻塞接收某种信号。当一种信号被阻塞时,仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。

image.png

8.5.2 发送信号

Unix系统发送信号的机制都是基于进程组这个概念的。

  • 进程组

getpgrp返回当前进程的进程组ID:

  1. #include<unistd.h>
  2. pid_t getpgrp(void);

默认情况下,子进程和父进程同属一个进程组。可以通过setpgid改变自己活着其他进程的进程组:

  1. #include<unistd.h>
  2. int setpgid(pid_t pid,pid_t pgid);

setpgid将进程pid的进程组改为pgid。如果pid是0,那么就使用当前进程的PID。如果pgid是0那么就用pid指定的进程的PID作为进程组ID。


  • /bin/kill程序发送信号

image.png


  • 从键盘发送信号

shell使用作业(jod)这一抽象概念来表示对一条命令行求值而创建的程序。在任何时刻,最多只有一个前台作业和多个后台作业

image.png


  • kill函数发送信号
  1. #include<sys/types.h
  2. #include<signal.h>
  3. int kill(pid_t pid,int sig);

如果pid大于0,kill发送给信号号码sig给进程pid

如果pid等于0,kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己;

如果pid小于0,kill发送信号sig给进程组|pid|中的每个进程。


  • 用alarm函数发送信号

进程可以通过调用alarm函数向他自己发送SIGALRM信号:

  1. #include<unistd.h>
  2. unsigned int alarm(unsigned int secs);

alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程。

8.5.3 接收信号

image.png

每个信号类型都有一个预定义的默认行为:

  • 进程终止
  • 进程终止并转储内存
  • 进程停止(挂起)直到被SIGCONT信号重启
  • 进程忽略该信号

进程可以通过signal函数修改和信号相关联的默认行为。然而SIGSTOP和SIGKILL的默认行为是不可修改的

  1. #include<signal.h>
  2. //返回值void,参数为int的函数指针的typedef
  3. typedef void (*sighandler_t)(int);
  4. sighandler_t signal(int signum,sighandler_t handler);

signal函数可以通过以下三种方式之一修改和信号signum相关的行为:

  • 如果handler是SIG_IGN,那么忽略类型为signum的信号
  • 如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为
  • 否则,handler就是用户定义的函数地址,信号处理程序,只要进程接受到一个类型为signum的信号就会调用这个程序。调用信号处理程序被称为捕获信号。执行被称为处理信号

信号处理程序也可以被其他信号处理程序中断:

image.png

8.5.4 阻塞和解除阻塞信号

Linux提供阻塞信号的隐式和显式的机制:

  • 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号。
  • 显式阻塞机制。应用程序可以使用sigprocmask函数和它的辅助函数,明确阻塞和解除阻塞选定的信号。
  1. #include<signal.h>
  2. int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
  3. //初始化set为空集合
  4. int sigemptyset(sigset_t *set);
  5. //把每个信号都添加到set中
  6. int sigfillset(sigset_t *set);
  7. //把signum添加到set
  8. int sigaddset(sigset_t *set, int signum);
  9. //把signum从set中删除
  10. int sigdelset(sigset_t *set, int signum);
  11. //如果signum是set中的成员就返回1;否则返回0
  12. int sigismember(const sigset_t *set,int signum);

sigprocmask函数改变当前阻塞的信号集合,行为依赖于how

SIG_BLOCK:把set中的信号添加到blocked中(blocked=blocked |set)。

SIG_UNBLOCK:从blocked中删除set中的信号(blocked=blocked &~set)。

SIG_SETMASK:block=set。

如果oldset非空,那么block位向量之前的值保存在oldset中。

使用示例:

image.png

8.5.5 编写信号处理程序

信号处理程序和主程序并发运行,共享相同的全局变量,因此可能与主程序和其他处理程序互相干扰。


  • 信号处理程序中产生输出唯一安全的方法是使用write函数。调用printf和sprintf是不安全的。
  • 保存和恢复errno
  • 阻塞所有信号,保护对共享全局数据结构的访问
  • 用volatile声明全局变量
  • 用sig_atomic_t声明标志

正确的信号处理:

image.png

感觉剩下的过于虚幻……