父进程先创建子进程;子进程用 exec 加载另外一个程序(otherExe),把参数、环境变量传给它;父进程用 waitpid 等子进程结束、回收资源。 这就是你的 myshell 执行外部命令的底层原版模型!

分成 4 大板块来讲: 板块 1:全局环境变量准备 板块 2:fork 创建子进程,分出父子两条执行流 板块 3:子进程区域(exec 加载新程序,重点) 板块 4:父进程区域(waitpid 等待回收)

配套小提醒: otherExe 是另外一个 C++ 程序,作用:接收参数、接收环境变量并打印出来,用来观察 exec 传参效果。

最后配上完整带详细注释代码

板块1:全局环境变量准备

extern char **environ;
putenv("PRIVATE_ENV=666666");
  1. extern char **environ;``environ 是操作系统给每个进程自带的全局环境变量表。 extern 意思:这个变量不在当前文件定义,在系统库里面,我们只是声明拿来用。 shell 里面执行外部命令,默认就是把这个 environ 传给子进程。
  2. putenv("PRIVATE_ENV=666666"); 给当前这个进程新增一条环境变量 PRIVATE_ENV=666666。 fork 之后子进程默认继承父进程的环境变量; 如果 exec 不指定自定义环境,子进程加载出来的程序也能读到这条变量。

对应你myshell:export 本质就是类似 setenv/putenv 修改 environ。


板块2:fork 创建子进程,分流父子进程

pid_t id = fork();
if(id == 0)
{
    // 子进程代码
}
// 下面是父进程代码
  • fork():系统调用,复制当前进程,生成子进程。
  • 返回值规则:
    • 返回 >0:当前是父进程,返回值就是子进程pid
    • 返回 0:当前是子进程
    • 返回 <0:fork失败

fork之后,父子进程代码完全一样,但是从fork这一行之后分开跑。

重点:fork 只是复制进程,不会执行新程序,想要跑 ls、otherExe,必须靠 exec。


板块3:if(id == 0) 里面 —— 子进程板块(核心!exec)

