【Linux】【实战向】Linux 进程替换避坑指南:从理解 bash 阻塞等待,到亲手实现能执行 ls/cd 的 Shell

前言:欢迎 各位光临本博客,这里小编带你直接手撕,文章并不复杂,愿诸君耐其心性,忘却杂尘,道有所长!!!!

IF'Maxue个人主页
🔥 个人专栏 :
《C语言》
《C++深度学习》
《Linux》
《数据结构》
《数学建模》

⛺️生活是默默的坚持,毅力是永久的享受。不破不立!


文章目录

一、为啥sleep命令会"卡住"?------bash的阻塞等待

先从一个小问题切入:为啥用execl调用sleep时,程序像"卡住"了?

其实这不是卡住,是bash在等它执行完

比如你写了个程序,里面用execl调用系统的sleep命令(如图1、图2所示):

c 复制代码
// 类似图中代码的逻辑
#include <unistd.h>
int main() {
    // 执行sleep 1秒:路径/bin/sleep,参数sleep、1,末尾NULL
    execl("/bin/sleep", "sleep", "1", NULL); 
    return 0;
}

当你运行这个程序时,bash(你的命令行父进程)会"阻塞等待"这个子进程(你写的程序)执行完。而你的程序又被sleep替换了,所以bash会等sleep跑完1秒才恢复,看起来就像"卡住"------这其实是正常的等待逻辑,如图1、图2里的执行效果所示。

(图1:程序执行execl调用sleep的效果)

(图2:sleep执行时bash阻塞等待的状态)

二、进程替换到底是啥?------不建新进程,只换"代码和数据"

很多人以为"执行新程序"就是"新建进程",其实不是!进程替换的核心是:不创建新进程,只把当前进程的"代码和数据"换成新程序的

就像一个人(进程)换衣服(代码和数据),身份证(PID、PCB进程控制块)没变,还是同一个人,只是穿的衣服不一样了。如图3、图4所示:进程的"结构"(PID、PCB)早就建好,替换时只把里面的"代码段、数据段"覆盖掉。

(图3:进程替换前的结构,PID等信息已存在)

(图4:替换时只覆盖代码和数据,结构不变)

关键问题:为啥exec系列函数"只有失败返回值"?

你查man execl会发现:exec*函数成功时没有返回值 ,只有失败时返回-1(如图5所示)。为啥?

因为一旦替换成功,当前进程的代码已经被新程序覆盖了------原来的代码(包括exec之后的返回语句)全没了,根本没法返回!只有替换失败时,原来的代码还在,才能返回-1告诉你"没换成"。

(图5:man execl说明"只有错误时有返回值")

比如图6的代码:execl之后写了printf("替换后执行");,但如果execl成功,这段代码会被覆盖,永远不会执行;只有execl失败(比如路径写错),才会打印"替换失败"。

(图6:exec成功后,后续代码不执行的验证)

三、exec系列接口怎么用?------5个函数,记住"字母含义"就够了

exec有5个常用函数:execlexeclpexecvexecvpexecvpe。不用死记,记住字母代表的意思:

  • l(list) :参数用"列表"一个个传(比如sleep 1传成sleep, 1, NULL);
  • v(vector):参数用"数组"传(把参数放进字符数组,最后放NULL);
  • p(path) :不用写全路径,系统会在PATH环境变量里找程序(比如直接写sleep,不用/bin/sleep);
  • e(environment):可以自定义环境变量(默认继承父进程环境变量)。

1. execl:传全路径+参数列表

格式:execl(全路径, 程序名, 参数1, 参数2, ..., NULL)

注意:

  • 第一个参数是"程序全路径"(比如/bin/sleep);
  • 第二个参数是"程序名"(和执行时的名字一致,比如sleep);
  • 后面跟参数,最后必须用NULL结尾(告诉系统参数传完了)。

如图7的代码,调用sleep 1

c 复制代码
execl("/bin/sleep", "sleep", "1", NULL); // 正确,最后有NULL

如果少传NULL或路径错,会替换失败(如图8所示)。

(图7:execl的正确用法,全路径+NULL结尾)

(图8:少传NULL或路径错误导致替换失败)

2. execlp:不用写路径,靠PATH找

execl多了个p,意思是"用PATH找程序"。比如调用sleep,不用写/bin/sleep,直接写sleep

c 复制代码
execlp("sleep", "sleep", "1", NULL); // 正确,系统会在PATH里找sleep

如图9的例子,执行ls时,execlp("ls", "ls", "-l", NULL)能成功,因为系统在PATH(比如/bin)里找到了ls

