CS:APP 第八章学习笔记

CS:APP 第八章 Exceptional Control Flow 的学习笔记

本章的主要内容为 exceptionsystem callprocesssignallongjmp

在一般情况下PC 会按照指令的顺序以及跳转指令来变化但在很多时候这样的控制流是不能满足需要的需要 exceptional control flow (ECF) 作为跳转指令的补充以处理一些异常的或者来自外部的变化

ECF 存在于各个层次例如

  • 硬件监测到事件发生时调用 exception handler
  • 操作系统在不同进程之间进行 context switch
  • 不同进程间通过发送 signal 来调用接收者的 signal handler
  • 程序内部通过 nonlocal jump 来实现错误处理

Exceptions

exception 是由某种状态改变可能是某条指令执行的结果或者来自外部 I/O 的变化等等导致的控制流的突变

处理器检测到这种状态改变后会调用 exception handler然后跳转到触发前的指令或下一条指令或者终止整个程序

Exception Handling

每种 exception 都会有一个 exception number某些 exception 的 number 由硬件决定另一些由操作系统决定

内存中会有一个 exception table以 exception number 为索引每一项是对应的 exception handler处理器中有一个 exception table base register用来存 exception table 的起始地址结合 exception number 就可以对每一项寻址

exception 与 procedure call 的主要区别有

  • procedure call 返回到栈顶存储的返回地址而 exception 返回到触发时的指令或下一条指令或终止程序
  • 调用 exception handler 时会保存包括 condition codes 在内的一些处理器状态在返回时恢复
  • exception handler 在 kernel mode 下运行使用的运行栈也是 kernel 的

Classes of Exceptions

exception 一般有四种

  • interrupt: 异步触发不是某条指令的执行导致了 exception返回到下一条指令一般是由外部 I/O 设备触发设备通过 interrupt pin 告诉处理器有 interrupt通过 system bus 发送 exception number处理器在每执行完一条指令后检查 interrupt pin触发后调用 interrupt handler再回到原来的位置继续执行下一条指令
  • trap: 同步触发返回到下一条指令比如 system call 是一种常见的 trap通过 syscall 指令主动触发 exception看上去和函数调用类似但可以在 kernel mode 下运行
  • fault: 同步触发返回到触发 exception 的指令或退出一般来说fault handler 会尝试解决导致 fault 发生的问题如果成功解决则返回到触发 exception 的指令并且能够不再次触发 exception 而继续执行下去如果没能成功解决则 abort
  • abort: 同步触发一定退出一般代表严重的不可恢复的错误

Exceptions in Linux/x86-64 Systems

x86-64 中的 fault / abort

  • Divide Error Exception (Interrupt 0): 除以零它是 fault但实际上 Linux 不会尝试从 divide error 中恢复而是会直接 abort一般会显示为 floating point exception
  • General Protection Exception (Interrupt 13): 有多种触发原因例如访问未定义的内存尝试写入只读的内存段Linux 也不会尝试从中恢复而是会直接 abort一般会显示为 segmentation fault
  • Page-Fault Exception (Interrupt 14): page fault 是一个名副其实的 fault会尝试恢复详见第九章
  • Machine-Check Exception (Interrupt 18): 严重的硬件错误是 abort

完整列表参见 Intel® 64 and IA-32 Architectures Software Developer Manuals Volume 3A 的 6.15 EXCEPTION AND INTERRUPT REFERENCE 一节

Linux 中的 system call

Linux 中常用的一些 system call 如 CS:APP Figure 8.10 所示

Linux 中常用的一些 system call

更多 system call 参见 man syscalls

在 C 语言中可以使用 syscall 函数来调用 system call但一般不这样做而是使用每个 system call 对应的 wrapper functionsyscall 和 wrapper function 统称为 system-level function

Processes

一个系统中会有很多进程同时运行但营造出了每个进程都独占了处理器和内存的假象

进程独占内存的假象是通过每个进程的 private address space 实现的详见第九章

Logical / Concurrent Flow

根据一个程序的指令得到的 control flow 称作 logical (control) flow系统会在不同的进程间来回切换从一个进程切换出去称作将这个进程 preempt

