深入理解Linux进程控制:从fork到exec,手写一个迷你Shell

前言

进程控制是操作系统最核心的概念之一。你是否好奇过:当我们在终端输入一个命令,按下回车后,计算机内部到底发生了什么?Shell是如何创建新进程的?父进程和子进程之间又是什么关系?

本文将带你从理论到实践,彻底搞懂Linux进程控制的四大核心操作:进程创建、进程终止、进程等待、进程程序替换。最后,我们将手写一个迷你Shell,把所有知识点串起来。

一、进程创建:fork函数的奥秘

1.1 fork基础

在Linux中,fork是用于创建新进程的系统调用。新创建的进程称为子进程 ,原来的进程称为父进程

cpp 复制代码
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();
    
    if (pid < 0) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // 子进程
        printf("我是子进程,PID: %d,父进程PID: %d\n", getpid(), getppid());
    } else {
        // 父进程
        printf("我是父进程,PID: %d,子进程PID: %d\n", getpid(), pid);
    }
    
    return 0;
}

运行结果:

cpp 复制代码
我是父进程,PID: 43676,子进程PID: 43677
我是子进程,PID: 43677,父进程PID: 43676

1.2 fork的奇妙之处

fork函数有一个非常反直觉的特性:调用一次,返回两次

  • 在父进程中,fork返回子进程的PID(正整数)

  • 在子进程中,fork返回0

  • 如果出错,返回-1

为什么要这样设计?

复制代码
pid_t pid = fork();
if (pid == 0) {
    // 子进程执行的代码
    exec(...);
} else {
    // 父进程执行的代码  
    wait(pid);
}

这种设计让父子进程可以通过返回值轻松区分自己,从而执行不同的代码逻辑。

1.3 写时拷贝技术

fork后,父子进程共享 同一份代码和数据(这是Linux的优化)。只有当某一方试图写入数据时,系统才会真正拷贝一份副本。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int global_var = 100;

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        global_var = 200;  // 子进程修改,触发写时拷贝
        printf("子进程: global_var = %d\n", global_var);
    } else {
        sleep(1);
        printf("父进程: global_var = %d\n", global_var);
    }
    return 0;
}

运行结果:

复制代码
子进程: global_var = 200
父进程: global_var = 100    // 父进程不受影响

写时拷贝的好处:

  • 节省内存资源

  • 提高fork执行效率

  • 保证进程间的独立性

二、进程终止:进程的生命终点

2.1 进程退出的三种场景

  1. 代码运行完毕,结果正确 → 退出码0

  2. 代码运行完毕,结果错误 → 退出码非0

  3. 代码异常终止 → 被信号杀死(如段错误、Ctrl+C)

2.2 三种退出方式

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    // 方式1: main函数return
    return 0;
    
    // 方式2: exit() - 会执行清理工作
    exit(0);
    
    // 方式3: _exit() - 直接退出,不做清理
    _exit(0);
}

2.3 exit和_exit的区别

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    printf("Hello, World!");  // 没有换行,数据还在缓冲区
    
    exit(0);   // 会刷新缓冲区,输出 "Hello, World!"
    // _exit(0);  // 不会刷新缓冲区,不输出任何内容
}

运行对比:

  • 使用exit(0):会输出"Hello, World!"

  • 使用_exit(0):不会输出任何内容

2.4 退出码的意义

cpp 复制代码
#include <stdio.h>
#include <string.h>

int main() {
    // 查看退出码对应的含义
    for (int i = 0; i <= 10; i++) {
        printf("退出码 %d: %s\n", i, strerror(i));
    }
    return 0;
}

常见退出码:

退出码 含义
0 成功
1 一般错误
2 命令使用不当
127 命令未找到
130 Ctrl+C终止

三、进程等待:避免僵尸进程

3.1 为什么要等待子进程?

子进程退出后,会变成僵尸进程

  • 占用内核资源

  • 无法被kill -9杀死

  • 如果不回收,会导致内存泄漏

父进程通过waitwaitpid来:

  1. 回收子进程资源

  2. 获取子进程的退出状态

3.2 wait和waitpid的使用

函数原型:

cpp 复制代码
#include <sys/wait.h>

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

区别:

函数 特点
wait() 阻塞等待任意一个子进程退出
waitpid() 指定 PID 、可非阻塞、功能更强
int *status 一句话讲透

status 是用来存放「子进程退出状态信息」的 它是输出型参数,函数内部把子进程死活信息塞进这个变量里。

本质:

复制代码
int status;
wait(&status);   // 传地址进去
  • 你定义一个普通整型变量 status
  • 地址传给 wait /waitpid
  • 系统自动把子进程退出信息写入这个变量

status 里面存了啥(二进制低 16 位)

