# 第06讲:进程控制(进阶) > 🎯 **本节目标**:深入理解 fork/exec 的工作原理,掌握 Shell 的实现机制 ## 📋 前置知识 - [[06_进程控制]] — fork、exec、wait 的基本概念 --- ## 🤔 为什么需要这个? 你每天都在用 Shell(命令行),但你有没有想过: - Shell 是怎么执行你的命令的? - 为什么输入 `ls` 就能列出文件? - 后台运行 `&` 是怎么实现的? 理解这些,需要深入掌握 fork 和 exec 的配合机制。 --- ## 📖 核心概念 ### 1. Shell 的工作原理 ```mermaid 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 的核心逻辑**: ```c 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() 的实现细节 ```mermaid 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() 时不立即复制物理内存 - 父子进程共享同一份物理页面 - 只有当某一方尝试写入时,才复制该页面 ```mermaid sequenceDiagram participant 父进程 participant 子进程 participant 内存 父进程->>内存: fork() Note over 内存: 父子共享物理页面 子进程->>内存: 尝试写入 Note over 内存: 触发写时复制 Note over 内存: 复制该页面给子进程 子进程->>内存: 写入新页面 ``` ### 3. exec() 的工作流程 ```mermaid 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. 进程组与会话 ```mermaid 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. 守护进程 守护进程是在后台运行的特殊进程,没有控制终端: ```mermaid 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 ```c // myshell.c - 简单的 Shell 实现 #include #include #include #include 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); // 父进程等待子进程结束 } } ``` **编译运行**: ```bash gcc -o myshell myshell.c ./myshell % ls -l ``` ### 示例2:后台运行 ```c // 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); } } ``` **编译运行**: ```bash gcc -o shellex shellex.c -L. -lwrapper ./shellex % sleep 10 & # 后台运行 % ps # 查看进程 ``` --- ## 🔗 知识关联 - Shell 的 I/O 重定向在 [[04_文件IO编程]] 中的 dup2 有详细讲解 - 守护进程在 [[09_网络编程]] 中会实际使用 - 进程组在 [[11_处理机调度]] 中用于作业控制 --- ## 📝 思考题 1. **为什么 exec() 后文件描述符还保留?** 什么时候需要关闭它们? 2. **写时复制的优势是什么?** 如果 fork() 时立即复制所有内存会怎样? 3. **Shell 是怎么实现管道的?** 例如 `ls | grep .c` 的执行过程是什么? --- ## 📚 扩展阅读 - 《UNIX环境高级编程》第8章:进程控制 - 《深入理解计算机系统》第8章:异常控制流 - [Bash 源码](https://git.savannah.gnu.org/cgit/bash.git/)