Linux进程控制完全笔记(fork→exec→wait→Shell)

本文是Linux系统编程中进程控制模块的全面总结,涵盖进程创建、终止、等待、程序替换及Shell实现,包含大量代码示例、图解和面试常考点。无论你是初学者还是准备校招,这篇笔记都值得收藏。


📌 目录

  1. 进程创建
    • fork函数详解
    • 写时拷贝技术
    • vfork补充
  2. 进程终止
    • 退出场景与退出码
    • exit vs _exit vs return
  3. 进程等待
    • 为什么必须等待
    • wait/waitpid完全解析
    • status位图结构(重点)
    • 阻塞与非阻塞等待
  4. 进程程序替换
    • 替换原理
    • exec族函数大全(6个+记忆口诀)
    • 环境变量传递
  5. 实战:手写迷你Shell
    • 整体框架
    • 内建命令实现
    • 完整代码
  6. 总结与易错点

1. 进程创建

1.1 fork函数初识

在Linux中,fork是用于创建新进程的核心系统调用。它从已存在的进程中复制出一个几乎完全相同的副本 :原进程称为父进程 ,新进程称为子进程

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

经典代码示例:

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

int main() {
    pid_t pid;
    printf("Before fork, my pid: %d\n", getpid());
    
    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(1);
    }
    
    if (pid == 0) {
        // 子进程
        printf("Child:  pid = %d, fork() returned %d, my parent = %d\n", 
               getpid(), pid, getppid());
    } else {
        // 父进程
        printf("Parent: pid = %d, fork() returned %d, child pid = %d\n", 
               getpid(), pid, pid);
    }
    
    printf("这句代码父子进程都会执行,pid=%d\n", getpid());
    return 0;
}

运行结果(可能顺序不同):

复制代码
Before fork, my pid: 1234
Parent: pid = 1234, fork() returned 1235, child pid = 1235
Child:  pid = 1235, fork() returned 0, my parent = 1234
这句代码父子进程都会执行,pid=1234
这句代码父子进程都会执行,pid=1235

三个经典疑问解答:

问题 解答
为什么要给子进程返回0,父进程返回子进程PID? 一个父进程可以有多个子进程,父进程需要知道每个子进程的ID以便管理;子进程只需知道自己是否为子进程,用0标识简单高效。
为什么fork会有两个返回值? fork调用时,内核先复制PCB,然后两个进程都从fork函数返回。父进程返回子进程PID,子进程返回0。
为什么一个变量(pid)既能等于0又能大于0? 父子进程有各自独立的地址空间,pid是各自栈上的变量,互不影响。父进程中pid=1235,子进程中pid=0。

1.2 写时拷贝(Copy-On-Write)

核心思想: fork之后,父子进程共享同一份代码和数据(只读)。只有当某一方试图修改数据时,内核才为修改方分配新的物理页面,复制原内容,这种"延迟复制"技术称为写时拷贝。

图示:

写时拷贝的优点:

  • 避免不必要的复制,提高fork效率
  • 最大程度节省物理内存
  • 保证了进程的独立性------一个进程崩溃不会影响另一个

1.3 fork的常规用法与失败原因

常规用法:

  1. 并行处理:父进程等待请求,子进程处理具体任务(如Web服务器)
  2. 执行新程序:子进程fork后立即调用exec族函数执行另一个程序

