当你在 Linux 中编译好自己写的程序后,在命令行中输入路径,敲下回车运行。这个看似简单的过程,背后都发生了什么呢?
实际上在你敲下回车之后,Shell 会调用 fork()
函数去创建一个子进程。接着子进程再调用 exec*()
系列方法,这个方法可以载入一个外部程序,并覆盖掉当前进程内容,覆盖后进程 ID 保持不变。等到外部程序运行完毕之后,子进程退出,便又回到 Shell 进程了。
Linux 中进程的创建大致分为这两类函数。
第一类函数是复制进程自身,在子进程中运行的代码是自身代码的某个分支。这类函数创建的子进程代码与父进程一致,只是运行与父进程不同的函数分支。
pid_t fork(void);
pid_t vfork(void);
第二类函数是运行从外部加载的程序,因此子进程运行的代码与父进程的代码是不同的。
// exec*() 其中的*是通配符,这里指以 exec 开头的系列函数
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
int system(const char *command);
这两类函数的主要区别是:第一类函数复制进程本身,在子进程中执行自身程序的某一分支;而第二类函数是载入外部已经编译好的程序到进程中运行。
接下来我们详细的去看一下各个函数的具体用法。
fork()
我们写一个例子来简单了解一下 fork()
函数的使用。创建一个文件名为 fork.c
的文件,在文件中写入下面示例的代码。代码中首先会调用 fork()
函数来创建子进程,如果创建失败直接返回,若创建成功接着判断返回值是否为 0,如果返回值为 0 表示当前正处于子进程;如果返回值大于 0 则表示当前正处于父进程。子进程进入 child()
函数分支,而父进程进入 parent()
函数分支。在父进程中 fork()
函数的返回值对应的是子进程的进程 ID。而子进程中要想获取自身进程 ID 可以通过 getpid()
函数,想获取父进程的进程 ID 可以通过 getppid()
函数。代码的最后我们让程序 sleep()
5秒,以便打印出进程的树状图。
// fork.c
#include <stdio.h>
#include <unistd.h>
void parent(int ch_pid) {
printf("Parent pid[%d], child pid[%d]\n", ch_pid, getpid());
}
void child() {
printf("Child process pid[%d], parent pid[%d]\n", getpid(), getppid());
}
int main() {
int pid = fork();
if (pid < 0) {
printf("Fork fail!\n");
return 1;
}
if (pid == 0) {
child(); // 子进程执行
} else {
parent(pid); // 父进程执行
}
sleep(5);
return 0;
}
保存文件命名为 fork.c
,然后执行下面的命令编译代码并运行:
~$ gcc -o fork fork.c && ./fork
输出结果如下:
Parent pid[830], child pid[829] Child process pid[830], parent pid[829]
在运行的同时,打开一个新的命令窗口,输入 pstree -ap
打印进程的树状图:
init,1
├─init,7
│ └─init,8
│ └─bash,9
│ └─fork,829
│ └─fork,830
...
总结一下 fork()
函数的特点:
fork()
出来的进程是一份完整的拷贝,父子进程的变量、堆栈、内存虚拟地址都是不相同;fork()
出来的子进程与父进程各自同时运行,互不干扰;- 如果父进程先执行完退出,子进程会被挂在到
init
进程上。
vfork()
对上个示例的代码稍加改造,把其中的 fork()
函数替换成 vfork()
函数。接着添加一个全局的变量 int num = 10
,在子进程中修改这个 num
变量,然后在父进程中打印查看。此外我们把 main()
函数中的 sleep()
移动到子进程中,让子进程打印后停顿 5秒。
// vfork.c
#include <stdio.h>
#include <unistd.h>
int num = 10;
void parent(int ch_pid) {
printf("Parent pid[%d], child pid[%d]\n", ch_pid, getpid());
printf("num[%d]\n", num);
}
void child() {
printf("Child process pid[%d], parent pid[%d]\n", getpid(), getppid());
num = 20;
sleep(5);
_exit(0);
}
int main() {
int pid = vfork();
if (pid < 0) {
printf("Fork fail!\n");
return 1;
}
if (pid == 0) {
child(); // 子进程执行
} else {
parent(pid); // 父进程执行
}
return 0;
}
保存文件命名为 vfork.c
,然后执行下面的命令编译代码并运行:
~$ gcc -o vfork vfork.c && ./vfork
输出结果如下:
Child process pid[847], parent pid[846] Parent pid[847], child pid[846] num[20]
观察打印可以发现,父进程是在子进程执行完毕之后再运行的。而且父进程中的 num
变量的值也跟着子进程修改为了 20。
总结一下 vfork()
函数的特点:
vfork()
出来的进程与父进程共享变量,堆栈,内存虚拟地址,子进程修改变量值,父进程对应的变量也变了;vfork()
出来的子进程会导致父进程挂起(暂停运行),直到子进程调用_exit()
函数退出,才会继续运行父进程,子进程如果不退出而是return
会导致异常;- 此外
vfork()
子进程也不能使用exit()
函数来退出,因为这可能会调用到父进程的exit()
函数,导致父进程退出,并刷新父进程输入输出(stdio)缓存,因此子进程一定是调用_exit()
函数来退出。
exec*()系列函数
exec*()
系列函数可以将一个外部程序替换当前进程,并保持当前进程的进程 ID 不变。
l
- 带 l 的函数:execl()
execlp()
execle()
,表示程序的参数以可变形式提供,最后一个以NULL
结束;v
- 带 v 的函数:execv()
execvp()
execvpe()
,表示程序参数以数组形式提供,数组最后一个元素是NULL
;p
- 带 p 的函数:execlp()
execvp()
execvpe()
,表示当指定程序时不包含/
,则会自动通过环境变量PATH
中的路径去搜索程序。e
- 带 e 的函数:execle()
execvpe()
,表示将环境变量通过数组参数传递给程序。对于不带 e 的函数,程序可以通过extern char **environ
变量从调用者那里获得所有环境变量。
execl()
// execl.c
#include <stdio.h>
#include <unistd.h>
int main() {
printf("Before execl.\n");
execl("/bin/ls", "ls", "-l", NULL); // 省略路径可用:execlp("ls", "ls", "-l", NULL);
printf("After execl.\n");
return 0;
}
编译并运行上面的代码,结果如下:
~$ gcc -o execl execl.c && ./execl
Before execl.
-rwxrwxrwx 1 test test 16736 Jul 3 15:12 execl
-rwxrwxrwx 1 test test 169 Jul 3 15:12 execl.c
这里需要注意两点:
execl()
函数执行完毕后,After execl.
并不会打印,因为原程序已经被载入的程序覆盖掉了。execl()
函数最后面的那个NULL
是必须的,不能省略。execl()
函数的第二个参数会被忽略,因此-l
不能放在第二个参数位置。
execv()
和上面的 execl()
函数类似,下面给个简单例子,不做过多解释,输出结果和上面是一致的。
// execv.c
#include <stdio.h>
#include <unistd.h>
int main() {
printf("Before execv.\n");
char *argv[] = {"ls", "-l", NULL}; // 数组后面的那个 NULL 是必须的,不能省略!
execv("/bin/ls", argv);
printf("After execv.\n");
return 0;
}
execle()
创建一个 mycmd.c
文件,在文件中写入下面的代码,主要作用是打印外部传入的环境变量。将文件编译为 mycmd
命令存放在到当前目录下。
// mycmd.c
#include <stdio.h>
extern char** environ;
int main() {
printf("My comamnd.\n");
for (int i = 0; environ[i] != NULL; i++) {
printf("%s\n", environ[i]);
}
return 0;
}
接着再创建一个文件 execle.c
写入下面的代码,代码中我们创建了一个环境变量数组,数组中存放两个自定义的环境变量“AA=11”和 “BB=22”,接着执行 mycmd
程序,并把这两个环境变量传入过去。
// execle.c
#include <stdio.h>
#include <unistd.h>
int main() {
printf("Before execle.\n");
char *envp[] = {"AA=11", "BB=22", NULL};
execle("./mycmd", "mycmd", NULL, envp);
printf("After execle.\n");
return 0;
}
将上面的代码编译并执行得到下面的输出:
~$ gcc -o execle execle.c && ./execle Before execle. My comamnd. AA=11 BB=22
可以看到 AA 和 BB 这两个环境变量已经被成功传入了 mycmd
程序。对于那些不带 e 的 exec*()
系列函数,打印 environ
变量会得到原程序的所有环境变量。
system()
与 exec*()
系列函数不同的是,system()
函数并不直接覆盖当前进程,而是会先 fork 一个 sh 子进程。把要运行的程序传给这个 sh 子进程去执行。下面我们用一个例子来看一下:
// system.c
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("Before system.\n");
system("./mycmd -a");
printf("After system.\n");
return 0;
}
上面 system()
函数中调用的脚本 mymd
源代码如下,代码中只打印一条语句,接着 sleep()
5秒。
// mycmd.c
#include <stdio.h>
#include <unistd.h>
int main() {
printf("My comamnd.\n");
sleep(5);
return 0;
}
把上面两个文件的代码都编译好,执行 ./system
输出如下:
~$ gcc -o system system.c && ./system
Before system.
My comamnd.
After system.
在运行的同时,打开一个新的命令窗口,输入 pstree -ap
打印进程的树状图:
init,1
├─init,7
│ └─init,8
│ └─bash,9
│ └─system,1036
│ └─sh,1037 -c ./mycmd -a
│ └─mycmd,1038 -a
...
模拟 Shell
下面我们写一个模拟的 Shell 程序,可以在我们的 Shell 里面输入简单的单条命令执行并输出结果,执行完命令后依旧回到 Shell 界面等待下一此输入。
// shell.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
const int SIZE = 200; // 单行命令字符长度
const int ARGC = 10; // 命令参数个数
void child(char *str) {
char *sp = " ";
char *args[ARGC];
args[0] = strtok(str, sp); // strtok按分隔符拆分字符串
int i = 1;
while (args[i] = strtok(NULL, sp)) i++;
execvp(args[0], args); // 载入外部程序,覆盖此子程
printf("Command not found: %s\n", args[0]);
exit(1);
}
int main() {
char str[SIZE];
while (1) {
printf("shell$ ");
fgets(str, SIZE, stdin);
int len = strlen(str);
str[len-1] = '\0'; // 去掉末尾的\n
if (strcmp(str, "exit") == 0) break; // 是否退出
int pid = fork();
if (pid < 0) {
printf("System error.\n");
continue;
}
if (pid == 0) {
child(str);
} else {
wait(NULL); // 父进程等待,直到子进程退出
}
}
}
把上面这个文件的代码都编译好,执行 ./shell
,接着输入一个命令试试:
~$ gcc -o shell shell.c && ./shell
shell$ ls -a -l
-rw------- 1 test test 40238 Jul 2 22:47 .bash_history
-rw-r--r-- 1 test test 3771 Dec 8 2021 .bashrc
-rwxrwxrwx 1 test test 16736 Jul 3 15:12 shell
-rwxrwxrwx 1 test test 169 Jul 3 15:12 shell.c
shell$ exit
~$