如果两个 control flow 的存活时间有重叠则称它们是 concurrent flow 或它们 run concurrently这种现象被称作 concurrency也被称作 multitasking每次连续执行的同一个 logical flow 中的一段称作一个 time slice所以 multitasking 也被称作 time slicing如果两个 logical flow 在不同的 processor core 上运行则称它们是 parallel flowrun in parallel

User / Kernel Mode

在处理器中存有一个 mode bit表示当前是 user mode 还是 kernel mode只有在 kernel mode 下才能执行某些 privileged instruction修改 mode bit访问地址空间中属于 kernel 的区域

user mode 的程序只能通过 exception 来进入 kernel mode以执行 privileged instruction 或者访问 kernel 的数据在 Linux 中也可以在 user mode 下访问 /proc/sys 来获得一些 kernel 的数据

Context Switch

每个进程都有一个 context包括寄存器内容PCuser stackkernel stackcondition codespage tableprocess tablefile table 等等

操作系统通过 context switch 来在不同进程间切换即保存当前进程的 context恢复要切换到的进程的 context最后切换过去context switch 在 exception 中发生处理 exception 时操作系统中的 scheduler 会决定是否进行 context switchschedule 到哪个进程例如

  • 在通过 system call 读取文件时进行 context switch以在等待读取文件时先执行其他进程读取到文件后在 interrupt 中再 context switch 回来
  • 系统会周期性地例如每 1ms触发 interrupt从而可以在一个进程执行了一段时间后进行 context switch

因为程序不知道操作系统会如何 schedule一般来说不同进程的执行顺序是没有保证的

System Call Error Handling

system-level function 一般以返回 -1 代表出错而将具体的错误记录在全局整型变量 errno (#include <errno.h>)函数 strerror 可以用来根据 errno 得到文字错误信息

调用 system-level function 时应当检查错误为了使错误处理更加简便可以使用类似下面的 wrapper function

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void unix_error(char *msg)
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(errno);
}

pid_t Fork(void)
{
    pid_t pid = fork();

    if (pid < 0)
        unix_error("Fork error");

    return pid;
}

Process Control

C 语言中有很多用来控制 Unix 进程的函数

获取 PID

每个进程都有一个 PID

  • pid_t getpid(void): 返回当前进程的 PID
  • pid_t getppid(void): 返回当前进程的 parent 的 PID

进程的状态

每个进程可能处于三种状态之一

  1. Running: 正在运行中会被 schedule
  2. Stopped: 被 suspend 了不会被 scheduleStopped 可能是 SIGSTOPSIGTSTPSIGTTINSIGTTOU 导致的可以由 SIGCONT 恢复运行
  3. Terminated: 进程永久地结束了可能是从 main 函数返回调用了 exit 函数或者收到了某些 signal
  • void exit(int status): 以某个 exit status 将当前进程 terminate

fork

  • pid_t fork(void): 创建子进程

fork 会将当前进程的所有状态复制一份创建一个新的进程新的进程有着和原来相同的代码数据文件例如 stdout但 PID 不同并且后续对数据的修改是和原进程独立的

fork 会调用一次返回两次分别在两个进程中返回在 parent 中返回 child 的 PID在 child 中返回 0出错则返回 -1

fork 出的进程和原进程在接下来会执行同一份代码所以一般会判断 fork 的返回值是否为 0 来让两个进程执行不同的分支

process group

每个进程会属于一个 process group每个 process group 有一个 ID

创建子进程时子进程会默认处于 parent 的 process group

  • pid_t getpgrp(void): 返回当前进程的 process group ID
  • int setpgid(pid_t pid, pid_t pgid): 将 pid 对应的进程的 progress group ID 修改为 pgidpid 为 0 表示当前进程pgid 为 0 表示修改为 pid 对应的进程的 PID

wait

  • pid_t waitpid(pid_t pid, int *statusp, int options): 等待子进程结束
  • pid_t wait(int *statusp): waitpid(-1, statusp, 0)

waitpid 的 pid 参数

参数 pid 决定了要等待的是哪些子进程

  • -1: 所有子进程
  • > 0: PID 为 pid 的子进程
  • 0: process group 与当前进程相同的子进程
  • < -1: process group ID 为 -pid 的子进程

