Author:onceday Date:2022年8月3日
长路漫漫,而今才刚刚启程!
本内容收集整理于《深入理解计算机系统》一书。
从给处理器加电开始,程序处理器一直在执行一个值的序列: a 0 , a 1 , a 2 , . . . , a n − 1 a_0,a_1,a_2,...,a_{n-1} a0,a1,a2,...,an−1。
每次从 a k a_k ak到 a k + 1 a_{k+1} ak+1的过渡称为控制转移(control transfer),这样的控制转移序列叫做处理器的控制流(flow of control 或 control flow)。
现代操作系统通过使控制流发生突变来对系统状态的变化做出反应,一般称为异常控制流(Exceptional Control Flow, ECF)。
现代操作系统依靠异常控制流来实现以下功能:
硬件中断服务程序
进程和线程切换
系统服务调用
信号处理程序
异常处理代码
在这里,异常(exception)被定义为控制流中的突变,用来响应处理器状态中的某些变化。
这个状态变化往往是 某些事件(event) 发生了。
当处理器检测到了有事件发生时,它会通过一张叫做异常表(exception table),进行一个间接过程调用(异常),到一个专门处理该事件的操作系统子程序(异常处理程序(exception handler)。
根据引起异常的事件类型,会发生以下三种情况:
处理程序将控制返回给当前指令 I c u r r I_{curr} Icurr,即当事件发生时正在执行的指令。
处理程序将控制返回给 I n e x t I_{next} Inext,如果没有发生异常将会执行的下一条指令。
处理程序终止被中断的程序。
系统会给每种类型的异常都分配一个唯一的非负整数的异常号(exception number)。
诸如零除、缺页、内存访问违例、断点以及算术溢出是处理器的设计者分配的方案。
系统调用和来自外部I/O设备的信号是操作系统分配。
在处理器复位时,会初始化异常表,填入各类服务例程函数,以及异常表的起始地址—异常表基址寄存器(exception table base register)
异常调用过程和普通函数过程的不同:
过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中,当前指令或者下一条指令。
处理器会把一些额外的处理器状态压入栈里。
如果控制从用户程序转移到内核,所有这些项目被压到内核栈,而不是用户栈中。
异常处理程序运行到内核模式下,这意味着它们对所有系统资源都有完全的访问权限。
异常服务程序执行完后,会使用从中断返回指令,该指令会恢复CPU的寄存器状态。
异常的类别:
| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
| 陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
| 故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
| 终止 | 不可恢复的错误 | 同步 | 不会发生 |
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果,硬件中断不是任何一条专门的指令造成的,从这个意义来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。
陷阱是有意的异常,是执行一条指令的结果。
处理器提供一条特殊的syscall n指令,当用户想要使用内核服务时,可以使用该指令,系统调用运行在内核模式中,允许系统调用执行特权指令,并访问内核栈。
故障由错误情况引起,可能被故障处理程序修正。当故障发生时,处理器将控制转移给故障程序。
如果故障处理程序能修正这个情况,它就将控制返回到引起故障的指令,从而重新执行它。
否则,处理程序返回到内核中的abort例程,会终止引起故障的应用程序。
一个典型的例子是缺页异常,当成功载入物理页面之后,就可恢复正常运行。
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。
每个进程有一个自己的逻辑控制流,里面也可以在细分线程。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),
多个流并发地执行的一般现象被称为并发(concurrency)。
一个进程和其他进程轮流运行的概念称为多任务(multitasking)。
一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。
因此,多任务也叫做时间分片(time slicing)。
如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称他们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)。
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。
上下文是内核重新启动一个被抢占的进程所需的状态:
数据寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈
内核数据结构,如描述地址空间的页表、包含有关当前进程信息的进程表、包含进程已打开文件的信息的文件表。
每个进程都有唯一的非负数的进程ID。
使用函数getpid获取当前进程的PID,函数getppid获取当前进程的父线程的PID。
#include
#include
#include
int main(void)
{
pid_t temp;
temp = getpid();
printf("pid = %d \n",temp);
temp = getppid();
printf("pgrp = %d \n",temp);
return 0;
}
fork函数创建的子进程会继承父进程的进程环境、堆栈、文件描述符、信号控制设定、调度方式等。但父进程的线程、定时器、信号状态等不继承。
fork函数在子进程里返回0,在父进程里返回子进程PID。
#include
#include
#include
#include
int main(void)
{
pid_t temp;
int x=1;
temp = getpid();
printf("1: father pid = %d \n",temp);
temp = fork();
if(temp == 0) {
temp = getpid();
printf("2: son pid = %d \n",temp);
temp = getppid();
printf("2: my father pid = %d \n",temp);
x++;
printf("2: son's x = %d\n",x);
}
sleep(1);
printf("x's value = %d \n",x);
return 0;
}
虽然子进程的上下文环境复制于父进程,但两者是独立的,一旦修改,就会创建出副本出来。
不可以假设父进程代码和子线程代码的运行顺序,他们之间是拓扑排序,即任意可能排序。
使用void exit(int status)函数可以主动终止进程。
父进程终止了,但还没有回收已终止的子进程,那么子进程会被init(PID=1)收养,子进程此时也被称为僵死进程。
可使用以下两个函数来等待子进程结束:
pid_t wait(int *stat_loc); //相当于waitpid(-1,xxx,0)
pid_t waitpid(pid_t pid, int *stat_loc, int options);
pid用来指定等待的子进程,如果为-1,那么等待集合由所有子进程组成。
options选项可填入三个常量:
WNOHANG,如果等待集合的任何子进程都没有终止,那么就立即返回。
WUNTRACED,挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。
WCONTINUED,挂起被调用进程的执行,直到等待集合中一个正在运行的进程终止,或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。
这些选项可以用或运算组合起来。
stat_loc是进程的返回状态,可以用WIFEXITED(status)等宏来检查,具体可man waitpid来查看具体说明。
如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。如果被信号中断,也会返回-1,并且设置errno为EINTR。
#include
#include
#include
#include
#include
int main(void)
{
pid_t temp;
int x;
temp = getpid();
printf("1: father pid = %d \n",temp);
temp = fork();
if(temp == 0) {
temp = getpid();
printf("2: son pid = %d \n",temp);
temp = getppid();
printf("2: my father pid = %d \n",temp);
sleep(1);
}
else {
wait(&x);
printf("son return %d\n",x);
if (WIFEXITED(x) != 0) {
printf("exit number =%d\n",WEXITSTATUS(x));
}
}
return 1;
}
sleep函数可以让进程挂起一段指定的时间,但这个睡眠的时间可能少于给出的时间。
因为信号可以打断睡眠。
unsigned int sleep (unsigned int __seconds);
返回的是实际睡眠的时间。
pause函数可以让一个进程休眠,直到该进程收到一个信号:
int pause (void);
execve函数在当前的进程的上下文中加载并运行一个新程序。
int execve (const char *path, char *const argv[],
char *const envp[]);
参数列表和环境变量列表都指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。
按照惯例,参数列表是第一个参数字符串是可执行目标文件的文件名。

