Linux C 中的 fork()、vfork()、exec*()、system() 等进程函数

Linux系统
331
0
0
2022-11-06

当你在 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() 函数的特点:

  1. fork() 出来的进程是一份完整的拷贝,父子进程的变量、堆栈、内存虚拟地址都是不相同;
  2. fork() 出来的子进程与父进程各自同时运行,互不干扰;
  3. 如果父进程先执行完退出,子进程会被挂在到 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() 函数的特点:

  1. vfork() 出来的进程与父进程共享变量,堆栈,内存虚拟地址,子进程修改变量值,父进程对应的变量也变了;
  2. vfork() 出来的子进程会导致父进程挂起(暂停运行),直到子进程调用 _exit() 函数退出,才会继续运行父进程,子进程如果不退出而是 return 会导致异常;
  3. 此外 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

这里需要注意两点:

  1. execl() 函数执行完毕后,After execl. 并不会打印,因为原程序已经被载入的程序覆盖掉了。
  2. execl() 函数最后面的那个 NULL 是必须的,不能省略。
  3. 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
~$