Liunx 操作系统 进程控制 (下)
进程程序替换一、为什么需要程序替换在 Linux 中我们可以使用 fork() 创建子进程。子进程会获得父进程的执行环境并从 fork() 返回的位置继续执行。但是在实际开发中子进程往往需要执行与父进程不同的程序。例如Shell 接收到用户输入的 ls 命令后就需要让相应的进程执行 ls 程序。此时就需要使用程序替换机制。程序替换是指通过特定的接口将磁盘上的新程序加载到当前进程的地址空间中替换原来的用户空间程序映像并从新程序的启动代码开始执行。需要注意程序替换不会创建新进程。调用 exec 前后进程的 PID 不会改变。因此fork() 和 exec() 的作用不同- fork()创建一个新的进程。- exec()让当前进程执行另一个程序。- fork() exec()先创建子进程再让子进程执行指定程序。程序替换不会改变PID假设子进程的 PID 是 1234。调用 execl() 之前子进程的 PID 是 1234调用成功后运行 ls 的进程 PID 仍然是 1234。因为 exec 替换的是进程的程序映像而不是创建一个新进程。二、exec 函数族Linux 常见的 exec 系列函数有六个只要理解函数名称中几个字母的含义就可以把它们记住。其中l 和 v 决定参数怎么传p 决定怎么查找程序e 决定怎么指定环境变量。1.l参数列表传递例如这里的参数依次是execl(/bin/ls, ls, -l, -a, NULL);/bin/ls要执行的程序的路径。ls新程序接收到的 argv[0]通常用于表示程序名。-l第一个命令行参数。-a第二个命令行参数。NULL参数列表的结束标志。这相当于在终端执行ls -l -a为什么最后要写NULL因为 execl() 的参数是通过可变参数列表传递的函数需要知道参数在哪里结束。2.v参数数组传递如果参数很多或者参数需要动态生成可以使用数组这里的 argv 相当于数组同样必须使用NULL作为结束标志对比一下:两种写法都可以执行相同的命令区别仅在于参数的组织方式。3.p自动搜索 PATH先看两种写法第一种写法指定了程序的完整路径 /bin/ls。第二种写法只提供程序名 ls带有 p 的函数会按照 PATH 环境变量查找可执行文件。例如环境变量可能包含程序就会依次在这些目录中搜索 ls。因此不带 p通常需要指定要执行文件的路径。带 p可以通过 PATH 搜索程序。严格来说不带 p 的函数并不强制要求使用绝对路径也可以传入相对路径关键是它不会像带 p 的函数那样自动搜索 PATH。4.e自己指定环境变量程序运行时可以读取环境变量例如 PATH、HOME 等。通常新程序会继承调用进程的环境变量。如果希望为新程序提供一套指定的环境变量就可以使用带 e 的函数。例如这里的 envp 就是新程序的环境变量数组。它同样以 NULL 结尾。需要注意使用 e 并不意味着在原有环境变量上自动追加几个变量而是将你传入的环境变量数组作为新程序的环境。因此如果没有把 HOME 等变量放进去新程序就不一定能读取到它们。execle() 和 execve() 都可以指定环境变量但传参方式不同。execle() 的 NULL 用来结束可变参数列表然后才是 envp。而 execve() 直接通过数组 argv 传递参数不需要额外的可变参数结束标志5. e自己指定环境变量程序运行时可以读取环境变量例如 PATH、HOME 等。通常新程序会继承调用进程的环境变量。如果希望为新程序提供一套指定的环境变量就可以使用带 e 的函数。例如这里的 envp 就是新程序的环境变量数组。它同样以 NULL 结尾。需要注意使用 e 并不意味着在原有环境变量上自动追加几个变量而是将你传入的环境变量数组作为新程序的环境。因此如果没有把 HOME 等变量放进去新程序就不一定能读取到它们。execve 才是真正的系统调用六个函数中execl()、execlp()、execle()、execv()、execvp() 是供程序使用的库函数接口。execve() 对应 Linux 的底层系统调用接口。其他五个函数通常会在用户空间完成参数组织、环境处理或 PATH 搜索等工作最终调用 execve() 执行程序替换。例如:由于 execlp() 需要根据 PATH 找到 ls它可能先尝试多个候选路径然后调用 execve() 执行找到的程序。在 Linux 的手册中execve 位于第 2 节即系统调用其他几个函数位于第 3 节即库函数。exec 调用成功后不会返回因为原来的程序已经被新程序替换了。成功之后执行的是新程序的代码并不是原程序继续往下运行。这就是 exec 最重要的特点成功不返回失败才返回。三.fork() 和 exec() 如何配合使用这个程序的执行过程是父进程调用 fork() 创建子进程。子进程调用 execlp()执行 ls -l。父进程调用 waitpid()等待子进程结束。子进程运行结束后父进程继续输出提示。这里使用 _exit(1) 处理替换失败是因为子进程在 fork() 后如果替换失败通常应该尽快结束而不是继续执行原程序中不该执行的逻辑。这也是 Shell 执行外部命令时常见的基本思路创建子进程、执行新程序、等待子进程结束。⾃主Shell命令⾏解释器一.Shell到底是什么Shell 就是我们在 Linux 终端中使用的命令行解释器。例如你在终端输入lsShell 并不是自己去读取磁盘目录并打印文件而是负责组织整个执行过程fork() 创建的是一个新进程exec() 不创建新进程而是替换当前进程正在执行的程序。假设你在 Shell 中输入ls此时可以把进程关系理解为为什么不直接让 Shell 自己执行 ls因为 exec() 会替换当前进程的程序。如果 Shell 直接调用 exec()Shell 自己就会变成 ls原来的 Shell 程序也就不再运行了。这样 ls 执行完以后原来的 Shell 无法继续接收命令。所以正确的做法是父进程 Shell 保留自己。fork() 创建子进程。子进程通过 exec() 执行 ls。父进程通过 waitpid() 等待子进程结束。子进程结束后父进程继续运行。写作Linux 进程控制实战从 fork、exec 到自主 Shell 的实现二、Shell 的整体工作原理Shell 的核心任务是将用户输入的命令转化为实际的执行操作。例如用户输入ls -l此时Shell 首先读取这行文本然后解析出命令名 ls 和参数 -l。接下来它判断这是否属于 Shell 自己实现的内建命令。如果是内建命令就由当前 Shell 直接处理如果是普通外部命令则创建子进程让子进程执行对应程序。父进程随后等待子进程结束获取执行状态再回到循环开头等待用户输入下一条命令。Shell 本身并不需要实现所有命令的功能。它主要负责理解用户的需求并利用操作系统提供的机制安排程序执行。三、Shell 的基础数据结构在实现过程中需要使用一些全局变量保存命令参数、退出状态和环境变量。const int basesize 1024;const int argvnum 64;const int envnum 64;char *gargv[argvnum];int gargc 0;int lastcode 0;char *genv[envnum];char pwd[basesize];char pwdenv[basesize];各变量的作用如下basesize命令行缓冲区的容量。argvnum命令参数数组的容量。envnum环境变量数组的容量。gargv保存解析后的命令名和参数。gargc记录当前命令的参数数量。lastcode保存上一条命令的退出状态。genv保存 Shell 维护的环境变量。pwd保存当前工作目录。pwdenv保存 PWD路径 形式的环境变量字符串。例如用户输入ls -l解析后参数数组可以表示为gargv[0] lsgargv[1] -lgargv[2] nullptrgargc 2其中gargv 是字符串指针数组最后一个 nullptr 用于标记参数结束。这些数据结构为后续命令执行提供了基础。四、命令提示符的实现为了让用户知道当前 Shell 已经准备好接收输入需要显示一个命令提示符。例如[userlocalhost project]#这个提示符包含用户名、主机名和当前目录名称。4.1 获取用户名和主机名可以通过 getenv() 获取对应的环境变量string GetUserName(){const char *value getenv(USER);return value ? value : None;}string GetHostName(){const char *value getenv(HOSTNAME);return value ? value : None;}getenv() 用于读取当前进程环境中的指定变量。如果变量不存在则返回空指针。需要注意USER 和 HOSTNAME 并不保证在所有环境中都存在因此实际代码需要处理变量缺失的情况。4.2 获取当前工作目录string GetPwd(){if (getcwd(pwd, sizeof(pwd)) nullptr){return None;}snprintf(pwdenv, sizeof(pwdenv),PWD%s, pwd);return pwd;}getcwd() 用于获取当前进程的工作目录并将结果写入缓冲区。例如当前目录为/home/user/project函数就会获得对应路径。snprintf() 则可以将路径格式化成 PWD/home/user/project 这样的字符串。4.3 提取最后一级目录string LastDir(){string curr GetPwd();if (curr / || curr None){return curr;}size_t pos curr.rfind(/);if (pos string::npos){return curr;}return curr.substr(pos 1);}rfind() 用于查找最后一个 / 的位置substr() 用于截取该位置之后的字符串。例如/home/user/project最终得到project这样提示符只需要显示当前目录的最后一级名称即可。五、读取并解析命令行5.1 获取用户输入bool GetCommandLine(char command_buffer[], int size){if (fgets(command_buffer, size, stdin) nullptr){return false;}size_t len strlen(command_buffer);if (len 0 command_buffer[len - 1] \n){command_buffer[len - 1] \0;}return strlen(command_buffer) 0;}fgets() 从标准输入读取用户输入并将内容保存到缓冲区。例如输入ls -l读取到的字符串通常包含末尾的换行符ls -l\n因此需要在确认末尾存在换行符后将其删除。这里还需要注意如果读取失败特别是遇到 EOF主循环应该适当退出而不是不断重新显示提示符。上述代码仅展示读取逻辑实际主循环还应对返回值进行正确处理。5.2 解析命令行用户输入的是完整字符串但执行程序时需要将命令名和参数分别组织起来。例如ls -a -l可以解析为gargv[0] lsgargv[1] -agargv[2] -lgargv[3] nullptr简化的解析方式可以使用 strtok()void ParseCommandLine(char command_buffer[]){memset(gargv, 0, sizeof(gargv));gargc 0;char *token strtok(command_buffer, );while (token ! nullptr gargc argvnum - 1){gargv[gargc] token;token strtok(nullptr, );}gargv[gargc] nullptr;}第一次调用 strtok() 时传入原字符串后续传入 nullptr表示从上一次处理的位置继续查找。需要注意strtok() 会直接修改原字符串并且这种解析方式不能完整处理引号、转义、管道和重定向等 Shell 语法。六、执行普通命令fork、exec 与 waitpid6.1 为什么需要创建子进程假设 Shell 直接用新程序替换自己那么原来的 Shell 就无法继续执行也就无法在命令结束后读取下一条输入。因此通常采用父子进程分工的方式父进程保留 Shell负责管理命令执行过程。子进程负责执行外部程序。这就是 fork() 和 exec() 需要配合使用的原因。6.2 fork()创建子进程pid_t id fork();if (id 0){// 创建失败}else if (id 0){// 子进程}else{// 父进程}fork() 会创建一个子进程。它的返回值有三种情况小于零创建失败。等于零当前代码运行在子进程中。大于零当前代码运行在父进程中返回值是子进程的 PID。调用后父子进程都会从 fork() 返回处继续执行但可以根据返回值走不同的分支。6.3 exec()替换程序映像在子进程中可以调用execvp(gargv[0], gargv);它会尝试执行指定的程序并使用 gargv 作为参数数组。例如ls -l对应的调用参数为execvp(ls, gargv);成功后当前子进程原来的程序映像会被新程序替换。需要特别注意exec() 不会创建新进程。fork() 负责创建子进程exec() 负责替换当前进程的程序映像。成功执行后进程的 PID 通常保持不变。如果 exec() 返回了说明执行失败因此应该处理错误并退出子进程。6.4 waitpid()等待子进程结束父进程需要等待子进程结束避免命令尚未完成时就继续执行后续流程。int status 0;pid_t rid waitpid(id, status, 0);参数含义id需要等待的子进程 PID。status用于保存子进程的终止状态。0使用阻塞等待方式。如果指定的子进程尚未结束父进程通常会等待它结束后再继续执行。6.5 获取退出状态if (WIFEXITED(status)){lastcode WEXITSTATUS(status);}WIFEXITED(status) 用于判断子进程是否正常退出。WEXITSTATUS(status) 则用于提取正常退出时的退出码。通常退出码 0 表示成功非零值表示失败或其他状态但具体含义由程序决定。Shell 保存退出码后就可以为后续的 $? 等功能提供基础。完整的执行流程可以概括为父进程 Shell|| fork()↓创建子进程|| exec()↓执行外部程序|| 进程结束↓父进程 waitpid()|| 获取退出状态↓继续读取下一条命令七、内建命令与普通命令并不是所有命令都适合通过创建子进程来执行。本次实验主要实现了 cd、export、env 和 echo 等简化版内建命令。7.1 cd改变当前工作目录chdir(gargv[1]);chdir() 用于改变调用进程的当前工作目录。为什么 cd 必须由当前 Shell 自己执行因为如果在子进程中调用 chdir()改变的只是子进程自己的工作目录。子进程结束后父 Shell 的工作目录不会自动改变。因此cd 必须由当前 Shell 处理。实际代码中还应检查 chdir() 的返回值确认目录切换是否成功。7.2 export管理环境变量环境变量可以为程序提供运行配置例如HOME/home/userPATH/usr/bin:/binUSERusergetenv() 可以读取环境变量而 setenv() 可以设置当前进程的环境变量。本次实验通过 genv 数组维护一份环境变量列表并在启动外部程序时将其传递给执行接口。需要注意这份实现属于教学用简化版本还没有完整实现标准 Shell 的本地变量、导出属性和同名变量更新机制。7.3 env输出环境变量程序遍历 genv 数组逐行输出环境变量。由于环境变量数组以 nullptr 作为结束标记因此可以通过检查当前指针是否为空来决定是否继续循环。7.4 echo输出内容与退出状态例如echo hello输出hello而echo $?用于查看上一条命令的退出状态。本次实验只实现了非常有限的变量展开并不具备标准 Shell 的完整变量处理能力。八、环境变量的初始化与传递Shell 启动时通常已经从父进程继承了一组环境变量。通过 environ程序可以访问当前进程的环境变量数组。例如extern char **environ;在初始化过程中可以遍历 environ将环境变量复制到 genv 中。在实际实现中需要注意数组容量、内存分配失败、字符串释放以及同名变量更新等问题。执行外部程序时可以使用显式传递环境变量的接口例如 execve()execve(path, gargv, genv);这里需要传入可执行文件的路径而不是简单依赖 PATH 搜索。本次源码使用的 execvpe() 属于 GNU 扩展它支持 PATH 搜索并允许显式传入环境变量数组。但在 glibc 中程序路径搜索所使用的 PATH 与传入的环境数组并不完全等同。因此环境变量不仅影响当前 Shell 的行为还会影响后续启动的程序。九、exec/exit 与 call/return 的联系教材最后将 exec/exit 与 call/return 进行类比主要是为了说明不同执行单元之间的任务分工。9.1 函数调用例如int Add(int a, int b){return a b;}调用者传递参数被调用函数执行计算然后通过 return 返回结果。main()↓ 调用 Add()Add() 执行计算↓ return 结果main() 获取返回值9.2 进程协作在 Shell 中父进程通过 fork() 创建子进程子进程通过 exec() 执行新程序程序结束时提供退出状态父进程再通过 waitpid() 获取该状态。父进程↓ fork()子进程↓ exec()新程序执行任务↓ exit()父进程 waitpid()二者都体现了任务分工与结果反馈的思想。不过它们并不完全相同函数调用发生在同一个进程内部。fork() 创建新的进程。exec() 替换当前进程的程序映像。exit() 终止进程并提供退出状态。waitpid() 用于等待子进程并获取其终止状态。因此exec/exit 与 call/return 的类比强调的是任务委托与结果反馈而不是接口之间的一一对应关系。