写在前面的话:此系列文章为笔者学习CSAPP时的个人笔记,分享出来与大家学习交流,目录大体与《深入理解计算机系统》书本一致。因是初次预习时写的笔记,在复习回看时发现部分内容存在一些小问题,因时间紧张来不及再次整理总结,希望读者理解。
《深入理解计算机系统(CSAPP)》第3章 程序的机器级表示 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第5章 优化程序性能 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第6章 存储器层次结构 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第7章 链接- 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第8章 异常控制流 - 学习笔记_友人帐_的博客-CSDN博客
《深入理解计算机系统(CSAPP)》第9章虚拟内存 - 学习笔记_友人帐_的博客-CSDN博客
第八章 异常控制流
**控制流:**处理器执行的指令序列。
- 可以对(由程序变量表示)程序状态的变化做出反应:跳转和分支、调用和返回。
- 难以对系统状态的变化(不能用程序变量表示)做出反应:磁盘或网络适配器的数据到达、除零错误、用户的键盘输入(Ctrl-C)、系统定时器超时。
**异常控制流(Exceptional Control Flow, ECF):**通过使控制流发生突变对这些情况做出反应。
发生在计算机系统的所有层次:
- 低层机制(硬件层)
- 异常:由操作系统和硬件共同实现,硬件检测到的事件会触发控制转移到异常处理程序。
- 高层机制
- 进程切换(Process context switch):通过操作系统和硬件定时器实现
- 信号(Signals):操作系统实现
- 非本地跳转(Nonlocal jumps):setjmp()和longjmp(),C运行库实现,跨越函数之间的控制权跳转
1. 异常
异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化。(为响应某个事件将控制权转移到操作系统内核中的情况(内核:操作系统常驻内存的部分))。
在处理器中,状态被编码为不同的位和信号。状态变化称为事件(event)。事件可能和当前指令的执行直接相关(虚拟内存缺页、算术溢出,除以零),方也可能和当前指令的执行没有关系(系统定时器产生信号、一个I/O请求完成)。
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler))。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况中的一种:
1)处理程序将控制返回给当前指令 I c u r r I_{curr} Icurr,即当事件发生时正在执行的指令。
2)处理程序将控制返回给 I n e x t I_{next} Inext,如果没有发生异常将会执行的下一条指令。
3)处理程序终止被中断的程序。Abort
1.1 异常处理
需要硬件和软件紧密合作。系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。
-
在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址。
-
在处理器检测到发生了一个事件,并且确定了相应的异常号k时,处理器触发异常(执行间接过程调用),通过异常表的表目k跳转到相应的处理程序。
-
由异常处理程序在软件中完成处理工作。处理完成后,执行一条特殊的“从中断返回”指令。如果异常中断的是一个用户程序,就将状态恢复为用户模式,然后将控制返回给被中断的程序。
- 异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊CPU寄存器里。
异常与过程调用的不同之处:
- 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。而根据异常类型,返回地址或是 I c u r r I_{curr} Icurr或是 I n e x t I_{next} Inext。
- 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。
- 异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都有完全的访问权限。
1.2 异常的类别
- 异步异常是由处理器外部的I/O设备中的事件产生的。同步异常是执行一条指令的结果。
- 分为中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。
(1)中断
- 中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。(硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的)
Examples: I/O设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备。
(2)陷阱和系统调用
- 陷阱是有意的异常,是执行一条指令的结果。
- 陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
系统调用:
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。
为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令。执行syscal1指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。
从程序员的角度来看,系统调用和普通的函数调用是一样的。然而,它们的实现非常不同。
- 普通的函数运行在用户模式中,用户模式限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。
- 系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。
(3)故障
-
由错误情况引起,可能能够被故障处理程序修正。
-
当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
Examples:缺页故障(可恢复),保护故障(protection faults, 不可恢复),浮点异常(floating point exceptions)。
(4)终止
- 不可恢复的致命错误造成的结果,通常是一些硬件错误。
- 终止处理程序从不将控制返回给应用程序。将控制返回给一个abort例程,该例程会终止这个应用程序。
Examples: 比如DRAM或者SRAM位被损坏时发生的奇偶错误。
1.3 Linux/x86-64系统异常示例
Linux的系统调用:
- 每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量。(注意:这个跳转表和异常表不一样。)
- 将系统调用和与它们相关联的包装函数都称为系统级函数。
- 所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递的。寄存器号%rax包含系统调用号,%rdi、%rsi、%rdx、%r10、%r8、%r9包含最多6个参数。从系统调用返回时,寄存器号%rcx和%r11都会被破坏,%rax包含返回值。
2. 进程 process
上下文(context):程序正确运行所需的状态的集合。包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程是一个正在运行的程序的实例,系统中的每个程序都运行在某个进程的上下文中。
进程提供给应用程序的关键抽象:
- 逻辑控制流(Logical control flow):它提供一个假象,好像我们的程序独占地使用处理器。由OS内核通过上下文切换机制实现。
- 私有的地址空间(Private address space):它提供一个假象,好像我们的程序独占地使用内存系统。由OS内核的虚拟内存机制实现。
2.1 逻辑控制流
逻辑控制流:PC值的序列,对应于该程序所要执行的那些指令。
多个进程轮流使用处理器。每个进程执行它的流的一部分,然后被抢占(preempted)(暂时挂起),然后轮到其他进程。对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器。
2.2 并发流
(1)并发流和多任务的概念
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow)。如上图8-12,进程A和B,A和C的运行时间有重叠,属于并发流;而进程C在B结束后才开始,二者不重叠,是顺序的。
一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。单处理器在并发地执行多个进程,地址空间由虚拟内存系统管理,未执行进程的寄存器值保存在内存中。
(2)多任务执行过程
执行当前进程 -> 将寄存器的值保存到内存 -> 转向下一个进程,进行上下文切换(装载保存的寄存器等信息、切换地址空间) -> 将控制传递给新的进程
(3)多核处理器
而对于多核处理器:单个芯片有多个CPU,共享主存,有的还共享cache,每个核可以执行独立的进程,kernel负责处理器的内核调度。
2.3 私有地址空间
进程为每个程序提供它自己的私有地址空间。一般而言,这个空间中的内存字节是不能被其他进程读或者写的。
通用结构:
-
地址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址0x400000开始。
-
地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈。
2.4 用户模式和内核模式
用以限制一个应用可以执行的指令以及它可以访问的地址空间范围。通常是用某个控制寄存器中的一个模式位(mode bit)来标记当前进程所享有的特权。
- 设置模式位,进程处在内核模式,可以执行指令集中的任何指令、访问系统中的任何内存位置。
- 没有设置模式位,进程运行在用户模式。不允许执行特权指令(停止处理器、改变模式位,发起一个I/O操作);不允许直接引用地址空间中内核区内的代码和数据,必须通过系统调用接口间接地访问内核代码和数据。
2.5 上下文切换
进程由常驻内存的操作系统代码块(内核)管理。内核不是一个单独的进程,而是作为现有进程的一部分运行。通过上下文切换,控制流通从一个进程传递到另个进程,实现多任务。
3. 系统调用错误的处理
当Linuⅸ系统级函数遇到错误时,通常返回-1并设置全局整数变量errno来标示出错原因。
硬性规定:
- 必须检查每个系统级函数的返回状态。
- 只有少数是返回空的函数。
strerror函数返回一个文本串,描述了和某个errno值相关联的错误。
4. 进程控制
4.1 获取进程ID
每个进程都有一个唯一的正数(非零)进程ID(PID)。
- getpid函数返回调用进程的PID。
- getppid函数返回它的父进程的PID(创建调用进程的进程)。
4.2 创建和终止进程
从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
- 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度(选中并执行)。
- 停止。进程的执行被挂起(suspended),且不会被调度,直到收到一个SIGCONT通知信号。
- 终止。进程永远地停止了。
(1)终止进程
进程会因为三种原因终止:
- 收到一个信号,该信号的默认行为是终止进程。
- 从主程序返回。
- 调用exit函数。
程序运行过程中,exit函数只能被调用一次,且不返回到调用函数中。
(2)创建进程
父进程通过调用fork
函数创建一个新的运行的子进程。
int fork(void)
- 子进程返回0,父进程返回子进程的PID,出错则返回-1。(调用一次,返回两次)
- 新创建的子进程几乎但不完全与父进程相同:
- 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本
- 子进程获得与父进程任何打开文件描述符相同的副本:意味着子进程可以读写父进程中打开的任何文件。
- 子进程有不同于父进程的PID。
父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令,不能预知执行顺序。
(3)用进程图刻画fork
进程图:
- 每个顶点对应一条语句的执行。
- 有向边a->b表示语句a发生在语句b之前。
- 边上可以标记信息,如变量的当前值。
- printf语句的顶点可以标记上printf的输出。
- 每张图从一个没有入边的顶点开始。
- 图的任何拓扑排序对应于程序中语句的一个可行的全序排列(每条边都是从左到右的)
4.3 回收子进程
当进程终止时,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped),仍然消耗系统资源。终止但未被回收的进程称为僵死进程(zombie)。
回收
- 父进程执行回收(使用函数wait或waitpid)。
- 父进程收到子进程的退出状态。
- 内核删掉僵死子进程。
父进程不回收子进程的后果
如果父进程没有回收它的僵死子进程就终止了,内核安排init进程去回收它们(init进程PID为1,系统启动时创建,不会终止,是所有进程的祖先)。
- 若子进程已结束,父进程卡死,则无人回收僵死子进程;
- 若父进程已结束,而子进程卡死,则会一直卡着。
(1)waitpid
pid_t waitpid(pid_t pid, int *statusp, int options);
默认情况下(当options=0时),waitpid挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么waitpid就立即返回已终止子进程的PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。
(int* statusp指的是要存放返回状态变量的地址,如child status!=NULL,则在该指针指向的整型量表明子进程终止原因和退出状态信息)
由参数pid来确定等待集合的成员:
- pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid。
- pid=-1,那么等待集合就是由父进程所有的子进程组成的。
- pid< -1,等待ID为|pid|的进程组的任何子进程。
- pid=O时,等待同一个进程组中的任何子进程。
由参数options修改默认行为:
- WNOHANG:不挂起,立即返回,若无子进程终止返回0值。
- WUNTRACED:挂起,直到等待集合中的一个进程终止或停止。
- WCONTINUED:挂起,直到等待集合中的一个进程终止或收到SIGCONT而从停止状态重新开始。
用wait.h头文件中定义的宏函数来检查已回收子进程的退出状态:
-
WIFEXITED(status):如果子进程通过调用exit或者一个返回(return)正常终止,就返回真。
-
WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。
-
WIFSIGNALED (status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
-
WTERMSIG(status):返回导致子进程终止的信号的编号。
-
WIFSTOPPED(status):如果引起返回的子进程当前是停止的,那么就返回真。
-
WSTOPSIG(status):返回引起子进程停止的信号的编号。
-
WIFCONTINUED(status):如果子进程收到SIGCONT信号重新启动,返回真。
子进程结束的顺序是任意的(没有固定的顺序),可用宏函数WIFEXITED和WEXITSTATUS获取进程的退出状态信息。
(2)wait
int wait(int *statusp)
调用wait(&status)等价于调用waitpid(-1, &status, 0)。 (等待所有子进程结束)
4.4 加载并运行程序
int execve(char *filename, char *argv [], char *envp[])
- filename:可执行文件(目标文件或脚本,用#!指明解释器,如#!/bin/bash)。
- argy:参数列表。惯例:argv[0]==filename。
- envp:环境变量列表
- "name=value"strings (e.g.,USER=droh)
- getenv, putenv, printenv
在当前进程中载入并运行程序,覆盖当前进程的代码、数据、栈,有相同的PID,继承已打开的文件描述符和信号上下文。
调用一次,从不返回(除非有错误,指定的文件不存在返回-1)。
参数列表如下所示。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。
环境变量的列表结构如下所示。envp变量指向一个以ul结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name-value”的名字-值对。
新程序将会覆盖当前进程的代码、数据、栈。
NULL作为分隔标识符。
5. Shell程序
shell是一个交互型应用级程序,代表用户运行其他程序。
Shell操作模拟:
6. 信号signal
6.1 定义
signal就是一条小消息,它通知用户进程系统中发生了一个某种类型的系统事件。
- 类似于异常和中断
- 从内核发送到(有时是在另一个进程的请求下)一个进程
- 信号类型是用小整数ID来标识的(1-30)
- 信号中唯一的信息是它的ID和它的到达
例如:
特别注意:
- SIGKILL、SIGTOP不可忽略、阻塞、捕获:这两个信号为root用户、kernel在任意情况下kill或stop任何进程提供了一种途径,默认行为分别是终止和停止。
- 信号值小于SIGRTMIN(34)的信号都是不可靠信号。信号值位于SIGRTMIN和SIGRTMAX之间是实时信号,都是可靠信号,支持排队,不会丢失。本课程只介绍不可靠信号。
6.2 信号术语
**(1)发送信号:**内核通过更新目的进程上下文中的某个状态项(struct sigpending pending),来实现发送(递送)一个信号给目的进程。
发送信号的原因:
- 内核检测到一个系统事件,如除零错误(SIGFPE)或者子进程终止(SIGCHLD)。
- 一个进程使用系统调用函数kill,显式地/直接请求内核发送一个信号到目的进程。
- 一个进程可以给自己发信号
**(2)接收信号:**目的进程被内核以某种方式对发送来的信号做出反应时,它就接收了信号。
反应的方式:
- 忽略这个信号(do nothing)
- 终止进程(with optional core dump)
- 通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号,类似于响应异步中断而调用的硬件异常处理程序。
**(3)待处理信号:**一个发出而没有被接收的信号叫做待处理信号,也称:未决信号。存放在pending里。
- 非实时/不可靠信号的任一类型(1-31/SIGRTMIN-1)最多有一个待处理信号。
- 非实时信号不会排队等待。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都会被丢弃。
- 一个待处理信号最多只能被接收一次。只要传送了一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位。
**(4)阻塞信号:**一个进程可以选择阻塞接收某种信号,将blocked对应bit位置为1。阻塞的信号仍可以被发送,但不会被接收,直到进程取消对该信号的阻塞。
6.3 信号在内核中的表示
进程控制块:内核为每个进程维护着待处理位向量(pending)、阻塞位向量(blocked)。
pending:待处理信号集合,也称未决信号集合
- 若传送了一个类型为k的信号,内核会设置pending中的第k位为1(注册);
- 若接收了(开始处理)一个类型为k的信号,内核将立即清除pending中的第k位。
blocked:被阻塞信号的集合
- 通过sigprocmask函数设置和清除;
- 也称信号掩码mask。
6.4 进程组
Unix shell使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。
每个进程只属于一个进程组,用pgid表示组号。
6.5 发送信号
(1)用/bin/kill程序发送信号
/bin/kill程序可以向另外的进程或进程组发送任意的信号。
Examples:
- /bin/kill -9 24818:发送信号9(SIGKILL)给进程24818
- /bin/kill -9 -24817:发送信号SIGKILL给进程组24817中的每个进程(负的PID会导致信号被发送到进程组PID中的每个进程)
(注意,在此我们使用完整路径/ bin/ki1l,因为有些Unix shell有自己内置的ki11命令。)
(2)从键盘发送信号
ctrl-c:内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下,结果是终止前台作业。
ctrl-z:内核发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起)前台作业。
(3)用kill函数发信号
int kill(pid_t pid, int sig);
- 功能:将信号号码sig指定的信号发送给参数pid指定的进程;
- 参数pid:
- pid>0 将信号发给id为pid的进程。
- pd=0 将信号发给和当前进程在同一进程组的所有进程,包括调用进程自己。。
- pid=-1 将信号广播发送给系统内所有的进程。
- pid<0 将信号发送给进程组id为pid绝对值的所有进程。
- 参数sig最好使用信号名(宏定义),而非编码值,便于代码移植
- 返回值:执行成功则返回0,如果有错误则返回-1。
6.6 接收信号
(1)接收时机与过程
假设内核正在从异常处理程序返回,并准备将控制权传递给进程p时:
- 内核计算进程p的未被阻塞的待处理信号的集合pnb=pending& ~blocked
- if(pnb==O),即集合pnb为空,则将控制传递到的逻辑控制流中的下一条指令;
- else,即pnb不为空
- 选择集合pnb中最小的非零位k(优先级最高),强制p接收信号k,触发进程p采取某种行为;
- 对所有的非零k,重复上述操作;
- 控制传递到p的逻辑控制流中的下一条指令;
(2)接收行为
-
默认行为:每个信号类型都有一个预定义默认行为,是下面中的一种:
- 进程终止
- 进程停止(挂起)直到被SIGCONT信号重启
- 进程忽略该信号
-
指定行为:调用并执行预先设置好的信号处理程序。
(3)修改默认接收行为:设置信号处理程序
使用signal函数设置信号signum的处理函数或恢复默认函数/行为:
sighandler_t signal(int signum, sighandler_t handler)
-
sighandler_t:
typedef void (*sighandler_t)(int)
-
handler的不同取值:
- SIG_IGN:忽略类型为signum的信号
- SIG_DFL:类型为signum的信号行为恢复为默认行为
- handler:用户自定义函数的地址,这个函数称为信号处理程序。
-
返回值
- 设置成功,返回原处理函数指针
- 设置失败,返回SIG_ERR #define SIG_ERR(void (*)0)-1
7. 信号处理
7.1 信号处理流程
- 只要进程接收到类型为sig的信号就会“自动”调用信号处理程序。
- 设置信号处理程序:使用signal函数,设定指定信号的处理程序(地址),从而改变默认行为。
- 捕获/处理信号:调用(执行)信号处理程序。
- 当信号处理程序执行return时,控制会传递到控制流中被信号接收所中断的指令处。
7.2 用信号处理程序捕获SIGINT信号
它捕获用户在键盘上输人Ctrl+C时发送的SIGINT信号。SIGINT的默认行为是立即终止该进程。在这个示例中,我们将默认行为修改为捕获信号,输出一条消息,然后终止该进程。
7.3 作为并发流的信号处理程序
信号处理程序是与主程序同时运行、独立的逻辑流(不是进程),并发运行。
7.3 嵌套的信号处理程序
- 信号处理程序可以被其他信号处理程序中断
7.4 阻塞和解除阻塞信号
(1)隐式阻塞机制
内核默认阻塞与当前正在处理信号类型相同的待处理信号。例如,一个SIGINT信号处理程序不能被另一个SIGINT信号中断(此时另一个SIGINT信号被阻塞)。
(2)显示阻塞和解除阻塞机制
sigprocmask
函数及其辅助函数可以明确地阻塞/解除阻塞选定的信号。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
作用:改变当前阻塞的信号集合(blocked位向量)
how的值决定行为:
SIG_BLOCK:把set中的信号添加到blocked中(blocked=blocked|set)。
SIG_UNBLOCK:从blocked中删除set中的信号(blocked=blocked &~set)。
SIG_SETMASK:block=set。
如果oldset非空,那么blocked位向量之前的值保存在oldset中。
辅助函数(信号集合操作):
sigemptyset:初始化set为空集合。
sigfillset:把每个信号都添加到set中。
sigaddset:把指定的信号signum添加到set。
sigdelset:从set中删除指定的信号signum。
(3)临时阻塞接收信号
可以通过sigprocmask来临时阻塞接收SIGINT信号。
- 使用mask作为位向量掩码。
- 先将需要阻塞的信号add进这个set。
- 然后对进程的blocked进行设置,并使用prev_mask保存之前的掩码。
- 在临时阻塞某些信号的情况下进行操作。
- 再将原先的掩码情况重新恢复,恢复阻塞。
7.5 编写信号处理程序
信号处理程序很麻烦,因为:
- 处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰;
- 如何以及何时接收信号的规则常常有违人的直觉;
- 不同的系统有不同的信号处理语义。
给出编写安全、正确和可移植的信号处理程序的一些基本规则。
(1)安全的信号处理
异步信号安全的函数:函数要么是可重入的(不引用任何共享数据,如只访问局部变量),要么不能被信号处理程序中断。
(2)正确的信号处理
**未处理的信号是不排队的:**pending位向量每种类型最多只能有一个未处理的信号。如果两个类型k的信号发送给一个目的进程,而因为目的进程当前正在执行信号k的处理程序,所以信号被阻塞了,那么第二个信号就简单地被丢弃了;
不能以信号出现的次数来对其他进程中发生的事件计数,如子程序的终止
在fork14函数中,父进程使用for循环创建N个子进程,用ccount记数作为父进程结束的条件。对于每个子进程来说,在被创建后sleep1秒后即退出,然后内核给父进程发送一个SIGCHILD信号,父进程调用指定的child_handler处理程序。在每个处理程序中进行回收子进程的操作,回收后则ccount–。当所有子进程回收完毕后ccount=0,父进程结束。
而这段程序的问题在于:父进程的for循环中,每个if在父进程中都不会执行,也即不会睡眠1s,导致5个子进程几乎同时被创建,而最终同时退出。故会产生,同时向父进程发送5个SIGCHILD信号,而child_handler中睡眠1s,处理较慢。当接收第一个信号时,pending对应位置1,然后立马开始处理,再置0,然后第2个SIGCHILD信号进入待处理,将pending置1。后续的3个信号由于位置被占,会被丢弃。
故这段程序最终会产生5个子进程,而仅回收2个,导致ccount=3,卡死。
针对于该程序的一种解决方法:将wait放入while循环,从而回收所有终止的子进程。
(3)可移植的信号处理
不同的Unix版本有不同的信号处理语义,解决办法:sigaction函数,可明确指定信号处理语义。
7.6 同步流以避免并发错误(竞争)
如何编写读写相同存储位置的并发流程序的问题
对于父进程的main程序和信号处理流的某些交错,可能会在addjob之前调用de1 etejob。这导致作业列表中出现一个不正确的条目,对应于一个不再存在而且永远也不会被删除的作业。
一种解决方法消除竞争,避免并发错误:
在调用fork之前,阻塞SIGCHLD信号,然后在调用addjob之后取消阻塞这些信号,我们保证了在子进程被添加到作业列表中之后回收该子进程。注意,子进程继承了它们父进程的被阻塞集合,所以我们必须在调用execve之前,小心地解除子进程中阻塞的SIGCHLD信号。
7.7 等待信号
(1)显式地等待信号
先阻塞SIGCHILD信号,置一个子进程结束的标记为(此例中pid)不去回收子进程,然后接触阻塞,一直去判断这个标记位,直到子进程被回收后再继续父进程的操作。
(2)用sigsuspend等待信号
int sigsuspend(const sigset_t *mask);
- 设定临时的信号屏蔽字为mask
- 等待信号
- 收到信号后自动恢复原信号屏蔽字,函数返回
没看懂
8. 非本地跳转
它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。强大的(但危险的)用户级机制。
8.1 实现机制
非本地跳转是通过setjmp和longjmp函数来提供的。
int setjmp(jmp_buf envbuf)
- 必须在longjmp之前被调用
- 在envbuf中保存当前调用环境(包括寄存器、栈指针和程序计数器),供后续longjmp使用
- 被调用一次,返回多次
- 返回0
- 返回值不能给变量赋值:rc=setjmp(env);//Error,但可以用在switch里
void longjmp(jmp_buf envbuf, int iRet)
- 从envbuf中恢复调用环境,并从对应的setjmp返回(跳转至保存在envbuf中的PC所指示的位置)
- 返回数值iRet,而非0,存放在%eax中
- 在setjmp之后被调用
- 被调用一次,从不返回
8.2 示例
从深层嵌套函数调用中直接返回:
main函数首先调用setjmp以保存当前的调用环境,然后调用函数foo,foo依次调用函数bar。如果foo或者bar遇到一个错误,它们立即通过一次longjmp调用从setjmp返回。setjmp的非零返回值指明了错误类型,随后可以被解码,且在代码中的某个位置进行处理。
8.3 非本地跳转的局限
工作在堆栈规则下:只能跳到被调用但尚未完成的函数环境中。同时会清空中间过程的栈帧(丢失)。
若在A设置跳转点,A调用B,B调用C,在C中跳到A,没问题。
若在B设置跳转点,A调用B,然后A调用C,但在C中跳转回B,则出问题。(B已经结束,B的栈帧被C覆盖,所以跳转点实际上指向的位置为位置地点,是原来B的那里)
8.4 用于信号处理程序的非本地跳转函数
(可以选择是否保存信号屏蔽字)