(图9:execlp用PATH找ls,不用写全路径)

3. execv:用数组传参数

execl多了个v,参数放进字符数组,最后放NULL。比如调用sleep 1

c 复制代码
char* arg[] = {"sleep", "1", NULL}; // 参数数组,最后NULL
execv("/bin/sleep", arg); // 传数组,不用一个个写参数

如图10所示,数组必须以NULL结尾,否则系统会读错参数。

(图10:execv的参数数组必须NULL结尾)

4. execvp:结合v和p的优点

既可以用数组传参数,又不用写全路径(靠PATH找)。比如:

c 复制代码
char* arg[] = {"sleep", "1", NULL};
execvp("sleep", arg); // 不用写路径,数组传参

如图11,执行ls -l时,execvp("ls", arg)能成功,不用写/bin/ls

(图11:execvp结合数组和PATH的用法)

5. execvpe:自定义环境变量

execvp多了个e,可以传自定义环境变量。默认情况下,子进程继承父进程的环境变量(比如PATH),但execvpe可以指定新的环境变量数组。

如图12,自定义env数组,传给execvpe,新程序会用这个环境变量:

c 复制代码
char* arg[] = {"echo", "$MY_ENV", NULL};
char* env[] = {"MY_ENV=hello", NULL}; // 自定义环境变量
execvpe("echo", arg, env); // 传自定义env

(图12:execvpe传自定义环境变量)

四、为啥子进程替换,父进程没事?------写时拷贝的"保护"

如果直接在父进程里做替换,父进程会被改成新程序,这显然不行(比如bash自己不能被替换成sleep)。所以通常的做法是:fork一个子进程,让子进程做替换,父进程继续运行。

为啥子进程替换不影响父进程?因为Linux有"写时拷贝"机制:

  • 父进程和子进程一开始共享代码和数据;
  • 当子进程要修改代码(比如替换程序)时,系统会"拷贝一份代码"给子进程,让子进程改自己的拷贝,父进程的代码不变。

如图13的代码:fork后,子进程执行execl替换,父进程执行wait等待,父进程的代码没被改,所以能继续运行。

(图13:子进程做替换,父进程不受影响)

五、进程替换能跨语言吗?------当然能!不管啥语言,能执行就行

exec不管程序是用啥语言写的,只要是"可执行文件"(或脚本加了shebang),就能替换执行。比如:

  • C程序替换执行C++编译的程序;
  • C程序替换执行Python脚本。

例子1:C程序执行C++程序

  1. 先写个C++程序cpp_prog.cpp,编译成cpp_prog

    cpp 复制代码
    #include <iostream>
    int main() { std::cout << "我是C++程序" << std::endl; return 0; }
  2. 写C程序c_prog.c,用execl执行cpp_prog

    c 复制代码
    #include <unistd.h>
    int main() {
        execl("./cpp_prog", "cpp_prog", NULL); // 执行C++编译的程序
        return 0;
    }

如图14所示,C程序能成功执行C++程序,输出"我是C++程序"。

(图14:C程序替换执行C++程序的效果)

例子2:C程序执行Python脚本

  1. 写Python脚本py_prog.py,开头加shebang(告诉系统用Python解释器):

    python 复制代码
    #!/usr/bin/python3
    print("我是Python程序")
  2. 给脚本加执行权限:chmod +x py_prog.py

  3. C程序用execl执行脚本:

    c 复制代码
    execl("./py_prog.py", "py_prog.py", NULL);

如图15所示,C程序能成功执行Python脚本,输出"我是Python程序"。

(图15:C程序替换执行Python脚本的效果)

六、怎么证明没建新进程?------看PID就知道

前面说"替换不建新进程",怎么证明?打印PID,替换前后PID不变,就说明没建新进程

如图16的代码:

  1. 先打印当前进程PID(getpid());
  2. 然后用execl替换成sleep,并让sleep执行echo $$$$是当前进程PID);
  3. 替换后,echo $$打印的PID和之前一致,说明还是同一个进程。

(图16:打印PID证明替换不建新进程)

如图17的输出,替换前打印的PID是1234,替换后sleep执行echo $$也打印1234,说明没建新进程。

(图17:PID不变的输出结果)

七、自己写个简单Shell?------4步搞定:循环、获取命令、解析、执行

Shell的核心是"死循环":不断获取用户命令→解析命令→执行命令。我们一步步实现:

1. 第一步:显示命令提示符(用户名@主机名:路径$)

要显示类似user@ubuntu:~/test$的提示符,需要获取3个信息:

  • 用户名:用getenv("USER")(环境变量里有);
  • 主机名:用gethostname()函数;
  • 当前路径:用getcwd()函数。

