文章目录
- 一、进程的程序替换(exec系列的接口)
-
- 简单认识全部接口
- [1. execl](#1. execl)
- [2. execlp](#2. execlp)
- [3. execv](#3. execv)
- [4. execvpe](#4. execvpe)
- 二、Shell(命令行解释器)
-
- [1. shell的核心工作流程](#1. shell的核心工作流程)
- [2. 小知识点补充](#2. 小知识点补充)
- [3. 显示命令提示符(接口)](#3. 显示命令提示符(接口))
- [4. 读取用户输入的命令](#4. 读取用户输入的命令)
- [5. 解析命令行](#5. 解析命令行)
- [6. 执行命令](#6. 执行命令)
- [7. 检查与处理内建命令](#7. 检查与处理内建命令)
一、进程的程序替换(exec系列的接口)
在Linux中,进程除了能通过fork创建子进程外,还可以通过exec系列函数进行进程替换。
为什么需要程序替换?
之前学习的fork(),创建的子进程执行的还是原来的代码。想让子进程运行新的程序运行,就要用程序替换(exec 系列函数)。进程不变,PCB 不变,只是代码和数据全换掉。
所谓程序替换,就是让一个正在运行的进程丢掉原来的程序映像,转而执行另一个可执行文件。
替换,简单理解:就是将磁盘中全新的程序 (代码和数据)覆盖式的加载到当前进程代码和数据 的位置,然后修改页表即可。程序替换本质上并不会创建新的进程(验证:可以在子输出一次pid,在替换之后再输出一次pid)
一旦程序替换成功,就去执行新代码。原来的代码将不再执行,已经不存在了
exec系列函数失败时才会返回 -1 并继续向下执行,成功时没有返回值,所以不需要做返回值判断,返回即失败
简单认识全部接口

补充:
(1)v是将一个个的参数,放在字符指针数组里;然后将数组整个的传给函数
(2)exec系列函数所有接口的调用关系
execve是系统调用,头文件为<unistd.h>
其他exec函数是库函数,头文件为<stdlib.h>,由C语言进行封装,底层会去调用系统调用execve
1. execl
execl 会将(当前进程的程序)替换为(新程序)
原进程的代码和数据被覆盖,PID保持不变,替换成功后不再返回,后续代码无法执行。
bash
int execl(const char *pathname, const char *arg, ...);
它的第一个参数const char* pathname表明:我要执行谁 (要有路径+程序名);
剩下的参数表明:如何执行那个程序 (简单方法:命令行怎么写,我就怎么传。看下图)

execl中 l 可以理解为list风格的「变长参数列表」。(将命令行的字符串一个一个传给参数,即参数以逐个列举的形式传入。而list 风格:逐个列举)
(...代表可变参数列表)。最后一个参数必须是以NULL结尾(表明参数传递完成)
- execl程序替换之后的将不会再执行
bash
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("replace begin\n");
//这个效果和 ls -a -l效果一样
execl("/usr/bin/ls","ls","-a","-l",NULL);
// 程序路径 arg0,arg1,arg2,结束标记
//这个不会被执行,因为上一步是程序替换中的execl,
//原来的代码(从 execl 往下)直接丢掉,不再执行
printf("replaxe end\n");
return 0;
}

- 程序替换的弊端:就是一个程序替换的代码,如果它一旦替换,就会影响到当前进程(会转而执行新程序,旧的后面就不再执行)。
解决办法:(让父进程安安心心执行这个程序,它常见其他子进程执行新的程序):
通过fork创建一个子进程,(if(fork()==0))让子进程去执行新程序,父进程在这里进行进程等待,之后继续执行原来的程序)
fork()用于创建子进程。子进程和父进程最初运行的是相同的程序exec()用于替换子进程的内存映像,加载并运行新程序。(子进程一般会立刻调用 exec,以执行一个全新的程序)

子进程的代码数据都拷贝父进程的同时,这里父子进程也不再共享,子进程有自己新的代码。--->父子进程彻底分离

-
任何一个程序进入内存,必须先变为进程。所以加载程序 的本质就是动态创建进程的过程。

-
我们自己写的进程可以被替换吗?--------可以
只要是(能转换为进程的)程序,都可以进行程序替换
(例子:原本的程序是C写的(test.c),现在想在c的程序中,将C++的程序(other.cc)调起来。可以在pro.c的程序中写:execl("./myother","myother",NULL);。这里没有选项,所以不需要写

- 程序替换是系统级行为。以下是原因:
(1)任何程序要运行,必须先加载为进程。只要是进程,就可以通过exec系列函数完成程序替换。
(2)程序替换的代码不是自己随便换,而是操作系统帮你把进程里的代码、数据全换掉。程序替换是操作系统提供的能力。
程序替换是系统级行为:程序运行必为进程,凡是进程均可替换。
- 程序替换是系统级能力,C 语言进程可替换执行一切能跑成进程的程序,跨编译型 / 脚本型语言都支持。
程序替换是操作系统层面的机制。在一个 C 语言进程里,可以通过程序替换,执行任何编译型语言、脚本语言编写的程序------ 只要它最终要在系统里运行,就必须被加载为进程;而只要是进程,系统都支持对其进行程序替换,从而实现跨语言调用(前端页面类语言除外)。
- 程序替换的核心 exec 本质就是系统加载器,能加载并运行各类程序:对编译型语言直接加载执行,对脚本语言则加载对应的解释器来运行,实现一个进程执行任意程序。
2. execlp
bash
int execlp(const char *file, const char *arg, ... );
execlp中,p是PATH,因为execlp会自动在环境变量PATH中查找指定命令,所以不用路径,只需要文件名即可。
l的含义同上:将参数以列表的形式传递
bash
execlp("ls","ls","-a","-l",NULL);
3. execv
没有p(path)就说明:这里需要写路径(绝对/相对路径)
v表示vector(数组):第二个参数以数组呈现,即:字符指针数组,曾经命令行参数以列表形式传,现在将这些字符串"ls","a","l",一个一个放在数组,整体传进来。也以NULL结尾
cpp
#include <stdio.h>
#include<unistd.h>
int main()
{
char* const argv[]={
(char* const)"ls",
(char* const)"-l",
NULL
};
printf("开始\n");
execv("/usr/bin/ls",argv);
printf("结束\n");
}

知识点:argv:所有命令参数的指针数组整体。argv[0]:数组首元素,固定为当前程序名
问题1:为什么是字符指针数组,而不是字符数组呢?
因为我们的命令行参数:"ls","a","l",它们是n个独立字符串。如果 用字符数组:char argv[] = "ls -l /home";内核根本分不清:哪一段是命令名/参数 1/参数 2,它只会当成一整个参数。。而字符指针数组 char *argv[]是多个独立字符串的地址列表,内核一看就懂:第 0 项:命令名。第 1 项:参数 1。第 2 项:参数 2
问题2:char *argv[] = { "ls", "-l", "/home", NULL };中括号里的是字符串,为什么类型是字符指针

问题3:ls是不是一个二进制程序?
是的。
先了解什么是二进制文件:代码.c给程序员看的文字。经过gcc编译后生成的文件(比如 a.out、ls)就是电脑CPU能直接跑的机器码,这叫做编译好的二进制程序。(不是文本,打开是乱码;不能直接编辑修改;电脑加载进去就能直接跑;是ELF格式(Linux)、EXE 格式(Windows)
(1)当在命令行敲 ls,就是运行这个二进制程序。它不是脚本、不是别名,就是真正的程序本体。
问题4:我们自己程序的命令行参数谁传的,怎么传?
任何一个进程的命令行参数 = 父进程通过 exec 传进来的
在bash里敲命令 → bash把参数打包好 → 通过 exec 扔给你的程序。你的程序 main 函数的:int main(int argc, char *argv[])
(1)用户输入命令(终端运行bash父进程):./myprog a b c
(2)bash(父进程)解析命令,构造参数数组argv[]:
bash
char *argv[] = {"./myprog", "a", "b", "c", NULL};
(3)bash 调用 fork() 创建子进程
(4)子进程调用 exec 系列函数。exec 替换子进程内存空间,变成新程序
(5)内核把 argv 数组直接塞进新程序的 main 函数里
同时,这也是LInux/Unix 程序启动的唯一标准流程
问题5:所有程序都是 bash 的子进程?父进程用 exec 传参给子进程?
在终端敲的所有命令,全是 bash 的子进程
命令行里的所有程序,都是 bash 生的子进程,参数全靠 exec 传递。
4. execvpe
execvpe中:
v的意思是:将命令行参数以(字符指针)数组的形式传递。
p的意思是:会去环境变量找路径,不用自己写路径,直接传文件名。
e的意思是:使用带e的exec系列函数,会舍弃继承的环境变量,而强制子进程仅使用自定义传入的环境变量。
详解e :e 对应环境变量environment,接收char* const envp[]环境指针数组;无 e 函数默认继承父进程环境全局表,携带 e 的 exec 系统调用会完全替换环境上下文,子进程独立使用自定义envp,不再继承父进程环境。
如果自己传了envp,就是用自己的。没传,就是用默认的(语言级别的environ),即从父进程的进程地址空间继承的表
问题1:什么是环境上下文?
环境上下文 = 该进程能看到的所有环境变量的整体,比如PATH,HOME,USER,LANG,PWD。这些变量合在一起,就叫环境上下文。
问题2:新增环境变量
我们还可以以新增的方式将env传给子进程,将传进去的环境变量加在原来的环境变量列表后面
问题3:创建子进程时,为什么父进程不传环境变量和命令行参数,子进程也能获得?

在 Linux 系统中,每个进程都拥有独立于堆、栈的专属内存区域,用来存放命令行参数与环境变量,并通过全局指针extern char** environ维护环境变量表;调用fork()创建子进程时,会完整拷贝父进程的地址空间,自动复刻环境变量与参数信息。不带e的 exec 函数会默认沿用全局environ,让新进程天然继承父进程全部环境上下文;而携带e的 exec 调用,会舍弃继承的所有环境配置,以开发者手动传入的envp[]字符指针数组作为新进程唯一的环境变量集合,完成环境上下文的完全替换。
- 查看环境变量可以用
env,得到环境变量那个值,可以使用getenv
int putenv(char* string); putenv是在该进程的环境变量表里,新增一个环境变量
(1)改自身进程环境(全局继承式):
在当前进程直接用putenv()/setenv()新增环境变量,不会覆盖,是追加 / 修改父进程原有环境;后续调用不带 e的 exec系列函数,子进程自动继承原环境 + 你新增的变量。
即:我们直接在本进程里使用putenv()新增环境变量,之后创建子进程,调用exec后,子进程就自动继承了父的所有环境变量(包括旧的+新增的部分)
把xx用yy替换:%s/xx/yy/g
二、Shell(命令行解释器)
1. shell的核心工作流程
bash
while(true) {
1. 显示命令提示符
2. 读取用户输入的命令
3. 解析命令(分割成程序名和参数)
4. fork创建子进程
5. 子进程exec执行命令
6. 父进程wait等待子进程
}
bash
while(1) {
// 1. 显示提示符
printf("[user@host dir]$ ");
// 2. 读取命令
fgets(command, sizeof(command), stdin);
// 3. 解析命令
parse(command, argv);
// 4. 创建子进程
pid_t id = fork();
if(id == 0) {
// 5. 子进程执行命令
execvp(argv[0], argv);
exit(1);
}
else {
// 6. 父进程等待
waitpid(id, &status, 0);
}
}
2. 小知识点补充
- 什么是内建命令,外部命令
内建命令:不需要跑到硬盘找独立二进制程序,直接藏在 Shell(bash)内部、自带的小命令。输入命令时,不创建子进程,Shell 自己当场执行。
ls:外部命令,是 /usr/bin/ls 独立二进制文件,bash 要 fork+exec 建子进程运行。
cd、exit、export:内建命令,没有单独的程序文件,就在 bash 代码里写死的,直接本机执行,不开子进程。
外部命令:必须 fork + exec 替换子进程
内建命令:不走 fork、不走 exec,直接在当前 bash 进程里运行
内建命令是 Shell 程序内部内置的指令,无独立可执行文件,执行时:不fork子进程、不调用exec系列函数,由当前Shell进程直接解析运行。
- 为什么cd不是外部命令,而是内建?
设想一个,fork一个子进程去执行切换路径的命令,确实是可以切换。但是子进程执行cd,那肯定是cd自己的,子进程退出后,父进程丝毫不会改变,还在原来的目录。所以cd命令不应该由子进程执行,应该由shell亲自执行
3. 显示命令提示符(接口)
获取用户名的函数:可通过环境变量表知道。获取环境变量的内容:char *getenv(const char *name);其他同理

再将函数进行封装,达到将命令行提示符打印出来的效果

4. 读取用户输入的命令
- 一旦有命令行之后,bash就卡在这里不动了,在等什么?等待用户输入
- 在现有的shell中,用户可以让shell做几次事情?无数件 (shell完成一件事情,就等着下一件,所以等待用户输入是需要循环的 )(当用户输入一条命令,回车,shell完成后会继续等待用户输入)
知识点:
用户输入ls -a -l,bash需要理解为"ls -a -l"字符串
scanf是以空格为分隔符。scanf会弄成3个字符串"ls" "-a" "-l",所以完成任务,不使用scanf,使用fgets
fgets简介:
bash
char *fgets(char *str, int n, FILE *stream);
从指定的文件流(键盘stdin或文件)中读取一行字符,最多读取n-1个字符,自动在末尾添加\0构成合法C字符串。
输入的命令带回车,(那显式打印一下,也会有回车),如何去掉?
修改被写入的数组,将数组最后一个成员(回车)变成 0(即:清理\n)

5. 解析命令行
走到这里,说明用户输入的命令已经获取成功
-
我们获取到的是一串字符串"ls -a -l",在之前程序替换中,参数可以以"列表"传,以数组传,但是不能一个字符串传,所以第一步:将字符串拆分(这个命令不能直接在shell中进行,需要创建子进程去完成命令,所以这一步为后续的程序替换做铺垫了)
-
系统自带的shell会默认去获取:用户的命令输入,再把用户输入的信息构建成一张命令行参数表(在shell内部维护)
所以第二步=:将之前拆分的命令行参数,放入到全局的命令行参数表中。(首先,先在myshell中,创建一个全局 的命令行参数表,并将参数个数初始化为0)
(修改:将return true修改为return g_argc > 0 ? true : false;)

通过这一步:获取到了命令函参数表

strtok的详细解析:
strtok 函数首次调用时,第一个参数传入待分割的原始字符串
(函数从字符串起始位置开始扫描,直至遇到指定分隔符后,截取当前位置之前的子串并返回,同时内部记录本次分割结束的位置)
后续对同一字符串的继续分割,第一个参数必须传入 NULL,函数会基于上一次记录的位置继续向后扫描、截取剩余部分,直至字符串末尾。
一直切一直切,最后会切到NULL。切到NULL就会去将argv最后一个内容置为NULL,符合命令行参数表的设定。赋值之后,会将argv[argc]作为while的判断条件,最后退出循环
无数次的拆分:通过循环来继续拆分,直到结束
6. 执行命令
获取到了命令函参数表,接下来执行。
但不是让当前进程去执行命令,当前进程有自己的任务。

7. 检查与处理内建命令
如果是内建命令,在封装的函数中,直接让shell执行即可,执行完回到main函数里,直接continue。(因为后面调用的是:外部命令的执行方法)
查看命令是否为内建命令:看g_argv[0]是否为内建命令之一即可
一个进程更改自己的工作路径,调用chdir,参数是:绝对/相对
让shell进程执行chdir