fork失败原因:

  • 系统进程数达到上限(cat /proc/sys/kernel/pid_max查看)
  • 当前用户进程数超过限制(ulimit -u

1.4 vfork补充(面试常考)

vfork也是创建子进程,但有以下区别:

特性 fork vfork
地址空间 写时拷贝,独立 共享父进程地址空间(不拷贝页表)
执行顺序 父子进程调度器决定 子进程先运行,直到调用exit或exec,父进程才恢复
性能 较慢(但现代写时拷贝已优化) 更快(适合立即exec的场景)
现代用途 普遍使用 已不推荐,基本被fork替代
c 复制代码
// vfork示例:子进程必须立即exec或exit
if (vfork() == 0) {
    execl("/bin/ls", "ls", NULL);
    exit(0);  // 如果exec失败,必须exit
}
// 父进程在这里继续执行

2. 进程终止

2.1 退出场景

场景 说明 举例
代码运行完毕,结果正确 正常退出,退出码0 return 0;
代码运行完毕,结果错误 正常退出,退出码非0 return 1;(如文件不存在)
代码异常终止 被信号杀死 段错误(SIGSEGV)、Ctrl+C(SIGINT)

2.2 常见退出方法

正常终止(3种):

c 复制代码
int main() {
    // 1. main函数return
    return 0;
    
    // 2. 调用exit() - 标准库函数
    exit(0);
    
    // 3. 调用_exit() - 系统调用
    _exit(0);
}

异常终止:

  • Ctrl + C → 发送SIGINT
  • kill -9 pid → 发送SIGKILL
  • 野指针/除零等 → 发送SIGSEGV/SIGFPE

2.3 exit vs _exit vs return ------ 核心区别

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

int main() {
    printf("Hello");   // 没有换行符,数据在缓冲区
    
    exit(0);   // 会刷新缓冲区,输出"Hello"
    // _exit(0);  // 不会刷新缓冲区,无输出
    // return 0;  // 同exit(0),也会刷新缓冲区
}

区别总结:

函数 类型 刷新缓冲区 执行清理函数(atexit) 关闭流
return C语言关键字
exit() 标准库函数
_exit() 系统调用

💡 注意 :只有main函数中的return才表示进程退出。其他函数的return只是函数返回,不会终止进程。

2.4 退出码与echo $?

在Shell中执行程序后,echo $?可以查看上一个程序的退出状态。

bash 复制代码
$ ./a.out
$ echo $?
0   # 正常退出

$ ls /notexist
ls: cannot access /notexist: No such file or directory
$ echo $?
2   # ls命令返回2表示错误

常用退出码含义表:

退出码 含义
0 成功
1 通用错误(如权限不足、除零)
2 命令使用不当(参数错误)
126 命令不可执行(权限拒绝)
127 命令未找到
128+n 被信号n终止(如130=SIGINT, 137=SIGKILL)
c 复制代码
// 获取退出码对应的描述
#include <string.h>
printf("%s\n", strerror(1));  // 输出 "Operation not permitted"

3. 进程等待

3.1 为什么要等待子进程?(必考)

  1. 避免僵尸进程 :子进程退出后,如果父进程不调用wait,子进程的PCB(进程控制块)无法释放,变成僵尸进程(Z),造成内存泄漏。
  2. 无法杀死僵尸kill -9对僵尸进程无效,因为它们已经"死"了,只留下一个空壳PCB。
  3. 获取退出信息:父进程需要知道子进程的任务完成情况(成功/失败/被信号杀死)。
bash 复制代码
# 查看僵尸进程
ps aux | grep defunct

3.2 wait和waitpid详解

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

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait函数
  • 阻塞等待任意一个子进程退出
  • 等价于waitpid(-1, &status, 0)
waitpid参数详解

① pid参数取值规则:

pid值 含义
-1 等待任意子进程(同wait)
>0 等待进程ID等于pid的指定子进程
0 等待与父进程同进程组的任意子进程
< -1 等待进程组ID等于pid的任意子进程

② status ------ 输出型参数,存储退出状态

status是32位整型,只使用低16位,位图结构如下:

  • 正常终止:低7位全0,高8位存储退出码
  • 被信号杀死:低7位存储终止信号编号,高8位无意义

③ 宏函数(推荐使用,避免手动位运算):

宏函数 作用
WIFEXITED(status) 判断是否正常退出
WEXITSTATUS(status) 获取退出码(需先WIFEXITED为真)
WIFSIGNALED(status) 判断是否被信号终止
WTERMSIG(status) 获取终止信号的编号

④ options ------ 等待模式:

选项 含义
0 阻塞等待:子进程不退出,父进程一直挂起
WNOHANG 非阻塞等待:若子进程未退出,立即返回0

3.3 阻塞等待 vs 非阻塞等待(代码对比)

阻塞等待示例:

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

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:睡5秒后退出
        sleep(5);
        printf("Child exit\n");
        return 42;
    } else {
        int status;
        printf("Parent waiting...\n");
        pid_t ret = waitpid(pid, &status, 0);  // 阻塞等待
        if (WIFEXITED(status)) {
            printf("Child exited normally, code=%d\n", WEXITSTATUS(status));
        }
    }
    return 0;
}

