进程
异常是允许操作系统内核提供进程(process)概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。异常控制流与进程有密切的关系,因为它为进程的多任务运行、异常处理和资源管理提供了基础。
进程是操作系统对正在运行的程序的一种抽象。它为每个程序提供了一种假象,仿佛每个程序都在独占地使用处理器、主存和 I/O 设备,而实际上系统中可能同时存在多个进程并发执行,操作系统负责管理和协调这些进程对硬件资源的共享。
私有地址空间
进程的私有地址空间包括
- 代码区:存放程序代码,通常是只读的,以防止程序意外修改自身指令。
- 数据区:包含已初始化的全局和静态变量。
- 堆区:从低地址向高地址增长,用于动态内存分配,如程序运行过程中根据需要创建的数据结构。
- 栈区:从高地址向低地址增长,用于存放函数调用相关信息,如局部变量、函数参数、返回地址等。
这种布局有助于组织和管理进程的数据和指令存储,并且通过地址空间的隔离保证了进程间的独立性和安全性,防止一个进程意外访问或修改另一个进程的数据。
用户模式和内核模式
在用户模式下,进程只能访问自己的虚拟内存空间,无法直接操作硬件或执行特权指令。而在内核模式下,进程具有完全的权限,可以访问整个内存空间并与硬件直接交互,操作系统的核心功能大多在内核模式下执行。
进程从用户模式切换到内核模式通常由系统调用、中断或异常触发。当进程请求操作系统服务时,它会通过系统调用触发模式切换。系统调用通过陷阱指令使得进程从用户模式转入内核模式,操作系统处理完请求后,再返回用户模式继续执行。
上下文切换
进程上下文包含了进程执行所需的所有信息,这些信息完整地描述了进程在某一时刻的执行状态,当进程被暂停或切换时,操作系统需要保存这些上下文信息,以便在下次恢复执行时能够准确地回到之前的执行状态。
- 程序计数器(PC)的值:指示下一条要执行的指令地址。
- 寄存器的值:包括通用寄存器、栈指针、程序状态字寄存器,用于存储进程当前的计算状态和临时数据。
- 页表:用于将虚拟地址转换为物理地址,实现内存管理。
- 内核栈:用于保存进程在内核态执行时的函数调用信息和临时数据。
进程控制
获取进程 ID
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
// 返回:调用者或其父进程的 PID。
getpid
和 getppid
函数返回一个类型为 pid_t 的整数值,在 Linux 系统上它在 types.h 中被定义为 int。
创建和终止进程
#include <stdlib.h>
void exit(int status);
// 该函数不返回。
exit 函数以 status 退出状态来终止进程(或返回一个整数值)。
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
// 返回:子进程返回 0,父进程返回子进程的 PID,如果出错,则为 -1。
父进程调用fork
后,操作系统会创建一个新的进程控制块(PCB)和地址空间等资源给子进程,然后将父进程的上下文复制到子进程中。之后,父进程和子进程从fork
函数返回后开始独立执行,虽然它们从相同的代码位置继续执行,但由于返回值不同,可以通过判断返回值来执行不同的代码分支,例如父进程可以在fork
后继续执行其他任务,同时等待子进程完成某些操作,而子进程可以执行特定于子进程的任务,如执行另一个程序等。
// 使用 fork 创建一个新进程
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
回收子进程
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options);
// 返回:如果成功,则为子进程的 PID,如果 WNOHANG,则为 0,如果其他错误,则为 -1。
pid
:指定要等待的子进程的 PID(进程标识符)。
- 如果
pid > 0
,则等待进程 ID 为pid
的子进程结束。 - 如果
pid == 0
,则等待与当前进程同属一个进程组的任何子进程结束。 - 如果
pid == -1
,则等待任何子进程结束(类似于wait
)。 - 如果
pid < -1
,则等待进程组 ID 为pid
的子进程结束(即进程组内的任何子进程)。
status
:指向整型变量的指针,用于存储子进程的退出状态。如果该参数为 NULL
,则不保存子进程的状态。
options
:控制等待行为的选项,常见的选项有:
WNOHANG
:非阻塞模式,如果没有子进程退出,waitpid
立即返回而不是阻塞。WUNTRACED
:在子进程停止(但没有终止)时也返回,即使它没有结束。WCONTINUED
:如果子进程因信号而暂停,可以在它继续运行时通知父进程。WNOHANG | WUNTRACED
:立即返回,如果等待集合中的子进程都没有被停止或终止,则返回值为 0;如果有一个停止或终止,则返回值为该子进程的 PID。
子进程的退出状态:如果 status
参数不为 NULL
,waitpid
会将子进程的退出状态存储在 status
指向的变量中。退出状态可以通过以下宏来解析:
WIFEXITED(status)
:如果子进程正常退出,返回真。WEXITSTATUS(status)
:返回子进程的退出码。只有在 WIFEXITED() 返回为真时,才会定义这个状态。WIFSIGNALED(status)
:如果子进程因为未捕捉到的信号退出,返回真。WTERMSIG(status)
:返回导致子进程终止的信号号码。WIFSTOPPED(status)
:如果子进程被信号暂停,返回真。WSTOPSIG(status)
:返回导致子进程暂停的信号号码。WIFCONTINUED(status)
:如果子进程因接收到继续信号(如SIGCONT
)而恢复,返回真。
错误条件:如果调用进程没有子进程,那么 waitpid 返回 -1,并且设置 errno 为 ECHILD。如果 waitpid 函数被一个信号中断,那么它返回 -1,并设置 errno 为 EINTR。
wait 函数
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *statusp);
// 返回:如果成功,则为子进程的 PID,如果出错,则为 -1。
调用 wait(&status)
等价于调用 waitpid(-1, &status, 0)
。
让进程休眠
#include <unistd.h>
unsigned int sleep(unsigned int secs);
// 返回:还要休眠的秒数。
int pause(void);
// 总是返回 -1。
sleep
函数将一个进程挂起一段指定的时间。
pause
函数让调用函数休眠,直到该进程收到一个信号。
加载并运行程序
#include <unistd.h>
int execve(const char *filename, const char *argv[],
const char *envp[]);
// 如果成功,则不返回,如果错误,则返回 -1。
execve
是一个用于启动新程序的系统调用,它是 UNIX 和 Linux 系统中执行程序的重要机制。通过 execve
,当前进程可以加载并执行一个新的程序文件。执行该系统调用后,当前进程的地址空间(包括代码、数据、堆栈等)将被替换为新程序的内容,原进程的代码不再执行。
pathname
:要执行的程序的路径。它是一个以 null 结尾的字符串,指定了要执行的文件的绝对路径或相对路径。argv
:一个指向参数数组的指针,用于传递给新程序的命令行参数。它是一个数组,其中第一个元素通常是程序的名称,接下来的元素是传递给程序的各个参数。数组的最后一个元素必须是NULL
。envp
:一个指向环境变量数组的指针。它是一个包含环境变量的数组,每个环境变量是一个以=
分隔的字符串。数组的最后一个元素必须是NULL
。
int main(int argc, char *argv[], char *envp[]);
argc
是一个整数,表示命令行中传递给程序的参数的数量。它的值至少为 1,因为argv[0]
始终是程序的名称或路径。argv
是一个指向字符串数组的指针,数组中的每个元素是一个指向以 null 字符结尾的字符串的指针。这些字符串对应于命令行中传递给程序的参数。envp
是一个指向环境变量数组的指针。每个环境变量是一个以 null 字符结尾的字符串,它的格式通常是KEY=VALUE
,例如PATH=/usr/bin
。
当 main 开始执行时,用户栈的组织结构下图所示。从栈底(高地址)往栈顶(低地址)依次看。首先是参数和环境字符串。栈往上紧随其后的是以 null 结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量 environ
指向这些指针中的第一个 envp[0]
紧随环境变量数组之后的是以 null 结尾的 argv[]
数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数 libc_start_main
的栈帧。
操作环境数组
#include <stdlib.h>
char *getenv(const char *name);
// 返回:若存在则为指向 name 的指针,若无匹配的,则为 NULL。
getenv
用于从环境变量中获取指定变量的值。环境变量是操作系统用于存储关于当前进程的配置信息的键值对,比如用户的主目录、系统路径等。
#include <stdlib.h>
int setenv(const char *name, const char *newvalue, int overwrite);
// 返回:若成功则为 0,若错误则为 -1。
void unsetenv(const char *name);
// 返回:无。
setenv
用于设置(或修改)一个环境变量的值。如果该环境变量已存在,它将被更新;如果不存在,它会被创建。
unsetenv
用于删除指定的环境变量。调用该函数后,该环境变量在当前进程的环境中将不再可用。
系统调用错误处理
void unix_error(char *msg)
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
pit_t Fork()
{
pit_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
Unix 系统级函数出错时通常返回 -1 并设置 errno,上面是对系统调用的错误处理封装,下面的部分中都使用错误处理包装函数。它们能够保持代码示例简洁,而又不会给你错误的假象,认为允许忽略错误检査。包装函数定义在一个叫做 csapp.c 的文件中,它们的原型定义在一个叫做 csapp.h 的头文件中;可以从 CS:APP 网站上在线地得到这些代码。
利用fork和execve运行程序
实现了一个简单的命令行解释器(极简的 Shell),它可以读取用户输入的命令行,解析命令并执行。如果命令是内置命令(如quit
),则直接执行相应操作;如果是外部命令,则通过fork
创建子进程,在子进程中使用execve
来加载并执行该命令,父进程根据命令是否在后台运行来决定是否等待子进程结束。
#include "csapp.h"
#define MAXARGS 128
// Evaluate a command line
void eval(char *cmdline);
// Parse the command line and build the argv array
int parseline(char *buf, char **argv);
// If first arg is a builtin command, run it and return true
int builtin_command(char **argv);
int main()
{
char cmdline[MAXLINE];
while (1) {
// Read
printf("> ");
Fgets(cmdline, MAXLINE, stdin);
if (feof(stdin))
exit(0);
// Evaluate
eval(cmdline);
}
return 0;
}
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);
if (argv[0] == NULL)
return; // Ingore empty lines
if (!(builtin_command(argv))) {
if ((pid = Fork()) == 0) // Child runs user job
{
if (execve(argv[0], argv, environ) < 0)
{
printf("%s Commmand not found.\n", argv[0]);
exit(0);
}
}
// Parent waits for foreground job to terminate
if (!bg)
{
int status;
if (waitpid(pid, &status, 0) < 0)
unix_error("waitfg: wait error");
}
else
printf("%d %s", pid, cmdline);
}
}
int parseline(char *buf, char **argv)
{
char *delim; // Points to first space delimiter
int argc; // Number of args
int bg; // Background jobs?
buf[strlen(buf) - 1] = ' '; // Replace trailing '\n' with space
while (*buf && (*buf == ' ')) // Ignore leading space
buf++;
// Build the argv list
argc = 0;
while ((delim = strchr(buf, ' '))) {
*delim = '\0';
argv[argc++] = buf;
buf = delim + 1;
while (*buf && (*buf == ' '))
buf++;
}
argv[argc] = NULL;
if (argc == 0) // Ignore blank line
return 1;
// Should the job run in the background?
if (bg = ((*argv[argc-1]) == '&'))
argv[--argc] = NULL;
return bg;
}
int builtin_command(char **argv)
{
if (!strcmp(argv[0], "quit")) // quit command
exit(0);
if (!strcmp(argv[0], "&")) // Ignore singleton
return 1;
return 0; // Not a builtin command
}
注意,这个简单的 shell 是有缺陷的,因为它并不回收它的后台子进程。修改这个缺陷就要求使用信号,详见信号部分。