复制代码
高8位:正常退出码 exit(code)
低7位:杀死进程的信号值
第8位:core dump标志
  1. 怎么取出有用数据(只用系统宏,别手算)
cpp 复制代码
// 1. 判断是否正常退出
WIFEXITED(status)  成立 = 正常退出

// 取出正常退出码
WEXITSTATUS(status)

// 2. 判断是否被信号杀死
WIFSIGNALED(status) 成立 = 异常死亡

// 取出杀死它的信号编号
WTERMSIG(status)

三种常用场景

  1. 子进程 exit(5) 退出

    • WIFEXITED = 真
    • WEXITSTATUS=5
  2. 子进程被 kill 杀掉

    • WIFSIGNALED = 真
    • WTERMSIG = 对应信号值
  3. 不需要获取退出信息直接传 NULL

    wait(NULL);
    waitpid(-1, NULL, 0);

wait /waitpid 返回值

一、wait () 返回值

只有 2 种情况

  1. > 0 → 成功,返回已退出子进程的 PID
  2. -1 → 失败(没有子进程、调用出错)永远不会返回 0

二、waitpid () 返回值(重点)

3 种情况

  1. > 0 → 成功,返回已退出子进程的 PID
  2. = 0子进程还活着,未退出(只有 WNOHANG 非阻塞才会出现)
  3. -1 → 失败(无子进程、参数错误)

1. wait () 使用

功能

父进程阻塞 ,直到任意子进程退出

代码示例

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        printf("子进程开始执行,PID: %d\n", getpid());
        sleep(3);
        printf("子进程即将退出\n");
        exit(42);  // 返回退出码42
    } else {
        // 父进程
        int status;
        pid_t ret = wait(&status);  // 阻塞等待
        
        if (ret > 0 && (status & 0x7F) == 0) {
            // 正常退出
            printf("子进程正常退出,退出码: %d\n", (status >> 8) & 0xFF);
        } else if (ret > 0) {
            // 异常退出
            printf("子进程异常退出,终止信号: %d\n", status & 0x7F);
        }
    }
    return 0;
}

2. waitpid () 使用(重点)

3 个参数

  1. pid

    • -1:等待任意子进程(等价 wait)
    • >0:等待指定 PID的子进程
  2. status:存储退出信息

  3. options

    • 0:阻塞等待
    • WNOHANG非阻塞等待(不卡住父进程)

示例 1:等价 wait ()

复制代码
waitpid(-1, &status, 0);

示例 2:等待指定子进程

复制代码
waitpid(pid, &status, 0);
示例 3:非阻塞等待(超级常用)

父进程不会卡住,可以一边做自己的事,一边检查子进程。

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        sleep(5);
        exit(0);
    } else {
        int status;
        pid_t ret;
        
        // 非阻塞轮询
        while ((ret = waitpid(pid, &status, WNOHANG)) == 0) {
            printf("子进程还在运行,我可以做其他事情...\n");
            sleep(1);
        }
        
        printf("子进程已退出\n");
    }
    return 0;
}

四、进程程序替换:让子进程"变身"

4.1 什么是程序替换?

fork创建的子进程默认执行父进程的代码。如果我们想让子进程执行一个全新的程序 (比如ls命令),就需要用到exec系列函数。

核心原理:

  • 加载新程序到内存

  • 替换当前进程的代码段和数据段

  • 进程ID不变

4.2 exec函数族详解

cpp 复制代码
#include <unistd.h>

// 参数使用列表
int execl(const char *path, const char *arg, ...);
// 参数使用数组
int execv(const char *path, char *const argv[]);
// 自动搜索PATH
int execlp(const char *file, const char *arg, ...);
// 带环境变量
int execle(const char *path, const char *arg, ..., char *const envp[]);

命名规律:

  • l (list):参数是列表形式

  • v (vector):参数是数组形式

  • p (path):自动搜索环境变量PATH

  • e (env):可以传递环境变量

4.3 exec使用示例

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程:执行ls命令
        
        // 方式1: execl
        execl("/bin/ls", "ls", "-l", "-a", NULL);
        
        // 方式2: execlp(自动搜索PATH)
        // execlp("ls", "ls", "-l", "-a", NULL);
        
        // 方式3: execv
        // char *argv[] = {"ls", "-l", "-a", NULL};
        // execv("/bin/ls", argv);
        
        // 如果exec成功,下面代码不会执行
        perror("exec failed");
        exit(1);
    } else {
        wait(NULL);
        printf("子进程执行完毕\n");
    }
    
    return 0;
}

4.4 execve:真正的系统调用

其他5个exec函数最终都调用execve

cpp 复制代码
#include <unistd.h>