非阻塞等待示例(父进程可做其他事):

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

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        sleep(5);
        return 10;
    } else {
        int status;
        pid_t ret;
        int count = 0;
        while (1) {
            ret = waitpid(pid, &status, WNOHANG);  // 非阻塞
            if (ret == 0) {
                printf("Child still running... (%d)\n", ++count);
                // 这里可以做其他事情
                sleep(1);
            } else if (ret == pid) {
                if (WIFEXITED(status))
                    printf("Child exit code: %d\n", WEXITSTATUS(status));
                break;
            } else {
                perror("waitpid");
                break;
            }
        }
    }
    return 0;
}

输出:

复制代码
Child still running... (1)
Child still running... (2)
Child still running... (3)
Child still running... (4)
Child still running... (5)
Child exit code: 10

3.4 完整status解析示例

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

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:被信号杀死
        int *p = NULL;
        *p = 100;  // 段错误,SIGSEGV
        exit(0);
    } else {
        int status;
        wait(&status);
        
        if (WIFEXITED(status)) {
            printf("Normal exit, code=%d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Killed by signal %d (%s)\n", 
                   WTERMSIG(status), strsignal(WTERMSIG(status)));
        }
    }
    return 0;
}

4. 进程程序替换

4.1 替换原理

核心概念: 程序替换是指在进程中执行一个全新的程序 ,替换掉当前进程的代码段、数据段、堆栈,但进程的PCB(包括PID)不变

重要特性:

  • 调用成功没有返回值(因为当前进程已经被替换)
  • 失败返回-1
  • 不会创建新进程,只是"变身"

4.2 exec族函数大全(6个)

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

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
命名记忆口诀
字母 含义 说明
l list 参数以列表形式传递(逐个写出)
v vector 参数以数组(向量)传递
p path 自动搜索PATH环境变量,只需写程序名
e env 可以自定义环境变量(最后一个参数传递envp数组)

💡 注意 :只有execve是真正的系统调用 ,其他5个都是库函数,最终都会调用execve

详细示例
c 复制代码
#include <unistd.h>
#include <stdio.h>

int main() {
    // 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);
    
    // 4. execvp: 数组传参,自动搜索PATH
    execvp("ls", argv);
    
    // 5. execle: 列表传参,自定义环境变量
    char *envp[] = {"PATH=/bin", "HOME=/root", NULL};
    execle("/bin/ls", "ls", "-l", NULL, envp);
    
    // 6. execve: 系统调用,数组+自定义环境变量
    execve("/bin/ls", argv, envp);
    
    perror("exec failed");  // 只有exec失败才会执行到这里
    return 1;
}

4.3 环境变量继承与传递

子进程默认继承父进程的环境变量:

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

int main() {
    extern char **environ;  // 全局环境变量表
    
    printf("Parent env: %s\n", getenv("MY_VAR"));  // 假设父shell没有这个变量
    
    if (fork() == 0) {
        // 子进程会继承父进程的环境变量
        printf("Child env: %s\n", getenv("MY_VAR"));  // 可能输出null
        execlp("env", "env", NULL);  // 打印所有环境变量
    }
    return 0;
}

通过execle传递自定义环境变量:

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

int main() {
    char *envp[] = {
        "MY_VAR=hello_from_exec",
        "PATH=/usr/bin",
        NULL
    };
    execle("/bin/echo", "echo", "MY_VAR=$MY_VAR", NULL, envp);
    // 输出:MY_VAR=hello_from_exec
    return 0;
}

4.4 常见错误与最佳实践

❌ 错误1:exec后还写代码

c 复制代码
if (fork() == 0) {
    execlp("ls", "ls", NULL);
    printf("This will never print if exec succeeds\n");  // 永远不会执行
}

✅ 正确:处理exec失败情况

c 复制代码
if (fork() == 0) {
    if (execlp("ls", "ls", NULL) == -1) {
        perror("execlp");
        exit(1);  // exec失败必须退出,否则会继续执行父进程的代码
    }
}

❌ 错误2:忘记参数列表以NULL结尾

c 复制代码
execlp("ls", "ls", "-l");  // 错误,缺少NULL
execlp("ls", "ls", "-l", NULL);  // 正确

5. 实战:手写迷你Shell

5.1 设计原理

一个Shell的核心工作流程是无限循环

5.2 内建命令 vs 外部命令

类型 示例 为什么必须Shell自己执行
内建命令 cd, export, exit 需要改变Shell自身状态(如工作目录、环境变量)
外部命令 ls, ps, grep 由子进程执行即可

5.3 完整代码实现

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

#define MAX_CMD_LEN 1024
#define MAX_ARGS 64

