CS:APP第八章学习
开始学 CSAPP的第八章
逐步更新:
- 2023-1-25:开始写
- 2023-1-28:写完
梗概
程序计数器假设有一个值序列 $a_1,a_2, … , a_{n-1}$,其中,每个 ak是某个相应指令 Ik的地址,每次从地址 ak到 ak+1的过渡称为 控制转移。而这样的控制转移序列叫做处理器的 控制流。
最简单的一种控制流是一个平滑的序列,其中每个指令 $I_k$ 和 $I_{k+1}$ 在内存中的位置都是相邻的。当然也有平滑流的突变,即指令 $I_k$ 和 $I_{k+1}$ 在内存中的位置不相邻,通常是由跳转、调用和返回这种程序指令造成的。这些指令都是一些必要机制,使得程序能够对由程序变量表示的内部程序状态的变化做出反应。
同理,系统也必须能够对系统状态的变化做出反应,这些系统状态不能由内部程序变量捕获,而且也不一定要会程序的执行相关,现代系统通过使控制流突变来对系统状态变化做出反应,一般将这种突变称为异常控制流。
调试器触发断点的一种方法是,将断点处的目标指令替换为一个不规范指令(int 3
),并捕获由此引发的异常。
所以,对于我们来说,学习异常处理是非常有必要的。
异常
异常是一场控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。当处理器状态发生一个重要的变化时,处理器正在执行某个指令 $I_{curr}$,此时发生了某个异常,会通过一张 异常表(exception table
)跳转到指定异常的异常处理程序(exception handler
)。完成之后,有三种情况:
- 返回给发生异常时,正在执行的那个指令
- 返回给发生异常时,当前指令的下一条指令
- 结束被中断的程序
异常处理
系统会对每种类型的异常都分配一个异常号,有些是处理器设计者分配的,有些是操作系统开发者分配的。前者包括一些指令方面的异常(除零,缺页,违规内存访问),后者就是包括向操作系统请求硬件服务的信号。
异常表的基址存储在一个特殊的寄存器中。
异常处理和过程调用比较类似但是有些许区别。
- 过程调用时,跳转到处理程序之前,处理器会把返回地址压入栈中,以及其它一些由调用者保存的寄存器,返回地址通常是下一条指令。而异常处理时,会把所有的寄存器放到栈中,并且根据异常情况,会执行原来的指令,下一条指令或者是终止程序。
- 如果控制从用户态(ring3)到内核态(ring0),所有的项目会被压入内核栈。
- 异常处理程序运行在内核态下,对操作系统所有资源都有绝对的访问权限。
异常的类别
异常可以分为以下四种:
- 中断
- 陷入
- 故障
- 终止
中断
中断是来自处理器外部 I/O
设备的信号的结果,它不是指令的直接产物。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。
中断处理完成之后,将继续处理下一条指令,好像中断没有发生过一样。
而剩下的异常类型都是同步的,也就是由指令直接引发的异常。
陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果,在异常处理完毕之后,也会顺着程序流往下运行。
用户程序需要经常向内核请求服务,比如读写文件(read/write
),创建进程(fork
),加载一个新的进程(execve
),终止当前进程(exit
)。处理器为我们提供了一条特殊的 syscall
指令,当用户想要请求服务 n 时,会把 n 保存给 %rax
寄存器,再执行 syscall
此时会进入一个陷阱处理函数,并根据传进来的参数调用合理的处理程序。
虽然在我们看来,系统调用跟调用一个普通函数没有什么区别,但是因为很多操作需要在内核态下完成,所以我们需要系统调用。
故障
故障发生时,会首先交由故障处理程序尝试修正故障,如果成功修正那么可能返回给引起故障的指令并重新执行那条指令,如果不能成功修正,那么调用内核中的 abort
例程终止该程序。
终止
终止一般是因为发生了不可恢复的致命错误造成的结果,比如 CPU 断电,内存位损坏发生的奇偶校验错误。终止发生之后,不会返回控制权给应用程序。
Linux/x86_64系统异常
这里面没有什么新的知识点。就是说异常号 0~31
为系统架构师定义,剩余为操作系统开发者定义,比如除数为 0 或者是写只读文本段都是 0~31
的异常。
进行系统调用的时候,%rax
保存系统调用号,%rdi,%rsi,%rdx,%r10,%r8,%r9
保存参数,和正常寄存器传参略微有点区别。返回时,%rax
保存返回值,%rcx,%r11
寄存器都会被破坏。
进程
进程是当今计算机科学中最深刻最成功的概念之一。
进程的定义是:每个程序的运行实例都运行在进程的上下文(context
)之中,上下文是由 代码,数据,栈,寄存器内容,程序计数器,环境变量,以及打开的文件描述符的集合。
它提供了两种抽象:
- 一个独立的逻辑控制流:给进程一种自己独占处理器的假象
- 一个私有的地址空间:它提供一个程序独占系统内存的假象。
逻辑控制流
一个进程的程序计数器(PC)序列叫逻辑控制流。在实际运行的情况中,进程并不独占处理器,就像下面这张图:
三个逻辑控制流的执行是交错的,但是单独看一个进程可以认为是连续的,并不改变程序的运行结果。
并发流
我们说两个进程并发当且仅当程序开始到结束的时间片有重合,下面介绍一些概念:
- 并发:多个流并发地执行
- 多任务:一个进程和其它进程轮流执行,也叫时间分片
- 时间片:进程独占处理器的一段时间
私有地址空间
进程之间是独立运行的,进程的地址空间不能被任意读写的。从这个意义上来说,这个地址空间是私有的。
下面的图中我们可以看到一个进程的地址空间分布:
用户模式和内核模式
为了防止用户的不规范操作损坏操作系统,处理器规定了一个用户模式和内核模式,在用户模式下,可执行的指令可操作的地址是有限的,只有当中断,故障,陷入时,操作系统才会把进程切换为内核模式。
处理器会控制寄存器中的一个模式位来提供私有的地址空间。这个寄存器描述了当前进程拥有的特权,当设置了模式位的时候,进程运行在内核模式,此时可以访问系统中的任何内存位置。没设置位模式的时候,操作系统运行在用户模式,不允许执行特权指令和访问内核数据,否则会导致致命的保护故障,我们必须通过系统调用接口间接地访问内核代码和数据。
Linux 提供了 /proc
文件系统,允许我们用户模式进程访问内核数据结构的内容。在 2.6 版本的 Linux 中引入了 /sys
文件系统他输出关于系统总线和设备的额外的底层信息。
比如 /proc/cpuinfo
里面包含了 CPU 的类型,/proc/<process id>/maps
包含了进程使用的内存段。
上下文切换
内核为每个进程维持一个上下文,上下文就是重新启动一个被抢占的进程所需要的状态,包括寄存器、程序计数器、用户栈、内核栈和各种内核数据结构。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种策略叫做调度。内核中有一个专门的调度程序,被称为调度器。
当内核选择一个进程时,就说内核调度了该进程,内核调度新进程抢占当前进程后,会使用上下文切换机制来进行控制转移,如从进程A切换到进程B:
保存进程A的上下文
恢复被保存的进程B的上下文
将控制传递到新恢复的进程
执行上下文切换时,CPU 会切换到内核模式。
中断会引发上下文切换,前面其实说过,中断一般是由硬件引发的,异步的,比如磁盘读取,一般由内存和硬盘自己交换数据而不需要 CPU 干预,磁盘只需要在硬盘读取完数据之后告诉 CPU 即可,而这个告诉的手段实际上就是用中断。
系统调用错误处理
告诉我们学会包装系统调用的函数,使得代码看起来更加简单。
进程控制
unix 提供了大量进程操作的系统调用。
获取pid
getpid
和 getppid
分别获取自己的进程 id 和父进程的进程 id。
创建和终止进程
我们认为进程永远处于以下三种状态之一:
- 运行:要么正在占用 CPU 执行,要么等待被执行并且会被调度。
- 停止:进程的执行被挂起(
suspended
),且不会被调度,运行的进程收到了SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU
信号时,进程停止,并且一直保持收到SIGCONT
信号为止。 - 终止:进程永远停止,不会被恢复执行状态。导致进程停止的原因有三点:收到一个终止进程的信号、从主程序返回、调用 exit。
使用 fork 系统调用可以创建一个新的进程,与父进程上下文几乎一致的子进程。fork 调用一次,返回两次,父进程返回子进程的 pid,子进程返回 0,因此我们可以使用返回值来区分是父进程还是子进程。
虽然父进程和子进程的地址空间值一模一样,但是他们之间是独立的。
回收子进程
进程终止之后,内核不会立即把它从系统清除,而是会保持终止状态直到被父进程回收。回收时做以下几步:
- 子进程的退出状态通过内核告知父进程
- 父进程选择抛弃子进程
- 子进程彻底消失
已终止但是未回收的进程叫僵死进程。
如果父进程还没有回收僵死进程就终止了,那么僵死进程会变成孤儿进程,由 init
进程回收,init
是系统运行时就一直存在的进程,进程 pid 为 1,是所有进程的父进程或祖先进程。因为僵死进程即使没有运行,依然要占用内存资源。
使用 waitpid(pid_t pid,int *statusp,int options)
函数等待它的子进程终止或者停止。
默认情况下(options=0
)waitpid
一旦接收到等待集合中的其中一个子进程的信号,就会立刻返回。
等待集合成员
等待集合由 pid 参数控制
pid>0
,则等待的进程为单独的进程 id 为 pid 进程。pid=-1
,则等待进程为当前进程所有的子进。
修改默认行为
可以通过一些宏的组合来达到某些行为:
- WNOHANG:让父进程处于不挂起(Wait No Hang)状态,不会被阻塞,如果此时子进程返回则函数返回子进程 pid,否则返回0。
- WUNTRACED:就是不跟踪(Untraced)子进程,这个再看吧
- WCONTINUED:如果父进程进程在
stop
状态又收到了SIGCONT
信号,也返回。
为了研究这个 WCONTINUED
信号,写了一个 demo
。
1 |
|
可以发现,
检查退出状态
这里有一个 int*
类型的参数,我们可以使用这个参数获得进程退出的状态。
然后有一些宏可以判断程序的返回值:
- WIFEXIED(status):如果调用 exit 或者是 return 终止,那么此值为真。
- WEXITSTATUS(status):只有在上面状态为真时定义此状态,返回一个正常中止的程序定义的退状态。
- WIFSINALED(status):如果因为一个未捕获的信号终止,此值为真。
- WTERMSIG(status):返回导致子进程终止的信号的编号,只有上面状态为真时有此字段。
- WIFSTOPPED(status):如果当前子进程是停止的,那么此值为真。
- WSTOPSIG(status):引起子进程停止的信号只有上面状态为真时有此字段。
- WIFCONTINUED(status):如果子进程收到了
SIGCONT
信号启动,则返回真。
错误条件
如果进程无子进程,那么返回 -1 并设置 errno 为 ECHILD
,如果被信号中断也返回 -1并设置 errno
为 EINTR
。
wait函数
waitpid的简化版本,调用 wait(&status)
等同于 waitpid(-1,&status,0)
。
使用waitpid的示例
这里给了一个程序,可以让我们看到 waitpid
的妙用。
1 |
|
也可以发现,程序不会按照顺序回收子进程,这取决于机器的调度。
我们可以手动编写代码让它变得按照顺序回收,就是创建一个数组保存 pid,每次循环只回收指定进程,但是这依然不能保证子进程会按顺序结束,但是一定按顺序回收。
让进程休眠
使用 sleep(int secs)
让程序休眠指定的秒数,Sleep()
是休眠指定的毫秒数。
一般返回为 0,如果被某些信号终止,则返回剩余秒数。
使用 pause()
让程序休眠直到收到某个信号,它总是返回 -1
。
加载并运行程序
execve(const char *filename,const char *argv[,const char *env[])
函数用于在当前进程中加载并运行一个新的程序。
execve
调用一次就无法返回了,除非加载失败并返回错误。
一般来说,加载的程序会以这样的入口去加载 int main(int argc,char *argv[],char *env[])
,这里 execve
的后面两个参数就是这里的参数。
对于环境变量,Linux 提供了几个函数来操作环境变量。
char *getenv(char *name)
用于获取一个环境变量,成功就返回 value
的指针,这里是 value
的指针,不是一整个条目的指针,失败就返回 NULL
。
int setenv(const char *name,const char *newvalue,int overwrite)
用于给环境变量 name
设置一个新的值 newvalue
,如果已经存在,那么判断 overwrite
是否非 0,非 0 则直接替换。
void unsetenv(char *name)
用于取消一个环境变量。
据此我们可以写出一个简易的 SHELL 程序,利用 fork
和 execve
的搭配就可以解决,主要工作量还是在解析命令上面。
利用 fork 和 execve 运行程序
这里就教我们写一个shell了,其实很简单,我们 fork
出一个子进程,然后子进程用于 execve
装载父进程读入的一个命令,父进程有两种选择,一个是等待执行完毕(一般shell都支持的),这个可以直接使用 wait 实现,或者是加一个 &
挂在后台运行,我们只要根据加没加 &
判断 wait 的参数即可。
此时我们还有选择处理信号,因为收到了 Ctrl+C
的终止信号之后,我们不应当终止父进程,而应该把它转发给子进程,让子进程去处理。这个是后话了,本节的内容应该到此就结束了。
信号
Linux 支持以下 30 多种不同的信号。
信号术语
发送信号
发送信号可以因为某些事件导致内核发送信号,也可以是某些进程主动调用 kill
函数发送信号。
接收信号
进程接收到信号之后,可以忽略这个信号,终止,或者是执行一个信号处理程序(signal handler)的用户层函数捕获这个信号。
一种信号只能被接收最多一次,一个发出但没有被接收的信号称为待处理信号(pending signal),内核为每个进程维护一个 pending 位向量,表示哪些类型的向量被传送但未被接收。
信号可以被阻塞,被阻塞的信号仍然可以被发送但是不会做出任何反应,维护一个 Block
位向量表示哪些类型的信号被阻塞。接收一个信号会清除 pending
位向量的对应位。
发送信号
所有的信号发送机制都是基于进程组的概念。
进程组
每个进程都只属于一个进程组(group id)。使用 getpgrp()
可以返回当前进程进程组的 id,setpgid(pid_t pid,pid_t pgid)
可以为指定进程设置进程组 id
,如果 pid
参数为 0
,那么表示对自身设置,如果 pgid
为 0
,表示创建一个 gid 等于 pid 的一个组并加入其中。
使用 /bin/kill 发送信号
/bin/kill -9 11111
可以给 pid 为 11111
的进程发送 信号 9(SIGKILL)。如果 pid 为负,则表示给对应的组中所有的进程发送这个信号。
此时除了发送信号 9 给该进程以外,内核还会额外发送一个 17 (SIGCHLD)信号给它的父进程。
从键盘发送信号
在 shell 中,使用作业(job)来表示一条命令所创建的进程,在任何时刻,最多有一个前台作业和 0 个或多个后台作业。
默认情况下,在键盘中输入 Ctrl+C
会导致内核发送一个 SIGINT
信号到前台进程组中的每一个进程,最终结果就是终止前台作业,使用 Ctrl+Z
会发送一个 SIGSTOP
信号给所有前台作业,默认情况下会挂起前台作业。
kill函数发送信号
使用 kill(pid_t pid,int sig)
发送一个 sig
信号给进程 id 为 pid 的进程。
特殊地:
- pid = 0:给自己所在的进程组中的每一个进程发送信号
- pid < 0:给组 id 为
|pid|
的所有进程发送信号
alarm 函数发送信号
unsigned alarm(unsigned secs)
函数可以给自身发送 SIGALRM
信号,内核会创建一个定时器,到指定秒数之后,内核会发送一个 SIGALRM
信号,返回值为上一次闹钟所剩余的秒数,如果是第一次调用,则返回 0。
接收信号
当内核吧进程从内核模式切换到用户模式时,会检查进程中未阻塞的待处理信号的集合(pending&~blocked),接下来会按照从小到大的顺序强制进程接收这些信号,收到信号会让进程采取某些行为,一旦完成那就把控制流交还给进程,并执行原逻辑流中的下一条指令。
每个信号会有一个默认的行为,这些行为是下面中的一种:
- 终止进程
- 终止进程并转储内存
- 挂起进程
- 忽略信号
使用 signal(int signum,sighandler_t handler)
函数可以修改 signum 信号的默认行为为 handler
函数处理。但是它的 handler 可以是一些特殊值。
- SIG_IGN 表示忽略该信号
- SIG_DFL 表示恢复该信号的默认处理
只有两种信号不能修改默认行为,也不能忽略,那就是 SIGKILL
和 SIGSTOP
。
在服务器中常见的可能会是前台任务卡住,并且 Ctrl+C
也不好使,那么我们使用 Ctrl+Z
是一定能够停止当前任务的,因为 Ctrl+Z
发送信号 SIGSTOP
不能被忽略不能被捕获。
通常情况下,在信号处理程序执行 return
时,会把控制流交还给被之前被中断那条指令的后面一条指令,例外的情况就是某些系统在执行系统调用的时候收到信号,系统调用会直接返回一个错误。
一个信号处理程序可能会被另一个信号处理程序打断,但是不会被自己打断。
阻塞和解除阻塞信号
内核默认阻塞已经处于 pending
集合中的信号,我们也可以显式地阻塞信号,使用函数 sigprocmask(int how,const sigset_t *set,sigset_t *oldset)
可以改变当前的 block
集合值。
根据 how 参数的值函数会有以下行为:
- SIG_BLOCK:把 set 中的信号添加到 block 中。
- SIG_UNBLOCK:从 block 中删除 set 中的信号。
- SIG_SETMASK:让 block 直接等于 set 集合。
在此之前的值会保存在 oldset
所指向的参数中。
对于集合的操作,我们可以使用下面的一些函数:
int sigemptyset(sigset* set)
初始化 set 为空集int sigfillset(sigset* set)
将 set 填满信号int sigaddset(sigset* set, int signum)
将 signum 信号添加到集合中int sigdelset(sigset* set, int signum)
将 signum 信号从集合中删除
编写信号处理程序
安全的信号处理
G0
让我们尽量对 handler进行简单的处理,过于复杂的逻辑尽量在主函数实现。
G1
要编写异步信号安全的函数。要么这个函数不可被中断,要么它可重入。
- 可重入意味着它只会调用局部变量而不访问任何全局变量。
- 不可被中断意味着这个函数要么不执行,要么完全执行。
例如以下信号处理程序:
1 | int n; |
它就显然不符合上面的其中一个要求,我们知道 n++
实际上会分三步完成,从内存中取值,+1,放回内存中。
假设在中间被其它信号打断,同样对 n 进行了运算,那么就会导致出现非预期的结果,而且这种 bug 往往十分难查,因此我们要从写代码层面杜绝此类事件的发生。
所有的 IO 函数都是不安全的,因为它们可被打断,且在调用 IO 函数的时候都会访问一个 _IO_2_1_stdout
的全局结构,我们最安全的选择就是使用 write
函数打印,因为它是直接走系统调用去输出的,系统调用不会被打断。
G2
保存和恢复 errno,有些函数依赖于 errno 的内容,尽量保证进入之前保存这个字段,等到结束了再恢复回去。
G3
在进去之前可以选择阻塞所有信号,等到结束了再恢复阻塞,可以保证不会被其它信号处理程序打断。
G4
使用 volatile 声明全局变量,这个关键字会让编译器不对其中的代码做出优化,每次引用都会从内存中读出值,保证了安全性。
G5
使用 sig_atomic_t
类型,它保证了读和写都是原子的不可被打断的。
正确的信号处理
这一节基于一个特性:同种类型的信号不会排队,当 pending
集合中已经有了对应种类的信号时,再次收到就会被内核丢弃,所以我们不能使用信号来计数。
可移植的信号处理
略
同步流以避免讨厌的并发错误
略
显式地等待信号
略
非本地跳转
C 语言提供了一种用户级异常控制流形式,称为非本地跳转(non local jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用—返回序列。非本地跳转是通过 setjmp 和 longjmp 函数来提供的。
1 |
|
下面举了一个例子助我们理解这两个函数
1 |
|
总结一下就是: setjmp
函数调用一次返回多次,调用会把当前状态保存在 buf
中并返回 0。直到后面遇到 longjmp
时,会根据保存的位置恢复寄存器状态,并将返回值置为第二个参数。
它的另一个妙用是可以在接收信号的时候不返回被中断的位置,也不直接退出,而是可以重新指定跳转位置。
比如下面的例程
1 |
|
它实现了我们按 ctrl+C
就重启的一个效果。
我们先看看没有信号时的逻辑:先设置一个处理函数,输出 start
,再 while 1
去输出 processing...
。
在 while 1
中当我们按下 Ctrl+C
,指令在循环中的一个地方被中断,如果没有 setjmp
和 longjmp
的处理,那么我们收到这个信号之后,要么接着回去(return),要么退出(exit),但是有了这两个函数我们可以设置一个点位,让它信号处理完毕之后都回到那个点位去。
操作进程的工具
操作进程的工具
Linux 为我们提供了大量操作进程的工具
- STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这是一个令人着迷的工具。用 -static 编译你的程序,能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
- PS:列出当前系统中的进程(包括僵死进程)。
- TOP:打印出关于当前进程资源使用的信息。
- PMAP:显示进程的内存映射。
- /proc:一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如,输入 “cat/proc/loadavg”,可以看到你的 Linux 系统上当前的平均负载。
小结
复制粘贴一下吧:
异常控制流(ECF)发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。
在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流。
有四种不同类型的异常:中断、故障、终止和陷阱。当一个外部 I/O 设备(例如定时器芯片或者磁盘控制器)设置了处理器芯片上的中断管脚时,(对于任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的执行可能导致故障和终止同步发生。故障处理程序会重新启动故障指令,而终止处理程序从不将控制返回给被中断的流。最后,陷阱就像是用来实现向应用提供到操作系统代码的受控的人口点的系统调用的函数调用。
在操作系统层,内核用 ECF 提供进程的基本概念。进程提供给应用两个重要的抽象:
- 逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器。
- 私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存。
在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。信号处理的语义是微妙的,并且随系统不同而不同。然而,在与 Posix 兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义。
最后,在应用层,C 程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。