Files
obsidian/操作系统/06_进程控制/06_进程控制_深入.md

7.3 KiB
Raw Permalink Blame History

第06讲进程控制进阶

🎯 本节目标:深入理解 fork/exec 的工作原理,掌握 Shell 的实现机制

📋 前置知识


🤔 为什么需要这个?

你每天都在用 Shell命令行但你有没有想过

  • Shell 是怎么执行你的命令的?
  • 为什么输入 ls 就能列出文件?
  • 后台运行 & 是怎么实现的?

理解这些,需要深入掌握 fork 和 exec 的配合机制。


📖 核心概念

1. Shell 的工作原理

graph TD
    A[用户输入命令] --> B[Shell 解析命令]
    B --> C[Shell 调用 fork]
    C --> D[子进程调用 execvp]
    D --> E[执行命令程序]
    E --> F[子进程结束]
    F --> G[Shell 调用 waitpid]
    G --> A

    style A fill:#e1f5fe
    style D fill:#e8f5e9

Shell 的核心逻辑

while (1) {
    printf("%% ");           // 打印提示符
    fgets(cmdline);          // 读取命令
    if (feof(stdin)) exit(0);

    pid = fork();            // 创建子进程
    if (pid == 0) {          // 子进程
        execvp(argv[0], argv);  // 执行命令
        exit(1);             // exec 失败
    }

    if (!background)         // 前台运行
        waitpid(pid);        // 等待子进程结束
}

2. fork() 的实现细节

graph TD
    A[父进程调用 fork] --> B[内核复制父进程的 PCB]
    B --> C[复制页表(写时复制)]
    C --> D[设置子进程的 PID]
    D --> E[父子进程各返回一次]

    style A fill:#e1f5fe
    style B fill:#fff3e0

写时复制Copy-on-Write

  • fork() 时不立即复制物理内存
  • 父子进程共享同一份物理页面
  • 只有当某一方尝试写入时,才复制该页面
sequenceDiagram
    participant 父进程
    participant 子进程
    participant 内存

    父进程->>内存: fork()
    Note over 内存: 父子共享物理页面
    子进程->>内存: 尝试写入
    Note over 内存: 触发写时复制
    Note over 内存: 复制该页面给子进程
    子进程->>内存: 写入新页面

3. exec() 的工作流程

graph TD
    A[调用 execvp] --> B[查找可执行文件]
    B --> C[释放旧的地址空间]
    C --> D[加载新的代码段]
    D --> E[加载新的数据段]
    E --> F[设置新的栈]
    F --> G[跳转到新程序入口]

    style A fill:#e1f5fe
    style G fill:#e8f5e9

