本章讲操作系统中与高级语言设计有关的所有内容,尤其是进程控制有关的内容。
从给处理器加电开始,到断电为止,程序计数器假设一个值的序列:a0, a1, a2, …, an。其中每个 a(k) 都是某个相应的指令 I(k) 的地址。
每次从 a(k) 到 a(k+1) 的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流(control flow)。
说白了,处理器从加电到断电,处理器只是简单地读取和执行一个指令序列(一次执行一条指令)
最简单的控制流是一个平滑的序列,其中每个 I(k) 和 I(k+1) 都是相邻的。而诸如跳转、调用、返回等指令则会造成平滑流的突变,这些突变是由程序内部变量带来的。
还有一种突变是由程序外部的原因造成的,比如磁盘返回数据,鼠标关闭程序等,这种突变就叫做异常控制流(Exceptional Control Flow, ECF)。
能够对(由程序变量表示的) 程序状态的变化做出反应
不足:难以对系统状态的变化做出反应
磁盘或网络适配器的数据到达
除零错误
用户的键盘输入( 例如:Ctrl-C )
系统定时器超时
上述系统变化不能用程序变量表示
现代系统通过使控制流发生突变对这些情况做出反应,就是异常控制流
异常控制流 ECF 发生在计算机系统的各个层次:
ECF 的应用:
理解:首先要知道,异常控制流是从程序计数器的控制流层面来描述的。异常控制流就是程序计数器的控制流产生了程序外部原因带来的突变。
**异常(exception)**是异常控制流的一种形式,一部分由硬件实现(也因此它的具体细节会随系统的不同而有所不同),一部分由操作系统实现。异常位于硬件和操作系统交界的部分。
异常(Exception)是指为了响应某事件将控制权转移到操作系统内核(操作系统作为一个程序常驻内存的部分)的情况。其实就是控制流里的突变以响应处理器状态的某些变化。
状态变化称为事件。在任何情况下,当处理器检测到有事件发生时,它就会通过叫做异常表的跳转表,进行间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。
异常处理完成后,根据事件类型,会有三种情况:
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,表目包含异常的处理程序的地址。
在运行时,处理器遇到了一个事件,并且确定了特定的异常号k,触发异常,执行间接过程调用,找到相应的异常处理程序
异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,他通过执行“从中断返回”指令,可选地返回到被中断的程序。
异常可以分为四类:中断、陷阱、故障和终止。
中断:
故障(fault) :执行指令引起的异常事件,如溢出、非法指令、缺页、访问越权等。“断点”为发生故障指令的地址。
故障的例子:缺页故障
自陷(Trap) :预先安排的事件(“埋地雷”),如单步跟踪、断点、
系统调用 (执行访管指令) 等。是一种自愿中断。“断点”为自陷指令下条指令地址。
每个x86-64系统调用有一个唯一的ID号
关于系统调用的例子:文件读取
用户调用函数: open(filename, options)
,调用_open函数
所有系统调用函数都是调用syscall指令,_open也不例外。
00000000000e5d70 <__open>:
...
e5d79: b8 02 00 00 00 mov $0x2,%eax # open is syscall #2
e5d7e: 0f 05 syscall # Return value in %rax
e5d80: 48 3d 01 f0 ff ff cmp $0xfffffffffffff001,%rax
...
e5dfa: c3 retq
进程提供给应用程序两个假象:
独立的逻辑控制流:每个程序似乎独占CPU。(由内核通过上下文切换机制来实现)
使用调试器单步执行程序时会看到一系列的程序计数器(PC)值,这个 PC 的值的序列叫做逻辑控制流,简称逻辑流。
PC 的值唯一地对应于包含在程序的可执行目标文件中的指令,或包含在运行时动态链接到程序的共享对象中的指令。
逻辑流有许多不同的形式,异常处理程序、进程、信号处理程序、线程等都是逻辑流的例子。
进程是轮流使用处理器的。
私有的空间地址:每个程序似乎独占内存。(由内核的虚拟内存机制来实现)
进程为每个程序提供它自己的私有地址空间。一般而言,和这个私有地址空间中某个地址相关联的那个内存字节是不能被其他进程读或写的。
不同进程的私有地址空间关联的内存的内容一般不同,但是每个这样的空间都有相同的通用结构。
此处拿出老图:
地址空间的顶部保留给内核(操作系统常驻内存的部分),包含内核在代表进程执行指令时(比如当执行了系统调用时)使用的代码、数据、堆和栈。
地址空间的底部留给用户程序,包括代码段、数据段、运行时堆、用户栈、共享库等。代码段总是从地址 0x400000 开始。
内核栈和用户栈是分开的。
关于内存分配的结构详见虚拟内存一章。
进程就是一个执行中的程序的实例。系统中每个程序都运行在某个进程的上下文中。
以下两章内容主讲Linux内核的进程控制,可参见:
计算机同时运行许多进程,如:
单/多用户的应用程序
Web 浏览器、email客户端、编辑器…
后台任务(Background tasks)
监测网络和I/O 设备
多重处理的真相:
寄存器当前值保存到内存
调度下一个进程执行
加载被保存的寄存器,并切换地址空间 (上下文切换)
多核处理器的用处:
单个芯片有多个CPU
共享主存、有的还共享cache
每个核可以执行独立的进程kernel负责处理器的内核调度
每个进程都是逻辑控制流,若其在时间上是有重叠的就是并发的,否则是顺序的,如该图
AB、AC是并发的,BC是顺序的。(上图是系统视角,并发进程的控制流物理上是不相交的,用户视角下A、C均为连续的,如下图)
处理器提供一种机制,限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。这就是用户模式和内核模式。
处理器通过控制寄存器中的一个模式位来提供这个功能。
Linux提供一种聪明的机制,叫/proc文件系统(将进程及其内存数据在/proc目录下以文件样式提供)。
允许用户模式访问内核数据结构的内容。
/proc文件将许多内核数据结构输出为一个用户程序可以读的文本文件的层次结构。
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文是由程序运行所需的状态组成的,包括存放在内存中的程序的代码和数据,是内核重新启动一个被抢占的进程所需的状态。
当内核决定抢占(暂时挂起)当前进程后,它使用上下文切换机制来将控制转移到新的进程(也是先前被抢占的进程)。上下文切换包括(其实就是多重处理的过程):
会引发上下文切换的状况:
内核代表用户进行系统调用
系统中断(例如上图的磁盘中断,因读取磁盘本身比CPU时钟时间长很多)
在代码中进行错误检查是必要的!!!
Unix及Linux等类Unix遇到错误时,它们通常会返回-1
并设置全局变量errno
来表示出错原因。
strerror 函数返回一个文本串,描述了和某个 errno 值相关联的错误。
if((pid = fork()) < 0) //如果发生错误,此时 errno 已经被设置为对应值了
{
fprintf(stderr, "fork error: %s\n", strerror(errno));//strerror(errno) 返回描述当前 errno 值的文本串
exit(0);
}
可以使用错误处理包装函数以简化错误处理过程
//错误报告函数
void unix_error(char *msg)
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
//fork 函数的错误处理包装函数 Fork
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error"); //调用上面定义的包装函数
return pid;
}
//错误处理包装函数使用原函数的首字母大写形式,以便隐式地进行错误处理
每个进程都有一个唯一的非零正整数表示的进程 ID,叫做 PID。有两个获取进程 ID 的函数:
#include
#include
pid getpid(void);
pid getppid(void);
进程总是处于以下三种状态之一:
信号是一种软件中断的形式。
void exit(int status)
父进程通过调用fork
函数创建一个新的运行的子进程
int fork(void)
子进程返回0,父进程返回子进程的PID(运行一次,返回两次)
新创建的子进程几乎但不完全与父进程相同:
子进程得到与父进程虚拟地址空间相同的(但是独立的) 一份副本
子进程获得与父进程任何打开文件描述符相同的副本(共有文件)
子进程有不同于父进程的PID
例子:
#include
#include
#include
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0) { /* Child */
printf("child : x=%d\n", ++x);
exit(0);
}
/* Parent */
printf("parent: x=%d\n", --x);
exit(0);
}
编译后运行结果:
linux> ./fork
parent: x=0
child : x=2
得到以下规律:
进程图有助于理解父进程和子进程之间的关系。
进程图是捕获并发程序中语句偏序的有用工具***
每个顶点对应一条语句的执行
有向边a -> b 表示语句 a发生在语句 b 之前
边上可以标记信息如变量的当前值
printf语句的顶点可以标记上printf的输出
每张图从一个没有入边的顶点开始
图的任何拓扑排序对应于程序中语句的一个可行的全序排列**.**
所有顶点的总排序,这些顶点的每条边都是从左到右的
上面例程的进程图如下:
另可以造一个程序,如下:
#include
#include
#include
void main()
{
Fork();
//主进程和子进程1
Fork();
//主进程产生子进程2,子进程1产生子进程3
printf("hello\n");
//共计4个进程,产生4个hello
exit(0);
}
void fork4()
{
printf("L0\n");
if (fork() != 0) {
printf("L1\n");
if (fork() != 0) {
printf("L2\n");
}
}
printf("Bye\n");
}
void fork5()
{
printf("L0\n");
if (fork() == 0) {
printf("L1\n");
if (fork() == 0) {
printf("L2\n");
}
}
printf("Bye\n");
}
fork4:
fork5:
当进程终止时,它仍然消耗系统资源,除非被父进程回收
即使主进程已经终止,子进程也还在消耗系统资源,我们称之为“僵尸”。为了“打僵尸”,就可以采用“收割”(Reaping) 的方法。
父进程利用 wait 或 waitpid 回收已终止的子进程,然后给系统提供相关信息,内核就会把 zombie child process 给删除。
如果父进程不回收子进程的话,通常来说会被 init 进程(pid == 1)回收,所以一般不必显式回收。但是在长期运行的进程(例如 shell 和 server)中,就需要显式回收。
系统启动时内核会创建一个 init 进程,它的 PID 为 1,不会终止,是所有进程的祖先。
如果一个父进程终止了,init 进程会成为它的孤儿进程的养父。init 进程会负责回收没有父进程的僵死子进程。
#include
#include
pid_t waitpid(pid_t 47pid, int *child_status, int options); //如果成功,返回对应的已终止的子进程的 PID;如果其他错误,返回 -1
//只有当参数 options=WNOHANG 时,才有可能返回 0;其他情况要么返回子进程 PID,要么返回 -1
参数:
pid,设置成-1则表示等待任意一个子进程,同wait;如果>0则表示等待一个指定的子进程,pid就是被等待子进程的进程号
status,出参,获取子进程的退出状态,同wait
options,可以设置为0、WNOHANG或其他值(见下表)。设置为0则与wait一样,如果没有等待到子进程退出会一直阻塞;而设置为WNOHANG则表示非阻塞,如果被等待的子进程未退出,则会返回0值,成功等待到子进程则会返回被等待子进程的pid
WNOHANG | 若无子进程结束也会返回(返回值为0),不会挂起当前进程。 |
---|---|
WUNTRACED | 若等待集合中一个进程被停止也返回。返回值为导致返回的已终止或停止子进程的PID |
WCONTINUED | 若等待集合中一个进程收到SIGCONT从停止重新开始也返回。 |
WNOHANG|WUNTRACED | 立即返回,返回值为导致返回的已终止或停止子进程的PID(若无子进程结束也会返回,返回值为0) |
返回值:
错误条件:
pid_t wait(int *child_status)
//调用wait等价于waitpid(-1,&status,0)
挂起当前进程的执行直到它的一个子进程终止
返回已终止子进程的pid
如 child_status != NULL
, 则在该指针指向的整型量中写入关于终止原因和退出状态的信息):
子进程完成结束的顺序是任意的(没有固定的顺序)
如果 child_status
参数是非空的,那么 wait 就会在 status 中放上关于导致 wait 返回的子进程的状态信息,status 是 child_status
指向的值。
wait.h 头文件定义了解释 status 参数的几个宏:
例程:
void fork10() {
pid_t pid[N];
int i, child_status;
for (i = 0; i < N; i++)
if ((pid[i] = fork()) == 0) {
exit(100+i); /* Child */
}
for (i = 0; i < N; i++) { /* Parent */
pid_t wpid = wait(&child_status);
if (WIFEXITED(child_status))
printf("Child %d terminated with exit status %d\n",wpid,WEXITSTATUS(child_status));
else
printf("Child %d terminate abnormally\n", wpid);
}
}
sleep函数
sleep 函数将一个进程挂起一段指定的时间。注意:sleep 不是 C 标准库里的函数,是 unistd 中的控制进程的函数。
#include
unsigned int sleep(unsigned int secs); //返回还要休眠的秒数
如果请求的休眠时间量到了,sleep 返回 0,否则返回还剩下的要休眠的秒数(当 sleep 函数被一个信号中断而过早地返回,会发生这种情况)。
pause函数
pause 函数让调用函数休眠,直到该进程收到一个信号。
#include
int pause(void);
execve:加载并运行程序
int execve(char *filename, char *argv[], char *envp[])
在当前进程中载入并运行程序:
Filename:可执行文件
目标文件或脚本(用#!指明解释器,例如 #!/bin/bash)
argv:命令行参数列表(字符串数组)
惯例:argv[0]==filename
envp:环境变量列表
“name=value” strings (e.g., USER=droh)
getenv, putenv, printenv
覆盖当前进程的代码、数据、栈
调用一次,并从不返回
进程程序替换与fork不同,它并不会创建新的进程,而是该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。替换前后的进程号并未改变。
shell 是一个交互型应用级程序,代表用户运行其他程序
shell 会打印一个命令行提示符,等待用户在 stdin 上输入命令行,然后对这个命令行求值。
一个极简的 shell 程序包括以下几个函数:main 函数、eval 函数、parseline 函数、buildin 函数,它们的各自的主要职责如下:
#include "csapp.h"
#define MAXARGS 128
int main(){
char cmdline[MAXLINE]; /* Command line */
while (1) {
/* Read */
printf("> ");
Fgets(cmdline, MAXLINE, stdin); //读取用户的输入
if (feof(stdin))
exit(0);
/* Evaluate */
eval(cmdline); //解析命令行
}
}
/* eval - Evaluate a command line */
void eval(char *cmdline)
{
char *argv[MAXARGS]; /* Argument list execve() */
char buf[MAXLINE]; /* Holds modified command line */
int bg; /* Should the job run in bg or fg? */
pid_t pid; /* Process id */
strcpy(buf, cmdline);
bg = parseline(buf, argv); //调用 parseline 函数解析以空格分隔的命令行参数
if (argv[0] == NULL) //表示是空命令行
return; /* Ignore empty lines */
//调用 builtin_command 检查第一个命令行参数是否是一个内置的 shell 命令。如果是的话返回 1,并在函数内就解释并执行该命令。
if (!builtin_command(argv)) //如果返回 0,即表明不是内置的 shell 命令
{
if ((pid = Fork()) == 0) //创建一个子进程
{ /* Child runs user job */
if (execve(argv[0], argv, environ) < 0) //在子进程中执行所请求的程序
{
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}
/* Parent waits for foreground job to terminate */
if (!bg) // bg=0 表示是要在前台执行的程序,shell 会等待程序执行完毕
{
int status;
if (waitpid(pid, &status, 0) < 0) //等待子进程结束回收该进程
unix_error("waitfg: waitpid error");
}
else // bg=1 表示是要在后台执行的程序,shell 不会等待它执行完毕
printf("%d %s", pid, cmdline);
}
return;
}
注意:上面的 shell 程序有缺陷,它只回收了前台的子进程,没有回收后台子进程。不回收后台子进程会导致进程空转,占有内存甚至导致内存泄露。
还有一个 parseline 函数和 builtn_command 函数不再列出。 其中 parseline 函数负责解析以空格分隔的命令行参数字符串并构造要传递给 evecve 的 argv 向量。builtn_command 函数负责检查第一个命令行参数是否是一个内置的 shell 命令。
信号(signal)就是一条小消息,它通知进程系统中发生了 一个某种类型的事件
类似于异常和中断
从内核发送到(有时是在另一个进程的请求下)一个进程
信号类型是用小整数ID来标识的(1-30) (P527)
信号中唯一的信息是它的ID和它的到达
信号类型:
内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程
发送信号可以是如下原因之一:
内核检测到一个系统事件如除零错误(SIGFPE)或者子进程终止(SIGCHLD)
一个进程调用了kill系统调用,显式地请求内核发送一 个信号到目的进程
发送信号的机制都是基于进程组这个概念的。
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp
函数返回当前进程的进程组ID,setpgid
函数可以改变自己或者其他进程的进程组。
pid_t getpgrp(void)
int setpgid(pid_t pid, pid_t pgid)
/bin/kill 程序可以向另外的进程或进程组发送任意的信号
/bin/kill –9 24818
发送信号9(SIGKILL)给进程24818
/bin/kill –9 –24817
发送信号SIGKILL给进程组24817中的每个进程(负的PID会导致信号被发送到进程组PID中的每个进程)
输入 ctrl-c (ctrl-z) 会导致内核发送一个 SIGINT (SIGTSTP)信号到前台进程组中的每个作业
SIGINT – 默认情况是终止前台作业
SIGTSTP – 默认情况是停止(挂起)前台作业
接收信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。
接收信号的时机:内核把进程从内核模式切换到用户模式时,例如从系统调用返回或是完成了一次上下文切换。
接收信号的过程:
内核计算进程的为被阻塞的待处理信号的集合pnb=pending & ~blocked
。
如果集合为空:
接收信号后反应的方式:
默认行为,是下面的一种:
指定行为:
调用执行预先设置好的信号处理程序。
我们可以使用signal函数设置信号处理程序,从而修改和信号相关联的默认行为。
handler_t *signal(int signum, handler_t *handler)
handler的不同取值:
注意,信号处理程序是与主程序同时运行、独立的逻辑流(不是进程)。如下图所示。
一个发出而没有被接收的信号叫做待处理信号 (pending),一个进程可以选择阻塞接收某种信号
阻塞的信号仍可以被发送,但不会被接收,直到进程取消对该信号的阻塞
一个待处理信号最多只能被接收一次
内核为每个进程在 pending 位向量中维护着待处理信号的集合,在 blocked 位向量中维护着被阻塞的信号集合。
只要传送了一个类型为 k 的信号,内核就会设置 pending 中的第 k 位,只要接收了一个类型为 k 的信号,内核就会清除 blocked 中的第 k 位。
blocked: 被阻塞信号的集合,通过 sigprocmask 函数设置和清除,也称信号掩码
Linux提供信号的隐式和显式阻塞机制。
隐式阻塞机制:内核默认阻塞与当前正在处理信号类型相同的待处理信号。
显式阻塞机制:可以使用sigprocmask
函数和它的辅助函数明确地阻塞和解除阻塞选定的信号。
sigprocmask函数
sigprocmask 函数改变当前阻塞的信号集合(blocked 位向量),具体行为依赖 how 的值:
SIG_BLOCK:把 set 中的信号添加到 blocked 中(blocked = blocked | set)。
SIG_UNBLOCK:从 blocked 中删除 set 中的信号(blocked = blocked & ~set)。
SIG_SETMASK:block = set。
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
如果 oldset 非空,blocked 位向量之前的值保存在 oldset 中。
其他辅助函数
辅助函数用来对 set 信号集合进行操作:
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(segset_t *set, int signum);
一个临时阻塞 SIGINT 信号的例子
sigset_t mask, prev_mask;
Sigemptyset(&mask);
Sigaddset(&mask, SIGINT); //将 SIGINT 信号添加到 set 集合中
Sigprocmask(SIG_BLOCK, &mask, &prev_mask); //阻塞 SIGINT 信号,并把之前的阻塞集合保存到 prev_mask 中。
... //这部分的代码不会被 SIGINT 信号所中断
Sigprocmask(SIG_SETMASK, &prev_mask, NULL); //恢复之前的阻塞信号,取消对 SIGINT 的阻塞
信号处理是 Linux 系统编程最棘手的问题。
处理程序的几个复杂属性:
编写处理程序的原则
G0: 处理程序尽可能简单
G1: 在处理程序中只调用异步信号安全1的函数
printf
, sprintf
,malloc
,and exit
are not safe!G2:保存和恢复errno
G3: 阻塞所有信号保护对共享全局数据结构的访问
G4: 用volatile
声明全局变量
G5: 用sig_atomic_t
声明标志
原子型标志: 只适用于单个的读或者写 (e.g. flag = 1, not flag++)
采用这种方式声明的标志不需要类似其他全局变量的保护
异步信号安全的,指函数要么是可重入的(如只访问局部变量),要么不能被信号处理程序中断 ↩︎