目录

1.进程创建

1.1fork函数初识

1.2fork函数返回值

1.3写时拷贝

1.4fork常规用法

1.5fork调用失败的原因

2.进程 终止

2.1进程退出场景

2.2进程常见退出方法

2.2.1退出码

2.2.2_exit函数

2.2.3exit函数

2.2.4return退出

3.进程等待

3.1进程等待必要性

3.2进程等待的方法

3.2.1wait方法

3.2.2waitpid方法

3.2.3获取子进程status

3.2.4阻塞与非阻塞等待

4.进程程序替换

4.1替换原理

4.2替换函数

         4.2.1函数解释

4.2.2命名理解

5.自主shell命令行解释器


1.进程创建

1.1fork函数初识

linux fork 函数是⾮常重要的函数,它从已存在进程中创建⼀个新进程。新进程为⼦进程,
⽽原进程为⽗进程。

# include <unistd.h>
pid_t fork ( void );
返回值:⼦进程中返回 0 ,⽗进程返回⼦进程 id ,出错返回 -1
进程调⽤ fork ,当控制转移到内核中的 fork 代码后,内核做:
·分配新的内存块和内核数据结构给⼦进程
·将⽗进程部分数据结构内容拷⻉⾄⼦进程
·添加⼦进程到系统进程列表当中
·fork 返回,开始调度器调度
当⼀个进程调⽤fork之后,就有两个⼆进制代码相同的进程。⽽且它们都运⾏到相同的地⽅。但每个进
程都将可以开始它们⾃⼰的旅程,看如下程序:

这⾥看到了三⾏输出,⼀⾏before,两⾏after。进程43676先打印before消息,然后它有打印after。 另⼀个after消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所⽰

所以,fork之前⽗进程独⽴执⾏,fork之后,⽗⼦两个执⾏流分别执⾏。注意,fork之后,谁先执⾏完 全由调度器决定。

1.2fork函数返回值

⼦进程返回0
⽗进程返回的是⼦进程的pid。

1.3写时拷贝

通常,⽗⼦代码共享,⽗⼦再不写⼊时,数据也是共享的,当任意⼀⽅试图写⼊,便以写时拷⻉的⽅式各⾃⼀份副本。具体⻅下图:
因为有写时拷⻉技术的存在,所以⽗⼦进程得以彻底分离离!完成了进程独⽴性的技术保证!
写时拷⻉,是⼀种延时申请技术,可以提⾼整机内存的使⽤率!

1.4fork常规用法

·⼀个⽗进程希望复制⾃⼰,使⽗⼦进程同时执⾏不同的代码段。例如,⽗进程等待客⼾端请求,⽣成⼦进程来处理请求。
·⼀个进程要执⾏⼀个不同的程序。例如⼦进程从fork返回后,调⽤exec函数。

1.5fork调用失败的原因

·系统中有太多的进程
·实际用户的进程数超过了限制

2.进程终止

进程终⽌的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。

2.1进程退出场景

2.2进程常见退出方法

正常终⽌(可以通过 echo $? 查看进程退出码): echo $打印最近一个程序的退出码,写到你的进程的task_struct内部的
1. 从main返回
2. 调⽤exit
3. _exit
异常退出:
ctrl + c,信号终⽌

2.2.1退出码

小知识点:

退出码(退出状态) 可以告诉我们最后⼀次执⾏的命令的状态。在命令结束以后,我们可以知道命令 是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码 0 时表⽰执⾏成功,没有问题。 代码 1 0 以外的任何代码都被视为不成功。
Linux Shell 中的主要退出码:
退出码(退出状态)可以告诉我们最后⼀次执⾏的命令的状态。在命令结束以后,我们可以知道命令 是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码 0 时表⽰执⾏成功,没有问题。 代码 1 0 以外的任何代码都被视为不成功。
Linux Shell 中的主要退出码:

2.2.2_exit函数

# include <unistd.h>
void _exit( int status);
参数: status 定义了进程的终⽌状态,⽗进程通过 wait 来获取该值

说明:虽然status是int,但是仅有低8位可以被⽗进程所⽤。所以_exit(-1)时,在终端执⾏$?发现返回值是255。

2.2.3exit函数

# include <unistd.h>
void exit ( int status);
exit最后也会调⽤_exit, 但在调⽤_exit之前,还做了其他⼯作:
1. 执⾏⽤⼾通过 atexit或on_exit定义的清理函数。
2. 关闭所有打开的流,所有的缓存数据均被写⼊
3. 调⽤_exit

2.2.4return退出

return是⼀种更常⻅的退出进程⽅法。执⾏return n等同于执⾏exit(n),因为调⽤main的运⾏时函数会 将main的返回值当做 exit的参数。

3.进程等待

3.1进程等待必要性

·之前讲过,⼦进程退出,⽗进程如果不管不顾,就可能造成‘僵⼫进程’的问题,进⽽造成内存泄漏。
·另外,进程⼀旦变成僵⼫状态,那就⼑枪不⼊,“杀⼈不眨眼”的kill -9 也⽆能为⼒,因为谁也
没有办法杀死⼀个已经死去的进程。
·最后,⽗进程派给⼦进程的任务完成的如何,我们需要知道。如,⼦进程运⾏完成,结果对还是不对,或者是否正常退出。
·⽗进程通过进程等待的⽅式,回收⼦进程资源,获取⼦进程退出信息

3.2进程等待的方法

3.2.1wait方法