waitpid 的行为 (options)

默认情况下waitpid 会等待到有某个被等待的子进程 terminate 再返回options 可以改变这一行为其值可以包含下列 flag

  • WNOHANG: 立即返回如果没有符合条件的子进程则返回 0
  • WUNTRACED: 除了 terminate子进程 stop 也可以结束等待
  • WCONTINUED: 除了 terminate子进程从 stopped 中 continue 也可以结束等待

reap

除了等待wait 还会将 terminated 的子进程 reap即彻底清除掉没有被 reap 但 terminated 的进程被称作 zombie会占用一定的系统资源pszombie 显示为 [defunct]

如果 parent terminate 了没有 terminate 的子进程会被设置为 PID 为 1 的 init 进程的子进程而 zombie 子进程则会被 init reap

wait 获取子进程的 status

如果 statusp 参数不是 NULLwaitpid 返回时 *statusp 内就会存有引起等待结束的那个子进程的信息

有一系列 macro 可以用来提取 status 中的信息参数是 *statusp不是指针

  • WIFEXITED(status): 是否正常退出 (从 main 函数返回或调用了 exit 函数)
  • WEXITSTATUS(status): 如果正常退出则返回 exit status (main 函数返回值 / exit 函数参数)
  • WIFSIGNALED(status): 是否由某个 signal terminate
  • WTERMSIG(status): 如果是由某个 signal terminate返回这个 signal
  • WIFSTOPPED(status): 是否被 stop
  • WSTOPSIG(status): 如果被 stop返回使其 stop 的 signal
  • WIFCONTINUED(status): 是否被 continue

wait 的报错

出错时 wait 会返回 -1errno 可能是 ECHILD 表示被等待的子进程集合为空可能是 EINTR 表示 wait 函数被某个 signal 中断了

wait 会在每有一个子进程结束时返回但子进程全部结束时会报错 ECHILD可以利用这一点通过 while 循环来等待所有子进程全部结束

sleep

  • unsigned int sleep(unsigned int secs): sleep 若干秒返回剩余应当 sleep 的秒数正常情况下没被 interrupt 就是 0
  • int pause(void): 一直 sleep直到被 signal interrupt总是返回 -1

execve

  • int execve(const char *filename, char *const argv[], char *const envp[])

execve 会以 argv 作为参数envp 作为环境变量在当前进程内执行 executable object file filename可以和 fork 配合来在子进程内执行其他程序

argv 是一个以 NULL 为结尾的字符串数组表示各个参数其中第一个一般是程序的名称

envp 也是以 NULL 为结尾的字符串数组每个字符串形如 name=value

有一些函数可以用来获取设置环境变量

  • char *getenv(const char *name): 返回 NULL 或环境变量的值
  • int setenv(const char *name, const char *newvalue, int overwrite): 成功则返回 0失败overwrite 为 0 而 name 已存在则返回 -1
  • void unsetenv(const char *name)

Signals

signal 的种类

可以用 man signal.7 查看 signal 的列表名称语义编号默认行为

特别地

  • 除以零时会被发送 SIGFPE
  • 执行非法指令时会被发送 SIGILL
  • 非法访问内存时会被发送 SIGSEGV
  • 按 Ctrl+C 时 foreground process group 会被发送 SIGINT
  • 子进程 terminate 时会向 parent 发送 SIGCHLD
  • 可以通过 SIGKILL 来强行 terminate 一个进程

signal 的工作流程

  • 每个进程会记录每个 signal 是否 pending是否 blocked
  • 发送 signal 会使接收者的这个 signal 变为 pending
  • 进程可以改变每个 signal 的 blocked 状态
  • 在切换到 user mode 执行进程时如果一个 signal 处于 pending 状态且没有被 blocked就会接收这个 signal并设为没有在 pending

这意味着

  • signal 只记录是否 pending不会记录发送了几次在被接收前多次发送只会被接收一次
  • 在 blocked 状态下被发送 signal会在 unblock 时收到

发送 signal

kill 命令

可以用 kill 命令在 shell 中向指定的进程发送信号一般 shell 会有 builtin 的 kill也有位于 /usr/bin/killkill可能有一定的区别

