​ 现代系统通过使控制流发生突变来对系统状态的变化(例如:硬件定时器产生的周期信号,包到达网络适配器…)做出反应,我们把这些突变称作异常控制流。异常控制流发生在系统的各个层次。(包括硬件层,内核层,应用层)。理解ECF将有助于你理解OS的I/O,进程和虚拟存储器机制,有助于理解应用程序如何与OS交互(system call/trap),有助于理解并发,软件异常,编写有趣的程序。

8.1异常:概述

异常的机制

​ 系统为每种异常分配一个非负的异常号。一部分是处理器的设计者分配,另一部分则由操作系统的设计者分配。前者的示例包括:零除,缺页,存储器访问违例,断点以及算术溢出【主要是硬件相关的】。后者的示例包括系统调用和来自I/O设备的信号【主要是操作系统能够处理的】。由操作系统分配的异常号在操作系统启动时初始化一张称为异常表的跳转表。当发生异常时通过下面的方式计算出异常处理程序的地址:

异常表基址寄存器 + 异常号*4

异常处理后

  • 返回触发异常的指令(eg:内存缺页异常)
  • 返回到下一指令(eg:系统调用)
  • 终止被中断的程序

异常的分类

异常分类 异常产生原因/说明 异常处理之后的操作 备注
中断 I/O设备的信号 返回到下一条指令 I/O设备指:网卡,磁盘,定时器等
陷阱 有意引起的异常 返回到下一条指令 系统调用引起
故障 可恢复的异常 可能返回到当前指令 如缺页故障
终止 不可恢复的异常 不返回 硬件错误

注:中断是四种异常中唯一的异步异常。

Linux的系统调用

在x86架构上,Linux系统的系统调用通过一条成为 int n的陷阱指令完成。历史上是通过128(0*80)异常号实现。

按照惯例,寄存器%eax包含系统调用号,寄存器ebx,ecx,edx,edi,esi和ebp存储最多6个任意的参数【esp不能使用,原因是进入内核模式时会被覆盖掉】。以上的规则我们称之为调用规约。

注:为提高性能,在最新的架构和操作系统下有一种新的系统调用的实现机制(利用 SYSENTER / SYSEXIT指令)

扩展阅读:

https://github.com/cch123/llp-trans/blob/master/part3/translation-details/function-calling-sequence/calling-convention.md

https://github.com/cch123/asmshare/blob/master/layout.md

8.2进程

进程为程序提供了两个非常重要的抽象:

  • 一个独立的逻辑控制流:好像我们的程序独占的使用处理器
  • 一个私有的地址空间:好像我们的程序独占的使用存储器系统

用户模式和内核模式

处理器通常是用某个控制寄存器中的一个模式位来标记程序是否可以执行的某些指令和访问某些特定的地址空间[即内核模式]。用户从用户模式进入内核模式的唯一方式是通过某种异常,当异常处理完毕后重新改为用户模式。

上下文切换(调度)

内核为每一个进程维持一个重新开始一个先前被抢占的进程所需的状态,称之为上下文(包括各种寄存器,用户栈,内核栈和各种内核数据结构,如页表,进程信息表,文件表)。通过上下文切换的机制实现控制转移到新的进程。当内核代表用户执行系统调用时可能发生上下文切换(典型的那些慢系统调用),中断也可能引发上下文切换。

高速缓存污染

一般而言硬件高速缓存存储器不能和诸如中断和上下文切换这样的异常控制流很好地交互。当中断发生或返回时告诉缓存对于当前执行的代码段总是冷的(即程序所需的数据都不在告高速缓存中,这也很好理解,因为局部性原理)。

8.3 系统调用错误处理

unix中的系统调用发生错误时通常返回-1并设置errno变量。我们可以通过strerror(errno)函数得到出错原因对应的字符串。程序员应该像例如demo1这样总是去检查错误(像go的返回值总是包含一个error类型,更极端的rust返回一个枚举类型强制你去处理)。然而这样会导致程序变得臃肿难懂。我们可以简化这个代码(参见demo2)。更进一步,习惯上可以为系统调用进行一种例如demo3这样的封装(似乎APUE里提到过这玩意),称之为包装函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//demo1:
if( (pid = fork()) < 0){
fprintf(stderr,"fork err:%s\n",strerror(errno));
exit(0);
}
//demo2
void unix_error(char *msg){
fprintf(stderr,"fork err:%s\n",strerror(errno));
exit(0);
}
if( (pid = fork()) < 0){
unxi_error("fork error");
}
//demo3
// Fork是一个参数和返回值与fork均相同的函数
pid_t Fork(void){
pid_t pid;
if( (pid = fork()) < 0)
unxi_error("fork error");
return pid;
}

8.4进程控制

Unix提供了大量的系统调用来操作进程。

fork:复制一个进程。更多请参考apue.或man page手册

exec函数簇:一系列以exec开头的函数,将某程序载入此进程(进程号不变,相当于‘换壳’)。