// 全局变量:上一次命令的退出码
int last_exit_code = 0;

// 获取提示符(简化版:显示当前目录)
void print_prompt() {
    char cwd[256];
    getcwd(cwd, sizeof(cwd));
    printf("[myshell %s]$ ", cwd);
    fflush(stdout);
}

// 读取用户输入
char* read_command(char *buf, size_t size) {
    if (fgets(buf, size, stdin) == NULL)
        return NULL;
    // 去掉末尾换行符
    buf[strlen(buf) - 1] = '\0';
    return buf;
}

// 解析命令:将字符串分割成 argv 数组
int parse_command(char *cmd, char *argv[]) {
    int argc = 0;
    char *token = strtok(cmd, " ");
    while (token != NULL && argc < MAX_ARGS - 1) {
        argv[argc++] = token;
        token = strtok(NULL, " ");
    }
    argv[argc] = NULL;
    return argc;
}

// 执行外部命令
void exec_external(char *argv[]) {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return;
    }
    
    if (pid == 0) {
        // 子进程:执行命令
        execvp(argv[0], argv);
        // 如果exec失败
        perror("execvp");
        exit(1);
    } else {
        // 父进程:等待子进程结束
        int status;
        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) {
            last_exit_code = WEXITSTATUS(status);
        } else if (WIFSIGNALED(status)) {
            printf("Child killed by signal %d\n", WTERMSIG(status));
            last_exit_code = 128 + WTERMSIG(status);
        }
    }
}

// 内建命令:cd
void builtin_cd(char *path) {
    if (path == NULL) {
        path = getenv("HOME");
    }
    if (chdir(path) != 0) {
        perror("cd");
        last_exit_code = 1;
    } else {
        last_exit_code = 0;
        // 更新 PWD 环境变量
        char cwd[256];
        getcwd(cwd, sizeof(cwd));
        setenv("PWD", cwd, 1);
    }
}

// 内建命令:export
void builtin_export(char *arg) {
    if (arg == NULL) {
        printf("Usage: export VAR=value\n");
        last_exit_code = 1;
        return;
    }
    if (putenv(arg) != 0) {
        perror("export");
        last_exit_code = 1;
    } else {
        last_exit_code = 0;
    }
}

// 内建命令:echo(支持 $?)
void builtin_echo(char *argv[]) {
    if (argv[1] == NULL) {
        printf("\n");
        return;
    }
    
    // 检查是否为 $?
    if (strcmp(argv[1], "$?") == 0) {
        printf("%d\n", last_exit_code);
    } else {
        // 简单打印所有参数
        for (int i = 1; argv[i] != NULL; i++) {
            printf("%s%c", argv[i], (argv[i+1] == NULL) ? '\n' : ' ');
        }
    }
    last_exit_code = 0;
}

// 检查并执行内建命令,返回1表示已处理,0表示需要外部执行
int run_builtin(char *argv[], int argc) {
    if (strcmp(argv[0], "cd") == 0) {
        builtin_cd(argv[1]);
        return 1;
    } else if (strcmp(argv[0], "export") == 0) {
        builtin_export(argv[1]);
        return 1;
    } else if (strcmp(argv[0], "echo") == 0) {
        builtin_echo(argv);
        return 1;
    } else if (strcmp(argv[0], "exit") == 0) {
        exit(0);
    }
    return 0;
}

int main() {
    char cmd_line[MAX_CMD_LEN];
    char *argv[MAX_ARGS];
    
    while (1) {
        print_prompt();
        if (read_command(cmd_line, sizeof(cmd_line)) == NULL) {
            printf("\n");
            break;
        }
        if (strlen(cmd_line) == 0)
            continue;
        
        int argc = parse_command(cmd_line, argv);
        if (argc == 0)
            continue;
        
        // 先检查内建命令
        if (run_builtin(argv, argc)) {
            continue;
        }
        
        // 执行外部命令
        exec_external(argv);
    }
    return 0;
}

5.4 编译与运行

bash 复制代码
$ gcc -o myshell myshell.c
$ ./myshell
[myshell /home/user]$ ls -l
total 32
-rwxr-xr-x 1 user user 18000 Jan 1 12:00 myshell
...
[myshell /home/user]$ cd /tmp
[myshell /tmp]$ pwd
/tmp
[myshell /tmp]$ echo $?
0
[myshell /tmp]$ export MYVAR=hello
[myshell /tmp]$ echo $MYVAR
hello
[myshell /tmp]$ exit