int execve(const char *filename, char *const argv[], char *const envp[]);

五、综合实战:手写一个迷你Shell

现在,我们把前面学到的所有知识综合起来,实现一个真正的命令行解释器!

5.1 Shell的工作原理

Shell的本质是一个无限循环:

  1. 显示提示符

  2. 读取用户输入

  3. 解析命令

  4. 创建子进程执行命令

  5. 等待子进程结束

  6. 回到步骤1

5.2 完整代码实现

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

#define MAX_CMD_LEN 1024
#define MAX_ARG_NUM 64

// 全局命令行参数
char *g_argv[MAX_ARG_NUM];
int g_argc = 0;
int last_exit_code = 0;

// 显示提示符
void print_prompt() {
    char cwd[256];
    getcwd(cwd, sizeof(cwd));
    
    char *user = getenv("USER");
    char *host = getenv("HOSTNAME");
    
    printf("[%s@%s %s]$ ", user ? user : "user", 
           host ? host : "localhost", 
           cwd);
    fflush(stdout);
}

// 读取命令
int read_command(char *buf, int size) {
    char *ret = fgets(buf, size, stdin);
    if (!ret) return 0;
    
    // 去掉末尾的换行符
    buf[strlen(buf) - 1] = '\0';
    return strlen(buf) > 0;
}

// 解析命令
void parse_command(char *cmd) {
    g_argc = 0;
    char *token = strtok(cmd, " ");
    
    while (token && g_argc < MAX_ARG_NUM - 1) {
        g_argv[g_argc++] = token;
        token = strtok(NULL, " ");
    }
    g_argv[g_argc] = NULL;
}

// 内建命令:cd
int builtin_cd() {
    if (g_argc != 2) {
        printf("Usage: cd <directory>\n");
        return 1;
    }
    
    if (chdir(g_argv[1]) != 0) {
        perror("cd failed");
        return 1;
    }
    return 0;
}

// 内建命令:exit
int builtin_exit() {
    printf("Goodbye!\n");
    exit(0);
}

// 内建命令:echo
int builtin_echo() {
    for (int i = 1; i < g_argc; i++) {
        // 处理 $? 获取上次退出码
        if (g_argv[i][0] == '$' && g_argv[i][1] == '?') {
            printf("%d", last_exit_code);
        } else {
            printf("%s", g_argv[i]);
        }
        if (i < g_argc - 1) printf(" ");
    }
    printf("\n");
    return 0;
}

// 检查并执行内建命令
int execute_builtin() {
    if (strcmp(g_argv[0], "cd") == 0) {
        last_exit_code = builtin_cd();
        return 1;
    }
    if (strcmp(g_argv[0], "exit") == 0) {
        builtin_exit();
        return 1;
    }
    if (strcmp(g_argv[0], "echo") == 0) {
        last_exit_code = builtin_echo();
        return 1;
    }
    return 0;
}

// 执行外部命令
void execute_external() {
    pid_t pid = fork();
    
    if (pid < 0) {
        perror("fork failed");
        last_exit_code = 1;
        return;
    }
    
    if (pid == 0) {
        // 子进程:执行命令
        execvp(g_argv[0], g_argv);
        
        // 如果exec失败
        printf("%s: command not found\n", g_argv[0]);
        exit(127);
    } else {
        // 父进程:等待子进程
        int status;
        waitpid(pid, &status, 0);
        
        if (WIFEXITED(status)) {
            last_exit_code = WEXITSTATUS(status);
        } else {
            last_exit_code = 128 + WTERMSIG(status);
        }
    }
}

int main() {
    char cmd_line[MAX_CMD_LEN];
    
    printf("=== 欢迎使用迷你Shell ===\n");
    printf("支持内建命令: cd, exit, echo\n");
    printf("支持外部命令: ls, ps, etc.\n\n");
    
    while (1) {
        print_prompt();
        
        if (!read_command(cmd_line, sizeof(cmd_line))) {
            printf("\n");
            continue;
        }
        
        if (strlen(cmd_line) == 0) continue;
        
        parse_command(cmd_line);
        
        // 先检查是否是内建命令
        if (!execute_builtin()) {
            // 否则作为外部命令执行
            execute_external();
        }
    }
    
    return 0;
}

5.4 代码核心解析

  1. 主循环:无限循环处理用户命令

  2. 命令解析 :使用strtok分割命令字符串

  3. 内建命令cdexitecho由Shell自己处理

  4. 外部命令fork创建子进程,execvp执行命令

  5. 退出码 :通过waitpid获取子进程退出状态

相关推荐
A小辣椒15 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒19 小时前
TShark:基础知识
linux
AlfredZhao21 小时前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao1 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
大树882 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质2 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式