在 Linux 系统编程中,进程控制是绕不开的核心基础 —— 小到执行一条ls命令,大到开发高并发服务器,背后都离不开进程的创建、终止、等待与程序替换。今天这篇文章,我们就从最基础的概念入手,一步步拆解 Linux 进程控制的关键技术,最后再通过一个微型 Shell 的实现,把这些知识点串联起来,帮你彻底搞懂 “进程” 到底是怎么工作的。

一、进程创建:fork ()—— 给进程 “生个孩子”

要控制进程,首先得学会 “造” 进程。在 Linux 中,fork()函数就是创建新进程的 “主力军”,它的作用是从已存在的父进程中复制出一个子进程,从此父子进程各自独立运行。

1.1 fork () 的核心特性:“复制 + 分离”

先看fork()的函数原型和返回值,这是理解它的关键:

#include <unistd.h>
pid_t fork(void); // 返回值:子进程中返回0,父进程返回子进程PID,出错返回-1

一个简单的例子就能看出fork()的神奇之处:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void) {
    printf("Before: pid is %d\n", getpid()); // getpid()获取当前进程ID
    
    pid_t pid = fork();
    if (pid == -1) { // 出错处理
        perror("fork() failed");
        exit(1);
    }
    
    // fork()之后,父子进程都会执行下面的代码
    printf("After: pid is %d, fork return %d\n", getpid(), pid);
    sleep(1); // 防止进程提前退出,保证输出完整
    return 0;
}

运行结果如下:

Before: pid is 43676
After: pid is 43676, fork return 43677  # 父进程:返回子进程PID 43677
After: pid is 43677, fork return 0     # 子进程:返回0

为什么会这样?关键在于fork()的执行逻辑:

  • fork () 之前:只有父进程在运行,所以 “Before” 只打印一次;

  • fork () 之后:内核会给子进程分配新的内存和内核数据结构,复制父进程的代码和数据,然后把子进程加入系统进程列表 —— 从此父子进程是 “两个独立的执行流”,谁先运行由 CPU 调度器决定,没有固定顺序。

1.2 写时拷贝:父子进程的 “内存共享智慧”

你可能会问:fork () 要复制父进程的代码和数据,那如果进程很大,岂不是很浪费内存?Linux 用写时拷贝(Copy-On-Write, COW) 技术解决了这个问题。

简单说,写时拷贝的逻辑是:

  • 读的时候共享:fork () 后,父子进程的代码段(指令)和数据段(变量)默认共享物理内存,不做复制;

  • 写的时候分离:只有当父进程或子进程要修改数据(比如给变量赋值)时,内核才会为修改方复制一份数据的物理内存副本,确保双方的修改互不影响。

举个例子:父进程有个变量a=10,fork () 后子进程也能看到a=10;如果子进程把a改成20,内核会给子进程复制一份a的内存,此时父进程的a还是10,子进程的a是20—— 既保证了进程独立性,又节省了内存(没修改的数据不用复制)。

1.3 fork () 的常见用法与失败原因

常见用法:
  1. 父子分工:父进程等待客户端请求,子进程处理请求(比如 Web 服务器的多进程模型);

  2. 执行新程序:子进程 fork () 后,通过exec函数替换成新程序(比如 Shell 执行ls命令时,就是先 fork 子进程,再 execls)。

失败原因:
  • 系统中进程数量太多,达到内核限制;

  • 普通用户的进程数超过了ulimit设置的上限。

二、进程终止:exit ()—— 给进程 “善终”

进程不是永生的,执行完任务后需要 “优雅退出”。进程终止的本质是释放资源:包括内核数据结构、物理内存、打开的文件描述符等。

2.1 进程退出的 3 种场景

  1. 正常退出,结果正确:比如echo "hello"执行完,输出正确;

  2. 正常退出,结果错误:比如1/0(除零错误),代码跑完了但结果不对;

  3. 异常终止:比如按Ctrl+C强制终止进程,或进程收到kill信号。

2.2 3 种正常退出方式:return、exit ()、_exit ()

这三种方式的核心区别在于 “是否清理资源”:

退出方式 适用场景 特点
return n 只能在 main 函数中使用 等同于exit(n),会触发进程退出的完整流程
exit(int status) 任意函数中使用 退出前会:1. 执行atexit注册的清理函数;2. 刷新文件缓冲区;3. 调用_exit()
_exit(int status) 任意函数中使用 直接终止进程,不清理、不刷新(一般用于子进程或紧急退出)

举个例子,看exit()_exit()的区别:

// 例子1:用exit()
#include <stdio.h>
#include <stdlib.h>
int main() {
    printf("hello"); // 缓冲区未刷新
    exit(0); // 会刷新缓冲区,所以能打印出hello
}

// 例子2:用_exit()
#include <stdio.h>
#include <unistd.h>
int main() {
    printf("hello"); // 缓冲区未刷新
    _exit(0); // 不刷新缓冲区,所以不会打印hello
}

2.3 退出码:进程的 “执行报告”

进程退出时会返回一个8 位的退出码(0-255),告诉父进程 “我执行得怎么样”。我们可以通过 Shell 的$?变量查看上一个进程的退出码:

ls /tmp  # 执行成功
echo $?  # 输出0(成功的标志)

ls /nonexistent  # 执行失败(目录不存在)
echo $?  # 输出2(通用错误码)

常见的 Linux 退出码含义:

退出码 含义说明 例子
0 命令成功执行 ls找到目标目录
1 通用错误 除零错误、权限不足(非 root 用yum
2 命令或参数使用不当 ls --invalid-option(无效选项)
126 权限被拒绝(无法执行文件) chmod 644 ./a.out后执行./a.out
127 未找到命令(PATH 路径错误) 输入lss(拼写错误)
130 进程被Ctrl+C终止(收到SIGINT信号) 运行sleep 10后按Ctrl+C
143 进程被kill终止(收到SIGTERM信号) kill 12345(12345 是进程 ID)

如果想在代码中获取退出码的描述,可以用strerror()函数:

#include <stdio.h>
#include <string.h>
int main() {
    int code = 2;
    printf("退出码%d的含义:%s\n", code, strerror(code)); 
    // 输出:退出码2的含义:No such file or directory
    return 0;
}

三、进程等待:wait ()—— 父进程 “接孩子放学”

如果子进程退出了,父进程不管不顾,子进程会变成僵尸进程(Zombie) —— 虽然代码和数据已释放,但内核数据结构还在,会占用内存,长期积累会导致内存泄漏。

进程等待的作用就是:父进程回收子进程资源,获取子进程的退出信息(退出码或终止信号)。

3.1 两种等待方式:wait () 与 waitpid ()

1. wait ():等待任意子进程
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status); 
// 返回值:成功返回被回收子进程的PID,失败返回-1
// 参数status:输出型参数,存储子进程的退出信息(不想关心就传NULL)
2. waitpid ():更灵活的等待(推荐用)
pid_t waitpid(pid_t pid, int *status, int options);

关键参数说明:

  • pid:指定要等待的子进程(-1 表示等待任意子进程,与 wait () 等效;0 表示等待同组子进程);

  • status:和 wait () 一样,存储退出信息;

  • options:等待方式(0 表示阻塞等待,即父进程暂停运行,直到子进程退出;WNOHANG表示非阻塞等待,即父进程继续运行,定期检查子进程是否退出)。

3.2 解析 status:获取子进程的 “退出详情”

status不是普通的整数,而是一个16 位的位图,我们需要用宏来解析它:

位图位段(低 16 位) 含义 解析宏
第 0-6 位(低 7 位) 终止信号(异常退出用) WIFSIGNALED(status):是否被信号终止;WTERMSIG(status):获取终止信号值
第 8-15 位(高 8 位) 退出码(正常退出用) WIFEXITED(status):是否正常退出;WEXITSTATUS(status):获取退出码

举个实际的例子:

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

int main(void) {
    pid_t pid = fork();
    if (pid == -1) { perror("fork"); exit(1); }

    if (pid == 0) { // 子进程
        sleep(20); // 让子进程运行20秒,方便测试
        exit(10);  // 正常退出,退出码10
    } else { // 父进程
        int status;
        pid_t ret = waitpid(pid, &status, 0); // 阻塞等待子进程

        if (ret > 0) {
            if (WIFEXITED(status)) { // 正常退出
                printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
            } else if (WIFSIGNALED(status)) { // 被信号终止
                printf("子进程被信号终止,信号值:%d\n", WTERMSIG(status));
            }
        }
    }
    return 0;
}

测试两种情况:

  1. 正常等待:等 20 秒,输出 “子进程正常退出,退出码:10”;

  2. 强制终止:在另一个终端执行kill -9 子进程PID,输出 “子进程被信号终止,信号值:9”(9 是SIGKILL信号,强制终止)。

3.3 阻塞等待 vs 非阻塞等待

  • 阻塞等待:父进程 “啥也不干,就等子进程退出”,适合简单场景(比如 Shell 执行单个命令);

  • 非阻塞等待:父进程在等待的同时可以做其他事情(比如处理其他客户端请求),适合高并发场景。

非阻塞等待的代码示例(父进程等待时执行临时任务):

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

// 临时任务:等待时打印信息
void do_other_task() {
    printf("父进程:等待子进程的同时,做点其他事...\n");
    sleep(1); // 模拟耗时操作
}

int main() {
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); exit(1); }

    if (pid == 0) { // 子进程
        printf("子进程PID:%d,将运行5秒\n", getpid());
        sleep(5);
        exit(1);
    } else { // 父进程非阻塞等待
        int status;
        pid_t ret;
        do {
            ret = waitpid(pid, &status, WNOHANG); // 非阻塞,立即返回
            if (ret == 0) { // 子进程还在运行,执行其他任务
                do_other_task();
            }
        } while (ret == 0); // 子进程没退出就循环

        // 子进程退出,解析结果
        if (WIFEXITED(status)) {
            printf("子进程退出,退出码:%d\n", WEXITSTATUS(status));
        }
    }
    return 0;
}

四、进程程序替换:exec ()—— 给进程 “换身衣服”

fork () 创建的子进程和父进程执行相同的代码,如果想让子进程执行全新的程序(比如子进程原本执行main函数,现在想执行ls命令),就需要exec函数簇来完成 “程序替换”。

4.1 程序替换的核心原理

  • 替换内容:子进程的用户空间代码和数据会被新程序完全覆盖,从新程序的 “启动例程” 开始执行;

  • 不创建新进程:替换后进程的 PID 不变,只是 “内核数据结构不变,用户空间内容全换”;

  • 替换成功不返回:如果exec执行成功,新程序会直接接管进程,不会回到原来的代码;只有替换失败时才返回 - 1。

4.2 exec 函数簇:6 个函数的区别与用法

Linux 提供了 6 个以exec开头的函数,统称exec函数簇,它们的区别在于参数格式、是否自动搜路径、是否自定义环境变量

先看函数原型:

#include <unistd.h>

// l(list):参数用列表,以NULL结尾
int execl(const char *path, const char *arg, ...); // 需写全路径,用当前环境变量
int execlp(const char *file, const char *arg, ...); // 自动搜PATH,用当前环境变量
int execle(const char *path, const char *arg, ..., char *const envp[]); // 需写路径,自定义环境变量

// v(vector):参数用数组,数组最后一个元素是NULL
int execv(const char *path, char *const argv[]); // 需写全路径,用当前环境变量
int execvp(const char *file, char *const argv[]); // 自动搜PATH,用当前环境变量
int execve(const char *path, char *const argv[], char *const envp[]); // 需写路径,自定义环境变量
记忆口诀:
  • l(list):参数是 “逐个列出” 的,比如execl("/bin/ls", "ls", "-l", NULL)

  • v(vector):参数是 “数组”,比如char *argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv)

  • p(path):自动从PATH环境变量中找程序,不用写全路径(比如execlp("ls", "ls", "-l", NULL));

  • e(env):自定义环境变量,需要传一个环境变量数组(比如char *envp[] = {"PATH=/bin", NULL}; execle("/bin/ls", "ls", "-l", NULL, envp))。

关键注意点:
  • 所有exec函数的第一个参数是 “要执行的程序路径 / 文件名”,第二个参数开始是 “程序的命令行参数”,最后必须以NULL结尾(告诉函数参数列表结束);

  • 只有execve系统调用,其他 5 个函数都是对execve的封装(在man 3 exec中查看)。

4.3 程序替换的实际例子

比如让子进程执行ls -l命令:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); exit(1); }

    if (pid == 0) { // 子进程替换程序
        printf("子进程将执行ls -l\n");
        // 方式1:execlp(自动搜PATH,参数列表)
        execlp("ls", "ls", "-l", NULL); 
        // 如果替换失败,才会执行下面的代码
        perror("exec failed");
        exit(1);
    } else { // 父进程等待子进程
        wait(NULL);
        printf("子进程执行完毕\n");
    }
    return 0;
}

运行后,子进程会输出ls -l的结果,然后父进程打印 “子进程执行完毕”—— 这就是 Shell 执行命令的核心逻辑!

五、综合实战:实现一个微型 Shell

学到这里,我们已经掌握了进程控制的四大核心技术(fork、exit、wait、exec)。现在,我们就用这些技术实现一个简单的 Shell,彻底理解 “平时用的 bash 是怎么工作的”。

5.1 微型 Shell 的核心逻辑

Shell 的本质是一个 “命令解释器”,循环做四件事:

  1. 打印提示符:比如[user@``localhost`` ~]# 

  2. 获取命令:读取用户输入的命令(比如ls -l);

  3. 解析命令:把命令拆分成 “程序名 + 参数”(比如ls -l拆成argv[0] = "ls"argv[1] = "-l"argv[2] = NULL);

  4. 执行命令:fork 子进程,exec 替换成目标程序,父进程 wait 回收子进程。

另外,还有个特殊点:内建命令(比如cdexportenv)不能让子进程执行 —— 因为子进程执行cd后,父进程的当前目录不会变(进程独立),所以这些命令需要 Shell 自己执行。

5.2 微型 Shell 的完整代码

#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 BASE_SIZE = 1024;
const int ARGV_NUM = 64;

// 全局变量:命令参数、环境变量、上一次命令的退出码、当前路径
char *g_argv[ARGV_NUM];  // 存储命令参数(如 ["ls", "-l", NULL])
int g_argc = 0;          // 参数个数
int g_last_code = 0;     // 上一次命令的退出码
char *g_env[ARGV_NUM];   // 自定义环境变量
char g_pwd[BASE_SIZE];   // 当前工作路径

// 工具函数:去除字符串中的空格
void TrimSpace(char *&pos) {
    while (isspace(*pos)) pos++;
}

// 1. 打印命令提示符(如 [user@localhost ~]# )
void PrintPrompt() {
    // 获取用户名、主机名、当前路径
    char *user = getenv("USER");
    char *host = getenv("HOSTNAME");
    getcwd(g_pwd, sizeof(g_pwd)); // 获取当前工作路径
    string pwd_last = g_pwd;
    // 只显示路径的最后一部分(如 /home/user 显示为 user)
    size_t pos = pwd_last.rfind("/");
    if (pos != string::npos) pwd_last = pwd_last.substr(pos + 1);

    // 格式化提示符
    char prompt[BASE_SIZE];
    snprintf(prompt, BASE_SIZE, "[%s@%s %s]# ", 
             user ? user : "None", 
             host ? host : "None", 
             pwd_last.c_str());
    printf("%s", prompt);
    fflush(stdout); // 刷新缓冲区,确保提示符立即显示
}

// 2. 获取用户输入的命令
bool GetCommand(char *buf, int size) {
    char *ret = fgets(buf, size, stdin);
    if (!ret) return false; // 读取失败(如Ctrl+D)
    
    // 去掉换行符(fgets会把回车符\n也读进来)
    buf[strlen(buf) - 1] = '\0';
    return strlen(buf) > 0; // 空命令不处理
}

// 3. 解析命令(将命令字符串拆分成参数数组)
void ParseCommand(char *buf) {
    memset(g_argv, 0, sizeof(g_argv)); // 清空参数数组
    g_argc = 0;

    char *pos = buf;
    TrimSpace(pos); // 跳过开头空格
    if (*pos == '\0') return;

    // 拆分参数(以空格为分隔符)
    g_argv[g_argc++] = pos;
    while (*pos != '\0') {
        if (isspace(*pos)) {
            *pos = '\0'; // 把空格换成'\0',分割参数
            pos++;
            TrimSpace(pos);
            if (*pos != '\0') {
                g_argv[g_argc++] = pos;
            }
        } else {
            pos++;
        }
    }
    g_argv[g_argc] = NULL; // 最后一个参数必须是NULL
}