printf("before: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());

getpid():自己的pid getppid():父进程的pid 这一句是 exec 之前打印。 👉 只要 exec 成功,后面所有C代码直接被新程序覆盖,再也不会跑!

下面一堆注释的exec函数(6兄弟,myshell只用到execvp)

//execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
//execlp("ls", "ls", "-a", "-l", NULL);
//execl("/usr/bin/top", "top", NULL);
//execv("/usr/bin/ls", myargv);
//execvp("ls", myargv);

字母口诀:

  • l(list):参数一个个写出来,逗号隔开,末尾NULL
  • v(vector):参数放到 char* 数组里面传
  • p(path):自动去PATH里面找程序,不用写完整路径
  • e(env):自己指定一套环境变量,不继承父进程
函数特点
execl全路径 + 罗列参数
execlp名字搜索PATH + 罗列参数
execv全路径 + 参数数组
execvp名字搜索PATH + 参数数组 ✅ 你的myshell用它
char* const myargv[] = {
    "otherExe",
    "-a",
    "-b",
    "-c",
    NULL
};

构造命令参数数组,和你splitstring切出来的argv一模一样! 最后必须放 NULL,告诉exec:参数到此结束。

char *const myenv[] = {
    "MYVAL=1111",
    "MYPATH=/usr/bin/XXX",
    NULL
};

自定义环境数组,同样NULL结尾。

不写这个,exec默认继承父进程的 environ。 写了这个,execle / execve 就只用这一套,父的环境全部丢掉。

//execle("./otherExe", "otherExe", "-a", "-w", "-v", NULL, myenv);
//execve("./otherExe", myargv, environ);
//execl("/usr/bin/bash", "bash", "test.sh", NULL);
//execl("/usr/bin/python3", "python3", "test.py", NULL);
  • execle:罗列参数 + 自定义环境

  • execve:数组传参 + 自定义环境,6个exec里面最底层的系统调用,别的exec都是它的包装

  • 后面两个演示:exec可以调用bash跑shell脚本、调用python跑py脚本

    这就是注释那句话:不管什么语言,跑起来都是进程,exec只管加载可执行文件

printf("after: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());
exit(1);

⚠️ 超级重要: 只有exec调用失败的时候,这行printf才会打印! exec成功:当前进程的代码、数据全部被新程序(otherExe)替换,这行代码直接消失,不会执行。 exec失败(文件不存在、权限不够),才往下走,打印提示,然后exit(1)退出子进程,退出码1。

对应你myshell:execvp失败,我们设置退出码44,就是这个逻辑。


板块4:if外面 —— 父进程板块(waitpid回收)

pid_t ret = waitpid(id, NULL, 0);
if(ret > 0) printf("wait success, father pid: %d, ret id: %d\n", getpid(), ret);
sleep(5);
return 0;

waitpid(子进程pid, 状态指针, 选项)

  • 第二个参数写NULL:不关心子进程退出码(myshell里我们会传变量,拿到$?)
  • 第三个参数0:阻塞等待,父进程卡在这,一直等到子进程退出才往下走
  • 返回值ret>0:成功等到子进程,ret就是子进程pid

作用: 子进程退出之后内核会暂时保留它的退出信息,如果父进程不wait,就变成僵尸进程。myshell必须waitpid回收!

sleep(5); 父进程休眠5秒,方便你用 ps aux 命令观察父子进程状态,属于调试用,实际myshell不需要。


otherExe.cpp(被exec加载的程序,配套板块)

int main(int argc, char *argv[], char *env[])

main三参数版本:

  • argc:参数个数
  • argv:命令行参数数组(就是exec传进来的myargv)
  • env:环境变量数组(exec传进来的环境)

循环打印argv,循环打印env,用来直观看到: exec真的把参数和环境变量完整传给新程序了!


运行流程完整走一遍(打开 execve 那一行注释)

  1. 父进程启动,putenv增加环境变量
  2. fork,产生子进程
  3. 子进程先打印before
  4. execve 加载 ./otherExe,把子进程的代码全部替换成otherExe
  5. otherExe 运行,打印收到的参数、环境变量
  6. otherExe执行完毕,子进程退出
  7. 父进程waitpid收到子进程退出,打印wait success
  8. 父进程sleep5秒,程序结束

详细讲解execvp / execlp 里的 p,全称 PATH。

p 到底干什么

一句话:带 p 的函数,会自动去 PATH 环境变量里面搜索这个程序,不用你写完整绝对路径。

对比两组,一眼看懂

不带p(execv / execl)

第一个参数必须是完整路径,内核直接按这个路径去磁盘找文件:

execv("/usr/bin/ls", argv);
execl("/usr/bin/ls", "ls", "-l", NULL);

你写 ls 不行,会直接执行失败,找不到文件。

带p(execvp / execlp)

第一个参数只写程序名字就行:

execvp("ls", argv);
execlp("ls", "ls", "-l", NULL);

它内部做的事情:

  1. 拿到环境变量 PATH,PATH 是一串目录,用冒号隔开,类似:
PATH=/usr/bin:/bin:/usr/local/bin

PATH 里面就是放了好多个目录路径,用冒号 : 隔开。

冒号 : 只是分隔符,用来告诉系统:这里是不同的文件夹。
2. 把名字依次拼到每一个目录后面: /usr/bin/ls → 存在吗?存在,就用这个路径调用底层的 execve
3. 找到第一个存在、可执行的文件,就执行它
4. 全部目录找完都没有 → execvp 返回失败

底层本质:p只是帮你拼接路径查找,找到之后,内部最终还是调用 execve

重点!两个坑(非常容易错,写myshell必踩)

坑1:如果你给的名字里面带 /,p 直接失效,不再搜PATH

execvp("./otherExe", myargv);

里面有 /,execvp 判定这是路径,不会去PATH里面搜,直接当成路径传给execve。 和 execve("./otherExe", myargv, environ) 行为一样。

规则:参数包含 / → 跳过PATH搜索,直接当成路径使用。

坑2:搜索只看PATH,不看你当前目录

默认PATH里面没有.(当前目录),所以:

execvp("otherExe", myargv);

就算当前目录有 otherExe,也找不到! 想要执行当前目录的程序,必须写:

execvp("./otherExe", myargv);

因为带了 /,不再走PATH搜索。

(exec家族演示,C语言)

文件名:shell_code.c

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    // 声明全局环境变量表,系统自带,extern代表这个变量在别的地方定义好了,我们拿来用
    extern char **environ;
    // putenv:往当前进程的环境变量里新增一条环境变量
    // 格式:KEY=VALUE,字符串形式
    putenv("PRIVATE_ENV=666666");

    // fork() 创建子进程,返回值是pid_t(进程id类型,本质int)
    pid_t id = fork();

    // id == 0 代表【子进程】
    if(id == 0)
    {
        // getpid():获取自己的进程ID
        // getppid():获取父进程的ID
        printf("before: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());

        // sleep(5); 让子进程休眠5秒,方便用ps看父子进程关系,这里注释掉了
        //sleep(5);

        // ========== exec系列6个函数,作用:加载新程序,替换当前进程代码,不创建新进程!==========
        // execl  l=list:参数一个一个列出来,最后必须NULL结尾
        // 第一个参数:程序完整路径;第二个开始:命令参数,argv[0]是程序名,最后NULL
        //execl("/usr/bin/ls", "ls", "-a", "-l", NULL);

        // execlp  p=path:自动去PATH环境变量搜索程序,不用写全路径
        //execlp("ls", "ls", "-a", "-l", NULL);

        // execl加载top命令
        //execl("/usr/bin/top", "top", NULL);

        // execv  v=vector:参数放到数组里传进去,第一个参数是程序全路径
        //char *const myargv[] = { "ls", "-a", "-l", NULL };
        //execv("/usr/bin/ls", myargv);

        // execvp  vp:数组传参 + 自动走PATH搜索,就是你myshell正在用的!
        //execvp("ls", myargv);

        // 注释提问:为什么可以跨语言调用?
        // 答案:不管C/C++/python/shell脚本,运行起来在内核眼里统统都是【进程】
        // exec只负责加载可执行文件,不关心这个文件是什么语言写的

        // 定义参数数组,传给exec,argv数组,最后一定要NULL标记结束
        char* const myargv[] = {
            "otherExe",
            "-a",
            "-b",
            "-c",
            NULL
        };

        // 自定义环境变量数组,最后NULL结尾,给execle / execve用
        char *const myenv[] = {
            "MYVAL=1111",
            "MYPATH=/usr/bin/XXX",
            NULL
        };

        // execle  le:列表传参 + 自己指定环境变量(不继承父进程environ)
        //execle("./otherExe", "otherExe", "-a", "-w", "-v", NULL, myenv);

        // execve  ve:数组传参 + 自己指定环境变量,是exec系列最底层、系统原生版本!
        // 最后一个参数:environ,代表继承父进程全部环境变量
        //execve("./otherExe", myargv, environ);

        // execl调用bash,直接跑shell脚本test.sh
        //execl("/usr/bin/bash", "bash", "test.sh", NULL);

        // execl调用python3,运行python脚本test.py
        //execl("/usr/bin/python3", "python3", "test.py", NULL);


        // ⚠️重点:exec成功之后,后面代码全部不会执行!
        // 只有exec调用失败,才会往下跑这句printf
        printf("after: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());
        // 子进程失败直接退出,退出码1
        exit(1);
    }

    // ========== 下面是父进程代码 ==========
    // waitpid:等待子进程退出,回收子进程资源,防止僵尸进程
    // 参数1:要等待的子进程id;参数2:保存子进程退出状态(NULL代表不关心退出码);参数3:选项0=阻塞等待
    pid_t ret = waitpid(id, NULL, 0);
    // ret>0 代表成功等到子进程,ret是子进程pid
    if(ret > 0) printf("wait success, father pid: %d, ret id: %d\n", getpid(), ret);

    // 父进程休眠5秒,方便观察
    sleep(5);
    return 0;
}

第二份代码 otherExe.cpp(C++,给上面exec加载的被调用程序)

文件名:otherExe.cpp

#include <iostream>
using namespace std;

// main完整三参数版本:
// argc:参数个数
// argv:命令行参数数组
// env:环境变量数组
int main(int argc, char *argv[], char *env[])
{
    // argv[0] 永远是程序自身名字(标签字符串,内核不靠它找文件)
    cout << argv[0] << " begin running" << endl;

    cout << "这是命令行参数:\n";
    // 循环打印argv,argv数组最后一个元素是NULL,作为结束条件
    for(int i = 0; argv[i]; i++)
    {
        cout << i << " : " << argv[i] << endl;
    }

    cout << "这是环境变量信息:\n";
    // env数组同样以NULL结尾,遍历打印所有环境变量
    for(int i = 0; env[i]; i++)
    {
        cout << i << " : " << env[i] << endl;
    }

    cout << argv[0] << " stop running" << endl;
    return 0;
}

编译运行步骤(一定要试一遍,瞬间懂exec)

  1. 先编译C++程序:
g++ otherExe.cpp -o otherExe
  1. 编译exec演示C代码:
gcc shell_code.c -o shell_code
  1. 运行
./shell_code

核心知识点总结(必须背,myshell核心)

  1. fork:复制一份进程,父子进程代码一模一样,都从fork之后继续跑
  2. exec家族:只替换当前进程的程序代码,不新建进程,pid不变 ✅ exec成功:后面代码全部抛弃,不再执行 ✅ exec失败:继续往下执行,一般就是路径错、程序不存在
  3. 6个exec字母含义
    • l:list,参数一个个罗列
    • v:vector,参数放数组
    • p:PATH,自动搜索环境变量PATH,不用写全路径
    • e:env,自己传入环境变量,不继承父进程
  4. waitpid:父进程回收子进程,避免僵尸进程
  5. 环境变量两种方式:
    • 不传自定义env:继承父进程 environ(myshell默认就是这个)
    • 传myenv:完全使用自定义环境,父的环境全部丢掉

运行现象

打开注释 execve("./otherExe", myargv, environ); 再运行: 父进程fork → 子进程走到execve → 加载otherExe,打印argv和环境变量 你就能亲眼看到:myshell传给外部命令的参数、环境变量,就是这样送过去的!

1. argv[0]

刚才讲过:只是一个标记字符串,一般放程序名字(可以带路径 ./xxx,甚至乱写)。 内核不靠它找程序,找程序是 execve 第一个参数的活。 程序可以拿 argv [0] 知道自己是被以什么名字启动的。

2. argv [1], argv [2]…… 才是真正的选项和参数

程序内部自己读取 argv 来决定行为:

  • ls 读到 -a → 显示隐藏文件
  • ls 读到 -l → 长格式展示
  • 你写的 otherExe,循环遍历 argv,就是打印收到的这些参数

3 举个好玩的例子,看懂独立性

char* const myargv[] = {
    "testname",
    "-h",
    NULL
};
execve("./otherExe", myargv, environ);
  • 内核加载 ./otherExe(文件名叫 otherExe)
  • 但是 otherExe 里面读到 argv [0] 是 "testname" 程序会打印:testname begin running 程序照样正常跑,只是名字标签被改了。
    我们平时写 shell、用 execvp,习惯上 argv [0] 和程序名保持一致,是行业约定,不是内核强制要求。

问:execve(“./otherExe”, myargv, environ); 没有用到 otherexe main 函数的 argc 也没有传这个参数

这个点特别容易混淆,一句话先说结论: 我们代码里没有单独传 argc!argc 是操作系统拿到 argv 数组之后,自动帮你算出来的,不是我们手动传给 main 的。

// shell_code.c
char* const myargv[] = {
    "otherExe",
    "-a",
    "-b",
    "-c",
    NULL
};
execve("./otherExe", myargv, environ);

我们只交给内核两件东西:

  1. myargv:以 NULL 结尾的字符串数组
  2. 环境数组 environ

内核加载 otherExe 程序、准备调用 main 的时候:

  • 遍历 myargv,数有多少个有效字符串 → 这个数字就是 argc
  • 把整个数组原样交给 argv
  • 环境数组交给 env

所以 otherExe 的 main:

int main(int argc, char *argv[], char *env[])
  • argv:直接就是我们传过去的 myargv
  • argc:内核自己数出来的,不是我们代码传进去的参数

举这个例子:

myargv = {"otherExe", "-a", "-b", "-c", NULL};

内核数一下一共4个字符串,所以 argc = 4。


对比区分(很关键)

execve 的原型:

int execve(const char *path, char *const argv[], char *const envp[]);

它只有三个参数:路径、argv数组、环境数组。 根本没有 argc 的位置。

✅ 我们只负责构造 argv[]、末尾放 NULL ✅ 内核负责统计个数,填充 argc,再调用main

再联动你之前的疑问

argv[0] 也是放在这个数组里面一起传过去的,内核不会单独处理它,只是作为数组第一个元素。

简单一句话背下来

exec系列只传argv数组(NULL结尾),argc由内核自动统计生成,不用我们传。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