​ 在Unix中,一个进程结束后进程在内核中的记录(即pcb)并不会直接消失即进入僵死状态,而通常需要回收。如果子进程结束时父进程尚未结束则由父进程负责回收。如果此时父进程已经结束,则交由pid为1的init进程负责(因此有将init进程成为’进程孤儿院’)。

waitpid:用于回收子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
pid_t waitpid(pid_t pid, int *status, int options);
/*用于回收子进程。根据pid值,有3种情况
>0 回收指定ID的子进程
-1 回收任意子进程
0 回收和当前调用waitpid一个组的所有子进程
< -1 回收指定进程组内的任意子进程
status为传出参数,用于接收子进程的结束状态
options:主要用于设置非阻塞 WNOHANG
返回值
>0:表成功回收的子进程pid
0:参数三设置为WNOHANG且无可回收的子进程
-1:失败
*/

wait: 相当于waitpid(-1,status,0);

慢速系统调用

​ 在unix中有些系统调用不能立即返回,可能会阻塞直到某个条件达成才会返回,我们将这些系统调用称作慢速系统调用(主要是I/O相关)。这些慢速系统调用在被信号打断后会返回EINTR错误。有些情况下我们需要重新恢复该慢速系统调用。

两个非IO的阻塞系统调用

sleep函数会使得进程休眠直到设定的计时器到期,或者接收到信号。返回剩余的秒数。

pause函数使得进程暂停直到信号到来。

8.5信号

​ unix的信号是个复杂但是有非常有用的东西。很多功能的实现依赖于信号(例如进程间通信,go语言的抢占式调度…)。它是软件形式的异常(也称作软中断–争议)。在CSAPP中这一部分从504到521接近20页…本文中只介绍一部分内容。APUE中有更多的资料。

​ 你可以通过 man 7 signal命令查看UNIX中有哪些可以使用的信号。你可以通过 kill函数为其它进程(或自身)发送信号。内核中信号的存储是使用位向量完成。言外之意就是内核对于同一类型的信号只能保存一次(对于已经记录的信号再次发送信号则只能被粗暴的丢弃)。

进程组: 进程组是一个或多个进程的集合,通常它们与一组作业相关联,可以接受来自同一终端的各种信号。

kill:kill函数用于向发送信号。【send signal to a process –man page】

原型int kill(pid_t pid, int sig);如果pid大于0则向此进程发送信号,如果pid<0则向此进程组发送信号。

alarm:set an alarm clock for delivery of a signal。更多请参考man page手册或APUE等资料

补充阅读:阻塞信号集和未决信号集 。(其实我更建议你阅读APUE这种资料

注:unix的信号只有进入内核时才能被处理。

8.6非本地跳转

Unix通过以下的函数实现非本地跳转。它通常具有2种作用:从多层嵌套的函数调用中返回而无需遵从正常的函数调用机制(高级语言中的try,catch即是对此的封装),处理信号使得信号返回到一个指定的位置。当处理信号时应使用信号版本的函数。

1
2
3
4
5
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);
// 信号版本
int sigsetjmp(sigjmp_buf env, int savesigs);
void siglongjmp(sigjmp_buf env, int val);

setjmp用于保存当前的环境。当调用longjmp时函数将再次返回到setjmp函数处。(也就是说setjmp函数调用一次可以返回多次而longjmp调用却不返回)。

两个demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 普通版本
#include "csapp.h"
jmp_buf buf;
int error1 = 0;
int error2 = 1;
void foo(void), bar(void);

int main()
{
int rc;

rc = setjmp(buf);
if (rc == 0)
foo();
else if (rc == 1)
printf("Detected an error1 condition in foo\n");
else if (rc == 2)
printf("Detected an error2 condition in foo\n");
else
printf("Unknown error condition in foo\n");
exit(0);
}

/* deeply nested function foo */
void foo(void)
{
if (error1)
longjmp(buf, 1);
bar();
}

void bar(void)
{
if (error2)
longjmp(buf, 2);
}
// 信号版本---当按下键盘上的Ctrl + C 该程序可以重启
#include "csapp.h"

sigjmp_buf buf;

void handler(int sig)
{
siglongjmp(buf, 1);
}

int main()
{
Signal(SIGINT, handler);

if (!sigsetjmp(buf, 1))
/*The initial call to the sigsetjmp function saves the stack and signal context when the program first starts.*/
printf("starting\n");
else
printf("restarting\n");

while(1) {
Sleep(1);
printf("processing...\n");
}
exit(0);
}

8.7操作系统中的工具

STRACE:可以追踪系统调用。

PS,TOP,PMAP....等可自行了解。另外/proc下也有许多内核信息的映射。

tips:

+ 本文中将 混写 系统调用,syscall和sysyem call,都表示系统调用
+ 8.4,8.5中更多详细的资料可参考APUE
+ 文中的APUE指的是这个