进程创建的核心前提:依赖系统调用
许多应用程序的设计使得用户启动后,程序可在运行期间任意时刻自动生成新进程,无需用户额外操作。
我们首先思考一个问题。假设有个进程P1,它要如何创建一个新进程P2?首先,P1需要将P2的可执行代码载入内存,这样CPU才能开始执行。但就在这一步,我们已遇到关键难题。现代操作系统中,普通用户进程无权执行这类操作。每个用户进程被限制在操作系统分配的内存区域——即地址空间内。进程完全无法访问该区域外的内存,即便该内存未被其他进程占用。虽然进程P1可以将P2的可执行文件从磁盘读取到自己的内存中,但无法将这些代码放置到自身地址空间之外的其他内存区域。换言之,进程P1完全无法独立创建一个全新的进程。这正是进程创建不能完全在用户空间处理的诸多原因之一。
操作系统无法在用户空间执行的进程创建必要操作:
1.从全局 PID 命名空间分配进程 ID
2.创建内核进程对象(进程控制块)
3.将进程插入内核调度器运行队列
4.为新地址空间分配并初始化页表
5.利用页表对内存管理单元进行编程配置
6.为新的执行上下文分配内核栈
7.初始化 CPU 上下文(寄存器栈帧、特权级、标志位)
8.分配安全凭证(用户 ID / 组 ID / 令牌 / 权限能力)
9.将进程挂载到命名空间 / 作业对象 / 控制组
10.设置信号 / 异常 / 异步过程调用传递结构体
11.复制或初始化文件描述符表
12.向资源统计与限制模块注册进程
13.在进程树中建立父子进程关联关系
14.使进程对内核和其他进程可见
15.允许 CPU 执行上下文切换,转入新进程
16.通过内核应用程序接口(/proc 文件系统、任务列表、句柄)暴露进程
以上每一项操作都需要内核特权级访问权限。
当需要此功能时,必须通过系统调用(System Calls)委托给操作系统处理。简单来说,系统调用是操作系统提供的API接口,允许用户进程请求执行它们无权直接操作的功能。这些操作既包括硬件访问(如从磁盘读取文件或向屏幕写入字符),也包含操作系统提供的服务(比如获取系统信息),建立进程间通信通道,或者我们当前最关注的进程管理功能。该API通常以一组C或C++函数的形式公开,程序员在需要这些服务时可以调用它们。
可以这样理解系统调用:它们是从用户空间调用的函数,但却在内核空间执行。操作系统执行请求的任务,完成后会将执行权返回用户模式。问题在于每个操作系统都有自己的设计理念,这常常导致API不兼容。Windows和类Unix系统都专门提供了用于创建新进程的系统调用。
乍看之下,这里的兼容性问题似乎并不严重。看起来我们只需要根据程序运行的平台调用对应的函数即可。但如果我们比较这些系统调用的定义,立即就能发现一个明显的问题:Windows系统特有的操作方式。虽然Windows版本看起来确实过于复杂,但Unix/Linux版本其实更奇怪。
// Windows
NTSTATUS NtCreateUserProcess(
PHANDLE ProcessHandle,
PHANDLE ThreadHandle,
ACCESS_MASK ProcessDesiredAccess,
ACCESS MASK ThreadDesiredAccess,
POBJECT_ATTRIBUTES ProcessObjectAttributes,
POBJECT_ATTRIBUTES ThreadObjectAttributes,
ULONG ProcessFlags,
ULONG ThreadFlags,
PRTL_USER_PROCESS_PARAMETERS ProcessParameters,
PPS_CREATE_INFO CreateInfo,
PPS_ATTRIBUTE_LIST AttributeList
);
// Unix/Linux
pid_tfork(void);
Windows:直接创建新进程的直观方式
为了理解Windows的做法,我们将其简化为一个名为CreateProcess的概念函数,它只需要两个参数。
CREATE_PROCESS_RESULT CreateProcess(constchar *image, constchar *command_line);
typedefstruct {
NTSTATUS Status;
HANDLE ProcessHandle;
HANDLE ThreadHandle;
} CREATE_PROCESS_RESULT;
image就是我们要运行的程序的可执行文件完整路径,操作系统据此在磁盘上定位文件并将其加载到内存中。command_line顾名思义,就是包含调用程序所用完整命令行参数的字符串。这包括传给新进程的所有参数,进程由此得知自己是否通过启动标志或选项被调用。
简而言之,这个系统调用是告诉操作系统:用这个可执行文件创建新进程。操作系统会在磁盘上找到这个文件。为新进程找到一块空闲内存区域,将可执行代码加载到该内存中,并执行所有必要步骤以便将进程插入调度器。系统还会传递命令行字符串,使得新进程在运行任何用户代码前能解析其参数。并不复杂,对吧?
Unix/Linux:先克隆、再替换的 “怪异” 逻辑
而Unix系统的实现方式则更有趣些。类 Unix 系统创建进程依赖两个核心系统调用:fork和execve,二者组合实现进程创建。
fork:无参数的进程克隆
在类Unix系统中,用户进程可通过调用fork系统调用来创建新进程。有趣之处在于fork不需要参数,这意味着无法指定要运行哪个可执行文件。实际上,当操作系统收到fork请求时,它会克隆调用进程。父进程的整个地址空间(包括代码段、数据段、栈和堆)都会被复制。不仅如此,几乎整个进程上下文也会被克隆,只有少数与管理相关的小例外。比如进程上下文还包括其CPU状态。接下来举个例子说明。
#include<stdio.h>
#include<unistd.h>
#define SLEEPTIME 1
intmain(){
int i = 1;
for(; i <= 5; i++){
printf("count: %d\n", i);
sleep (SLEEPTIME);
}
printf("Calling fork()\n");
fork();
for(; i <= 10; i++){
printf("count: %d\n", i);
sleep (SLEEPTIME);
}
}
$./program
count: 1
count: 2
count: 3
count: 4
count: 5
Calling fork()
count: 6
count: 6
count: 7
count: 7
count: 8
count: 8
count: 9
count: 9
count: 10
count: 10
这里有一个声明变量i的程序,随后进入一个5次迭代的循环。每次迭代时递增该变量,打印其数值,然后休眠一秒。当第一个循环结束后,程序调用fork函数,接着进入几乎完全相同的第二个循环。它会持续每秒递增i的值并打印输出。运行该程序时你会发现,在调用fork之前一切运行正常。但只要fork被调用,输出内容就会翻倍。这是因为当fork创建子进程时,子进程会继承父进程的CPU状态。其中包括调用fork瞬间的程序计数器状态。因此,父进程和子进程都会从fork调用之后的下一条指令继续执行。因此子进程不会从程序开头开始执行。需要注意的是,fork如果使用不当会非常危险,尤其是在循环中调用时。
#include<stdio.h>
#include<unistd.h>
#define SLEEPTIME 1
intmain(){
int i = 1;
for(; i <= 5; i++){
printf("count: %d\n", i);
sleep (SLEEPTIME);
}
printf("Calling fork()\n");
fork();
fork();
fork();
for(; i <= 10; i++){
printf("count: %d\n", i);
sleep (SLEEPTIME);
}
}
让我们尝试多次调用fork,看看会发生什么。在这个例子中,连续调用了三次fork,但信息被打印了八次(此处省略输出结果,感兴趣可以自行尝试)。原始进程先执行第一部分内容,然后调用fork创建了一个子进程。现在父子进程都会执行下一行代码,即再次调用fork。当这四个进程都执行到下一个fork时,每个进程会再次复制自身,最终得到八个进程。这就是为什么信息会显示八次。总共有八个进程每秒各打印一次信息。
execve:有参数的进程替换
你可能会疑惑:如果只是重复相同操作,克隆进程的意义何在?难道没有更好的方式来加载并运行可执行文件吗?比如Windows那种方式?确实有,不过这种方式同样透着一股怪异感。现在登场的是execve系统调用。
intexecve(constchar *filename, char *const argv[], char *const envp[]);
如你所见,与fork不同,这个Unix/Linux系统调用确实需要参数。同样为了避免过度复杂化,我们将使用其简化版本execv。
intexecv(constchar *path, char *const argv[]);
这个系统调用的参数正如字面意思所示。path参数指向我们要运行的可执行文件路径。argv是由C字符串指针组成的数组,它定义了作为命令行参数传递给新程序的参数向量。比如说,终端里的这个命令ls -l /home,如果用系统调用来编程执行的话,会是这样的:
execv("/bin/ls", (char *[]){"ls", "-l", "/home", NULL});
看起来可能和 Windows 的方式很相似,表面区别只是参数传递方式不同:Windows 用单个命令行字符串,而 Unix/Linux 用已解析的参数数组,但这个区别其实是最无关紧要的。
正如前面所说,类Unix系统创建新进程的唯一方式就是克隆现有进程,那execv究竟做了什么?它根本不会创建新进程,相反,execv会替换调用进程中当前运行的程序,当用户进程调用execv时,操作系统会定位磁盘上指定的可执行文件,它并非创建新进程,而是完全重置调用进程本身,栈区被重置、堆区被清空、文本段和数据段的内容均被舍弃,CPU状态也会被重置,随后操作系统将新的可执行文件载入内存,程序计数器重置后,会指向新程序的第一条指令,从头开始执行。参数列表也会被载入特定内存段,供新程序调用。换言之,execv的作用是将进程(严格定义为执行中的程序)转变成另一个程序,而非创建新进程,虽然听起来奇怪,但机制就是如此。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
intmain( ){
printf("Hello before calling execv()\n");
constchar *path = "/usr/bin/google-chrome";
char *argv [] = {
"google-chrome",
" -- incognito",
"https://www.youtube.com/",
NULL
};
execv(path, argv);
printf("Hello after calling execv()\n");
}
举一个例子,上面程序启动时会先打印一条消息,接着程序会调用execv,传入google-chrome可执行文件的路径和几个参数。之后它会打印另一条信息。如果编译运行这个程序,你会先看到第一条信息,然后程序就变成了Google Chrome。但最后那条信息永远不会被打印出来。这很合理。一旦execv调用成功,当前程序会被完全替换,因此执行流程永远不会到达调用之后的代码。
fork + execv:类 Unix 系统创建进程的核心组合
很有趣对吧?但经历了这么多,我们仍未解答核心问题。fork能克隆进程,execv能转换进程,但这些都不是我们真正需要的。解决办法其实有点滑稽。如果进程先调用fork让操作系统克隆自己,然后只有克隆出的进程调用execv替换成目标程序,而原进程继续运行会怎样?信不信由你,Unix和Linux系统就是这样创建进程的。几乎所有用户进程都遵循这个模式创建。
现在只剩一个问题。如何确保只有子进程会调用execv?因为如果在fork后立即调用execv,父子进程都会执行该操作,导致两者都被替换。我们需要在源代码中区分它们,确保只有子进程调用execv。所以必须找到实现方法。
#include<stdio.h>
#include<unistd.h>
intmain(){
printf("Calling fork()\n");
fork();
printf("Calling execv()\n");
execv(
"/usr/bin/google-chrome",
(char *[]) {"google-chrome", NULL}
);
}
#include<stdio.h>
#include<unistd.h>
intmain(){
printf("Calling fork()\n");
fork();
bool is_parent = ???;
if (is_parent) {
printf("This is the parent process\n");
}else{
printf("This is the child process\n");
printf("Calling execv()\n");
execv (
"/usr/bin/google-chrome",
(char *[]) {"google-chrome", NULL}
);
}
答案就是进程ID。系统中每个运行的进程都有唯一ID。调用fork时会复制几乎所有内容,但进程ID必须保持唯一性,因此不会被复制。从技术上讲,每个进程通过查看自身的ID即可判断自己是父进程还是子进程。Unix和Linux系统为此提供了另一个系统调用pid_t getpid(void);。该函数无需参数,直接返回调用进程的ID。关键操作如下:在调用fork之前,进程先调用getpid并将结果存入变量。接着调用fork。由于内存会被复制,父进程和子进程此时都存有原始父进程ID。之后,两个进程再次调用getpid。若新获得的ID与先前存储的一致,则该进程可确定自身是父进程。如果ID不同,该进程便可判定自己是子进程。其逻辑实现如下:
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
intmain(){
printf("Hello before calling fork() !\n");
pid t ppid = getpid() : // -> 102
fork();
pid_t pid = getpid();
if(ppid != pid){
printf("Hello from the child process!\n");
}else{
printf("Hello from the parent process!\n");
}
}
执行fork后,两个进程会从相同位置继续运行下一条相同的代码。但由于它们是拥有不同ID的独立进程,getpid会返回不同的值。这导致条件检查在一个进程中成立,在另一个进程中失败,从而分岔它们的执行路径。因此该函数被命名为fork(分岔)。它并非简单地复制进程并保持所有内容相同。其核心目的是让执行流产生分岔。
实际上如果细看fork的定义,会发现它其实会返回一个值。与getpid类似,fork也会返回一个进程ID。该系统调用的实现方式为:在子进程中始终返回零,在父进程中则返回刚创建的子进程ID。
这种方式非常实用,因为无需额外系统调用就能区分父进程和子进程。我们在编译时无法预知操作系统会为子进程分配哪个进程ID,但有一条重要规则是明确的:在子进程中,fork调用永远返回零。仅凭这一保证就足以决定哪些代码应在子进程运行,哪些应在父进程运行。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
intmain(){
printf("Hello before calling fork() !\n");
pid_t cpid = fork();
if(cpid == 0){
printf("Hello from the child process!\n");
}else{
printf("Hello from the parent process!\n");
}
}
当我们运行这段代码时,fork调用会创建克隆进程。之后,父子进程都会接收并存储fork的返回值(该值在各自进程中不同),然后进行比对。若返回值非零,说明代码正在父进程中运行。若返回值为零,则说明代码正在子进程中运行。这就是fork 函数的用法——不仅能克隆进程,还能指令各进程的执行任务。
若要创建不干扰调用进程的新进程生成函数,其实现大致如此:
// 从可执行文件及参数列表中生成一个新进程
voidcreate_process(char *path, char *argv[]){
if (fork() == 0) {
execv(path, argv);
}
}
当然,系统调用可能失败,妥善处理错误情况总是必要的。
// 从可执行文件及参数列表中生成一个新进程
voidcreate_process(char *path, char *argv[]){
pid_t pid = fork();
if (pid < 0){
// fork 失败
returnfalse;
}
if (pid == 0){
// child: 替换进程镜像
execv(path, argv);
// execv 失败时返回
_exit(127) ;//用于"exec failed/command not found"的常规退出代码.
}
// parent: fork 成功 (子进程已创建)
returntrue;
}
最后,可能有些读者知道posix_spawn函数。该函数由符合POSIX标准的系统提供,用于生成新进程。但需注意:它并非系统调用。其概念模型如下所示。查看其实现会发现,内部机制与我们之前的操作完全一致。先创建子进程,再让子进程调用execve。
intposix_spawn(
pid_t *pid,
constchar *path,
constposix_spawn_file_actions_t *file_actions,
constposix_spawnattr_t *attrp,
char *const argv[],
char *const envp []
)
{
pid_t child;
int status = 0;
/* Use vfork-like semantics if possible */
child = fork();
if (child < 0) {
return errno;
}
if (child = 0) {
/* CHILD PROCESS */
/* 1. Apply spawn attributes */
if (attrp) {
if (attrp->flags & POSIX_SPAWN_SETSIGMASK)
sigprocmask(SIG_SETMASK, &attrp->sigmask, NULL);
if (attrp->flags & POSIX_SPAWN_SETSIGDEF)
reset_signals(attrp->sigdefault);
if (attrp->flags & POSIX_SPAWN_SETPGROUP)
setpgid(0, attrp->pgroup);
if (attrp->flags & POSIX_SPAWN_RESETIDS) {
setuid(getuid());
setgid(getgid());
}
}
/* 2. Apply file actions */
if (file_actions) {
for (each action in file_actions) {
switch (action.type) {
case OPEN:
open(action.path, action.flags, action.mode);
break;
case DUP2:
dup2(action.fd, action.newfd);
break;
case CLOSE:
close(action.fd);
break;
}
}
}
/* 3. Exec */
execve(path, argv, envp);
/* 4. Exec failed - exit with errno */
_exit(errno);
}
/* PARENT PROCESS */
if (pid)
*pid = child;
return0;
}
系统差异总结与延伸
至此,我们已清晰阐释Windows与Linux系统的进程创建机制。简而言之,在 Windows 系统中,进程可以通过系统调用来请求操作系统创建新进程,而 Windows 正是这样执行的。在 Linux 和类 Unix 系统中,进程必须先请求操作系统复制自身。随后,克隆出的进程会请求操作系统替换其程序。
值得注意的是,在类 Unix 系统中,所有用户进程都由其他用户进程创建。这意味着父进程也是由另一个进程创建的。这自然形成了进程树结构,由此引出一个有趣的问题。既然所有用户进程都源自其他用户进程,那么最终必然存在一个最初启动这一切的进程,对吗?