文章目录
-
- 进程程序替换与shell实现:从fork到exec的完整闭环
- 一、进程程序替换
-
- [1.1 为什么需要程序替换](#1.1 为什么需要程序替换)
-
- [1.1.1 shell如何执行命令](#1.1.1 shell如何执行命令)
- [1.2 程序替换的原理](#1.2 程序替换的原理)
- [1.3 exec函数族详解](#1.3 exec函数族详解)
-
- [1.3.1 命名规律](#1.3.1 命名规律)
- [1.3.2 六个函数的对比](#1.3.2 六个函数的对比)
- [1.4 exec函数使用示例](#1.4 exec函数使用示例)
-
- [1.4.1 基本使用:execl](#1.4.1 基本使用:execl)
- [1.4.2 使用execlp简化路径](#1.4.2 使用execlp简化路径)
- [1.4.3 使用execv传递参数数组](#1.4.3 使用execv传递参数数组)
- [1.4.4 fork + exec:让子进程执行新程序](#1.4.4 fork + exec:让子进程执行新程序)
- [1.4.5 使用execle传递自定义环境变量](#1.4.5 使用execle传递自定义环境变量)
- [1.5 exec调用关系图](#1.5 exec调用关系图)
- 二、实现mini-shell
-
- [2.1 shell的工作原理](#2.1 shell的工作原理)
- [2.2 内建命令 vs 外部命令](#2.2 内建命令 vs 外部命令)
-
- [2.2.1 什么是内建命令](#2.2.1 什么是内建命令)
- [2.2.2 为什么cd必须是内建命令](#2.2.2 为什么cd必须是内建命令)
- [2.2.3 为什么export必须是内建命令](#2.2.3 为什么export必须是内建命令)
- [2.2.4 常见的内建命令](#2.2.4 常见的内建命令)
- [2.3 命令行解析](#2.3 命令行解析)
- [2.4 完整的mini-shell实现](#2.4 完整的mini-shell实现)
- [2.5 代码详解](#2.5 代码详解)
-
- [2.5.1 命令提示符的生成](#2.5.1 命令提示符的生成)
- [2.5.2 命令行解析](#2.5.2 命令行解析)
- [2.5.3 内建命令cd的实现](#2.5.3 内建命令cd的实现)
- [2.5.4 内建命令export的实现](#2.5.4 内建命令export的实现)
- [2.5.5 外部命令的执行](#2.5.5 外部命令的执行)
- [2.6 编译和运行](#2.6 编译和运行)
- [2.7 与真实shell的差距](#2.7 与真实shell的差距)
- 三、总结:进程与函数的类比
-
- [3.1 call/return vs fork/exec/wait](#3.1 call/return vs fork/exec/wait)
- [3.2 进程通信的思想](#3.2 进程通信的思想)
- [3.3 Unix哲学的体现](#3.3 Unix哲学的体现)
- 四、总结与展望
进程程序替换与shell实现:从fork到exec的完整闭环
💬 欢迎讨论 :这是Linux系统编程系列的第六篇文章。在前五篇中,我们学习了进程的创建(fork)、状态管理和资源回收(wait/waitpid)。但fork出的子进程只能执行父进程的代码副本,如果我们想让子进程执行一个全新的程序,该怎么办?这就是本篇要深入讲解的进程程序替换技术。更重要的是,我们将把fork、exec、wait三大核心技术结合起来,实现一个真正的命令行解释器!
👍 点赞、收藏与分享:这篇文章包含了大量原理分析和一个完整的shell实现,如果对你有帮助,请点赞、收藏并分享!
🚀 循序渐进:建议先学习前五篇文章,理解fork、进程状态和wait机制,这样学习本篇会更轻松。
一、进程程序替换
1.1 为什么需要程序替换
在学习程序替换之前,我们先思考一个问题:fork创建的子进程有什么局限性?
让我们回顾一下fork的行为:
cpp
int main()
{
printf("父进程开始\n");
pid_t id = fork();
if(id == 0) {
// 子进程执行的还是父进程的代码
printf("我是子进程\n");
}
else {
printf("我是父进程\n");
}
return 0;
}
fork后,子进程获得了父进程的代码副本,它执行的仍然是父进程程序的代码 。虽然我们可以通过if-else让父子执行不同的代码分支,但本质上它们运行的是同一个程序的代码。
那么问题来了:如果我想让子进程执行一个完全不同的程序,比如执行ls命令,该怎么办?
这时就需要**程序替换(Program Replacement)**技术。
1.1.1 shell如何执行命令
让我们看一个日常操作:
bash
$ ls -l
total 64
-rwxr-xr-x 1 user user 8960 Dec 10 10:30 a.out
-rw-r--r-- 1 user user 256 Dec 10 10:25 test.c
当你在shell中输入ls -l时,发生了什么?
- shell(bash)是一个进程,它读取你输入的命令
- shell调用
fork()创建子进程 - 子进程调用exec加载
ls程序 - 子进程开始执行
ls的代码,而不是bash的代码 - 父进程(shell)调用
wait()等待子进程完成
这就是程序替换的典型应用场景。
1.2 程序替换的原理
程序替换的本质是:将磁盘上的一个程序加载到当前进程的地址空间,替换掉原有的代码和数据。
让我们从内存的角度来理解这个过程:
替换前(子进程刚fork出来):
bash
子进程地址空间
┌──────────────┐
│ 命令行参数 │
├──────────────┤
│ 环境变量 │
├──────────────┤
│ 栈 │ ← 父进程代码的栈
│ ↓ │
│ │
│ ↑ │
│ 堆 │ ← 父进程的堆数据
├──────────────┤
│ 未初始化数据 │ ← 父进程的BSS段
├──────────────┤
│ 初始化数据 │ ← 父进程的数据段
├──────────────┤
│ 代码段 │ ← 父进程的代码
└──────────────┘
调用exec后:
bash
子进程地址空间
┌──────────────┐
│ 命令行参数 │ ← 新程序的参数
├──────────────┤
│ 环境变量 │ ← 可以继承或重新设置
├──────────────┤
│ 栈 │ ← 新程序的栈
│ ↓ │
│ │
│ ↑ │
│ 堆 │ ← 新程序的堆
├──────────────┤
│ 未初始化数据 │ ← 新程序的BSS段
├──────────────┤
│ 初始化数据 │ ← 新程序的数据段
├──────────────┤
│ 代码段 │ ← 新程序的代码
└──────────────┘
关键点:
- 进程ID不变:还是同一个进程
- 代码和数据被完全替换:原来父进程的代码不见了
- 文件描述符表继承:打开的文件仍然有效(除非设置了FD_CLOEXEC)
- 从新程序的main函数开始执行
1.3 exec函数族详解
Linux提供了6个exec系列函数,它们都用于程序替换,但参数形式不同:
cpp
#include <unistd.h>
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[]);
返回值:
- 成功:不返回(因为当前进程的代码已经被替换了)
- 失败:返回-1,并设置errno
这6个函数看起来很复杂,但只要掌握了命名规律就很好记。
1.3.1 命名规律
函数名由exec加上1-2个字母后缀组成,每个字母都有特定含义:
l (list):参数列表
参数以列表形式逐个传递,必须以NULL结尾:
cpp
execl("/bin/ls", "ls", "-l", "-a", NULL);
// 程序路径 arg0 arg1 arg2 结束标记
v (vector):参数数组
参数放在一个字符指针数组中:
cpp
char *argv[] = {"ls", "-l", "-a", NULL};
execv("/bin/ls", argv);
p (path):搜索PATH环境变量
不需要写完整路径,会在PATH中搜索:
cpp
// 不用写/bin/ls,只写ls即可
execlp("ls", "ls", "-l", NULL);
e (environment):自定义环境变量
可以传递自定义的环境变量表:
cpp
char *envp[] = {"PATH=/bin:/usr/bin", "HOME=/home/user", NULL};
execle("/bin/ls", "ls", "-l", NULL, envp);
注意:这里子进程会完全替换父进程的环境变量,只会使用你传入的envp里面的环境变量
记忆技巧:
bash
exec + l/v + p + e
↓ ↓ ↓
参数形式 路径 环境
1.3.2 六个函数的对比
| 函数 | 路径 | 参数形式 | 环境变量 |
|---|---|---|---|
| execl | 完整路径 | 列表 | 继承 |
| execlp | 搜索PATH | 列表 | 继承 |
| execle | 完整路径 | 列表 | 自定义 |
| execv | 完整路径 | 数组 | 继承 |
| execvp | 搜索PATH | 数组 | 继承 |
| execve | 完整路径 | 数组 | 自定义 |
注意 :只有execve是真正的系统调用,其他5个都是库函数,最终都会调用execve。
1.4 exec函数使用示例
让我们通过实际例子来学习如何使用这些函数。
1.4.1 基本使用:execl
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("程序开始,PID=%d\n", getpid());
printf("即将执行ls命令\n");
// 替换当前进程为ls程序
execl("/bin/ls", "ls", "-l", "-h", NULL);
// 如果exec成功,下面的代码不会执行
printf("如果你看到这句话,说明exec失败了\n");
perror("execl");
return 1;
}
运行结果:
bash
$ gcc test.c -o test
$ ./test
程序开始,PID=15000
即将执行ls命令
total 64K
-rwxr-xr-x 1 user user 8.8K Dec 10 10:30 test
-rw-r--r-- 1 user user 256 Dec 10 10:25 test.c
关键点:
printf("如果你看到...")没有执行,因为进程已经被替换- PID没变,还是15000
- 执行的是
ls的代码,不是test的代码了
1.4.2 使用execlp简化路径
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("使用execlp执行命令\n");
// 不需要写/bin/ls,系统会在PATH中查找
execlp("ls", "ls", "-l", NULL);
perror("execlp");
return 1;
}
1.4.3 使用execv传递参数数组
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("使用execv执行命令\n");
// 参数放在数组中
char *argv[] = {"ls", "-l", "-a", "-h", NULL};
execv("/bin/ls", argv);
perror("execv");
return 1;
}
1.4.4 fork + exec:让子进程执行新程序
这是最常见的用法:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
printf("父进程[%d]开始\n", getpid());
pid_t id = fork();
if(id < 0) {
perror("fork");
return 1;
}
else if(id == 0) {
// 子进程:执行ls命令
printf("子进程[%d]即将执行ls\n", getpid());
execlp("ls", "ls", "-l", NULL);
// 如果exec失败才会执行到这里
perror("execlp");
exit(1);
}
else {
// 父进程:等待子进程
printf("父进程[%d]等待子进程[%d]\n", getpid(), id);
int status = 0;
waitpid(id, &status, 0);
if(WIFEXITED(status)) {
printf("子进程退出,退出码=%d\n", WEXITSTATUS(status));
}
printf("父进程继续运行\n");
}
return 0;
}
运行结果:
bash
父进程[15100]开始
父进程[15100]等待子进程[15101]
子进程[15101]即将执行ls
total 64
-rwxr-xr-x 1 user user 9216 Dec 10 11:00 test
-rw-r--r-- 1 user user 512 Dec 10 11:00 test.c
子进程退出,退出码=0
父进程继续运行
这就是shell执行命令的基本模型:fork + exec + wait
1.4.5 使用execle传递自定义环境变量
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("演示execle传递环境变量\n");
// 自定义环境变量
char *envp[] = {
"PATH=/bin:/usr/bin",
"MY_VAR=hello",
"USER=testuser",
NULL
};
// 创建一个简单的测试程序来接收环境变量
pid_t id = fork();
if(id == 0) {
execle("./printenv", "printenv", NULL, envp);
perror("execle");
exit(1);
}
else {
wait(NULL);
}
return 0;
}
创建接收程序printenv.c:
cpp
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("MY_VAR = %s\n", getenv("MY_VAR"));
printf("USER = %s\n", getenv("USER"));
printf("PATH = %s\n", getenv("PATH"));
return 0;
}
编译运行:
bash
$ gcc printenv.c -o printenv
$ gcc test.c -o test
$ ./test
演示execle传递环境变量
MY_VAR = hello
USER = testuser
PATH = /bin:/usr/bin
1.5 exec调用关系图
让我们通过一张图来理解这6个函数之间的关系:
bash
用户调用
↓
┌──────┬──────┬──────┬──────┬───────┐
│execl │execlp│execle│execv │execvp │
└──┬───┴──┬───┴──┬───┴──┬───┴───┬───┘
│ │ │ │ │
└──────┴──────┴──────┴───────┘
↓
参数处理/路径搜索
↓
┌────────┐
│ execve │ ← 唯一的系统调用
└────┬───┘
↓
内核加载程序
核心要点:
- execve是唯一的系统调用
- 其他5个都是库函数,最终调用execve
- 库函数做的工作:参数格式转换、PATH搜索、环境变量处理
二、实现mini-shell
现在我们已经掌握了fork、exec、wait三大技术,是时候把它们组合起来,实现一个真正的命令行解释器了!
2.1 shell的工作原理
shell的核心工作流程非常简单:
bash
while(true) {
1. 显示命令提示符
2. 读取用户输入的命令
3. 解析命令(分割成程序名和参数)
4. fork创建子进程
5. 子进程exec执行命令
6. 父进程wait等待子进程
}
让我们用伪代码表示:
cpp
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);
}
}
但实际实现要考虑更多细节,比如:
- 如何显示美观的命令提示符?
- 如何处理内建命令(cd、export等)?
- 如何维护环境变量?
2.2 内建命令 vs 外部命令
在实现shell之前,我们需要理解一个重要概念:内建命令(Built-in Command)。
2.2.1 什么是内建命令
Linux命令分为两类:
外部命令:
- 是独立的可执行文件
- 如:
ls对应/bin/ls,ps对应/bin/ps - shell通过fork+exec执行
内建命令:
- 是shell程序内部的函数
- 如:
cd、export、exit - shell直接调用自己的函数执行
2.2.2 为什么cd必须是内建命令
让我们思考一个问题:为什么cd不能做成外部命令?
假设cd是一个外部程序/bin/cd:
cpp
// shell执行cd命令的流程
pid_t id = fork(); // 创建子进程
if(id == 0) {
// 子进程
execl("/bin/cd", "cd", "/home/user", NULL);
// cd程序调用chdir()改变工作目录
// 但这只改变了子进程的工作目录!
}
waitpid(id, NULL, 0);
// 父进程(shell)的工作目录没有改变
问题在于:
- 子进程调用
chdir()只改变自己的工作目录 - 父进程(shell)的工作目录不受影响
- 子进程退出后,shell还在原来的目录
因此,cd必须由shell自己执行:
cpp
// shell内部直接调用
if(strcmp(argv[0], "cd") == 0) {
chdir(argv[1]); // shell进程自己改变目录
}
2.2.3 为什么export必须是内建命令
同样的道理:
cpp
// 如果export是外部命令
pid_t id = fork();
if(id == 0) {
// 子进程设置环境变量
setenv("MY_VAR", "value", 1);
// 只影响子进程的环境变量表
}
// 父进程(shell)的环境变量表没有改变
环境变量属于进程的私有数据,子进程无法修改父进程的环境变量表。
因此,export也必须由shell自己执行:
cpp
if(strcmp(argv[0], "export") == 0) {
// shell自己添加环境变量
putenv(argv[1]);
}
2.2.4 常见的内建命令
| 命令 | 原因 |
|---|---|
| cd | 必须改变shell自己的工作目录 |
| export | 必须修改shell自己的环境变量 |
| exit | 必须终止shell自己 |
| alias | 修改shell的命令别名表 |
| source | 在shell进程中执行脚本 |
| jobs | 查看shell的作业控制表 |
2.3 命令行解析
shell需要将用户输入的字符串解析成程序名和参数数组。
输入:
bash
"ls -l -a /home/user"
输出:
cpp
argv[0] = "ls"
argv[1] = "-l"
argv[2] = "-a"
argv[3] = "/home/user"
argv[4] = NULL
使用strtok()函数可以轻松实现:
cpp
void ParseCommandLine(char *cmdline, char **argv)
{
int argc = 0;
const char *sep = " \t\n"; // 分隔符
argv[argc++] = strtok(cmdline, sep);
while((argv[argc++] = strtok(NULL, sep)) != NULL);
argc--; // 最后一个NULL不计入
}
2.4 完整的mini-shell实现
现在让我们来实现一个功能完整的mini-shell!
cpp
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
using namespace std;
const int CMD_SIZE = 1024;
const int ARGC_MAX = 64;
const int ENV_MAX = 64;
// 全局变量
char *g_argv[ARGC_MAX]; // 命令参数数组
int g_argc = 0; // 参数个数
char *g_env[ENV_MAX]; // 环境变量表
int g_last_code = 0; // 上一个命令的退出码
char g_pwd[CMD_SIZE]; // 当前工作目录
char g_pwd_env[CMD_SIZE]; // PWD环境变量
// 获取用户名
string GetUserName()
{
const char *name = getenv("USER");
return name ? name : "unknown";
}
// 获取主机名
string GetHostName()
{
const char *hostname = getenv("HOSTNAME");
return hostname ? hostname : "localhost";
}
// 获取当前工作目录
string GetPwd()
{
if(getcwd(g_pwd, sizeof(g_pwd)) != NULL) {
// 更新PWD环境变量
snprintf(g_pwd_env, sizeof(g_pwd_env), "PWD=%s", g_pwd);
putenv(g_pwd_env);
return g_pwd;
}
return "/";
}
// 获取当前目录的最后一级
string LastDir()
{
string pwd = GetPwd();
if(pwd == "/") return "/";
size_t pos = pwd.rfind('/');
if(pos == string::npos) return pwd;
return pwd.substr(pos + 1);
}
// 生成命令提示符
string MakePrompt()
{
char prompt[CMD_SIZE];
snprintf(prompt, sizeof(prompt), "[%s@%s %s]$ ",
GetUserName().c_str(),
GetHostName().c_str(),
LastDir().c_str());
return prompt;
}
// 显示命令提示符
void PrintPrompt()
{
printf("%s", MakePrompt().c_str());
fflush(stdout);
}
// 读取命令行
bool GetCommandLine(char *cmdline, int size)
{
char *ret = fgets(cmdline, size, stdin);
if(ret == NULL) {
return false;
}
// 去掉换行符
cmdline[strlen(cmdline) - 1] = '\0';
// 空命令
if(strlen(cmdline) == 0) {
return false;
}
return true;
}
// 解析命令行
void ParseCommandLine(char *cmdline)
{
memset(g_argv, 0, sizeof(g_argv));
g_argc = 0;
const char *sep = " \t";
g_argv[g_argc++] = strtok(cmdline, sep);
while((g_argv[g_argc++] = strtok(NULL, sep)) != NULL);
g_argc--;
}
// 添加环境变量
void AddEnv(const char *item)
{
int i = 0;
while(g_env[i] != NULL) i++;
g_env[i] = (char*)malloc(strlen(item) + 1);
strcpy(g_env[i], item);
g_env[++i] = NULL;
}
// 内建命令:cd
bool BuiltinCd()
{
if(strcmp(g_argv[0], "cd") != 0) {
return false;
}
if(g_argc == 1) {
// cd without argument, go to home
const char *home = getenv("HOME");
if(home) chdir(home);
}
else if(g_argc == 2) {
if(chdir(g_argv[1]) != 0) {
perror("cd");
g_last_code = 1;
}
else {
g_last_code = 0;
}
}
else {
printf("cd: too many arguments\n");
g_last_code = 1;
}
return true;
}
// 内建命令:export
bool BuiltinExport()
{
if(strcmp(g_argv[0], "export") != 0) {
return false;
}
if(g_argc == 2) {
AddEnv(g_argv[1]);
g_last_code = 0;
}
else {
printf("Usage: export VAR=VALUE\n");
g_last_code = 1;
}
return true;
}
// 内建命令:echo
bool BuiltinEcho()
{
if(strcmp(g_argv[0], "echo") != 0) {
return false;
}
if(g_argc == 2) {
if(strcmp(g_argv[1], "$?") == 0) {
printf("%d\n", g_last_code);
}
else if(g_argv[1][0] == '$') {
// echo $VAR
const char *val = getenv(g_argv[1] + 1);
if(val) {
printf("%s\n", val);
}
}
else {
printf("%s\n", g_argv[1]);
}
g_last_code = 0;
}
else {
printf("Usage: echo STRING or echo $VAR\n");
g_last_code = 1;
}
return true;
}
// 内建命令:env
bool BuiltinEnv()
{
if(strcmp(g_argv[0], "env") != 0) {
return false;
}
for(int i = 0; g_env[i] != NULL; i++) {
printf("%s\n", g_env[i]);
}
g_last_code = 0;
return true;
}
// 检查并执行内建命令
bool CheckAndExecBuiltin()
{
return BuiltinCd() ||
BuiltinExport() ||
BuiltinEcho() ||
BuiltinEnv();
}
// 执行外部命令
bool ExecuteCommand()
{
pid_t id = fork();
if(id < 0) {
perror("fork");
return false;
}
else if(id == 0) {
// 子进程:执行命令
execvpe(g_argv[0], g_argv, g_env);
// 如果execvpe返回,说明执行失败
perror(g_argv[0]);
exit(127); // 命令未找到
}
else {
// 父进程:等待子进程
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0) {
if(WIFEXITED(status)) {
g_last_code = WEXITSTATUS(status);
}
else {
g_last_code = 128 + WTERMSIG(status);
}
return true;
}
}
return false;
}
// 初始化环境变量(从父shell继承)
void InitEnv()
{
extern char **environ;
int i = 0;
while(environ[i] != NULL) {
g_env[i] = (char*)malloc(strlen(environ[i]) + 1);
strcpy(g_env[i], environ[i]);
i++;
}
g_env[i] = NULL;
}
// 主函数
int main()
{
// 初始化环境变量
InitEnv();
char cmdline[CMD_SIZE];
while(true) {
// 1. 显示命令提示符
PrintPrompt();
// 2. 读取命令行
if(!GetCommandLine(cmdline, CMD_SIZE)) {
continue;
}
// 3. 解析命令
ParseCommandLine(cmdline);
// 4. 检查是否是内建命令
if(CheckAndExecBuiltin()) {
continue;
}
// 5. 执行外部命令
ExecuteCommand();
}
return 0;
}
2.5 代码详解
让我们逐个模块分析这个shell的实现。
2.5.1 命令提示符的生成
cpp
string MakePrompt()
{
char prompt[CMD_SIZE];
snprintf(prompt, sizeof(prompt), "[%s@%s %s]$ ",
GetUserName().c_str(),
GetHostName().c_str(),
LastDir().c_str());
return prompt;
}
生成类似bash的提示符:[user@hostname dir]$
关键技术:
GetUserName():从环境变量USER获取用户名GetHostName():从环境变量HOSTNAME获取主机名LastDir():提取当前路径的最后一级目录名
2.5.2 命令行解析
cpp
void ParseCommandLine(char *cmdline)
{
memset(g_argv, 0, sizeof(g_argv));
g_argc = 0;
const char *sep = " \t";
g_argv[g_argc++] = strtok(cmdline, sep);
while((g_argv[g_argc++] = strtok(NULL, sep)) != NULL);
g_argc--;
}
工作流程:
- 使用
strtok()按空格和制表符分割字符串 - 将分割结果存入
g_argv数组 - 最后一个元素设为NULL(exec要求)
示例:
bash
输入:"ls -l -a"
输出:g_argv = {"ls", "-l", "-a", NULL}
2.5.3 内建命令cd的实现
cpp
bool BuiltinCd()
{
if(strcmp(g_argv[0], "cd") != 0) {
return false; // 不是cd命令
}
if(g_argc == 1) {
// cd without argument, go to home
const char *home = getenv("HOME");
if(home) chdir(home);
}
else if(g_argc == 2) {
if(chdir(g_argv[1]) != 0) {
perror("cd");
g_last_code = 1;
}
else {
g_last_code = 0;
}
}
else {
printf("cd: too many arguments\n");
g_last_code = 1;
}
return true;
}
实现要点:
- shell进程自己调用
chdir()改变工作目录 - 支持
cd(回到HOME)和cd 目录两种用法 - 更新退出码
g_last_code
2.5.4 内建命令export的实现
cpp
bool BuiltinExport()
{
if(strcmp(g_argv[0], "export") != 0) {
return false;
}
if(g_argc == 2) {
AddEnv(g_argv[1]); // 添加到环境变量表
g_last_code = 0;
}
else {
printf("Usage: export VAR=VALUE\n");
g_last_code = 1;
}
return true;
}
void AddEnv(const char *item)
{
int i = 0;
while(g_env[i] != NULL) i++;
g_env[i] = (char*)malloc(strlen(item) + 1);
strcpy(g_env[i], item);
g_env[++i] = NULL;
}
实现要点:
- 将新环境变量添加到
g_env数组 - 子进程通过
execvpe()的第三个参数获得这些环境变量
2.5.5 外部命令的执行
cpp
bool ExecuteCommand()
{
pid_t id = fork();
if(id < 0) {
perror("fork");
return false;
}
else if(id == 0) {
// 子进程:执行命令
execvpe(g_argv[0], g_argv, g_env);
// 如果execvpe返回,说明执行失败
perror(g_argv[0]);
exit(127); // 命令未找到
}
else {
// 父进程:等待子进程
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0) {
if(WIFEXITED(status)) {
g_last_code = WEXITSTATUS(status);
}
else {
g_last_code = 128 + WTERMSIG(status);
}
return true;
}
}
return false;
}
这是fork + exec + wait的完美结合:
-
fork():创建子进程
-
execvpe():子进程加载新程序
- 自动搜索PATH
- 传递命令参数
- 传递环境变量
-
waitpid():父进程等待子进程,获取退出码
2.6 编译和运行
编译mini-shell:
bash
g++ -o myshell myshell.cpp -std=c++11
运行:
bash
./myshell
测试示例:
bash
[user@localhost test]$ ls -l
total 16
-rwxr-xr-x 1 user user 13824 Dec 10 15:30 myshell
-rw-r--r-- 1 user user 4096 Dec 10 15:25 myshell.cpp
[user@localhost test]$ pwd
/home/user/test
[user@localhost test]$ cd /tmp
[user@localhost tmp]$ pwd
/tmp
[user@localhost tmp]$ export MY_VAR=hello
[user@localhost tmp]$ echo $MY_VAR
hello
[user@localhost tmp]$ echo $?
0
[user@localhost tmp]$ ls /nonexist
ls: cannot access '/nonexist': No such file or directory
[user@localhost tmp]$ echo $?
2
[user@localhost tmp]$ ps
PID TTY TIME CMD
15500 pts/0 00:00:00 bash
15600 pts/0 00:00:00 myshell
15601 pts/0 00:00:00 ps
可以看到,我们的mini-shell已经能够:
- ✅ 显示漂亮的命令提示符
- ✅ 执行外部命令(ls、pwd、ps等)
- ✅ 实现内建命令(cd、export、echo)
- ✅ 维护环境变量
- ✅ 记录命令退出码
2.7 与真实shell的差距
我们的mini-shell虽然功能完备,但与真实的bash相比还有很多不足:
缺少的功能:
- 重定向 :
ls > file.txt、cat < input.txt - 管道 :
ps aux | grep myshell - 后台执行 :
sleep 100 & - 信号处理:Ctrl+C不应该终止shell
- 命令历史:上下箭头翻历史命令
- Tab补全:按Tab自动补全命令
- 通配符 :
ls *.txt - 条件执行 :
ls && pwd、ls || echo failed - 脚本执行 :
source script.sh
这些功能的实现会涉及到更多的系统编程知识,如:
- 文件描述符重定向(dup2)
- 管道(pipe)
- 信号处理(signal)
- 终端控制(termios)
这些知识我们都会在后续文章中逐渐讲解
三、总结:进程与函数的类比
通过本篇文章的学习,我们完成了从fork到exec再到shell的完整闭环。现在让我们站在更高的层次来理解这些技术。
3.1 call/return vs fork/exec/wait
我们在编程时经常使用函数:
cpp
int add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 5); // 调用函数
printf("result = %d\n", result);
return 0;
}
函数调用的特点:
- call:调用函数,传递参数
- 执行:函数执行自己的代码
- return:返回结果给调用者
这个模式与进程的使用非常相似:
cpp
int main() {
pid_t id = fork(); // 创建进程
if(id == 0) {
execl("/bin/ls", "ls", "-l", NULL); // 执行程序,传递参数
exit(1); // 返回退出码
}
else {
int status;
waitpid(id, &status, 0); // 等待结果
int code = WEXITSTATUS(status);
printf("exit code = %d\n", code);
}
return 0;
}
进程使用的特点:
- fork + exec:创建进程,加载程序,传递参数
- 执行:子进程执行自己的代码
- exit + wait:子进程返回退出码,父进程获取结果
3.2 进程通信的思想
函数之间通过参数和返回值通信:
bash
函数A → 调用函数B(参数) → 函数B → 返回结果 → 函数A
进程之间也通过类似的方式通信:
bash
进程A → fork+exec(参数) → 进程B → exit(退出码) → wait(获取结果) → 进程A
这种模式的优势:
- 模块化:每个程序专注于一个任务
- 复用性:程序可以被多个父进程调用
- 隔离性:子进程崩溃不影响父进程
- 并发性:多个子进程可以并发执行
3.3 Unix哲学的体现
我们实现的mini-shell体现了Unix的设计哲学:
"Write programs that do one thing and do it well. Write programs to work together."
"编写只做一件事并做好的程序。编写能协同工作的程序。"
具体体现:
ls专注于列出文件grep专注于搜索文本sort专注于排序- shell负责组合它们:
ls | grep test | sort
这种设计让系统变得:
- 灵活:可以任意组合命令
- 强大:简单命令组合出复杂功能
- 可维护:每个程序职责单一,易于理解和修改
四、总结与展望
通过本篇文章,我们系统地学习了进程程序替换和shell的实现原理:
进程程序替换:
- 理解了exec的作用:将磁盘程序加载到进程地址空间
- 掌握了exec函数族的使用和命名规律
- 理解了fork + exec + wait的完整流程
mini-shell实现:
- 掌握了shell的基本工作原理
- 理解了内建命令与外部命令的区别
- 实现了一个功能完整的命令行解释器
- 理解了为什么cd、export必须是内建命令
核心知识点:
- exec替换当前进程,不创建新进程
- execve是唯一的系统调用,其他都是库函数
- 内建命令修改shell自身状态,外部命令在子进程执行
- fork + exec + wait是进程协作的经典模式
至此,我们已经完整学习了Linux进程控制的核心技术。从第一篇的进程概念,到第二篇的进程状态,再到第三篇的调度算法,第四篇的虚拟内存,第五篇的进程等待,以及本篇的程序替换------我们构建了一个完整的进程管理知识体系。
在后续的文章中,我们将学习更高级的主题:
- 进程间通信(IPC):管道、共享内存、消息队列
- 信号机制:进程如何响应异步事件
- 守护进程:后台服务的实现原理
- 线程编程:多线程与多进程的选择
💡 思考题:
- 为什么exec函数成功时不返回,只有失败时才返回?
- 如果在exec之前打开了一个文件,exec之后文件描述符还有效吗?
- 如何在mini-shell中实现管道功能(
ls | grep test)?- 如果shell执行一个很慢的命令,如何让shell在等待期间响应Ctrl+C?
以上就是关于进程程序替换与shell实现的全部内容!至此,我们已经掌握了进程控制的完整技术栈,可以开始更高级的系统编程之旅了!