6. 总结与易错点

6.1 核心知识点速查表

知识点 关键点 面试高频
fork返回值 父进程返回子进程PID,子进程返回0 ⭐⭐⭐⭐⭐
写时拷贝 fork后不立即复制物理内存,写时才复制 ⭐⭐⭐⭐
僵尸进程 子进程退出,父进程未wait,PCB无法释放 ⭐⭐⭐⭐⭐
孤儿进程 父进程先退出,子进程被init收养 ⭐⭐⭐
waitpid 可指定等待特定子进程,支持非阻塞 ⭐⭐⭐⭐
status解析 低7位存信号,高8位存退出码 ⭐⭐⭐⭐
exec族 l/v/p/e区分,只有execve是系统调用 ⭐⭐⭐⭐⭐
环境变量 子进程默认继承父进程环境 ⭐⭐⭐

6.2 最常见的5个错误

  1. 忘记wait导致僵尸进程

    c 复制代码
    // 错误
    fork();
    // 父进程什么都不做直接退出 → 子进程变孤儿,但不僵尸(被init收养)
    // 但如果父进程长期运行不wait,就会产生僵尸
    
    // 正确
    pid = fork();
    if (pid > 0) wait(NULL);
  2. exec后忘记处理失败

    c 复制代码
    // 错误
    if (fork() == 0) {
        execlp("ls", "ls", NULL);
        // 如果exec失败,子进程还会执行后面的代码
        exit(0);  // 应该立即退出
    }
  3. 对status进行错误位运算

    c 复制代码
    // 错误
    printf("exit code: %d\n", status >> 8);  // 没有屏蔽高位符号位
    
    // 正确
    printf("exit code: %d\n", (status >> 8) & 0xFF);
    // 更好:使用宏
    printf("exit code: %d\n", WEXITSTATUS(status));
  4. vfork中子进程修改父进程变量

    c 复制代码
    int x = 10;
    if (vfork() == 0) {
        x = 20;   // 危险!会修改父进程的x
        exit(0);
    }
    printf("%d\n", x);  // 输出20,不是10
  5. exec参数列表忘记NULL结尾

    c 复制代码
    execlp("ls", "ls", "-l");  // 错误:可能读取随机内存
    execlp("ls", "ls", "-l", NULL);  // 正确

6.3 面试加分回答

Q: fork和vfork的区别?

fork使用写时拷贝技术,父子进程地址空间独立;vfork共享地址空间,且保证子进程先运行直到调用exec/exit。现代系统中vfork已不常用,因为fork的写时拷贝足够高效。

Q: 如何避免僵尸进程?

  1. 父进程主动调用wait/waitpid回收;2. 父进程忽略SIGCHLD信号(signal(SIGCHLD, SIG_IGN));3. 父进程先退出,让子进程被init收养。

Q: 为什么exec后进程的PID不变?

exec只是替换进程的代码和数据,不创建新的PCB,因此PID保持不变。可以理解为进程"变身"成了另一个程序。


📚 参考阅读

  • man 2 fork, man 2 wait, man 3 exec
  • 《Unix环境高级编程》第7-8章
  • Linux内核源码:kernel/fork.c, fs/exec.c

如果觉得本文对你有帮助,欢迎点赞收藏⭐,你的支持是我写作的动力!

本文原创,转载请注明出处。如有错误,欢迎指正。

相关推荐
ReaF_star2 小时前
K8s Pod调度【学习笔记】
笔记·学习·kubernetes
念恒123062 小时前
进程概念(2)
linux·c语言
程序员大辉2 小时前
Beaver Notes(海狸笔记)v4.4.0 中文版 ,开源免费、本地存储、零追踪的笔记软件
笔记·开源
charlie1145141912 小时前
嵌入式Linux驱动开发(4)——内核打印详解
linux·驱动开发·imx6ull
俺爱吃萝卜2 小时前
开源贡献指南:如何给Apache或Linux内核提PR?
linux·开源·apache
叛逆的小小黄2 小时前
maxent建模结果中响应曲线的美化
经验分享·笔记·r语言·maxent
handler013 小时前
Linux: 基本指令知识点(3)
linux·服务器·c语言·开发语言·c++·笔记
wuminyu3 小时前
专家视角看Java线程生命周期与上下文切换的本质
java·linux·c语言·jvm·c++
程序猿乐锅3 小时前
Java第十三篇:Stream流
java·笔记