# include <sys/types.h>
# include <sys/wait.h>
pid_t wait ( int * status);
返回值:
·成功返回被等待进程pid,并将退出状态存入 status 失败时,如果父进程没有任何子进程, 返回 -1
参数:
·输出型参数,获取子进程退出状态 , 不关⼼则可以设置成为 NULL
核心行为是:阻塞父进程,直到「任意一个子进程」退出,并且每次调用 wait() 只处理一个子进程的退出状态。

wait的使用:

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

int main()

{
    // 创建3个子进程
    for (int i = 0; i < 3; i++)

{
        pid_t pid = fork();
        if (pid == 0) {
            printf("子进程 %d 运行中...\n", getpid());
            sleep(1);
            exit(0);
        }
    }

    // 循环等待所有子进程退出
    pid_t pid;
    while ((pid = wait(NULL)) > 0)

    {
        printf("已回收子进程 %d\n", pid);
    }

    printf("所有子进程已退出\n");
    return 0;
}

3.2.2waitpid方法

pid_ t waitpid ( pid_t pid, int *status, int options);
返回值:
·当正常返回的时候 waitpid 返回收集到的⼦进程的进程 ID
·如果设置了选项 WNOHANG, ⽽调⽤中 waitpid 发现没有已退出的⼦进程可收集 , 则返回 0
·如果调⽤中出错 , 则返回 -1 , 这时 errno 会被设置成相应的值以指⽰错误所在;
参数:
pid
        Pid= -1 , 等待任⼀个⼦进程。与 wait 等效。
        Pid> 0. 等待其进程 ID pid 相等的⼦进程,等待特定子进程
status: 输出型参数
         WIFEXITED(status) : 若为正常终止子进程返回的状态,则为真。(查看进程
是否是正常退出) = (st>>8)&0xFF,代表正常状态时提取8-16位,拿到退出码
         WEXITSTATUS(status) : 若 WIFEXITED ⾮零,提取⼦进程退出码。(查看进程
的退出码) =st&0x7F,拿到低7位的终止信号
        options:默认为 0 ,表⽰阻塞等待
         WNOHANG : 若pid 指定的⼦进程没有结束,则 waitpid() 函数返回 0 ,不予以等 待。若正常结束,则返回该⼦进程的ID
·如果⼦进程已经退出,调⽤wait/waitpid时,wait/waitpid会⽴即返回,并且释放资源,获得子进程退出信息。
·如果在任意时刻调⽤wait/waitpid,⼦进程存在且正常运⾏,则进程可能阻塞。
·如果不存在该⼦进程,则⽴即出错返回。

代码测试:

运行结果:

3.2.3获取子进程status

·wait和waitpid,都有⼀个status参数,该参数是⼀个输出型参数,由操作系统填充。
·如果传递NULL,表⽰不关⼼⼦进程的退出状态信息。
·否则,操作系统会根据该参数,将⼦进程的退出信息反馈给⽗进程。
·status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16⽐特位):

代码测试:

正常退出:

异常退出:在其他终端kill掉

3.2.4阻塞与非阻塞等待

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <sys/wait.h>

// 演示:阻塞等待子进程(waitpid 无 WNOHANG)

void test_blocking_wait() {

    printf("===== 【阻塞等待】开始 =====\n");

   

    pid_t pid = fork();

    if (pid == -1) {

        perror("fork 失败");

        exit(1);

    }

    if (pid == 0) {

        // 子进程:模拟运行5秒后退出

        printf("子进程(PID: %d)开始运行,将睡眠5秒...\n", getpid());

        sleep(5);

        printf("子进程(PID: %d)运行结束,退出!\n", getpid());

        exit(0);

    } else {

        // 父进程:阻塞等待子进程(无 WNOHANG)

        printf("父进程(PID: %d)开始阻塞等待子进程(PID: %d)...\n", getpid(), pid);

       

        int status;

        // 阻塞等待:父进程会停在这里,直到子进程退出

        pid_t ret = waitpid(pid, &status, 0);

       

        if (ret > 0) {

            printf("父进程:子进程(PID: %d)已退出,阻塞等待结束!\n", ret);

        }

    }

    printf("===== 【阻塞等待】结束 =====\n\n");

}

// 演示:非阻塞等待子进程(waitpid 加 WNOHANG)

void test_nonblocking_wait() {

    printf("===== 【非阻塞等待】开始 =====\n");

   

    pid_t pid = fork();

    if (pid == -1) {

        perror("fork 失败");

        exit(1);

    }

    if (pid == 0) {

        // 子进程:模拟运行5秒后退出

        printf("子进程(PID: %d)开始运行,将睡眠5秒...\n", getpid());

        sleep(5);

        printf("子进程(PID: %d)运行结束,退出!\n", getpid());

        exit(0);

    } else {

        // 父进程:非阻塞等待子进程(加 WNOHANG)

        printf("父进程(PID: %d)开始非阻塞轮询子进程(PID: %d)...\n", getpid(), pid);

       

        int status;

        pid_t ret;

        int count = 0;

       

        // 非阻塞轮询:父进程不暂停,循环检查子进程状态

        while (1) {

            ret = waitpid(pid, &status, WNOHANG); // 非阻塞,立即返回

           

            if (ret == 0) {

                // 子进程未退出,父进程可以处理其他任务

                count++;

                printf("父进程:第%d次轮询 → 子进程还在运行,去处理其他任务...\n", count);

                sleep(1); // 模拟父进程处理其他任务的耗时

            } else if (ret > 0) {

                // 子进程已退出,结束轮询

                printf("父进程:子进程(PID: %d)已退出,非阻塞轮询结束!\n", ret);

                break;

            } else {

                // 等待出错

                perror("waitpid 出错");

                break;

            }

        }

    }

    printf("===== 【非阻塞等待】结束 =====\n");

}

int main() {

    // 先演示阻塞等待

    test_blocking_wait();

    // 再演示非阻塞等待

    test_nonblocking_wait();

    return 0;

}

运行结果:

📌 关键代码解释

  1. 阻塞模式waitpid(pid, &status, 0)0 表示默认阻塞,父进程会暂停在这一行,直到子进程退出才继续执行;
  2. 非阻塞模式waitpid(pid, &status, WNOHANG) 加了 WNOHANG 选项,调用后立即返回:
    • 返回 0:子进程未退出,父进程可以去处理其他任务;
    • 返回子进程 PID:子进程已退出,可处理退出状态;
  3. 轮询逻辑:非阻塞模式通常需要循环检查(轮询),直到子进程退出,这是它的核心特征。

总结

  1. 阻塞等待:父进程暂停执行,直到目标事件(子进程退出)完成,代码逻辑简单,但期间无法处理其他任务;
  2. 非阻塞等待:父进程调用后立即返回,无论事件是否完成,可同时处理其他任务,但需要额外的轮询 / 事件监听逻辑;
  3. 核心取舍:阻塞模式开发简单但资源利用率低,非阻塞模式资源利用率高但代码复杂度稍高,需根据场景选择(如简单脚本用阻塞,高并发服务用非阻塞)。

4.进程程序替换

fork() 之后,⽗⼦各⾃执⾏⽗进程代码的⼀部分如果⼦进程就想执⾏⼀个全新的程序呢?进程的程序 替换来完成这个功能!

4.1替换原理

⽤fork创建⼦进程后执⾏的是和⽗进程相同的程序(但有可能执⾏不同的代码分⽀),⼦进程往往要调⽤⼀种 exec 函数以执⾏另⼀个程序。当进程调⽤⼀种 exec 函数时,该进程的⽤⼾空间代码和数据完全被 新程序替换,从新程序的启动例程开始执⾏。调⽤ exec 并不创建新进程,所以调⽤ exec 前后该进程的 id 并未改变。

4.2替换函数

1.一旦程序替换成功就会去执行新代码了,原始代码的后半部分,不存在了

2.exec*函数,只有失败返回值,没有成功返回值

其实有六种以exec开头的函数,统称exec函数:
# include <unistd.h>
int execl ( const char *path, const char *arg, ...);

  • 参数
    • path:可执行文件的完整路径(如 /bin/ls
    • arg:命令行参数列表,第一个参数通常是程序名,后续是选项 / 参数,最后以 NULL 结束
  • 示例:execl("/bin/ls", "ls", "-l", NULL);
int execlp ( const char *file, const char *arg, ...);
  • 参数
    • file:可执行文件名(如 ls),函数会自动在 PATH 路径中查找
  • 示例:execlp("ls", "ls", "-l", NULL);
int execle ( const char *path, const char *arg, ..., char * const envp[]);

  • 参数
    • envp:自定义环境变量数组,格式为 key=value,最后以 NULL 结束
    • 示例:char *env[] = {"PATH=/usr/local/bin", "USER=test", NULL};                     execle("/bin/echo", "echo", "$USER", NULL, env);
int execv ( const char *path, char * const argv[]);
  • 参数
    • argv:字符串数组,存放命令行参数,数组末尾必须是 NULL
  • 示例:char *argv[] = {"ls", "-l", NULL};
  •            execv("/bin/ls", argv);
int execvp ( const char *file, char * const argv[]);

  • 参数
    • file:可执行文件名(如 ls),自动在 PATH 中查找
  • 示例:char *argv[] = {"ls", "-l", NULL};
    •     execvp("ls", argv);
int execve ( const char *path, char * const argv[], char * const envp[]);

  • 参数
    • 直接接收路径、参数数组、环境变量数组
  • 特殊地位:这是唯一的系统调用,其他 5 个 exec 函数都是基于它的库封装
  • 示例:char *argv[] = {"echo", "hello", NULL};
  •            char *env[] = {"HOME=/tmp", NULL};
    •     execve("/bin/echo", argv, env);

4.2.1函数解释

·这些函数如果调⽤成功则加载新的程序从启动代码开始执⾏,不再返回。
·如果调⽤出错则返回 -1
·所以 exec 函数只有出错的返回值⽽没有成功的返回值。

4.2.2命名理解

这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表⽰参数采⽤列表
v(vector) : 参数⽤数组
p(path) : 有 p ⾃动搜索环境变量 PATH
e(env) : 表⽰⾃⼰维护环境变量
exec调⽤举例如下:
# include <unistd.h>
int main ()
{
        char * const argv[] = { "ps" , "-ef" , NULL };
        char * const envp[] = { "PATH=/bin:/usr/bin" , "TERM=console" , NULL };
        execl( "/bin/ps" , "ps" , "-ef" , NULL );
        // 带 p 的,可以使⽤环境变量 PATH ,⽆需写全路径
        execlp( "ps" , "ps" , "-ef" , NULL );
        // 带 e 的,需要⾃⼰组装环境变量
        execle( "ps" , "ps" , "-ef" , NULL , envp);
        execv( "/bin/ps" , argv);
        // 带 p 的,可以使⽤环境变量 PATH ,⽆需写全路径
        execvp( "ps" , argv);
        // 带 e 的,需要⾃⼰组装环境变量
        execve( "/bin/ps" , argv, envp);
        exit ( 0 );
}
事实上,只有 execve 是真正的系统调⽤,其它五个函数最终都调⽤ execve ,所以 execve man⼿册 第2节,其它函数在 man ⼿册第3节。这些函数之间的关系如下图所⽰。
下图exec函数簇 ⼀个完整的例⼦:

5.自主shell命令行解释器

5-1 目标

·要能处理普通命令
·要能处理内建命令
·要能帮助我们理解内建命令/本地变量/环境变量这些概念
·要能帮助我们理解shell的允许原理

5-2 实现原理

考虑下⾯这个与shell典型的互动:

⽤下图的时间轴来表⽰事件的发⽣次序。其中时间从左向右。shell由标识为sh的⽅块代表,它随着时 间的流逝从左向右移动。shell从⽤⼾读⼊字符串"ls"。shell建⽴⼀个新的进程,然后在那个进程中运⾏ls程序并等待那个进程结束。
然后shell读取新的⼀⾏输⼊,建⽴⼀个新的进程,在这个进程中运⾏程序 并等待这个进程结束。 所以要写⼀个shell,需要循环以下过程:
1. 获取命令⾏
2. 解析命令⾏
3. 建⽴⼀个⼦进程(fork)
4. 替换⼦进程(execvp)
5. ⽗进程等待⼦进程退出(wait)
根据这些思路,和我们前⾯的学的技术,就可以⾃⼰来实现⼀个shell了。

5.3代码实现:

5.3.1整体架构梳理

5.3.2头文件与宏定义(基础准备)

#include<iostream>       // C++标准输入输出(代码中未直接使用,可移除)

#include<cstdio>         // C标准输入输出(printf/fflush/fgets等)

#include<cstring>        // C字符串操作(strlen/strtok/strrfind等)

#include<cstdlib>        // C标准库(exit等)

#include <stdexcept>     // 异常处理(代码中未使用,可移除)

#include<unistd.h>       // Linux系统调用(fork/execvp/getenv/chdir等)

#include<sys/wait.h>     // 进程等待(waitpid)

#include<sys/types.h>    // 系统类型定义(pid_t等)

// 命令行缓冲区大小(存储用户输入的命令)

#define COMMAND_SIZE 1024

// 提示符格式:[用户名@主机名 路径]# (模仿CentOS的bash提示符)

#define FORMAT "[%s@%s %s]# "

// Shell全局参数:存储解析后的命令参数

//1.命令行参数表//2.环境变量表

#define MAXARGC 128       // 最大参数个数(比如ls -l -a是3个参数,上限128)

char *g_argv[MAXARGC];    // 存储参数的数组(比如g_argv[0]是"ls",g_argv[1]是"-l")

int g_argc = 0;           // 实际参数个数

//2.环境变量表

#define  MAX_ENVS 100

char *g_env[MAX_ENVS];//自定义环境变量的数组

int g_envs = 0;//有效环境变量的个数

//3.别名映射表

std::unordered_map<std::string,std::string> alias_list;

//for test

char cwd[1024];

char cwdenv[1030];

//lst exit code

int lastcode = 0;

  • 头文件说明:unistd.h/sys/wait.h/sys/types.h是 Linux 系统编程核心头文件,提供进程、系统调用相关接口;cstdio/cstring是字符串和输入输出的基础。
  • 宏定义:COMMAND_SIZE限制命令行输入长度,FORMAT定义提示符格式,MAXARGC限制命令参数个数;全局变量g_argv/g_argc用于存储解析后的命令参数(全局变量方便各函数共享,简化传参)。

5.3.3辅助函数:获取系统信息(构建提示符)

这部分函数用于获取用户名、主机名、当前路径,为提示符提供数据。

1. 获取用户名 GetUserName()

const char *GetUserName()

{

    // getenv("USER"):读取环境变量USER(Linux中存储当前登录用户名)

    const char *name=getenv("USER");

    // 若环境变量不存在,返回"None";否则返回用户名

    return name == NULL ? "None" : name;

}

getenv():C 标准库函数,用于读取环境变量,参数是环境变量名,返回值是该变量的字符串值(不存在则返回 NULL)。

2. 获取主机名 GetHostName()

const char *GetHostName()

{

   //  读取环境变量HOSTNAME(Linux中存储主机名)

    const char *hostname = getenv("HOSTNAME");

    return hostname == NULL ? "None" : hostname;

}

3. 获取当前工作目录 GetPwd()

const char *GetPwd()

{

    // 读取环境变量PWD(Linux中存储当前工作目录,比如/home/user)

    const char *pwd  = getcwd(cwd,sizeof(cwd));

    if(pwd != NULL)

    {

        snprintf(cwdenv,sizeof(cwdenv),"PWD=%s",cwd);

        putenv(cwdenv);

    }

    return pwd == NULL ? "None" : pwd;

}

4.获取家目录路径GetHome()

const char *GetHome()

{

    const char *home = getenv("HOME");

    return home == NULL ? "" : home;//如果home是空返回空字符" ",否则返回home

}

5.环境变量初始化与管理InitEnv()----核心是接管系统环境变量表

void InitEnv()

{

    extern char **environ;//extern char **environ;:声明系统全局的环境变量表指针 environ(它是内核维护的、存储所有环境变量的字符串数组,以 NULL 结尾)

    memset(g_env,0,sizeof(g_env));//清空自定义环境变量数组的数据,以免出现脏数据

    g_envs = 0;//将自定义有效环境变量的个数置为0

    //本来要从配置文件来

    //1.获取环境变量

    for(int i=0;environ[i];i++)//循环拷贝environ中的数据

    {

        //1.1申请空间,申请与原环境变量+1的空间,最后一个存放\0

        g_env[i] = (char*)malloc(strlen(environ[i])+1);

        strcpy(g_env[i],environ[i]);//将系统环境变量拷贝到自定义数组

        g_envs++;//有效环境变量个数+1

    }

     //添加自定义环境变量

    g_env[g_envs++] =(char*)"HAHA=for_test";//向 g_env 的下一个位置添加自定义变量 HAHA=for_test

    g_env[g_envs] = NULL;//给环境变量数组加上 NULL 结尾(符合环境变量表的标准格式,所有遍历环境变量的逻辑都依赖这个 NULL 判断结束)。

    //2.导成环境变量

    for(int i=0;g_env[i];i++)

    {

        putenv(g_env[i]);//将自定义数组中的每个环境变量注册到系统环境中(putenv 的作用是让 getenv 等函数能识别这些变量)

    }

    environ = g_env;//将系统全局的环境变量表指针指向自定义的 g_env,这是关键操作—— 此后整个进程(包括 shell 自身和它创建的子进程)都会使用这份自定义的环境变量表。

}

6.CD命令的核心实现逻辑Cd()

//command

bool Cd()

{

    //cd 它的argc=1

    if(g_argc == 1)//相当于直接输入cd返回到家目录

    {

        std::string home = GetHome();//调用之前分析的家目录函数,返回值改为string

        if(home.empty()) return true;//如果家目录为空(GetHome() 返回空字符串),直接返回 true(无操作)

        chdir(home.c_str());//调用系统调用 chdir,切换到家用目录(c_str() 把 C++ string 转为 C 风格字符串,适配 chdir 参数要求)

-.c_str()std::string 的成员函数,它返回一个指向字符串内部的只读 C 风格字符数组指针const char*),并且保证该数组以 \0 结尾。

-作用:因为 chdir 是系统调用,它的参数只接受 C 风格字符串(const char*),所以需要用 .c_str() 完成类型转换。

    }

    else

    {

        std::string where = g_argv[1];//argv[0]是第一个命令名cd,argv[1]是第一个参数

        //cd - ;cd ~

        if(where == "-")//匹配-,返回上一次工作目录

        {

            //Tudu

        }

        else if(where == "~")//匹配~,切换到家目录

        {

            //Tudo

        }

        else

        {

            chdir(where.c_str());//匹配普通路径,直接调用系统接口转换

        }

    }

    return true;

}

7.ECHO命令的核心实现Echo()

void Echo()

{

    if(g_argc == 2)//仅处理单参数场景

    {

        //echo "hello world"

        std::string opt = g_argv[1];//获取第一个参数

        if(opt == "$?")//匹配 $? 命令

        {

            std::cout<<lastcode<<std::endl;//lastcode 是全局变量,存储上一条命令的退出状态(如 0 代表成功,非0 代表失败)

            lastcode = 0;//输出 lastcode 后立即重置为 0(符合 shell 行为,$? 仅能获取一次上一条命令的退出码)

        }

        else if(opt[0] == '$')//判断识别$开头的命令

        {

            std::string env_name = opt.substr(1);//opt.substr(1) 会截取 $ 之后的字符串作为环境变量名。例如,若 opt$PATHenv_name 就会是 PATH。这样就把用户输入的 $PATH 转换成了 getenv 可以识别的变量名。

            const char *env_value = getenv(env_name.c_str());//getenv 是系统函数,用于从环境变量表中查找指定变量的值,它的参数是 C 风格字符串,所以需要用 .c_str() 把 C++ std::string 转换为 const char*。如果环境变量存在,返回指向其值的指针;如果不存在,返回 NULL

            if(env_value)//如果不为空则打印

                std::cout<< env_value <<std::endl;

        }

        else

            std::cout<<opt<<std::endl;//处理普通字符直接输出参数内容(如 echo "hello" → 输出 hello),末尾加换行符(符合 echo 命令默认行为)

    }

}

8. 提取路径的最后一级目录 DirName()

//这个函数的核心作用是从完整路径中提取最后一级目录名,比如输入 /home/user/test 输出 test,输入根目录 / 则直接返回 /,是构建 Shell 提示符时的关键辅助函数。

// 输入:/home/user/test → 输出:test;输入:/ → 输出:/

std::string DirName(const char *pwd)

{

#define SLASH "/"  // 定义路径分隔符宏

    std::string dir = pwd;  // 将C字符串转为C++ string,方便操作,转换为 std::string 后,可以直接使用 C++ 标准库的字符串操作函数(如 rfindsubstr),比 C 语言的字符串处理更安全、更便捷。

    if(dir == "/") return SLASH;  // 若当前路径是根目录,直接返回/,避免后续逻辑处理出错

    auto pos = dir.rfind(SLASH);  // rfind:从字符串末尾向前找第一个"/"的位置(比如/home/user → 找到最后一个/在索引5的位置

    if(pos == std::string::npos) return "BUG?";  // 如果路径中没有找到 /(理论上在 Linux 路径中不会发生),rfind 会返回 std::string::npos,表示未找到。这里返回 "BUG?" 是一个兜底逻辑,防止程序因异常输入而崩溃

    return dir.substr(pos+1);//substr(pos+1) 会从 pos+1 的位置开始,截取到字符串末尾。示例:输入 /home/user/testpos 是 10,pos+1 是 11,从 11 开始截取得到 test

#undef SLASH  // 释放宏定义(可选,防止宏污染)

}

注:代码中MakeCommandline()里注释了调用这个函数的代码,实际用了完整路径,若取消注释则提示符会显示最后一级目录(如 bash 的默认行为)。

5.3.4提示符构建与输出

1. 构建提示符字符串 MakeCommandline()

void MakeCommandline(char cmd_prompt[],int size)

{

    // 注释的代码:使用DirName提取最后一级目录作为路径显示

    // snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),DirName(GetPwd()).c_str());

    // 实际执行的代码:使用完整路径作为路径显示

    // snprintf:安全的字符串格式化,避免缓冲区溢出(参数:目标数组、数组大小、格式串、参数)

    snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),GetPwd());

}

snprintf是关键:相比sprintf,它限制了写入的字符数(第二个参数size),防止输入过长导致缓冲区溢出(Shell 安全的核心点)。

//int snprintf(char *str, size_t size, const char *format, ...);

//将格式化后的字符串写入str指向的缓冲区,严格限制写入的字符数不超过size-1(预留 1 个字节存储字符串结束符\0),避免缓冲区溢出,是sprintf的安全升级版。

2. 输出提示符 PrintCommandPrompt()

void PrintCommandPrompt()

{

    char prompt[COMMAND_SIZE];  // 存储构建好的提示符字符串

    MakeCommandline(prompt,sizeof(prompt));  // 构建提示符

    printf("%s",prompt);  //prompt 是类似 [root@localhost /home]# 的提示符,末尾没有换行符,所以内容会留在缓冲区里,用户看不到提示符,自然不知道该输入命令了。

    fflush(stdout);  //fflush(stdout)强制把缓冲区里的内容立刻输出到终端,不管缓冲区有没有满、有没有换行符。

}

fflush(stdout):必须调用!因为 stdout 是行缓冲的,若提示符没有换行符(\n),内容会留在缓冲区不显示,用户看不到提示符,调用fflush强制输出。

5.3.5获取用户输入 GetCommandline()

bool GetCommandline(char *out,int size)

{    

    // 例如:输入 ls -l 再按回车,out 缓冲区里的内容是 ls -l\n

    // fgets:从标准输入(stdin)读取一行,存入out,最多读size-1个字符(留1个存\0)

    char *c = fgets(out,size,stdin);

    //fgets会把你输入的内容和最后的换行符 \n 一起读入缓冲区,并且后面加\0

    if(c == NULL) return false;  // 读取失败(比如Ctrl+D),返回false//判断fgets的返回值

    out[strlen(out)-1]=0;  // 去掉fgets读取的换行符(\n):strlen(out)是输入长度,最后一位是\n,置为\0//获得字符串长度,包括\n,拿到最后一个字符的位置,将其修改为0

    if(strlen(out) == 0) return false;  // 若用户只按了回车,输入为空,返回false

    return true;  // 输入有效,返回true

}

核心逻辑:读取用户输入 → 清理换行符 → 校验输入是否为空,保证后续解析的命令是有效的。

5.3.6解析命令行参数 CommandPrase()

bool CommandPrase(char *commandline)

{

#define SEP " "  // 定义分隔符为空格

    g_argc = 0;  // 重置参数个数(每次解析新命令前清空)

    // 命令行分析:"ls -a -l" → 拆分出第一个参数"ls",存入g_argv[0]

    // strtok:按SEP分割字符串,第一次调用传原始字符串,后续传NULL

    g_argv[g_argc++] = strtok(commandline,SEP);//strtok(commandline, SEP):第一次调用时传入原始字符串 commandline,它会找到第一个分隔符(空格),并把分隔符替换为 \0,返回指向第一个参数(如 "ls")的指针。

//例如:输入 "ls -a -l" 会被修改为 "ls\0-a -l"g_argv[0] 指向 "ls"g_argc 变为 1

    // 循环拆分剩余参数:strtok(NULL,SEP)会继续拆分上一个字符串,直到返回NULL

    while((bool)(g_argv[g_argc++] = strtok(nullptr,SEP)));

//strtok(nullptr, SEP):后续调用传入 nullptr,函数会从上次拆分的位置继续往后找下一个分隔符,返回下一个参数的指针。

//当没有更多参数时,strtok 返回 NULL,循环终止。

//例如:继续拆分 "ls\0-a -l",会得到 "a""l",分别存入 g_argv[1]g_argv[2]g_argc 会增加到 4(因为最后一次 strtok 返回 NULL 时,g_argc 也会自增一次)。

    g_argc--;  // 修正参数个数:最后一次strtok返回NULL,g_argc多加了1,需要减回去

    return true;

#undef SEP  // 释放宏定义

}    

strtok是核心:C 标准库的字符串分割函数,注意:它会修改原始字符串(将分隔符替换为 \0),所以commandline会被改变。

举例:输入ls -l -a,拆分后:

        g_argv[0] = "ls",g_argv[1] = "-l",g_argv[2] = "-a",g_argv[3] = NULL

        g_argc 最终为 3(正确的参数个数)。

5.3.7调试函数 PrintArgv()(可选)

void PrintArgv()

{

    for(int i=0;g_argv[i];i++)  // 遍历g_argv,直到遇到NULL

    {

        printf("argv[%d]->%s\n",i,g_argv[i]);  // 打印每个参数

    }

    printf("argc: %d\n",g_argc);  // 打印参数个数

}

5.3.8检测并处理内键命令CheckExecBuiltin()-内置命令识别与执行的核心入口

bool CheckAndExecBuiltin()

{

    std::string cmd = g_argv[0];//获取第一个元素

    if(cmd == "cd")

    {

        Cd();

        return true;

    }

    else if(cmd == "echo")

    {

        Echo();

        return true;

    }

    else if(cmd == "export")//环境变量设置

    {

    }

    else if(cmd == "alias")//命令别名

    {

        //std::string nickname = g_argv[1];

        //alias_list.insert(k,v);

    }

    return false;

}

5.3.9执行命令 Execute()

这是 Shell 的核心功能,通过fork()创建子进程,execvp()替换子进程执行命令,waitpid()等待子进程结束。

int Execute()

{

    // 1. 创建子进程:fork()返回值:子进程中返回0,父进程中返回子进程PID,失败返回-1

    pid_t id = fork();

    if(id==0)

    {

        // 子进程逻辑

        // execvp:执行命令(参数1:命令名,参数2:参数数组)

        // 作用:替换当前子进程的代码段、数据段,执行指定命令

        // 注意:若execvp执行失败(比如命令不存在),才会执行后续的exit(0)

        execvp(g_argv[0],g_argv);//execvp 是执行外部命令的核心系统调用,它的作用是用新的程序替换当前子进程的代码和数据,让子进程去执行你输入的命令(比如 lscat)。

        exit(0);  // 子进程退出(execvp成功的话,这行不会执行)

//execvp 是 “替换式” 调用:成功执行后,当前子进程的代码段、数据段都被新程序完全替换了,原来的 exit(0) 代码已经不存在了,自然不会执行。//只有当 execvp 失败(比如命令不存在,如输入 lss)时,才会执行后面的 exit(0),让子进程正常退出。

    }

       

    // 父进程逻辑(id != 0)

    // waitpid:等待指定子进程(id)结束,NULL表示不关心退出状态,0表示阻塞等待

   

    int status = 0;//把子进程的完整退出信息写入 status 这个变量

    //father

    pid_t rid = waitpid(id,&status,0);

    if(rid > 0)

    {

        lastcode = WEXITSTATUS(status);//提取退出码

    }

    return 0;

}

    

关键知识点:

  1. fork():复制当前进程,子进程和父进程共享代码段,但有独立的地址空间;
  2. execvp():属于 exec 函数族,不会创建新进程,只是替换当前进程的内容,路径会自动搜索 $PATH 环境变量(比如输入 ls,会找 /bin/ls);
  3. 假设用户输入命令:ls -l /home  

        经过 CommandPrase 解析后,全局变量:

execvp(g_argv[0], g_argv);

  4.waitpid():父进程阻塞等待子进程结束,避免子进程变成僵尸进程。

5.3.10主函数 main()(核心循环)

int main()

{

    //shell 启动的时候需要从环镜中获取环境变量

    //我们的环境变量信息应该从父shell统一来

    InitEnv();

    while(true)  // 无限循环,模拟Shell的持续运行

    {

        //1. 输出命令行提示符

        PrintCommandPrompt();

        //2. 获取用户输入的命令

        char commandline[COMMAND_SIZE];

        if(!GetCommandline(commandline,sizeof(commandline)))

            continue;  // 输入无效(空/读取失败),跳过本次循环,重新输出提示符

        //3. 解析命令行参数

        CommandPrase(commandline);  

        //PrintArgv();  // 调试用,注释掉

       

        //检测别名

        //4.检测并处理内键命令

        if(CheckAndExecBuiltin())

                continue;

        //5.执行命令

        Execute();

    }

    return 0;

}

逻辑:无限循环 → 输出提示符 → 读命令 → 解析 → 执行 → 循环,直到用户强制退出(Ctrl+C)。

5.3.11完整代码

#include<iostream>

#include<cstdio>

#include<cstring>

#include<cstdlib>

#include <stdexcept>

#include<unistd.h>

#include<sys/wait.h>

#include<sys/types.h>

#include<unordered_map>

#define COMMAND_SIZE 1024

#define FORMAT "[%s@%s %s]# "

//下面是shell定义的全局数据

//1.命令行参数表

#define MAXARGC 128

char *g_argv[MAXARGC];

int g_argc = 0;

//2.环境变量表

#define  MAX_ENVS 100

char *g_env[MAX_ENVS];

int g_envs = 0;

//3.别名映射表

std::unordered_map<std::string,std::string> alias_list;

//for test

char cwd[1024];

char cwdenv[1030];

//lst exit code

int lastcode = 0;

const char *GetUserName()

{

    const char *name=getenv("USER");

    return name == NULL ? "None" : name;

}

const char *GetHostName()

{

    const char *hostname = getenv("HOSTNAME");

    return hostname == NULL ? "None" : hostname;

}

const char *GetPwd()

{

    const char *pwd  = getcwd(cwd,sizeof(cwd));

    if(pwd != NULL)

    {

        snprintf(cwdenv,sizeof(cwdenv),"PWD=%s",cwd);

        putenv(cwdenv);

    }

    return pwd == NULL ? "None" : pwd;

}

const char *GetHome()

{

    const char *home = getenv("HOME");

    return home == NULL ? "" : home;

}

void InitEnv()

{

    extern char **environ;

    memset(g_env,0,sizeof(g_env));

    g_envs = 0;

    for(int i=0;environ[i];i++)

    {

        g_env[i] = (char*)malloc(strlen(environ[i])+1);

        strcpy(g_env[i],environ[i]);

        g_envs++;          

    }

    g_env[g_envs++] =(char*)"HAHA=for_test";//for_test

    g_env[g_envs] = NULL;

    for(int i=0;g_env[i];i++)

    {

        putenv(g_env[i]);

    }

    environ = g_env;

}

bool Cd()

{

    if(g_argc == 1)

    {

        std::string home = GetHome();

        if(home.empty()) return true;

        chdir(home.c_str());

    }

    else

    {

        std::string where = g_argv[1];

        if(where == "-")

        {

            //Tudu

        }

        else if(where == "~")

        {

            //Tudo

        }

        else

        {

            chdir(where.c_str());

        }

    }

    return true;

}

void Echo()

{

    if(g_argc == 2)

    {

        //echo "hello world"

        std::string opt = g_argv[1];

        if(opt == "$?")

        {

            std::cout<<lastcode<<std::endl;

            lastcode = 0;

        }

        else if(opt[0] == '&')

        {

            std::string env_name = opt.substr(1);

            const char *env_value = getenv(env_name.c_str());

            if(env_value)

                std::cout<< env_value <<std::endl;

        }

        else

            std::cout<<opt<<std::endl;

    }

}

std::string DirName(const char *pwd)

{

#define SLASH "/"

    std::string dir = pwd;

    if(dir == "/") return SLASH;

    auto pos = dir.rfind(SLASH);

    if(pos == std::string::npos) return "BUG?";

    return dir.substr(pos+1);

}

void MakeCommandline(char cmd_prompt[],int size)

{

    //snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),DirName(GetPwd()).c_str());

    snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),GetPwd());

}

void PrintCommandPrompt()

{

    char prompt[COMMAND_SIZE];

    MakeCommandline(prompt,sizeof(prompt));

    printf("%s",prompt);

    fflush(stdout);

}

bool GetCommandline(char *out,int size)

{    

    char *c = fgets(out,size,stdin);

    if(c == NULL) return false;

    out[strlen(out)-1]=0;

    if(strlen(out) == 0) return false;

    return true;

}

bool CommandPrase(char *commandline)

{

#define SEP " "

    g_argc = 0;

    //命令行分析"ls -a -l" -> "ls" "-a" "-l"

    g_argv[g_argc++] = strtok(commandline,SEP);

    while((bool)(g_argv[g_argc++] = strtok(nullptr,SEP)));

    g_argc--;

    return true;

}    

void PrintArgv()

{

    for(int i=0;g_argv[i];i++)

    {

        printf("argv[%d]->%s\n",i,g_argv[i]);

    }

    printf("argc: %d\n",g_argc);

}

bool CheckAndExecBuiltin()

{

    std::string cmd = g_argv[0];

    if(cmd == "cd")

    {

        Cd();

        return true;

    }

    else if(cmd == "echo")

    {

        Echo();

        return true;

    }

    else if(cmd == "export")

    {

    }

    else if(cmd == "alias")

    {

        //std::string nickname = g_argv[1];

        //alias_list.insert(k,v);

    }

    return false;

}

int Execute()

{

    //执行命令

    pid_t id = fork();

    if(id==0)

    {

        execvp(g_argv[0],g_argv);

        exit(0);

    }

    

    int status =0;

    pid_t rid = waitpid(id,nullptr,0);

    if(rid > 0)

    {

        lastcode = WEXITSTATUS(ststus);

     }

    return 0;

}

int main()

{

    

    //shell 启动的时候需要从环镜中获取环境变量

    //我们的环境变量信息应该从父shell统一来

    InitEnv();

    while(true)

    {

        //1.输出命令行提示符

        PrintCommandPrompt();

        //2.获取用户输入的命令

        char commandline[COMMAND_SIZE];

        if(!GetCommandline(commandline,sizeof(commandline)))

            continue;

        //3.命令行分析"ls -a -l" -> "ls" "-a" "-l"

        //全局命令行参数表

         if(!CommandPrase(commandline))

            continue;

        //PrintArgv();

        

        //检测别名

        //4.检测并处理内键命令

        if(CheckAndExecBuiltin())

                continue;

        //5.执行命令

        Execute();

    }

    return 0;

}

5.4补充和总结

5.4.1补充说明(代码的局限性)

这份代码是极简版 Shell,还有很多未实现的功能,帮助你理解完整 Shell 的复杂度:

  1. 不支持内置命令:比如cdexitexport等,这些命令需要父进程自己执行(不能 fork 子进程,比如 cd 子进程执行的话,父进程路径不变);
  2. 不支持管道 / 重定向:比如ls -l | grep txtecho hello > test.txt,需要处理文件描述符和管道;
  3. 不支持后台执行:比如ls &,需要处理 waitpid 的非阻塞模式;
  4. 错误处理不完善:比如 execvp 执行失败(命令不存在),没有提示用户;
  5. 不支持引号参数:比如echo "hello world",会被拆分为 "hello 和 world",而非一个参数。

5.4.2总结

这份代码的核心是实现了 Linux Shell 的最小可行版本,关键知识点可总结为:

  1. 核心流程:提示符输出 → 命令读取 → 参数解析 → 子进程执行 → 父进程等待,是所有 Shell 的基础逻辑;
  2. 关键系统调用fork()(创建进程)、execvp()(执行命令)、waitpid()(等待子进程)是实现命令执行的核心;
  3. 字符串处理strtok(分割参数)、snprintf(安全格式化)、fgets(安全读输入)是处理命令行的基础;
  4. 核心思想:父进程负责交互(读命令、解析),子进程负责执行命令,避免命令执行影响 Shell 本身。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