介绍/Intro
你想从你的代码中运行一个可执行文件吗?或者在代码中来运行shell命令?或者只是想并行执行你的代码?你是否读了很多关于execve( )系列函数和fork( )函数的信息,但脑子里还是一片混乱?那么这篇文章就是为你准备的。
如何启动Linux进程
1,System calls / 系统调用
让我们保持简单,从头开始。我们正在为Linux开发一个程序。让我们来看看所谓的系统调用,即Linux为我们提供的请求内核功能的接口。
Linux有以下的系统调用和进程的处理相关:
- fork(void) (man 2 fork)
创建一个调用进程的完整副本。由于需要复制进程的地址空间,听起来很不方便,但它使用了写时复制(copy-on-write)的优化。这是在Linux中创建进程的唯一方法,一般观念上是这样的。然而,在新版本的内核中,fork( )是基于clone( )系统调用来实现的,现在可以直接使用clone( )来创建进程,但为了简单起见,我们将跳过这些细节。
- execve(path, args, env) (man 2 execve)
通过执行指定路径下的文件,将调用进程转变为一个新的进程。实际上,它用一个新的进程映像替换了当前的进程映像,并不创建任何新的进程。
- pipe(fildes[2] __OUT) (man 2 pipe)
创建一个管道,这是一个进程间的通信原语。通常,管道是单向的数据流。数组的第一个元素连接到管道的读取端,而第二个元素连接到写入端。写入fildes[1]的数据可以从fildes[0]中读取。
我们不打算看一下上述的系统调用源代码,因为它是内核的一部分,可能很难理解。
我们考虑的另一个重要部分是Linux shell,一个命令解释器工具,本身也是一个普通程序。shell进程不断地从stdin中读取信息。用户通常通过输入一些命令并按下回车键与shell进行交互。然后shell进程执行所提供的命令,使用上面提到的方式来创建新的进程,这些进程的标准输出被连接到shell进程的stdout。
C standard library / C标准库
当然,我们用C语言开发程序是为了尽可能地接近操作系统级的实现方式。C语言有一个所谓的标准库libc,一个丰富的函数集,协助程序编写。同时,它提供了对系统调用的封装。
在基于Debian系列的发行版上,C标准库用的是glibc。运行命令下载glibc的源码:apt-get download glibc-source
C语言标准库包含以下函数:
- system(command) (man 3 system)
启动一个shell进程来执行提供的命令。调用进程被阻塞,直到被启动的shell进程执行结束。system()返回shell进程的退出代码(exit code)。让我们看一下这个函数在stdlib中的实现:
int system(char *command)
{
// ... skip signals tricks for simplicity ...
switch(pid = vfork()) {
case -1: // error
// ...
case 0: // child
execl("/bin/sh", "sh", "-c", command, (char *)NULL);
_exit(127); // will be called only if execl() returns, i.e. a syscall faield.
}
// ... skip signals tricks for simplicity ...
waitpid(pid, (int *)&pstat, 0); // waiting for the child process, i.e. shell.
return pstat.w_status;
}
所以实际上,system( )这个标准库的函数只是使用了fork( ) + exec( ) + waitpid( )的组合。
- popen(command, mode = 'r|w') (man 3 popen)
调用fork创建一个进程,用执行所提供命令的shell进程替换生成的进程。看起来和system( )很像?这里的区别在于可以通过子进程的stdin或stdout与之通信。但通常是单向的。为了与该进程通信,使用了一个管道。实现层面的摘要如下:
FILE * popen(char *program, char *type)
{
int pdes[2], fds, pid;
pipe(pdes); // create a pipe
switch (pid = vfork()) { // fork the current process
case -1: // error
// ...
case 0: // child
if (*type == 'r') {
dup2(pdes[1], fileno(stdout)); // bind stdout of the child process to the writing end of the pipe
close(pdes[1]);
close(pdes[0]); // close reading end of the pipe on the child side
} else {
dup2(pdes[0], fileno(stdin)); // bind stdin of the child process to the reading end of the pipe
close(pdes[0]);
close(pdes[1]); // close writing end of the pipe on the child side
}
execl("/bin/sh", "sh", "-c", program, NULL); // replace the child process with the shell running our command
_exit(127); // will be called only if execl() returns, i.e. a syscall faield.
}
// parent
if (*type == 'r') {
result = pdes[0];
close(pdes[1]);
} else {
result = pdes[1];
close(pdes[0]);
}
return result;
}
上面的代码,是标准库里使用Linux系统调用来操作进程,看过之后,对Linux中进程的创建就会更清楚一些了。
而shell的实现方式,启动子进程的方式,也是类似的: fork( ) + execve( )
为什么要启用Linux进程?
1,并行执行代码
最简单的原因。我们只需要fork()。调用fork()实际上是复制了当前程序进程。但是由于这个进程使用完全独立的地址空间,要想与之通信,我们无论如何都需要进程间的通信原语。Fork出来的进程,其程序代码和指令与父进程相同,可以把它看作是当前运行程序的另一个的实例。
2,从你的代码中运行另外一个程序
如果你只需要运行一个程序,而不需要与它的stdin/stdout通信,libc里的system()函数是最简单的解决方案。是的,你也可以fork()你的进程,然后在子进程中运行exec()。但由于这是一个相当常用的功能,所以提供了一个system()函数,方便使用。
3,运行一个进程并读取其stdout(或写入其stdin)。
我们需要使用libc中的popen( )函数。是的,你仍然可以通过组合pipe()+fork()+exec()来实现上述目标,但popen()是为了减少重复代码。
运行一个进程,向其stdin写入并从其stdout读出
这是一种有趣的情况。由于某些原因,默认的popen()实现通常是单向的。但看起来我们可以很容易地想出双向的解决方案:我们需要两条管道,第一条连接到子进程的stdin,第二条连接到子进程的stdout。剩下的部分是fork()一个子进程,通过dup2()将管道连接到IO描述符,然后execve()命令。其中一个潜在的实现可以在GitHub popen2()项目中找到,见参考。在开发这种功能时,你应该注意一个额外的问题,那就是之前通过popen()进程打开的管道的文件描述符的泄露。如果我们忘记在每个fork出的子进程中明确关闭外来文件描述符,就有可能对兄弟姐妹的stdins和stdouts进行IO操作。要避免这种问题。
关于Windows平台的一些说明
Windows操作系统家族处理进程方面的范式和Linux有所不同。如果我们跳过Windows 10上引入的新的Unix兼容层,并试图让Windows支持POSIX API,我们能用的只有两个古老的底层WinAPI函数:
- CreateProcess(filename)
为一个指定的可执行文件启动一个全新的进程。
- ShellExecute(Ex)(command)
启动一个shell(是的,Windows也有一个shell概念)进程来执行所提供的命令。
所以,没有fork和execve等函数。然而,为了与已经启动的进程进行通信,管道是可以使用的。
参考: