Linux 进程控制全解析:从创建到 Shell 实现

        进程控制是 Linux 系统编程的核心能力,涵盖进程的创建、终止、等待和程序替换四大核心操作。掌握这些操作不仅能理解程序的运行机制,更能构建如 Shell 这样的复杂工具。本文将从基础概念入手,逐步拆解每个进程控制接口的原理与使用场景,最终通过实战实现一个简易 Shell,帮助开发者彻底掌握 Linux 进程控制的精髓。

一、进程创建:fork 与写时拷贝

        创建进程是进程生命周期的起点,Linux 中最核心的接口是 fork,其设计巧妙地平衡了资源复用与进程独立性。

1.1 fork 函数的核心特性

fork 函数从已存在的进程(父进程)中创建一个新进程(子进程),核心定义如下:

#include <unistd.h>
pid_t fork(void);

        返回值:
                父进程:返回子进程的 PID(进程唯一标识);
                子进程:返回 0;
                出错:返回 -1(如系统进程数超限)。
        内核操作:
                为子进程分配新的内核数据结构(task_struct、页表等);
                拷贝父进程的部分数据(如 PCB 中的进程属性、虚拟地址空间映射);
                将子进程加入系统进程列表;
                调度器调度父子进程执行(谁先执行由调度器决定)。

1.2 fork 的执行逻辑

fork 调用后,父子进程共享代码段(二进制指令),但数据段(全局变量、局部变量)采用写时拷贝(Copy-On-Write, COW) 机制:

        未写入时:父子进程共享同一物理内存页,页表标记为 “只读”;

        写入时:触发 CPU 缺页异常,内核为写入方复制新的物理内存页,修改页表映射,实现 “数据独立”。

示例代码

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    printf("Before fork: PID = %d\n", getpid());
    pid_t pid = fork();
    
    if (pid < 0) {
        perror("fork error");
        return 1;
    } else if (pid == 0) {
        // 子进程:修改数据,触发写时拷贝
        printf("Child: PID = %d, Parent PID = %d\n", getpid(), getppid());
    } else {
        // 父进程:读取数据,不触发拷贝
        printf("Parent: PID = %d, Child PID = %d\n", getpid(), pid);
        sleep(1); // 避免父进程先退出,子进程成为孤儿
    }
    return 0;
}

运行结果

Before fork: PID = 12345
Parent: PID = 12345, Child PID = 12346
Child: PID = 12346, Parent PID = 12345

1.3 fork 的常见用法

  1. 父子分工:父进程等待客户端请求,子进程处理请求(如 Web 服务器);
  2. 程序替换:子进程通过 exec 系列函数执行全新程序(如 Shell 执行 ls 命令)。

二、进程终止:释放资源与退出码

进程终止的本质是 “释放内核分配的资源”(PCB、页表、物理内存等),需明确退出场景与退出状态,以便父进程获取执行结果。

2.1 进程退出的三种场景

  1. 正常退出:代码运行完毕,结果正确(如 main 函数返回 0);
  2. 异常退出:代码运行完毕,结果错误(如 main 返回 1);
  3. 强制终止:代码未运行完毕,被信号中断(如 Ctrl+C 发送 SIGINT 信号)。

2.2 常用退出接口

Linux 提供三种核心退出接口,差异主要在于是否处理用户清理逻辑和 I/O 缓冲:

2.2.1 _exit:直接终止进程(内核级)
#include <unistd.h>
void _exit(int status);

        功能:立即终止进程,释放内核资源,不处理用户空间资源;

        参数 status:退出状态码,仅低 8 位有效(如 _exit(-1) 对应退出码 255);

        特性:不刷新 I/O 缓冲(如 printf 未换行的内容会丢失)。

2.2.2 exit:处理清理后终止(用户级)
#include <stdlib.h>
void exit(int status);

        功能:

                在调用 _exit 前,额外执行三项操作:
                执行 atexit/on_exit 注册的清理函数(如释放自定义资源);
                刷新所有打开的 I/O 流(如 printf 缓冲内容写入终端);
                关闭所有打开的文件描述符;
        示例:

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

void clean() {
    printf("Custom clean function\n");
}

int main() {
    atexit(clean); // 注册清理函数
    printf("Hello "); // 未换行,缓冲未刷新
    exit(0); // 刷新缓冲并执行清理函数
}

运行结果

Hello Custom clean function
2.2.3 return:函数级退出

main 函数中 return n 等价于 exit(n)(其他函数的 return 仅退出函数,不终止进程),是最常用的退出方式。

2.3 退出码与状态解析

进程退出状态通过 退出码(0~255) 表示,规则如下:

        0:执行成功;

        1~255:执行失败(自定义含义,如 1 表示通用错误,2 表示参数错误);

        查看退出码:Shell 中通过 echo $? 查看上一个进程的退出码。

常见退出码含义

退出码含义示例场景
0成功ls 命令正常执行
1通用错误除以 0、权限不足
2命令使用不当ls --invalid-option
126权限拒绝(无法执行)./no-executable-file
127命令未找到lss(拼写错误)
128+n被信号终止(n 为信号值)Ctrl+C 对应 130(128+2)

三、进程等待:回收僵尸进程与获取状态

子进程退出后若父进程不处理,会成为僵尸进程(Z 状态),导致内存泄漏。进程等待通过 wait/waitpid 接口回收子进程资源,并获取其退出状态。

3.1 进程等待的必要性

  1. 避免僵尸进程:僵尸进程的 PCB 仍占用内存,长期积累会导致内存泄漏;
  2. 获取执行结果:父进程需知道子进程任务是否完成、结果是否正确;
  3. 同步父子进程:父进程可等待子进程完成后再继续执行。

3.2 核心等待接口

3.2.1 wait:阻塞等待任意子进程
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);

        功能:阻塞等待任意一个子进程退出,回收其资源;

        参数 status:输出型参数,存储子进程退出状态(不关心可传 NULL);

        返回值:成功返回被回收子进程的 PID,失败返回 -1(如无待回收子进程)。

3.2.2 waitpid:灵活等待指定子进程
pid_t waitpid(pid_t pid, int *status, int options);

        参数 pid:指定等待的子进程:
               
 -1:等待任意子进程(与 wait 等价);
                >0:等待 PID 等于该值的子进程;
        参数 options:等待方式:
               
 0:阻塞等待(子进程未退出时,父进程暂停执行);
                WNOHANG:非阻塞等待(子进程未退出时,立即返回 0,不阻塞);
        返回值:
          
      成功:返回被回收子进程的 PID;
                WNOHANG 时子进程未退出:返回 0;
                失败:返回 -1。

3.3 解析子进程退出状态

status 参数是一个 32 位整数,仅低 16 位有效,按位图解析:

        低 7 位:子进程是否被信号终止(非 0 表示信号终止,值为信号编号);

        第 8 位:正常退出时的退出码(仅当低 7 位为 0 时有效);       

        辅助宏:
              
  WIFEXITED(status):判断是否正常退出(低 7 位为 0);
                WEXITSTATUS(status):提取正常退出的退出码(需先通过 WIFEXITED 判断);
                WIFSIGNALED(status):判断是否被信号终止;
                WTERMSIG(status):提取终止信号编号。

示例代码

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

int main() {
    pid_t pid = fork();
    if (pid < 0) return 1;
    else if (pid == 0) {
        sleep(2);
        exit(10); // 子进程正常退出,退出码 10
    }

    int status;
    pid_t ret = waitpid(pid, &status, 0); // 阻塞等待
    if (ret > 0) {
        if (WIFEXITED(status)) {
            printf("Child exit code: %d\n", WEXITSTATUS(status)); // 输出 10
        } else if (WIFSIGNALED(status)) {
            printf("Child killed by signal: %d\n", WTERMSIG(status));
        }
    }
    return 0;
}

3.4 阻塞与非阻塞等待

3.4.1 阻塞等待

父进程暂停执行,直到子进程退出,适用于 “必须等待子进程完成” 的场景(如批处理任务):

// 阻塞等待子进程退出
waitpid(-1, &status, 0);
3.4.2 非阻塞等待

父进程不暂停,定期检查子进程状态,适用于 “需同时处理其他任务” 的场景(如服务器同时处理多个客户端):

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

int main() {
    pid_t pid = fork();
    if (pid < 0) return 1;
    else if (pid == 0) {
        sleep(5); // 子进程睡眠 5 秒
        exit(1);
    }

    int status;
    pid_t ret;
    // 非阻塞等待:每隔 1 秒检查一次
    while ((ret = waitpid(pid, &status, WNOHANG)) == 0) {
        printf("Child is running...\n");
        sleep(1);
    }

    if (WIFEXITED(status)) {
        printf("Child exit code: %d\n", WEXITSTATUS(status));
    }
    return 0;
}

四、进程程序替换:exec 系列函数

fork 创建的子进程默认执行与父进程相同的代码,若需执行全新程序(如 lscat),需通过 exec 系列函数 替换进程的用户空间代码和数据。

4.1 程序替换的原理

exec 函数不创建新进程(PID 不变),而是:

  1. 清空当前进程的用户空间代码段和数据段;
  2. 从磁盘加载新程序的代码和数据到用户空间;
  3. 重置程序计数器(eip),从新程序的入口(main 函数前的启动代码)开始执行。

4.2 exec 函数簇解析

Linux 提供 6 个 exec 函数,核心差异在于参数格式是否自动搜索 PATH是否自定义环境变量,具体如下表:

函数名参数格式是否自动搜 PATH是否自定义环境变量核心特点
execl列表(arg1,...NULL)适合参数固定的场景
execlp列表(arg1,...NULL)无需写全路径(如 execlp("ls", "ls", "-l", NULL)
execle列表(arg1,...NULL, envp)需手动传入环境变量数组
execv数组(argv [])适合参数数量不确定的场景
execvp数组(argv [])结合数组和 PATH 搜索,最常用
execve数组(argv [], envp [])唯一系统调用,其他函数基于它实现

函数名记忆规律

  l(list):参数为列表(需以 NULL 结尾);

  v(vector):参数为数组(需以 NULL 结尾);

  p(path):自动从 PATH 环境变量搜索程序;

  e(env):自定义环境变量(默认使用父进程环境变量)。

4.3 常用 exec 函数示例

4.3.1 execlp:执行 ls -l(自动搜 PATH)
#include <unistd.h>
int main() {
    // 格式:execlp(程序名, 程序名, 参数1, 参数2, ..., NULL)
    execlp("ls", "ls", "-l", "-a", NULL);
    // 若执行成功,以下代码不会执行
    perror("execlp error");
    return 1;
}
4.3.2 execvp:执行数组参数(适合动态参数)
#include <unistd.h>
int main() {
    char *argv[] = {"ls", "-l", "-a", NULL}; // 数组需以 NULL 结尾
    execvp("ls", argv); // 自动搜 PATH
    perror("execvp error");
    return 1;
}
4.3.3 execve:自定义环境变量
#include <unistd.h>
int main() {
    char *argv[] = {"echo", "Hello", NULL};
    // 自定义环境变量(仅包含 PATH 和 TERM)
    char *envp[] = {"PATH=/bin", "TERM=console", NULL};
    execve("/bin/echo", argv, envp); // 需写全路径
    perror("execve error");
    return 1;
}

五、实战:实现简易 Shell 命令行解释器

Shell 的核心是 “解析用户命令 → 创建子进程 → 程序替换 → 等待子进程退出”,结合前面的进程控制接口,我们可以实现一个支持基础命令的 Shell。

5.1 简易 Shell 的核心流程

  1. 打印提示符:如 [user@host dir]$
  2. 读取命令行:获取用户输入的命令(如 ls -l);
  3. 解析命令:拆分命令名和参数(如 ls -l 拆分为 argv = {"ls", "-l", NULL});
  4. 执行命令
    • 内建命令(如 cdexport):Shell 直接执行(子进程执行会失效);
    • 外部命令(如 lscat):创建子进程,通过 execvp 替换程序;
  5. 等待子进程退出:回收资源并记录退出码。

5.2 关键技术点

5.2.1 内建命令的处理

部分命令需修改 Shell 自身状态(如 cd 切换目录、export 设置环境变量),若由子进程执行会失效,需 Shell 直接处理:

  cd:调用 chdir 系统调用切换当前进程的工作目录;

  export:将环境变量加入 Shell 的环境变量数组;

  echo $?:打印上一个进程的退出码。

5.2.2 命令解析

将用户输入的字符串(如 ls -a -l)拆分为参数数组,需处理空格分隔:

void parse_command(char *cmd, char **argv) {
    int argc = 0;
    argv[argc++] = strtok(cmd, " "); // 第一次调用传入命令字符串
    while ((argv[argc++] = strtok(NULL, " "))); // 后续调用传入 NULL
    argc--; // 去掉最后的 NULL
}

5.3 完整实现代码

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

#define MAX_CMD_LEN 1024
#define MAX_ARGC 64
#define MAX_ENV 64

// 全局变量:命令参数、环境变量、上一个进程退出码
char *g_argv[MAX_ARGC];
char *g_env[MAX_ENV];
int g_last_code = 0;

// 初始化环境变量(从父 Shell 继承)
void init_env() {
    extern char **environ;
    int i = 0;
    while (environ[i] && i < MAX_ENV - 1) {
        g_env[i] = strdup(environ[i]); // 复制环境变量
        i++;
    }
    g_env[i] = NULL;
}

// 打印命令提示符(如 [user@host dir]$)
void print_prompt() {
    char user[32] = "unknown";
    char host[64] = "unknown";
    char pwd[1024] = "unknown";
    getlogin_r(user, sizeof(user));    // 获取当前用户
    gethostname(host, sizeof(host)); // 获取主机名
    getcwd(pwd, sizeof(pwd));       // 获取当前工作目录

    // 提取当前目录(如 /home/user/test → test)
    char *dir = strrchr(pwd, '/');
    dir = dir ? dir + 1 : pwd;

    printf("[%s@%s %s]$ ", user, host, dir);
    fflush(stdout); // 刷新缓冲,确保提示符立即显示
}

// 读取用户命令
int read_command(char *cmd) {
    char *ret = fgets(cmd, MAX_CMD_LEN, stdin);
    if (!ret) return 0; // EOF(如 Ctrl+D)
    cmd[strlen(cmd) - 1] = '\0'; // 去掉换行符
    return strlen(cmd) > 0; // 空命令返回 0
}

// 解析命令为参数数组
void parse_command(char *cmd) {
    memset(g_argv, 0, sizeof(g_argv));
    int argc = 0;
    // 跳过开头空格
    while (isspace(*cmd)) cmd++;
    if (*cmd == '\0') return;

    g_argv[argc++] = cmd;
    // 拆分后续参数
    while (*cmd && argc < MAX_ARGC - 1) {
        if (isspace(*cmd)) {
            *cmd = '\0';
            cmd++;
            while (isspace(*cmd)) cmd++;
            if (*cmd) g_argv[argc++] = cmd;
        } else {
            cmd++;
        }
    }
    g_argv[argc] = NULL;
}

// 执行内建命令(返回 1 表示内建命令,0 表示外部命令)
int exec_builtin() {
    if (strcmp(g_argv[0], "cd") == 0) {
        // cd 命令:切换工作目录
        char *dir = g_argv[1] ? g_argv[1] : getenv("HOME");
        if (chdir(dir) < 0) perror("cd error");
        g_last_code = 0;
        return 1;
    } else if (strcmp(g_argv[0], "export") == 0) {
        // export 命令:设置环境变量
        if (g_argv[1]) {
            int i = 0;
            while (g_env[i]) i++;
            if (i < MAX_ENV - 1) g_env[i] = strdup(g_argv[1]);
            g_env[i + 1] = NULL;
        }
        g_last_code = 0;
        return 1;
    } else if (strcmp(g_argv[0], "echo") == 0) {
        // echo 命令:打印参数或退出码
        if (g_argv[1] && g_argv[1][0] == '$' && g_argv[1][1] == '?') {
            printf("%d\n", g_last_code); // 打印上一个退出码
        } else if (g_argv[1]) {
            printf("%s\n", g_argv[1]); // 打印普通参数
        }
        g_last_code = 0;
        return 1;
    } else if (strcmp(g_argv[0], "exit") == 0) {
        // exit 命令:退出 Shell
        exit(g_last_code);
    }
    return 0; // 非内建命令
}

// 执行外部命令
void exec_external() {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork error");
        g_last_code = 1;
        return;
    } else if (pid == 0) {
        // 子进程:程序替换
        execvp(g_argv[0], g_argv);
        perror("execvp error"); // 替换失败才执行
        exit(127); // 命令未找到
    } else {
        // 父进程:等待子进程退出
        int status;
        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) {
            g_last_code = WEXITSTATUS(status);
        } else {
            g_last_code = 128 + WTERMSIG(status);
        }
    }
}

int main() {
    char cmd[MAX_CMD_LEN];
    init_env();

    while (1) {
        print_prompt();          // 1. 打印提示符
        if (!read_command(cmd))  // 2. 读取命令
            continue;
        parse_command(cmd);      // 3. 解析命令
        if (!g_argv[0])         // 空命令跳过
            continue;
        
        if (exec_builtin())      // 4. 执行内建命令
            continue;
        exec_external();         // 5. 执行外部命令
    }
    return 0;
}

5.4 编译与测试

# 编译
gcc myshell.c -o myshell
# 运行
./myshell
# 测试命令
[user@host myshell]$ ls -l
[user@host myshell]$ cd ..
[user@host ~]$ echo $?
0
[user@host ~]$ export MYENV=hello
[user@host ~]$ exit

六、总结

进程控制是 Linux 系统编程的基石,核心要点如下:

  1. 进程创建fork 通过写时拷贝实现资源复用,父子进程共享代码、独立数据;
  2. 进程终止exit 处理清理逻辑,_exit 直接终止,退出码反映执行结果;
  3. 进程等待wait/waitpid 回收僵尸进程,通过 status 解析退出状态;
  4. 程序替换exec 函数簇替换用户空间代码,execvp 因自动搜 PATH 最常用;
  5. Shell 实现:核心是 “解析命令 → 内建命令直接执行 → 外部命令创建子进程替换”。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值