如图18的代码:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
    char host[256];
    char cwd[1024];
    gethostname(host, sizeof(host)); // 获取主机名
    getcwd(cwd, sizeof(cwd));       // 获取当前路径
    // 打印提示符:用户名@主机名:路径$
    printf("%s@%s:%s$ ", getenv("USER"), host, cwd);
    return 0;
}

(图18:获取用户名、主机名、路径的代码)

2. 第二步:获取用户输入(处理回车)

fgets获取用户输入(scanf会被空格截断,不适合命令行),但fgets会把末尾的回车(\n)也读进来,需要去掉。

如图19的代码:

c 复制代码
char buf[1024];
fgets(buf, sizeof(buf), stdin); // 获取输入,比如输入"ls -l\n"
// 去掉末尾的\n:把\n换成\0
buf[strcspn(buf, "\n")] = '\0'; // strcspn找\n的位置,换成结束符

(图19:用strcspn去掉输入中的回车)

3. 第三步:解析命令(按空格切割)

strtok函数按空格切割命令,比如把"ls -l"切成"ls"和"-l",放进参数数组(最后放NULL)。

如图20的代码:

c 复制代码
char* arg[64] = {NULL}; // 参数数组,最多64个参数
int i = 0;
// 第一次调用strtok:传buf和分隔符" "
arg[i] = strtok(buf, " "); 
while (arg[i] != NULL) {
    i++;
    // 后续调用:第一个参数传NULL,用上次的位置继续切
    arg[i] = strtok(NULL, " "); 
}
// 循环结束后,arg = {"ls", "-l", NULL}

(图20:用strtok解析命令的代码)

4. 第四步:执行命令(子进程替换,父进程等待)

fork子进程,子进程用execvp执行命令(不用写路径,数组传参),父进程用wait等待子进程结束。

如图21的完整代码:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    while (1) { // 死循环,Shell不退出
        // 1. 显示提示符
        char host[256], cwd[1024];
        gethostname(host, sizeof(host));
        getcwd(cwd, sizeof(cwd));
        printf("%s@%s:%s$ ", getenv("USER"), host, cwd);

        // 2. 获取输入
        char buf[1024] = {0};
        fgets(buf, sizeof(buf), stdin);
        buf[strcspn(buf, "\n")] = '\0'; // 去掉\n

        // 3. 解析命令
        char* arg[64] = {NULL};
        int i = 0;
        arg[i] = strtok(buf, " ");
        while (arg[i] != NULL) {
            i++;
            arg[i] = strtok(NULL, " ");
        }
        if (arg[0] == NULL) continue; // 输入为空,跳过

        // 4. 执行命令
        pid_t pid = fork();
        if (pid == 0) { // 子进程
            execvp(arg[0], arg); // 执行命令
            exit(1); // 替换失败才会到这步
        } else if (pid > 0) { // 父进程
            wait(NULL); // 等待子进程结束
        }
    }
    return 0;
}

(图21:简单Shell的完整代码)

问题:cd命令为啥没用?------内建命令的必要性

上面的Shell能执行lssleep,但执行cd ~会发现路径没变化。为啥?

因为cd是"内建命令"------它需要修改当前Shell进程(父进程)的路径 ,而不是子进程的路径。如果让子进程执行cd,子进程的路径改了,但子进程退出后,父进程的路径没变,等于白改。

解决办法:父进程自己执行chdir函数(不用子进程),比如判断如果命令是cd,就调用chdir

c 复制代码
if (strcmp(arg[0], "cd") == 0) {
    if (arg[1] != NULL) chdir(arg[1]); // 父进程自己改路径
    continue;
}

这种"父进程亲自执行"的命令,就是内建命令(比如cdexit)。

总结

  1. sleep卡住:不是卡住,是bash作为父进程在等待子进程(sleep)执行完;
  2. 进程替换:不建新进程,只替换当前进程的代码和数据,PID不变;
  3. exec函数 :记l/v/p/e的含义,execlpexecvp最常用;
  4. 子进程替换:靠写时拷贝保护父进程,父进程不受影响;
  5. 自定义Shell:死循环+获取命令+解析命令+执行命令,内建命令需父进程亲自执行。
相关推荐
AlfredZhao4 小时前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户97183563346610 小时前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪12 小时前
linux 拷贝文件或目录到指定的位置
linux
大树881 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠1 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质1 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush41 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5201 天前
Linux 11 动态监控指令top
linux
Inhand陈工1 天前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信
酣大智1 天前
ARP代理--工作原理
运维·网络·arp·arp代理