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

316 lines
7.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第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 <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); // 父进程等待子进程结束
}
}
```
**编译运行**
```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/)