exec 的关键特性

  • 不创建新进程:只是替换当前进程的内容
  • PID 不变:进程还是原来的进程
  • 文件描述符保留:打开的文件不会自动关闭(除非设置了 close-on-exec

4. 进程组与会话

graph TD
    A[会话 Session] --> B[前台进程组]
    A --> C[后台进程组1]
    A --> D[后台进程组2]

    B --> B1[Shell]
    B --> B2[当前命令]

    style A fill:#ffcdd2
    style B fill:#e8f5e9
    style C fill:#e1f5fe
    style D fill:#e1f5fe

进程组:一组相关进程的集合

  • 用于信号的批量发送
  • 用于作业控制(前台/后台切换)

会话:一个用户登录期间的所有进程

  • 一个终端对应一个会话
  • 会话有一个控制终端

5. 守护进程

守护进程是在后台运行的特殊进程,没有控制终端:

graph TD
    A[创建子进程] --> B[父进程退出]
    B --> C[创建新会话]
    C --> D[改变工作目录]
    D --> E[关闭文件描述符]
    E --> F[重定向 stdin/stdout/stderr]
    F --> G[进入主循环]

    style A fill:#e1f5fe
    style G fill:#e8f5e9

💻 动手实践

示例1实现简单的 Shell

// myshell.c - 简单的 Shell 实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void) {
    int ret, i, k, len, pid;
    char cmd[100];      // 命令串最多100个字符
    char *arg[20];      // 参数数组最多20个参数

    printf("%% ");      // 打印提示符

    fgets(cmd, 100, stdin);  // 从标准输入读取一行命令

    // 将命令串中的空格替换成'\0',并将各参数提取出来
    // 例如 cmd[100]="ls -l -a\0",替换后变为 cmd="ls\0-l\0-a\0"
    len = strlen(cmd);
    cmd[len - 1] = '\0';    // 去掉 fgets 加在串尾的换行符

    for (i = 0; i < len - 1; i++)
        if (cmd[i] == ' ') cmd[i] = '\0';

    // 准备参数数组 arg
    // arg[0]=cmd, arg[1]=cmd+3, arg[2]=cmd+6, arg[3]=NULL
    arg[0] = cmd;
    k = 1;
    for (i = 1; i < len - 1; i++) {
        if (cmd[i] == '\0') {
            arg[k] = cmd + i + 1;
            k++;
        }
    }
    arg[k] = NULL;

    pid = fork();
    if (pid == 0) {
        ret = execvp(arg[0], arg);  // 子进程执行命令
        if (ret == -1) {
            perror("exec error");
            exit(1);
        }
    } else {
        wait(-1);  // 父进程等待子进程结束
    }
}

编译运行

gcc -o myshell myshell.c
./myshell
% ls -l

示例2后台运行

// shellex.c - 支持后台运行的 Shell
#include "wrapper.h"

#define MAXARGS 128

int parseline(char *buf, char **argv) {
    char *delim;
    int argc;
    int bg;  // 后台作业标志

    buf[strlen(buf) - 1] = ' ';  // 用空格替换末尾换行
    while (*buf && (*buf == ' '))
        buf++;

    argc = 0;
    while ((delim = strchr(buf, ' '))) {
        argv[argc++] = buf;
        *delim = '\0';
        buf = delim + 1;
        while (*buf && (*buf == ' '))
            buf++;
    }
    argv[argc] = NULL;

    if (argc == 0) return 1;

    // 检查是否应该在后台执行
    if ((bg = (*argv[argc - 1] == '&')) != 0)
        argv[--argc] = NULL;

    return bg;
}

void execute(char *cmdline) {
    char *argv[MAXARGS];
    char buf[MAXLINE];
    int bg;
    pid_t pid;

    strcpy(buf, cmdline);
    bg = parseline(buf, argv);

    if (argv[0] == NULL) return;  // 忽略空行

    if ((pid = fork()) == 0) {    // 子进程
        if (execvp(argv[0], argv) < 0) {
            printf("%s: Command not found.\n", argv[0]);
            exit(0);
        }
    }

    if (!bg) {  // 前台运行
        int status;
        if (waitpid(pid, &status, 0) < 0)
            perror("waitpid error");
    } else {    // 后台运行
        printf("%d %s", pid, cmdline);
    }
}

int main() {
    char cmdline[MAXLINE];
    while (1) {
        printf("%% ");
        fgets(cmdline, MAXLINE, stdin);
        if (feof(stdin)) exit(0);
        execute(cmdline);
    }
}

编译运行

gcc -o shellex shellex.c -L. -lwrapper
./shellex
% sleep 10 &    # 后台运行
% ps            # 查看进程

🔗 知识关联


📝 思考题

  1. 为什么 exec() 后文件描述符还保留? 什么时候需要关闭它们?
  2. 写时复制的优势是什么? 如果 fork() 时立即复制所有内存会怎样?
  3. Shell 是怎么实现管道的? 例如 ls | grep .c 的执行过程是什么?

📚 扩展阅读

  • 《UNIX环境高级编程》第8章进程控制
  • 《深入理解计算机系统》第8章异常控制流
  • Bash 源码