// 4. 执行内建命令(cd、export、env、echo)
bool ExecBuiltin() {
    // 1. 处理cd命令(切换目录)
    if (strcmp(g_argv[0], "cd") == 0) {
        if (g_argc == 2) {
            chdir(g_argv[1]); // 切换目录(Shell自己执行)
            g_last_code = 0;
        } else {
            g_last_code = 1; // 参数错误
        }
        return true;
    }

    // 2. 处理export命令(设置环境变量)
    if (strcmp(g_argv[0], "export") == 0) {
        if (g_argc == 2) {
            // 将环境变量加入g_env数组
            int i = 0;
            while (g_env[i]) i++;
            g_env[i] = strdup(g_argv[1]); // 复制字符串
            g_env[i + 1] = NULL;
            putenv(g_argv[1]); // 同步到系统环境变量
            g_last_code = 0;
        } else {
            g_last_code = 2;
        }
        return true;
    }

    // 3. 处理env命令(查看环境变量)
    if (strcmp(g_argv[0], "env") == 0) {
        char **env = g_env;
        while (*env) {
            printf("%s\n", *env);
            env++;
        }
        g_last_code = 0;
        return true;
    }

    // 4. 处理echo命令(打印内容,支持echo $?)
    if (strcmp(g_argv[0], "echo") == 0) {
        if (g_argc == 2) {
            if (g_argv[1][0] == '$' && g_argv[1][1] == '?') {
                printf("%d\n", g_last_code); // 打印上一次退出码
            } else {
                printf("%s\n", g_argv[1]); // 打印普通内容
            }
            g_last_code = 0;
        } else {
            g_last_code = 3;
        }
        return true;
    }

    return false; // 不是内建命令
}

// 5. 执行外部命令(fork子进程+exec替换)
bool ExecExternal() {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork failed");
        return false;
    }

    if (pid == 0) { // 子进程:替换程序
        execvp(g_argv[0], g_argv); // 自动搜PATH,用当前环境变量
        // 替换失败才会执行下面的代码
        perror("exec failed");
        exit(1);
    } else { // 父进程:等待子进程
        int status;
        waitpid(pid, &status, 0);
        // 更新上一次命令的退出码
        if (WIFEXITED(status)) {
            g_last_code = WEXITSTATUS(status);
        } else {
            g_last_code = 100; // 异常退出码
        }
    }
    return true;
}

// 初始化环境变量(从系统环境变量复制)
void InitEnv() {
    extern char **environ; // 系统环境变量数组
    int i = 0;
    while (environ[i]) {
        g_env[i] = strdup(environ[i]); // 复制到自定义环境变量
        i++;
    }
    g_env[i] = NULL;
}

int main() {
    InitEnv(); // 初始化环境变量
    char command_buf[BASE_SIZE]; // 存储用户输入的命令

    while (true) { // Shell主循环
        PrintPrompt();          // 1. 打印提示符
        if (!GetCommand(command_buf, BASE_SIZE)) continue; // 2. 获取命令
        ParseCommand(command_buf); // 3. 解析命令
        if (ExecBuiltin()) continue; // 4. 执行内建命令
        ExecExternal(); // 5. 执行外部命令
    }

    return 0;
}

六、总结:进程控制的核心脉络

通过这篇文章,我们从 “创建进程” 到 “实现 Shell”,完整梳理了 Linux 进程控制的技术栈:

  1. 进程创建fork()复制父进程,写时拷贝技术节省内存;

  2. 进程终止exit()优雅退出,_exit()紧急退出,退出码传递执行结果;

  3. 进程等待waitpid()回收子进程,避免僵尸进程,解析退出信息;

  4. 程序替换exec函数簇替换进程代码,实现 “执行新程序”;

  5. Shell 实现:循环 “获取命令→解析→执行”,内建命令 Shell 自己处理,外部命令 fork+exec。

进程控制是 Linux 系统编程的 “基石”,掌握这些技术后,就能看透它们的底层逻辑。建议你动手敲一遍文中的代码,亲自测试每个函数的效果 —— 实践才是理解技术的最好方式!

Logo

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

更多推荐