基础的 kill 命令形如 kill -sig pid其中 -sig 可以形如 -INT/-SIGINT/-2pid 表示要把信号发送给

  • > 0: PID 为 pid 的进程
  • 0: process group 和当前进程相同的进程
  • -1: 除 PID 为 1 的 init 外的所有进程
  • < 0: process group ID 为 -pid 的进程

这与 waitpid 的 pid 参数 是类似的

在 shell 中使用键盘发送 SIGINT / SIGTSTP

shell 中会有至多一个 foreground job 和零个或若干个 background jobshell 会给每个 job 中的所有进程指定同样的 process group

Ctrl+C 会向 foreground group 发送 SIGINTCtrl+Z 会向 foreground group 发送 SIGTSTP

使用函数发送 signal

  • int kill(pid_t pid, int sig): 与 kill 命令类似
  • unsigned int alarm(unsigned int secs): 让 kernel 在 secs 秒后向当前进程发送 SIGALRM如果有尚未发送的 alarm 则取消掉如果 secs 为 0 则取消后不会发送新的 SIGALRM没有尚未发送的 alarm 则返回值是 0否则是被取消的 alarm 还剩的秒数

设置 signal handler

除了 SIGKILL 和 SIGSTOP其他 signal 的行为可以被改变

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

函数 signal 用来改变处理 signal signum 的方式handler 可以是一个函数指针也可以是 SIG_IGN 表示无视这个 signal或者 SIG_DFL 表示使用这个 signal 的默认行为

有 handler 时接收到一个 signal 就会触发 exception 来执行 handler在 handler 结束时一般会返回到原来的指令

在执行 handler 的过程中相应的 signal 会被 block但 handler 可以被其他类型的 signal interrupt在处理完这另一个 signal 后返回到一开始的 handler

block / unblock signal

进程可以主动 block / unblock 指定的 signal

  • int sigprocmask(int how, const sigset_t *set, sigset_t *oldset)

其中 howSIG_BLOCK / SIG_UNBLOCK / SIG_SETMASK分别表示 block set 里的 signal / unblock set 里的 signal / 将 blocked set 设为 set

oldset 不是 NULL则会将修改前的 blocked set 存下来

还有一些用来设置 sigset_t 的函数

  • int sigemptyset(sigset_t *set): 将 set 设为空
  • int sigfillset(sigset_t *set): 将 set 设为所有 signal
  • int sigaddset(sigset_t *set, int signum): 将 signum 加入 set
  • int sigdelset(sigset_t *set, int signum): 将 signumset 中删去
  • int sigismember(const sigset_t *set, int signum): 检查 signum 是否在 set返回 0/1 或出错返回 -1

编写使用 signal handler

编写安全的 signal handler

由于 signal handler 和主程序并行运行共享数据并且主程序可能在意想不到的地方接收到 signal 而被 interrupt编写安全的 signal handler 是困难的一般要遵循下面的守则

  1. handler 应当尽量简单例如可以设置一个 flag 而在主程序中检查 flag 并进行处理而非直接在 handler 中处理
  2. 在 handler 中只调用 async-signal-safe 的函数函数列表参见 man signal-safety常用的 printfsprintfmallocexit 都不是 async-signal-safe 的
  3. 存储并恢复 errno保证调用 handler 前后 errno 不变
  4. 访问 handler 与主程序共享的数据时block signal 以防止在访问的中途被 interrupt
  5. 把在 handler 中修改而在主程序中访问的的全局变量声明为 volatile防止编译器误认为变量没有被修改而错误地进行优化
  6. 将 flag 声明为 sig_atomic_t 类型它的单次访问是 atomic 的不会被 interrupt但先读后写是两次访问可能被 interrupt

正确处理多次发送的 signal

多次发送 signal 可能只会收到一次所以处理 signal 时不能误以为收到的次数与发送的次数相同

例如接收 SIGCHLD 来 reap child 时应当在 handler 中 reap 掉所有已 terminate 的子进程而非只 reap 一个子进程

不同系统上 signal handling 的差异

