深入理解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获取子进程退出状态

相关推荐
思麟呀2 小时前
C++工业级日志项目(七)日志器核心
linux·开发语言·c++·windows
满天星83035772 小时前
【Git】原理及使用(二) (版本回退)
linux·git
cd_949217212 小时前
水处理市场升级,台州海德能环保科技凭技术创新与服务并重脱颖而出
大数据·运维·科技
Strugglingler2 小时前
【Linux Device Drivers-第九章 与硬件通讯 I/O端口,I/O内存】
linux·i/o端口·i/o内存
.YYY2 小时前
万字详解|Linux Chrony 时间服务完整学习手册
linux·运维
疯狂成瘾者2 小时前
GHCR 是什么?GitHub 容器镜像仓库技术介绍
java·linux
QFIUNE2 小时前
使用 MMseqs2 计算多个 DTI 数据集的蛋白序列相似度
linux·python·ubuntu
Li-Yongjun2 小时前
Linux 内核等待队列(Wait Queue)
linux·运维·windows
字节高级特工2 小时前
【Linux】深入理解C语言命令行参数与环境变量
linux·c++·人工智能·后端