对应的main函数的原型为:
int main(int argc, char *argv[], char *envp[]);

argc 是参数列表的参数个数,其值在%rdi中
argv是参数列表的首地址,其值在%rsi中
envp是环境变量列表的首地址,其值在%rdx中
以下是几个操作环境变量数组的函数:
char *getenv (const char *name);//返回指定name的value指针,或者null
int putenv (char *string);//移除字符串
int setenv (const char *name, const char *value, int replace);//设置或更新
int unsetenv (const char *name);//移除字符串
实现一个输出参数列表和环境变量列表的程序:
#include
#include
#include
int main(int argc, char *argv[], char *envp[])
{
int i;
printf("command-line arguments:\n");
for (i = 0; i < argc; i++) {
printf("%s\n",argv[i]);
}
printf("Environment variable:\n");
while( *envp != NULL) {
printf("%s\n",*envp++);
}
return 0;
}
execve函数的参数列表和环境变量列表可以为空,此时加载后的程序两者都是空的。
可以使用全局变量environ或者__environ将当前程序的全局变量直接传给加载后的程序。
信号是一种更高级的软件形式的异常,允许进程和内核中断其他进程。
使用man 7 signal可以查看关于信号的具体解释。
底层的硬件异常是由内核异常程序处理的,正常情况下,用户进程是不可见,信号则提供了一种机制,通知用户进程发生了这些异常。
以下是常见的30种异常信号:
| 序号 | 名称 | 默认行为 | 相应事件 |
|---|---|---|---|
| 1 | SIGHUP | 终止 | 终端线挂断 |
| 2 | SIGINT | 终止 | 来自键盘的中断 |
| 3 | SIGQUIT | 终止 | 来自键盘的退出 |
| 4 | SIGILL | 终止 | 非法指令 |
| 5 | SIGTRAP | 终止并转储内存 | 跟踪陷阱 |
| 6 | SIGABRT | 终止并转储内存 | 来自abort函数的终止信号 |
| 7 | SIGBUS | 终止 | 总线错误 |
| 8 | SIGFPE | 终止并转储内存 | 浮点异常 |
| 9 | SIGKILL | 终止(不能被捕获或忽略) | 杀死程序 |
| 10 | SIGUSR1 | 终止 | 用户定义的信号1 |
| 11 | SIGSEGV | 终止并转储内存 | 无效的内存引用 |
| 12 | SIGUSR2 | 终止 | 用户定义的信号2 |
| 13 | SIGPIPE | 终止 | 向一个没有读用户的管道做写操作 |
| 14 | SIGALRM | 终止 | 来自alarm函数的定时器信号 |
| 15 | SIGTERM | 终止 | 软件终止信号 |
| 16 | SIGSTKFLT | 终止 | 协处理器上的栈故障 |
| 17 | SIGCHLD | 忽略 | 一个子进程停止或者终止 |
| 18 | SIGCONT | 忽略 | 继续进程如果该进程停止 |
| 19 | SIGSTOP | 停止直到下一个SIGCONT(不能被捕获或忽略) | 不是来自终端的停止信号 |
| 20 | SIGTSTP | 停止直到下一个SIGCONT | 来自终端的停止信号 |
| 21 | SIGTTIN | 停止直到下一个SIGCONT | 后台进程从终端读 |
| 22 | SIGTTOU | 停止直到下一个SIGCONT | 后台进程向终端写 |
| 23 | SIGURG | 忽略 | 套接字上的紧急情况 |
| 24 | SIGXCPU | 终止 | CPU时间超出限制 |
| 25 | SIGXFSZ | 终止 | 文件大小限制超出 |
| 26 | SIGVTALRM | 终止 | 虚拟定时器期满 |
| 27 | SIGPROF | 终止 | 剖析定时器期满 |
| 28 | SIGWINCH | 忽略 | 窗口大小变化 |
| 29 | SIGIO | 终止 | 在某个描述符上可执行IO操作 |
| 30 | SIGPWR | 终止 | 电源故障 |
发送信号:内核可以通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。
内核检测到一个系统事件
调用kill函数发送信号给目的进程
接受信号:当目的进程被内核强迫以某种方式对信号的发送做出反应,它就接受了信号。
一个发出而没有被接收的信号叫做待处理信号(pending signal)
任意一时刻一种类型信号只会有一个待处理信号,一个待处理信号最多只能被接收一次。
Unix系统提供了大量向进程发送信号的机制,所有的这些机制都是基于进程组(process)这个概念的。
getpgrp函数返回当前进程的进程组ID:
pid_t getggrp(void);
一个子进程默认与父进程属于同一个进程组。
setpgid函数将进程pid的进程组改为pgid。
如果pid是0,那么就使用当前进程组的PID,如果pgid是0,那么就用pid指定的进程的PID作为进程组ID。
1. 使用``/bin/kill`程序发送信号:
kill -9 15236 #发送信号9(SIGKILL)给进程15213
2. 通过按键也可以CTRL+C会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况下,终止前台作业。
3. 类似的地,输入CTRL+Z会发送一个SIGTSTP信号到前台进程组的每个进程,默认是停止前台作业。
4.用kill函数也可发送信号
#include
#include
int signal(pid_t pid, int sig);
如果PID大于0,则发给指定的进程。
如果PID=0,则发给所在进程组的每个进程。
如果PID<0,则发送给进程组abs(PID)的每个进程。
5.用alarm函数发送信号:
#include
unsigned int alarm(unsigned int secs);
alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程。返回值是前一次闹钟剩余的秒数,若没有设置则为0。
当每次从内核模式切换到用户模式时,都会检查进程P的未被阻塞的待处理信号的集合。
请注意,上下文切换也是内核调度。
进程可以通过signal函数来修改默认信号的行为,但SIGKILL和SIGSTOP信号的默认行为不能被修改。
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
signal函数成功返指向前次处理程序的指针,如出错,则返回SIG_ERR。
handler的值有以下三种情况:
如果handler是SIG_IGN,那么忽略类型为signum的信号
如果handler是SIG_DFL,那么类型为signum的信号行为恢复默认行为
非以上两种情况,那么handler就是用户定义的函数的地址,这个函数就被称为信号处理程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序,调用信号处理处理程序被称为捕获程序,执行信号处理程序被称为处理信号。
一个信号被阻塞时,它可以被发送,但是产生的待处理信号不会被接收,直到进程取消对他的阻塞。
隐式阻塞机制:
显示阻塞机制:
sigprocmask函数以及辅助函数,明确阻塞和解除阻塞选定的信号。#include
int sigprocmask(int how, const sigset_t *set,sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
//如果成功返回0,出错则返回-1。
int sigismember(const sigset_t *set, int signum);
//若signum是set的成员则为1,如果不是则为0,出错则为-1
sigprocmask函数改变当前阻塞的信号集合,即blocked位向量。具体行为由how决定:
blocked位向量之前的值保存在oldset中。
以下是辅助函数的作用:
信号处理非常棘手,原因如下:
以下是保守的建议:
man 7 signal-safety查看对应函数。未处理的信号是不排队的,因此信号不可以用来对事件来计数。
如果存在一个未处理的信号,至少代表有一个该信号到达了。
Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。
signal函数的语义不同。部分老系统在信号k被处理程序捕获之后就把对信号k的反应恢复到默认值。因此,每次运行之后,处理程序需要调用signal函数显示地重新设置自己。
系统调用可以被中断。像read、write和accept这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。在较早的系统中,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR。因此程序员需要手动重启被中断的系统调用的代码。
可以通过sigaction函数在设置信号处理时,明确指定他们想要的信号处理语义。
当信号处理程序和主程序同时需要访问全局变量或操作同一事物时, 就存在竞争问题。
在不需要等待,仅需要原子性操作某一段代码时,可用sigprocmask函数。
在需要等待信号处理程序完成某一工作时,可用sigsuspend函数来等待接收到一个信号。
#include
int sigsuspend(const sigset_t *mask);
sigsuspend函数暂时用mask奇幻当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。终止时不返回,如果是一个处理程序,那么sigsuspend从处理程序返回,恢复调用sigsuspend时原有的阻塞集合。
LInux的监控和操作进程的有用工具:
STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
PS:列出当前系统中的进程(包括僵死进程)。
TOP:打印出关于当前进程资源使用的信息。
PMAP:显示进程的内存映射。
STRACE和PMAP需要在较高的权限下才能运作,并且提供足够的信息。