在一些系统上signal handling 的语义会有区别

  • 在一些系统上调用了 handler 后这个 signal 就会恢复默认行为需要在 handler 中重新调用 signal 才能一直使用这个 handler

  • 在一些系统上需要执行较长时间的 system call 会在被 interrupt 后报错 EINTR而在现代系统上会尽可能地自动重新执行这个 system call详见 man signal.7Interruption of system calls and library functions by signal handlers 一节

    P.S. 这就是 Rise of Worse Is Better 中用来举例的 PC loser-ing problem原本采用 worse-is-better 的 Unix 现在也进化成了 the right thing P.P.S. 当时读这篇的时候我完全没看懂这一段没想到现在竟然还能记起来

可以通过 sigaction 函数来设置想要的 signal handling 语义

注意 handler 被调用的时机

handler 可能会在意想不到的时机被调用为了避免出错race可能会需要暂时 block signal 来确保 handler 在正确的时机被调用详见 CS:APP 上的例子

等待 signal

  • int sigsuspend(const sigset_t *mask): 将 blocked set 设为 mask在接收到任何 signal 后返回

可以在程序的其他部分 block 掉某个 signal然后在 sigsuspend 的参数中将其 unblock以达到等待该 signal 的目的因为 sigsuspend 等待的不是某个特定的 signal可以配合 while 循环来检查由 handler 设置的某个 flag

sigsuspend 的效果类似于下面的这段代码

sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);

不同的是上面这段代码有可能会恰好在 sigprocmask 之后pause 之前接收到 signal导致这个 signal 没有将 pause interrupt 而一直 sleep 下去sigsuspend 是 atomic 的就不存在这样的问题

Nonlocal Jumps

  • int setjmp(jmp_buf env)
  • void longjmp(jmp_buf env, int val)

setjmp 会将当前的 PC 和寄存器等信息存在 envlongjmp 会恢复 env 中保存的信息跳转到 setjmp 的位置

这意味着 setjmp 可能返回多次longjmp 不会返回第一次调用 setjmp 会返回 0而之后调用 longjmp 时会在 setjmp 的位置返回参数 val 的值特别地如果 val 的值是 0会返回 1强制和首次返回区分开

因为 setjmp / longjmp 只是恢复 PC 和寄存器包括 %rsp

  • 调用 longjmpsetjmp 所在的函数必须还没有返回否则 setjmp 所在的 stack frame 就失效了
  • setjmp 的返回值只应出现在一些简单的表达式中否则是 UB特别地不应将 setjmp 的返回值赋给一个变量但可以放在 ifswitch这是考虑到计算一个复杂的表达式可能会有一些中间量以及 dynamic stack allocationlongjmp 回来时这些中间量dynamic stack allocation 不一定能被正确恢复导致表达式不一定能被正确计算
  • 如果修改了存放在内存中的局部变量跳转后会是被修改过的值而不是原来的值而存放在寄存器中的值则会被恢复要确保变量不被存在寄存器中必须使用 volatile 声明变量否则即便使用了 registerauto 来声明变量编译器可能任意地把变量放在内存或寄存器中造成跳转后变量的值不确定
  • int sigsetjmp(sigjmp_buf env, int savesigs)
  • void siglongjmp(sigjmp_buf env, int val)

sigsetjmp / siglongjmp 会额外存储恢复 pending / blocked signal 的信息需要以非 0 savesigs 调用 sigsetjmp可以用于 signal handler

nonlocal jump 主要有两种用途

  • 出错时直接跳转到一个集中的位置来处理错误而不用一层层往上返回
  • 处理 signal 时不返回到被 interrupt 的位置而跳转到指定的位置

在 signal handler 中使用 nonlocal jump 时需要注意

  • sigsetjmp 再 install signal handler否则可能 race
  • siglongjmp 跳转到的后续代码中只能调用 async-signal-safe 的函数

nonlocal jump 可能造成可读性的问题也可能因为跳过了中间很多函数的返回造成内存泄露等后果要谨慎使用

Tools for Manipulating Processes

  • strace: 显示程序调用的所有 system call可以静态链接来避免看到大量共享库相关的输出
  • ps: 列出进程信息
  • top: 列出进程的资源使用可以用 htop
  • pmap: 查看进程的 memory map
  • /proc: 查看各种进程相关的信息 (man proc.5)