diff --git a/操作系统/02_Linux基础/02_Linux基础.md b/操作系统/02_Linux基础/02_Linux基础.md index 52070eb..5f3b8bb 100644 --- a/操作系统/02_Linux基础/02_Linux基础.md +++ b/操作系统/02_Linux基础/02_Linux基础.md @@ -1,353 +1,353 @@ -# 第02讲:Linux操作系统目录结构与文件操作 - -> **本节目标**:理解Linux系统的起源与设计哲学,掌握目录组织方式和常用文件操作命令,为后续系统编程打下基础 - -## 前置知识 -- [[01_系统运行机制]] -- 对操作系统的基本认识 -- 计算机基本操作能力 - ---- - -## 一、UNIX/Linux系统简介 - -### 1.1 UNIX的诞生 - -1969年,贝尔实验室(Bell Labs)的 **Ken Thompson** 和 **Dennis Ritchie** 在一台废弃的PDP-7小型机上开发了UNIX操作系统。此后Ritchie还创造了C语言,并用C语言重写了UNIX,使UNIX成为第一个用高级语言编写的操作系统,极大地提高了可移植性。 - -```mermaid -graph LR - A["1969 Ken Thompson -Dennis Ritchie"] -->|在PDP-7上开发| B[UNIX初版] - B -->|用C语言重写| C[UNIX V6/V7] - C -->|研究方向| D[BSD UNIX] - C -->|商业方向| E[System V] - D --> F[FreeBSD / NetBSD] - E --> G[AIX / Solaris / HP-UX] - - style A fill:#fff3e0 - style C fill:#e1f5fe -``` - -### 1.2 UNIX两大流派 - -| 流派 | 代表 | 特点 | -|------|------|------| -| **BSD UNIX**(研究版) | BSD 4.x | 由加州大学伯克利分校维护,偏重学术研究 | -| **System V**(商业版) | SVR4 | 由AT&T维护,偏重商业应用 | - -### 1.3 主要商业UNIX版本 - -| 版本 | 厂商 | 典型硬件平台 | -|------|------|-------------| -| **AIX** | IBM | Power/PowerPC | -| **Solaris** | Sun Microsystems | SPARC / x86 | -| **HP-UX** | HP | PA-RISC / Itanium | -| **IRIX** | SGI | MIPS | - ---- - -## 二、Linux系统 - -### 2.1 Linux的诞生 - -1991年,芬兰赫尔辛基大学的学生 **林纳斯·托瓦兹(Linus Torvalds)** 开发了Linux内核。最初这只是个人项目,但由于他将内核源码以GPL(GNU General Public License)协议发布,全球开发者得以自由修改和分发,Linux迅速成长为最重要的开源操作系统。 - -### 2.2 内核版本号 - -Linux内核版本号的格式为 **`r.x.y`**: - -- **r**(主版本号):内核有重大变更时递增 -- **x**(次版本号):偶数表示稳定版,奇数表示开发版 -- **y**(修订版本号):bug修复和小改动 - -常见内核版本线:2.4 -> 2.6 -> 3.2 -> 4.6.4 -> 5.x -> 6.x - -> **注意**:从3.0开始,主版本号不再有"奇偶"含义,版本号只是简单的递增计数器。 - -### 2.3 Linux发行版 - -内核本身只是操作系统的核心部分。**发行版(Distribution)** 将内核与各种工具、桌面环境、软件包管理器等打包在一起,形成完整的操作系统。 - -```mermaid -graph TB - A[Linux内核] --> B[GNU工具集] - B --> C[发行版] - C --> D[RHEL] - C --> E[Fedora] - C --> F[Ubuntu] - C --> G[CentOS] - C --> H[中标麒麟] - - style A fill:#ffcdd2 - style C fill:#e1f5fe -``` - -| 发行版 | 特点 | 典型用途 | -|--------|------|---------| -| **RHEL**(Red Hat Enterprise Linux) | 商业支持,稳定 | 企业服务器 | -| **Fedora** | 技术前沿,更新快 | 桌面 / 开发 | -| **Ubuntu** | 用户友好,社区活跃 | 桌面 / 云服务器 | -| **CentOS** | RHEL的免费克隆版 | 企业服务器 | -| **中标麒麟** | 国产化,政府认证 | 国内政府 / 国防 | - ---- - -## 三、Linux目录结构 - -### 3.1 核心概念:一切皆文件 - -与Windows不同,Linux **没有盘符**(C:、D:等)的概念。整个文件系统从根目录 **`/`** 开始,形成一棵倒置的树。 - -```mermaid -graph TB - ROOT["/ (根目录)"] --> BIN["/bin -基本命令"] - ROOT --> SBIN["/sbin -系统管理命令"] - ROOT --> ETC["/etc -配置文件"] - ROOT --> HOME["/home -用户家目录"] - ROOT --> VAR["/var -可变数据"] - ROOT --> USR["/usr -用户程序"] - ROOT --> TMP["/tmp -临时文件"] - ROOT --> DEV["/dev -设备文件"] - ROOT --> PROC["/proc -进程信息"] - ROOT --> BOOT["/boot -内核与启动"] - ROOT --> LIB["/lib -共享库"] - ROOT --> MNT["/mnt -挂载点"] - ROOT --> OPT["/opt -第三方软件"] - ROOT --> ROOT2["/root -root用户家目录"] - - style ROOT fill:#ffcdd2 - style ETC fill:#fff3e0 - style HOME fill:#e8f5e9 - style PROC fill:#e1f5fe - style DEV fill:#f3e5f5 -``` - -### 3.2 重要目录详解 - -| 目录 | 全称 | 作用 | 举例 | -|------|------|------|------| -| `/bin` | Binaries | 基本用户命令 | `ls`, `cp`, `cat` | -| `/sbin` | System Binaries | 系统管理命令 | `fdisk`, `ifconfig` | -| `/etc` | Editable Text Config | 系统配置文件 | `/etc/passwd`, `/etc/fstab` | -| `/home` | Home | 普通用户家目录 | `/home/zhangsan` | -| `/root` | Root Home | root用户的家目录 | -- | -| `/var` | Variable | 可变数据(日志、缓存等) | `/var/log/messages` | -| `/usr` | Unix System Resources | 用户程序和数据 | `/usr/bin`, `/usr/lib` | -| `/tmp` | Temporary | 临时文件,重启后可能清除 | -- | -| `/dev` | Device | 设备文件 | `/dev/sda`(硬盘) | -| `/proc` | Process | 虚拟文件系统,内核运行信息 | `/proc/cpuinfo` | -| `/boot` | Boot | 启动相关文件(内核镜像等) | `vmlinuz-xxx` | -| `/lib` | Library | 共享库文件 | `libc.so.6` | -| `/mnt` | Mount | 临时挂载点 | U盘、网络存储 | - -### 3.3 /proc文件系统 - -`/proc` 是一个 **虚拟文件系统** -- 它不占用磁盘空间,而是内核在内存中动态生成的。通过读取 `/proc` 下的文件,可以实时查看系统和进程信息。 - -```bash -# 查看CPU信息 -cat /proc/cpuinfo - -# 查看内存信息 -cat /proc/meminfo - -# 查看当前进程信息(PID=1的进程) -cat /proc/1/status -``` - ---- - -## 四、目录操作命令 - -### 4.1 特殊目录符号 - -| 符号 | 含义 | 示例 | -|------|------|------| -| `.` | 当前目录 | `./program`(运行当前目录下的程序) | -| `..` | 上一级目录 | `cd ..`(回到上级目录) | -| `~` | 当前用户的家目录 | `cd ~`(回到家目录) | -| `-` | 上一次所在的目录 | `cd -`(在两个目录间来回切换) | - -### 4.2 常用目录操作命令 - -```bash -# pwd - 显示当前工作目录(Print Working Directory) -pwd -# 输出:/home/zhangsan - -# cd - 切换目录(Change Directory) -cd /usr/local # 绝对路径切换 -cd .. # 回到上级目录 -cd ~ # 回到家目录(等同于 cd $HOME) -cd - # 回到上一次的目录 - -# mkdir - 创建目录(Make Directory) -mkdir mydir # 创建单个目录 -mkdir -p project/src/main # 递归创建多级目录 - -# rmdir - 删除空目录(Remove Directory) -rmdir mydir # 只能删除空目录 - -# rm - 删除文件或目录 -rm file.txt # 删除文件 -rm -r mydir # 递归删除目录及其内容 -rm -ri mydir # 递归删除,每个文件都询问确认 -``` - ---- - -## 五、文件操作命令 - -### 5.1 查看文件内容 - -```bash -# ls - 列出目录内容(List) -ls # 列出当前目录下的文件 -ls -l # 长格式显示(含权限、大小、时间) -ls -a # 显示隐藏文件(以 . 开头的文件) -ls -lh # 长格式 + 人类可读的文件大小 - -# cat - 连接并显示文件内容 -cat file.txt # 显示文件全部内容 -cat -n file.txt # 显示时带行号 - -# more / less - 分页查看 -more file.txt # 空格翻页,q 退出 -less file.txt # 更强大的分页器,支持搜索 -``` - -### 5.2 复制、移动和重命名 - -```bash -# cp - 复制文件或目录(Copy) -cp file1.txt file2.txt # 复制文件 -cp -r dir1/ dir2/ # 递归复制目录 -cp -i file1.txt file2.txt # 覆盖前询问 - -# mv - 移动/重命名(Move) -mv file.txt /tmp/ # 移动文件到 /tmp -mv oldname.txt newname.txt # 重命名 -``` - -### 5.3 文件权限管理 - -每个文件都有三组权限,分别针对三类用户: - -| 权限位 | 含义 | 对文件的效果 | 对目录的效果 | -|--------|------|-------------|-------------| -| **r**(读) | Read | 可以查看文件内容 | 可以列出目录内容 | -| **w**(写) | Write | 可以修改文件内容 | 可以在目录中创建/删除文件 | -| **x**(执行) | Execute | 可以运行文件 | 可以进入目录(`cd`) | - -三类用户: -- **u**(user/所有者):文件的拥有者 -- **g**(group/所属组):与文件同组的用户 -- **o**(others/其他人):其余所有用户 - -```bash -# chmod - 修改文件权限(Change Mode) -chmod u+x program.sh # 给所有者添加执行权限 -chmod g-w file.txt # 去掉组的写权限 -chmod 755 program.sh # 数字表示法:rwxr-xr-x -chmod 644 data.txt # 数字表示法:rw-r--r-- - -# chown - 修改文件所有者(Change Owner) -chown zhangsan file.txt # 修改所有者 -chown zhangsan:staff file.txt # 同时修改所有者和所属组 -chown -R zhangsan:staff mydir/ # 递归修改目录 -``` - -**权限的数字表示法**: - -| 数字 | 二进制 | 权限 | -|------|--------|------| -| 7 | 111 | rwx | -| 6 | 110 | rw- | -| 5 | 101 | r-x | -| 4 | 100 | r-- | -| 0 | 000 | --- | - -因此 `chmod 755` 等价于 `rwxr-xr-x`:所有者可读可写可执行,组和其他人可读可执行。 - ---- - -## 六、绝对路径与相对路径 - -### 6.1 概念对比 - -| 路径类型 | 起点 | 示例 | 特点 | -|----------|------|------|------| -| **绝对路径** | 根目录 `/` | `/home/zhangsan/file.txt` | 完整路径,从根开始,与当前位置无关 | -| **相对路径** | 当前目录 | `./file.txt` 或 `../dir/file.txt` | 相对于当前位置,更短更灵活 | - -### 6.2 示例 - -假设当前目录为 `/home/zhangsan/project`: - -```bash -# 绝对路径方式 -cat /home/zhangsan/project/src/main.c - -# 相对路径方式(效果相同) -cat ./src/main.c -cat src/main.c - -# 使用 .. 回到上级 -cat ../README.md # 访问上级目录中的 README.md -``` - -```mermaid -graph TB - HOME["/home"] --> ZS["zhangsan"] - ZS --> PROJ["project"] - PROJ --> SRC["src"] - SRC --> MAIN["main.c"] - ZS --> README["README.md"] - - PROJ -.->|"cd .."| ZS - PROJ -.->|"cd ./src"| SRC - PROJ -.->|"cat ../README.md"| README - - style PROJ fill:#fff3e0 - style MAIN fill:#e8f5e9 -``` - ---- - -## 七、知识关联 - -- Linux的"一切皆文件"思想在 [[04_文件IO编程]] 中会深入学习 -- 文件权限在 [[04_文件IO编程]] 中用 `open()` 系统调用的 `mode` 参数体现 -- `/proc` 文件系统在 [[05_进程控制]] 中用于查看进程状态 -- 后续C语言编程需要在Linux环境下进行,参见 [[03_C语言编程基础]] - ---- - -## 八、思考题 - -1. **为什么Linux没有盘符?** 这种设计有什么优势? -2. **/proc 为什么是"虚拟"文件系统?** 它与 /home 这样的目录有什么本质区别? -3. **`rm -rf /` 会发生什么?** 为什么root用户执行这个命令非常危险? -4. **`chmod 755` 和 `chmod 644` 分别适用于什么场景?**(提示:可执行文件 vs 数据文件) - ---- - -## 九、扩展阅读 - -- 《鸟哥的Linux私房菜》第5-6章:Linux文件权限与目录配置 -- 《UNIX环境高级编程》第1-2章:UNIX基础知识 -- Linux文件系统层次标准(FHS):https://pathname.com/fhs/ +# 第02讲:Linux操作系统目录结构与文件操作 + +> **本节目标**:理解Linux系统的起源与设计哲学,掌握目录组织方式和常用文件操作命令,为后续系统编程打下基础 + +## 前置知识 +- [[01_系统运行机制]] -- 对操作系统的基本认识 +- 计算机基本操作能力 + +--- + +## 一、UNIX/Linux系统简介 + +### 1.1 UNIX的诞生 + +1969年,贝尔实验室(Bell Labs)的 **Ken Thompson** 和 **Dennis Ritchie** 在一台废弃的PDP-7小型机上开发了UNIX操作系统。此后Ritchie还创造了C语言,并用C语言重写了UNIX,使UNIX成为第一个用高级语言编写的操作系统,极大地提高了可移植性。 + +```mermaid +graph LR + A["1969 Ken Thompson +Dennis Ritchie"] -->|在PDP-7上开发| B[UNIX初版] + B -->|用C语言重写| C[UNIX V6/V7] + C -->|研究方向| D[BSD UNIX] + C -->|商业方向| E[System V] + D --> F[FreeBSD / NetBSD] + E --> G[AIX / Solaris / HP-UX] + + style A fill:#fff3e0 + style C fill:#e1f5fe +``` + +### 1.2 UNIX两大流派 + +| 流派 | 代表 | 特点 | +|------|------|------| +| **BSD UNIX**(研究版) | BSD 4.x | 由加州大学伯克利分校维护,偏重学术研究 | +| **System V**(商业版) | SVR4 | 由AT&T维护,偏重商业应用 | + +### 1.3 主要商业UNIX版本 + +| 版本 | 厂商 | 典型硬件平台 | +|------|------|-------------| +| **AIX** | IBM | Power/PowerPC | +| **Solaris** | Sun Microsystems | SPARC / x86 | +| **HP-UX** | HP | PA-RISC / Itanium | +| **IRIX** | SGI | MIPS | + +--- + +## 二、Linux系统 + +### 2.1 Linux的诞生 + +1991年,芬兰赫尔辛基大学的学生 **林纳斯·托瓦兹(Linus Torvalds)** 开发了Linux内核。最初这只是个人项目,但由于他将内核源码以GPL(GNU General Public License)协议发布,全球开发者得以自由修改和分发,Linux迅速成长为最重要的开源操作系统。 + +### 2.2 内核版本号 + +Linux内核版本号的格式为 **`r.x.y`**: + +- **r**(主版本号):内核有重大变更时递增 +- **x**(次版本号):偶数表示稳定版,奇数表示开发版 +- **y**(修订版本号):bug修复和小改动 + +常见内核版本线:2.4 -> 2.6 -> 3.2 -> 4.6.4 -> 5.x -> 6.x + +> **注意**:从3.0开始,主版本号不再有"奇偶"含义,版本号只是简单的递增计数器。 + +### 2.3 Linux发行版 + +内核本身只是操作系统的核心部分。**发行版(Distribution)** 将内核与各种工具、桌面环境、软件包管理器等打包在一起,形成完整的操作系统。 + +```mermaid +graph TB + A[Linux内核] --> B[GNU工具集] + B --> C[发行版] + C --> D[RHEL] + C --> E[Fedora] + C --> F[Ubuntu] + C --> G[CentOS] + C --> H[中标麒麟] + + style A fill:#ffcdd2 + style C fill:#e1f5fe +``` + +| 发行版 | 特点 | 典型用途 | +|--------|------|---------| +| **RHEL**(Red Hat Enterprise Linux) | 商业支持,稳定 | 企业服务器 | +| **Fedora** | 技术前沿,更新快 | 桌面 / 开发 | +| **Ubuntu** | 用户友好,社区活跃 | 桌面 / 云服务器 | +| **CentOS** | RHEL的免费克隆版 | 企业服务器 | +| **中标麒麟** | 国产化,政府认证 | 国内政府 / 国防 | + +--- + +## 三、Linux目录结构 + +### 3.1 核心概念:一切皆文件 + +与Windows不同,Linux **没有盘符**(C:、D:等)的概念。整个文件系统从根目录 **`/`** 开始,形成一棵倒置的树。 + +```mermaid +graph TB + ROOT["/ (根目录)"] --> BIN["/bin +基本命令"] + ROOT --> SBIN["/sbin +系统管理命令"] + ROOT --> ETC["/etc +配置文件"] + ROOT --> HOME["/home +用户家目录"] + ROOT --> VAR["/var +可变数据"] + ROOT --> USR["/usr +用户程序"] + ROOT --> TMP["/tmp +临时文件"] + ROOT --> DEV["/dev +设备文件"] + ROOT --> PROC["/proc +进程信息"] + ROOT --> BOOT["/boot +内核与启动"] + ROOT --> LIB["/lib +共享库"] + ROOT --> MNT["/mnt +挂载点"] + ROOT --> OPT["/opt +第三方软件"] + ROOT --> ROOT2["/root +root用户家目录"] + + style ROOT fill:#ffcdd2 + style ETC fill:#fff3e0 + style HOME fill:#e8f5e9 + style PROC fill:#e1f5fe + style DEV fill:#f3e5f5 +``` + +### 3.2 重要目录详解 + +| 目录 | 全称 | 作用 | 举例 | +|------|------|------|------| +| `/bin` | Binaries | 基本用户命令 | `ls`, `cp`, `cat` | +| `/sbin` | System Binaries | 系统管理命令 | `fdisk`, `ifconfig` | +| `/etc` | Editable Text Config | 系统配置文件 | `/etc/passwd`, `/etc/fstab` | +| `/home` | Home | 普通用户家目录 | `/home/zhangsan` | +| `/root` | Root Home | root用户的家目录 | -- | +| `/var` | Variable | 可变数据(日志、缓存等) | `/var/log/messages` | +| `/usr` | Unix System Resources | 用户程序和数据 | `/usr/bin`, `/usr/lib` | +| `/tmp` | Temporary | 临时文件,重启后可能清除 | -- | +| `/dev` | Device | 设备文件 | `/dev/sda`(硬盘) | +| `/proc` | Process | 虚拟文件系统,内核运行信息 | `/proc/cpuinfo` | +| `/boot` | Boot | 启动相关文件(内核镜像等) | `vmlinuz-xxx` | +| `/lib` | Library | 共享库文件 | `libc.so.6` | +| `/mnt` | Mount | 临时挂载点 | U盘、网络存储 | + +### 3.3 /proc文件系统 + +`/proc` 是一个 **虚拟文件系统** -- 它不占用磁盘空间,而是内核在内存中动态生成的。通过读取 `/proc` 下的文件,可以实时查看系统和进程信息。 + +```bash +# 查看CPU信息 +cat /proc/cpuinfo + +# 查看内存信息 +cat /proc/meminfo + +# 查看当前进程信息(PID=1的进程) +cat /proc/1/status +``` + +--- + +## 四、目录操作命令 + +### 4.1 特殊目录符号 + +| 符号 | 含义 | 示例 | +|------|------|------| +| `.` | 当前目录 | `./program`(运行当前目录下的程序) | +| `..` | 上一级目录 | `cd ..`(回到上级目录) | +| `~` | 当前用户的家目录 | `cd ~`(回到家目录) | +| `-` | 上一次所在的目录 | `cd -`(在两个目录间来回切换) | + +### 4.2 常用目录操作命令 + +```bash +# pwd - 显示当前工作目录(Print Working Directory) +pwd +# 输出:/home/zhangsan + +# cd - 切换目录(Change Directory) +cd /usr/local # 绝对路径切换 +cd .. # 回到上级目录 +cd ~ # 回到家目录(等同于 cd $HOME) +cd - # 回到上一次的目录 + +# mkdir - 创建目录(Make Directory) +mkdir mydir # 创建单个目录 +mkdir -p project/src/main # 递归创建多级目录 + +# rmdir - 删除空目录(Remove Directory) +rmdir mydir # 只能删除空目录 + +# rm - 删除文件或目录 +rm file.txt # 删除文件 +rm -r mydir # 递归删除目录及其内容 +rm -ri mydir # 递归删除,每个文件都询问确认 +``` + +--- + +## 五、文件操作命令 + +### 5.1 查看文件内容 + +```bash +# ls - 列出目录内容(List) +ls # 列出当前目录下的文件 +ls -l # 长格式显示(含权限、大小、时间) +ls -a # 显示隐藏文件(以 . 开头的文件) +ls -lh # 长格式 + 人类可读的文件大小 + +# cat - 连接并显示文件内容 +cat file.txt # 显示文件全部内容 +cat -n file.txt # 显示时带行号 + +# more / less - 分页查看 +more file.txt # 空格翻页,q 退出 +less file.txt # 更强大的分页器,支持搜索 +``` + +### 5.2 复制、移动和重命名 + +```bash +# cp - 复制文件或目录(Copy) +cp file1.txt file2.txt # 复制文件 +cp -r dir1/ dir2/ # 递归复制目录 +cp -i file1.txt file2.txt # 覆盖前询问 + +# mv - 移动/重命名(Move) +mv file.txt /tmp/ # 移动文件到 /tmp +mv oldname.txt newname.txt # 重命名 +``` + +### 5.3 文件权限管理 + +每个文件都有三组权限,分别针对三类用户: + +| 权限位 | 含义 | 对文件的效果 | 对目录的效果 | +|--------|------|-------------|-------------| +| **r**(读) | Read | 可以查看文件内容 | 可以列出目录内容 | +| **w**(写) | Write | 可以修改文件内容 | 可以在目录中创建/删除文件 | +| **x**(执行) | Execute | 可以运行文件 | 可以进入目录(`cd`) | + +三类用户: +- **u**(user/所有者):文件的拥有者 +- **g**(group/所属组):与文件同组的用户 +- **o**(others/其他人):其余所有用户 + +```bash +# chmod - 修改文件权限(Change Mode) +chmod u+x program.sh # 给所有者添加执行权限 +chmod g-w file.txt # 去掉组的写权限 +chmod 755 program.sh # 数字表示法:rwxr-xr-x +chmod 644 data.txt # 数字表示法:rw-r--r-- + +# chown - 修改文件所有者(Change Owner) +chown zhangsan file.txt # 修改所有者 +chown zhangsan:staff file.txt # 同时修改所有者和所属组 +chown -R zhangsan:staff mydir/ # 递归修改目录 +``` + +**权限的数字表示法**: + +| 数字 | 二进制 | 权限 | +|------|--------|------| +| 7 | 111 | rwx | +| 6 | 110 | rw- | +| 5 | 101 | r-x | +| 4 | 100 | r-- | +| 0 | 000 | --- | + +因此 `chmod 755` 等价于 `rwxr-xr-x`:所有者可读可写可执行,组和其他人可读可执行。 + +--- + +## 六、绝对路径与相对路径 + +### 6.1 概念对比 + +| 路径类型 | 起点 | 示例 | 特点 | +|----------|------|------|------| +| **绝对路径** | 根目录 `/` | `/home/zhangsan/file.txt` | 完整路径,从根开始,与当前位置无关 | +| **相对路径** | 当前目录 | `./file.txt` 或 `../dir/file.txt` | 相对于当前位置,更短更灵活 | + +### 6.2 示例 + +假设当前目录为 `/home/zhangsan/project`: + +```bash +# 绝对路径方式 +cat /home/zhangsan/project/src/main.c + +# 相对路径方式(效果相同) +cat ./src/main.c +cat src/main.c + +# 使用 .. 回到上级 +cat ../README.md # 访问上级目录中的 README.md +``` + +```mermaid +graph TB + HOME["/home"] --> ZS["zhangsan"] + ZS --> PROJ["project"] + PROJ --> SRC["src"] + SRC --> MAIN["main.c"] + ZS --> README["README.md"] + + PROJ -.->|"cd .."| ZS + PROJ -.->|"cd ./src"| SRC + PROJ -.->|"cat ../README.md"| README + + style PROJ fill:#fff3e0 + style MAIN fill:#e8f5e9 +``` + +--- + +## 七、知识关联 + +- Linux的"一切皆文件"思想在 [[04_文件IO编程]] 中会深入学习 +- 文件权限在 [[04_文件IO编程]] 中用 `open()` 系统调用的 `mode` 参数体现 +- `/proc` 文件系统在 [[05_进程控制]] 中用于查看进程状态 +- 后续C语言编程需要在Linux环境下进行,参见 [[03_C语言编程基础]] + +--- + +## 八、思考题 + +1. **为什么Linux没有盘符?** 这种设计有什么优势? +2. **/proc 为什么是"虚拟"文件系统?** 它与 /home 这样的目录有什么本质区别? +3. **`rm -rf /` 会发生什么?** 为什么root用户执行这个命令非常危险? +4. **`chmod 755` 和 `chmod 644` 分别适用于什么场景?**(提示:可执行文件 vs 数据文件) + +--- + +## 九、扩展阅读 + +- 《鸟哥的Linux私房菜》第5-6章:Linux文件权限与目录配置 +- 《UNIX环境高级编程》第1-2章:UNIX基础知识 +- Linux文件系统层次标准(FHS):https://pathname.com/fhs/ diff --git a/操作系统/04_文件IO编程/04_文件IO编程.md b/操作系统/04_文件IO编程/04_文件IO编程.md index eb73bbe..d7625c4 100644 --- a/操作系统/04_文件IO编程/04_文件IO编程.md +++ b/操作系统/04_文件IO编程/04_文件IO编程.md @@ -1,619 +1,619 @@ -# 第04讲:文件IO编程 - -> 🎯 **本节目标**:掌握UNIX文件IO系统调用的使用方法,理解文件描述符、文件共享、mmap内存映射和I/O重定向机制 - -## 📋 前置知识 -- [[03_C语言编程基础]] — C语言编译链接、gdb调试、Makefile -- [[01_系统运行机制]] — 系统调用的概念 - ---- - -## 🤔 为什么需要这个? - -程序要读写文件、处理数据,必须通过操作系统的I/O接口。UNIX提供了一套简洁而强大的系统调用(open/read/write/close/lseek),几乎所有的Linux程序都建立在这套接口之上。Shell的重定向机制、数据库的存储引擎、Web服务器的文件传输,底层都依赖这些原语。 - -**生活比喻**: -- 文件描述符就像**取餐号牌**:你去餐厅点餐(open),服务员给你一个号牌(fd),之后你用号牌取餐(read/write),吃完归还号牌(close) -- lseek就像**唱片针头移动**:可以跳到唱片的任意位置开始播放 - ---- - -## 📖 核心概念 - -### 1. UNIX IO函数 - -Linux将所有I/O设备都视为**文件**,统一通过文件描述符进行操作。内核为每个进程维护一个**文件描述符表**,从0开始编号。 - -```mermaid -graph LR - subgraph 进程文件描述符表 - fd0[0 - stdin 标准输入] - fd1[1 - stdout 标准输出] - fd2[2 - stderr 标准错误] - fd3[3 - 普通文件] - fd4[4 - 普通文件] - end - - style fd0 fill:#e1f5fe - style fd1 fill:#e8f5e9 - style fd2 fill:#fff3e0 -``` - -#### open() - 打开/创建文件 - -```c -#include -int open(const char *pathname, int flags, mode_t mode); -// 返回:成功返回文件描述符(>=0),失败返回-1 -``` - -**flags参数**(可用 `|` 组合): - -| 标志 | 含义 | 说明 | -|------|------|------| -| `O_RDONLY` | 只读 | 三个互斥的访问模式之一 | -| `O_WRONLY` | 只写 | 三个互斥的访问模式之一 | -| `O_RDWR` | 读写 | 三个互斥的访问模式之一 | -| `O_CREAT` | 若文件不存在则创建 | 需要指定mode参数 | -| `O_TRUNC` | 截断文件为0 | 清空已有内容 | -| `O_APPEND` | 追加模式 | 每次写操作前定位到文件末尾 | - -**mode参数**(创建文件时的权限): - -| 八进制 | 含义 | -|--------|------| -| `0666` | 所有者/组/其他均可读写 | -| `0777` | 所有者/组/其他可读写执行 | -| `0644` | 所有者读写,组和其他只读 | - -#### close() - 关闭文件 - -```c -int close(int fd); -// 关闭文件描述符,释放内核资源 -``` - -#### read() / write() - 读写文件 - -```c -ssize_t read(int fd, void *buf, size_t count); -// 返回:实际读取的字节数,0表示EOF,-1表示错误 - -ssize_t write(int fd, const void *buf, size_t count); -// 返回:实际写入的字节数,-1表示错误 -``` - -#### 文件描述符分配规则 - -新打开的文件描述符总是取**当前未使用的最小值**。标准输入(0)、标准输出(1)、标准错误(2)默认被占用,因此普通文件通常从3开始。 - -```mermaid -graph TD - A["open('f1')"] --> B["返回 fd=3"] - B --> C["open('f2')"] - C --> D["返回 fd=4"] - D --> E["open('f3')"] - E --> F["返回 fd=5"] - F --> G["close(fd=3)"] - G --> H["open('f4')"] - H --> I["返回 fd=3(最小可用)"] - - style B fill:#e8f5e9 - style D fill:#e8f5e9 - style F fill:#e8f5e9 - style I fill:#fff3e0 -``` - -### 2. 文件共享 - -当多个进程打开同一个文件时,内核通过三层数据结构实现共享: - -```mermaid -graph TD - subgraph 进程A的描述符表 - A1["fd=3"] --> FT1 - A2["fd=4"] --> FT2 - end - - subgraph 进程B的描述符表 - B1["fd=3"] --> FT1 - B2["fd=5"] --> FT3 - end - - subgraph 文件表 全局 - FT1["文件表项1 -文件偏移=100 -引用计数=2"] - FT2["文件表项2 -文件偏移=0 -引用计数=1"] - FT3["文件表项3 -文件偏移=50 -引用计数=1"] - end - - subgraph v-node表 - V1["v-node -文件大小=1024 -inode信息"] - end - - FT1 --> V1 - FT2 --> V1 - FT3 --> V1 - - style FT1 fill:#ffcdd2 - style V1 fill:#e1f5fe -``` - -**关键点**: -- **描述符表**:每个进程独立,每个打开的文件描述符对应一个表项 -- **文件表**:所有进程共享,记录当前文件偏移量和引用计数 -- **v-node表**:所有进程共享,存储文件元数据(大小、类型、inode等) - -**同进程多次打开同一文件**:每次open创建新的文件表项(独立偏移量),但指向同一个v-node。 - -**不同进程打开同一文件**:每个进程各自创建独立的文件表项,共享同一个v-node。 - -### 3. lseek定位 - -`lseek` 修改文件的当前偏移量,实现随机访问: - -```c -off_t lseek(int fd, off_t offset, int whence); -// whence: SEEK_SET / SEEK_CUR / SEEK_END -// 返回:新的文件偏移量,-1表示错误 -``` - -| whence | 含义 | 计算方式 | -|--------|------|----------| -| `SEEK_SET` | 从文件开头 | 新偏移 = offset | -| `SEEK_CUR` | 从当前位置 | 新偏移 = 当前偏移 + offset | -| `SEEK_END` | 从文件末尾 | 新偏移 = 文件大小 + offset | - -**利用lseek获取文件大小**: -```c -off_t size = lseek(fd, 0, SEEK_END); -``` - -**利用lseek创建空洞文件**: -```c -lseek(fd, 1024*1024, SEEK_SET); // 跳到1MB位置 -write(fd, "X", 1); // 写1字节 -// 文件大小为 1MB+1,中间全是空洞('\0') -``` - -### 4. mmap内存映射 - -`mmap` 将文件直接映射到进程的虚拟地址空间,之后可以像访问内存一样读写文件,无需read/write系统调用。 - -```c -void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); -// addr: 建议映射地址(通常传NULL由内核选择) -// length: 映射长度 -// prot: 保护标志 PROT_READ | PROT_WRITE | PROT_EXEC -// flags: MAP_PRIVATE(私有副本)| MAP_SHARED(共享映射) -// fd: 文件描述符 -// offset: 文件中的起始偏移 -// 返回:映射区域的起始地址,失败返回MAP_FAILED -``` - -```mermaid -graph LR - subgraph 进程虚拟地址空间 - A["映射区域"] - end - subgraph 内核页缓存 - B["文件数据页1"] - C["文件数据页2"] - D["文件数据页3"] - end - - A -->|"缺页时加载"| B - A -->|"缺页时加载"| C - A -->|"缺页时加载"| D - - style A fill:#e8f5e9 - style B fill:#e1f5fe - style C fill:#e1f5fe - style D fill:#e1f5fe -``` - -**使用流程**: -1. `open()` 打开文件 -2. `fstat()` 获取文件大小 -3. `mmap()` 映射到内存 -4. 通过指针直接读写 -5. `munmap()` 解除映射 -6. `close()` 关闭文件 - -**MAP_PRIVATE vs MAP_SHARED**: - -| 特性 | MAP_PRIVATE | MAP_SHARED | -|------|-------------|------------| -| 写入效果 | 写时复制,不影响原文件 | 直接修改原文件 | -| 使用场景 | 只读浏览文件 | 需要修改文件 | -| 进程间共享 | 不共享 | 多进程共享同一映射 | - -### 5. dup2重定向 - -文件描述符复制是实现I/O重定向的核心机制。Shell的 `>`、`<`、`|` 管道都依赖于此。 - -#### dup() - 复制文件描述符 - -```c -int dup(int oldfd); -// 返回:新的文件描述符(取当前最小可用值) -``` - -#### dup2() - 原子复制并重定向 - -```c -int dup2(int oldfd, int newfd); -// 先关闭newfd,再将oldfd复制到newfd -// 返回:newfd,失败返回-1 -``` - -```mermaid -sequenceDiagram - participant P as 进程 - participant K as 内核 - - Note over P: save_fd = dup(STDOUT_FILENO) - P->>K: 复制fd=1 - K-->>P: save_fd=3(保存stdout) - - Note over P: dup2(fd, STDOUT_FILENO) - P->>K: 关闭fd=1,复制fd到1 - K-->>P: fd=1现在指向文件 - - Note over P: write(STDOUT_FILENO, ...) - P->>K: 写入fd=1(文件) - K-->>P: 数据写入文件 - - Note over P: dup2(save_fd, STDOUT_FILENO) - P->>K: 关闭fd=1,复制save_fd到1 - K-->>P: fd=1恢复为终端 - - Note over P: write(STDOUT_FILENO, ...) - P->>K: 写入fd=1(终端) - K-->>P: 数据显示在终端 -``` - -**dup() vs dup2()**: - -| 特性 | dup() | dup2() | -|------|-------|--------| -| 目标fd | 自动取最小可用值 | 指定目标fd | -| 关闭目标 | 不关闭 | 自动关闭目标fd | -| 原子性 | — | 原子操作,不会竞争 | -| 典型用途 | 简单复制 | Shell重定向 | - -### 6. 标准IO vs UNIX IO - -```mermaid -graph TD - subgraph 标准IO - A["fopen/fread/fwrite/fclose"] - B["用户缓冲区 -减少系统调用次数"] - end - subgraph UNIX IO - C["open/read/write/close"] - D["无用户缓冲区 -每次操作都是系统调用"] - end - - A --> B - C --> D - B -->|"底层调用"| D - - style A fill:#e1f5fe - style C fill:#fff3e0 -``` - -| 特性 | 标准IO (stdio) | UNIX IO | -|------|---------------|---------| -| 头文件 | `` | ``, `` | -| 缓冲 | 用户空间缓冲(默认行缓冲/全缓冲) | 无用户缓冲 | -| 性能 | 频繁小IO时快(减少系统调用) | 大块IO时相当 | -| 可移植性 | 高(ANSI C标准) | 低(POSIX,Linux/UNIX特有) | -| 功能 | 格式化printf/scanf | 更底层控制(如mmap) | - -**缓冲模式**: -- **无缓冲**:stderr,立即输出 -- **行缓冲**:stdout连接终端时,遇到换行符刷新 -- **全缓冲**:普通文件读写,缓冲区满时刷新 - -### 7. struct IO - 结构体二进制读写 - -在操作系统实验中,经常需要将结构体数据以二进制格式写入文件并读回: - -```c -// 写入结构体数组 -struct student { - int id; - char name[20]; - float score; -}; -struct student stu[100]; -// ... 填充数据 ... - -int fd = open("student.dat", O_WRONLY|O_CREAT|O_TRUNC, 0666); -write(fd, stu, sizeof(struct student) * 100); -close(fd); - -// 读回结构体数组 -fd = open("student.dat", O_RDONLY); -read(fd, stu, sizeof(struct student) * 100); -close(fd); -``` - -**注意事项**: -- 结构体可能存在**内存对齐**填充,文件中的字节布局与内存一致 -- 跨平台时需注意字节序(大端/小端) -- 标准IO的fread/fwrite也可以进行结构体二进制读写 - ---- - -## 💻 动手实践 - -### 示例1:逐字节文件拷贝(fcopy1.c) - -最基础的文件拷贝,逐字节读写,理解read/write的基本用法: - -```c -// fcopy1.c - 逐字节文件拷贝 -#include "wrapper.h" -int main() -{ - char c; - int in, out; - in = Open("file.in", O_RDONLY, 0); // 以只读方式打开源文件 - out = Open("file.out", O_WRONLY|O_CREAT, 0666); // 以写方式创建目标文件 - while (Read(in, &c, 1) == 1) // 每次读1字节 - Write(out, &c, 1); // 每次写1字节 - Close(in); // 关闭源文件 - Close(out); // 关闭目标文件 - exit(0); -} -``` - -**编译运行**: -```bash -gcc -o fcopy1 fcopy1.c -L. -lwrapper -echo "Hello, UNIX IO!" > file.in -./fcopy1 -cat file.out # 输出: Hello, UNIX IO! -``` - -**关键点**: -- `Open`、`Read`、`Write`、`Close` 是wrapper.h提供的带错误检查的包装函数 -- `O_CREAT` 标志需要提供第三个参数(文件权限) -- 逐字节拷贝效率很低,实际应用应使用缓冲区 - -### 示例2:块缓冲文件拷贝(fcopy2.c) - -改用1024字节的缓冲区,大幅提升性能: - -```c -// fcopy2.c - 块缓冲文件拷贝 -#include "wrapper.h" -int main() -{ - char block[1024]; // 1024字节缓冲区 - int in, out; - int nread; - in = Open("file.in", O_RDONLY, 0); - out = Open("file.out", O_WRONLY|O_CREAT, 0666); - while ((nread = Read(in, block, sizeof(block))) > 0) // 每次读最多1024字节 - Write(out, block, nread); // 写入实际读到的字节数 - Close(in); - Close(out); - exit(0); -} -``` - -**关键点**: -- `Read` 返回实际读取的字节数(可能小于请求值) -- 最后一次读取可能不满1024字节,需要用 `nread` 控制写入量 -- 磁盘文件通常4KB对齐,使用4096字节缓冲区效率更高 - -### 示例3:lseek随机访问(lseek1.c) - -演示文件随机读写: - -```c -// lseek1.c - 使用lseek进行随机访问 -#include "wrapper.h" -int main() -{ - char s1[6], s2[6]; - int fd; - fd = Open("infile", O_RDWR, 0); - lseek(fd, 10, SEEK_SET); // 定位到文件开头偏移10字节处 - Read(fd, s1, 5); // 读取5个字节 - s1[5] = '\0'; - printf("Read string: %s\n", s1); // 打印读到的内容 - - strcpy(s2, "12345"); - lseek(fd, -5, SEEK_CUR); // 从当前位置回退5字节 - Write(fd, s2, 5); // 覆写5字节 - close(fd); - exit(0); -} -``` - -**执行过程图示**: -``` -文件内容: ABCDEFGHIJ0123456789... - 0123456789(偏移) - -第1步: lseek(fd, 10, SEEK_SET) → 偏移=10 -第2步: Read(fd, s1, 5) → s1="01234",偏移=15 -第3步: lseek(fd, -5, SEEK_CUR) → 偏移=10 -第4步: Write(fd, s2, 5) → 写入"12345",偏移=15 - -结果: ABCDEFGHIJ123456789... -``` - -### 示例4:mmap内存映射(mmap1.c) - -将整个文件映射到内存,通过指针直接读取: - -```c -// mmap1.c - 内存映射文件读取 -#include "wrapper.h" -void main() -{ - int fd = open("test.file", 0); // 打开文件 - struct stat statbuf; - char *start; - char buf[2] = {0}; - int ret = 0; - fstat(fd, &statbuf); // 获取文件大小 - start = mmap(NULL, statbuf.st_size, // 映射整个文件 - PROT_READ, MAP_PRIVATE, fd, 0); - do { - *buf = start[ret++]; // 像访问数组一样读取 - } while(ret < statbuf.st_size); -} -``` - -**编译运行**: -```bash -gcc -o mmap1 mmap1.c -L. -lwrapper -./mmap1 -``` - -**关键点**: -- `fstat()` 获取文件大小,确定映射长度 -- `PROT_READ` 表示只读映射,写入会触发段错误 -- `MAP_PRIVATE` 表示私有映射(写时复制),修改不会影响原文件 -- 访问映射区域时触发缺页中断,内核按需加载文件数据 - -### 示例5:dup2实现I/O重定向(dup2.c) - -演示如何将stdout重定向到文件,再恢复: - -```c -// dup2.c - dup2实现I/O重定向 -#include "wrapper.h" -int main(void) -{ - int fd, save_fd; - char msg[] = "This is a test\n"; - fd = Open("somefile", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR); - save_fd = dup(STDOUT_FILENO); // 保存原始stdout(fd=1)到新fd - dup2(fd, STDOUT_FILENO); // 将stdout重定向到文件 - Close(fd); // 关闭原fd(不影响已复制的fd=1) - Write(STDOUT_FILENO, msg, strlen(msg)); // 写入文件 - dup2(save_fd, STDOUT_FILENO); // 恢复stdout为终端 - Write(STDOUT_FILENO, msg, strlen(msg)); // 写入终端 - Close(save_fd); - return 0; -} -``` - -**预期输出**: -``` -终端显示: This is a test -somefile内容: This is a test -``` - -**关键点**: -- `dup(STDOUT_FILENO)` 保存原始stdout的副本 -- `dup2(fd, STDOUT_FILENO)` 原子地关闭fd=1并复制fd到1 -- 恢复时再次用 `dup2` 将保存的副本写回fd=1 -- Shell实现 `>` 重定向的原理与此相同 - -### 示例6:标准IO与UNIX IO对比 - -通过对比 `read1.c` 和 `fread1.c` 理解缓冲差异: - -```c -// read1.c - UNIX IO(无缓冲,每次read都是系统调用) -#include "wrapper.h" -void main() -{ - int fd = open("test.file", O_RDONLY); - char buf[2] = {0}; - int ret = 0; - do { - ret = read(fd, buf, 1); // 每读1字节都触发一次系统调用 - } while(ret); -} -``` - -```c -// fread1.c - 标准IO(有用户缓冲区,减少系统调用次数) -#include "wrapper.h" -void main() -{ - FILE *pf = fopen("test.file", "r"); - char buf[2] = {0}; - int ret = 0; - do { - ret = fread(buf, 1, 1, pf); // 用户缓冲区存在时不需要系统调用 - } while(ret); -} -``` - -**性能对比**:读取同一个100KB的文件: -- `read1.c`:约102,400次系统调用 -- `fread1.c`:约128次系统调用(默认缓冲区8KB) - -### 示例7:文件描述符分配测试(fdtest1.c) - -```c -// fdtest1.c - 观察文件描述符的分配顺序 -#include "wrapper.h" -int main() -{ - int fd1, fd2, fd3; - fd1 = Open("f1", O_RDWR|O_CREAT, 0777); - fd2 = Open("f2", O_RDWR|O_CREAT, 0777); - fd3 = Open("f3", O_RDWR|O_CREAT, 0777); - printf("fd1=%d fd2=%d fd3=%d\n", fd1, fd2, fd3); - Close(fd1); - Close(fd2); - Close(fd3); -} -``` - -**预期输出**: -``` -fd1=3 fd2=4 fd3=5 -``` - -**关键点**:0/1/2被stdin/stdout/stderr占用,新文件从3开始分配。 - ---- - -## 🔗 知识关联 -- 文件描述符的系统调用通过 [[01_系统运行机制]] 中的陷入指令进入内核态执行 -- 编译链接这些程序需要 [[03_C语言编程基础]] 中的gcc、Makefile知识 -- 文件的物理存储方式由 [[05_磁盘空间管理]] 中的FAT/NTFS/Ext2决定 -- 实验要求见 [[实验01_IO编程]] - ---- - -## 📝 思考题 - -1. **概念理解题**:文件描述符、文件表、v-node表三者的关系是什么?同一个文件被同一个进程打开两次和被两个不同进程各打开一次,有什么区别? -2. **代码分析题**:以下代码的输出是什么?为什么? - ```c - fd = Open("test", O_RDWR|O_CREAT|O_TRUNC, 0666); - Write(fd, "hello", 5); - lseek(fd, 0, SEEK_SET); - Write(fd, "world", 5); - ``` -3. **应用题**:如何用dup2实现命令 `ls | grep .c` 的管道功能?需要哪些系统调用? -4. **性能题**:为什么fcopy2.c比fcopy1.c快得多?如果将缓冲区从1024改为4096,性能会如何变化? - ---- - -## 📚 扩展阅读 -- 《深入理解计算机系统》第10章:系统级I/O -- 《UNIX环境高级编程》第3章:文件I/O -- 《Linux编程》第4章相关源码:`实例源代码/chap4/` +# 第04讲:文件IO编程 + +> 🎯 **本节目标**:掌握UNIX文件IO系统调用的使用方法,理解文件描述符、文件共享、mmap内存映射和I/O重定向机制 + +## 📋 前置知识 +- [[03_C语言编程基础]] — C语言编译链接、gdb调试、Makefile +- [[01_系统运行机制]] — 系统调用的概念 + +--- + +## 🤔 为什么需要这个? + +程序要读写文件、处理数据,必须通过操作系统的I/O接口。UNIX提供了一套简洁而强大的系统调用(open/read/write/close/lseek),几乎所有的Linux程序都建立在这套接口之上。Shell的重定向机制、数据库的存储引擎、Web服务器的文件传输,底层都依赖这些原语。 + +**生活比喻**: +- 文件描述符就像**取餐号牌**:你去餐厅点餐(open),服务员给你一个号牌(fd),之后你用号牌取餐(read/write),吃完归还号牌(close) +- lseek就像**唱片针头移动**:可以跳到唱片的任意位置开始播放 + +--- + +## 📖 核心概念 + +### 1. UNIX IO函数 + +Linux将所有I/O设备都视为**文件**,统一通过文件描述符进行操作。内核为每个进程维护一个**文件描述符表**,从0开始编号。 + +```mermaid +graph LR + subgraph 进程文件描述符表 + fd0[0 - stdin 标准输入] + fd1[1 - stdout 标准输出] + fd2[2 - stderr 标准错误] + fd3[3 - 普通文件] + fd4[4 - 普通文件] + end + + style fd0 fill:#e1f5fe + style fd1 fill:#e8f5e9 + style fd2 fill:#fff3e0 +``` + +#### open() - 打开/创建文件 + +```c +#include +int open(const char *pathname, int flags, mode_t mode); +// 返回:成功返回文件描述符(>=0),失败返回-1 +``` + +**flags参数**(可用 `|` 组合): + +| 标志 | 含义 | 说明 | +|------|------|------| +| `O_RDONLY` | 只读 | 三个互斥的访问模式之一 | +| `O_WRONLY` | 只写 | 三个互斥的访问模式之一 | +| `O_RDWR` | 读写 | 三个互斥的访问模式之一 | +| `O_CREAT` | 若文件不存在则创建 | 需要指定mode参数 | +| `O_TRUNC` | 截断文件为0 | 清空已有内容 | +| `O_APPEND` | 追加模式 | 每次写操作前定位到文件末尾 | + +**mode参数**(创建文件时的权限): + +| 八进制 | 含义 | +|--------|------| +| `0666` | 所有者/组/其他均可读写 | +| `0777` | 所有者/组/其他可读写执行 | +| `0644` | 所有者读写,组和其他只读 | + +#### close() - 关闭文件 + +```c +int close(int fd); +// 关闭文件描述符,释放内核资源 +``` + +#### read() / write() - 读写文件 + +```c +ssize_t read(int fd, void *buf, size_t count); +// 返回:实际读取的字节数,0表示EOF,-1表示错误 + +ssize_t write(int fd, const void *buf, size_t count); +// 返回:实际写入的字节数,-1表示错误 +``` + +#### 文件描述符分配规则 + +新打开的文件描述符总是取**当前未使用的最小值**。标准输入(0)、标准输出(1)、标准错误(2)默认被占用,因此普通文件通常从3开始。 + +```mermaid +graph TD + A["open('f1')"] --> B["返回 fd=3"] + B --> C["open('f2')"] + C --> D["返回 fd=4"] + D --> E["open('f3')"] + E --> F["返回 fd=5"] + F --> G["close(fd=3)"] + G --> H["open('f4')"] + H --> I["返回 fd=3(最小可用)"] + + style B fill:#e8f5e9 + style D fill:#e8f5e9 + style F fill:#e8f5e9 + style I fill:#fff3e0 +``` + +### 2. 文件共享 + +当多个进程打开同一个文件时,内核通过三层数据结构实现共享: + +```mermaid +graph TD + subgraph 进程A的描述符表 + A1["fd=3"] --> FT1 + A2["fd=4"] --> FT2 + end + + subgraph 进程B的描述符表 + B1["fd=3"] --> FT1 + B2["fd=5"] --> FT3 + end + + subgraph 文件表 全局 + FT1["文件表项1 +文件偏移=100 +引用计数=2"] + FT2["文件表项2 +文件偏移=0 +引用计数=1"] + FT3["文件表项3 +文件偏移=50 +引用计数=1"] + end + + subgraph v-node表 + V1["v-node +文件大小=1024 +inode信息"] + end + + FT1 --> V1 + FT2 --> V1 + FT3 --> V1 + + style FT1 fill:#ffcdd2 + style V1 fill:#e1f5fe +``` + +**关键点**: +- **描述符表**:每个进程独立,每个打开的文件描述符对应一个表项 +- **文件表**:所有进程共享,记录当前文件偏移量和引用计数 +- **v-node表**:所有进程共享,存储文件元数据(大小、类型、inode等) + +**同进程多次打开同一文件**:每次open创建新的文件表项(独立偏移量),但指向同一个v-node。 + +**不同进程打开同一文件**:每个进程各自创建独立的文件表项,共享同一个v-node。 + +### 3. lseek定位 + +`lseek` 修改文件的当前偏移量,实现随机访问: + +```c +off_t lseek(int fd, off_t offset, int whence); +// whence: SEEK_SET / SEEK_CUR / SEEK_END +// 返回:新的文件偏移量,-1表示错误 +``` + +| whence | 含义 | 计算方式 | +|--------|------|----------| +| `SEEK_SET` | 从文件开头 | 新偏移 = offset | +| `SEEK_CUR` | 从当前位置 | 新偏移 = 当前偏移 + offset | +| `SEEK_END` | 从文件末尾 | 新偏移 = 文件大小 + offset | + +**利用lseek获取文件大小**: +```c +off_t size = lseek(fd, 0, SEEK_END); +``` + +**利用lseek创建空洞文件**: +```c +lseek(fd, 1024*1024, SEEK_SET); // 跳到1MB位置 +write(fd, "X", 1); // 写1字节 +// 文件大小为 1MB+1,中间全是空洞('\0') +``` + +### 4. mmap内存映射 + +`mmap` 将文件直接映射到进程的虚拟地址空间,之后可以像访问内存一样读写文件,无需read/write系统调用。 + +```c +void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); +// addr: 建议映射地址(通常传NULL由内核选择) +// length: 映射长度 +// prot: 保护标志 PROT_READ | PROT_WRITE | PROT_EXEC +// flags: MAP_PRIVATE(私有副本)| MAP_SHARED(共享映射) +// fd: 文件描述符 +// offset: 文件中的起始偏移 +// 返回:映射区域的起始地址,失败返回MAP_FAILED +``` + +```mermaid +graph LR + subgraph 进程虚拟地址空间 + A["映射区域"] + end + subgraph 内核页缓存 + B["文件数据页1"] + C["文件数据页2"] + D["文件数据页3"] + end + + A -->|"缺页时加载"| B + A -->|"缺页时加载"| C + A -->|"缺页时加载"| D + + style A fill:#e8f5e9 + style B fill:#e1f5fe + style C fill:#e1f5fe + style D fill:#e1f5fe +``` + +**使用流程**: +1. `open()` 打开文件 +2. `fstat()` 获取文件大小 +3. `mmap()` 映射到内存 +4. 通过指针直接读写 +5. `munmap()` 解除映射 +6. `close()` 关闭文件 + +**MAP_PRIVATE vs MAP_SHARED**: + +| 特性 | MAP_PRIVATE | MAP_SHARED | +|------|-------------|------------| +| 写入效果 | 写时复制,不影响原文件 | 直接修改原文件 | +| 使用场景 | 只读浏览文件 | 需要修改文件 | +| 进程间共享 | 不共享 | 多进程共享同一映射 | + +### 5. dup2重定向 + +文件描述符复制是实现I/O重定向的核心机制。Shell的 `>`、`<`、`|` 管道都依赖于此。 + +#### dup() - 复制文件描述符 + +```c +int dup(int oldfd); +// 返回:新的文件描述符(取当前最小可用值) +``` + +#### dup2() - 原子复制并重定向 + +```c +int dup2(int oldfd, int newfd); +// 先关闭newfd,再将oldfd复制到newfd +// 返回:newfd,失败返回-1 +``` + +```mermaid +sequenceDiagram + participant P as 进程 + participant K as 内核 + + Note over P: save_fd = dup(STDOUT_FILENO) + P->>K: 复制fd=1 + K-->>P: save_fd=3(保存stdout) + + Note over P: dup2(fd, STDOUT_FILENO) + P->>K: 关闭fd=1,复制fd到1 + K-->>P: fd=1现在指向文件 + + Note over P: write(STDOUT_FILENO, ...) + P->>K: 写入fd=1(文件) + K-->>P: 数据写入文件 + + Note over P: dup2(save_fd, STDOUT_FILENO) + P->>K: 关闭fd=1,复制save_fd到1 + K-->>P: fd=1恢复为终端 + + Note over P: write(STDOUT_FILENO, ...) + P->>K: 写入fd=1(终端) + K-->>P: 数据显示在终端 +``` + +**dup() vs dup2()**: + +| 特性 | dup() | dup2() | +|------|-------|--------| +| 目标fd | 自动取最小可用值 | 指定目标fd | +| 关闭目标 | 不关闭 | 自动关闭目标fd | +| 原子性 | — | 原子操作,不会竞争 | +| 典型用途 | 简单复制 | Shell重定向 | + +### 6. 标准IO vs UNIX IO + +```mermaid +graph TD + subgraph 标准IO + A["fopen/fread/fwrite/fclose"] + B["用户缓冲区 +减少系统调用次数"] + end + subgraph UNIX IO + C["open/read/write/close"] + D["无用户缓冲区 +每次操作都是系统调用"] + end + + A --> B + C --> D + B -->|"底层调用"| D + + style A fill:#e1f5fe + style C fill:#fff3e0 +``` + +| 特性 | 标准IO (stdio) | UNIX IO | +|------|---------------|---------| +| 头文件 | `` | ``, `` | +| 缓冲 | 用户空间缓冲(默认行缓冲/全缓冲) | 无用户缓冲 | +| 性能 | 频繁小IO时快(减少系统调用) | 大块IO时相当 | +| 可移植性 | 高(ANSI C标准) | 低(POSIX,Linux/UNIX特有) | +| 功能 | 格式化printf/scanf | 更底层控制(如mmap) | + +**缓冲模式**: +- **无缓冲**:stderr,立即输出 +- **行缓冲**:stdout连接终端时,遇到换行符刷新 +- **全缓冲**:普通文件读写,缓冲区满时刷新 + +### 7. struct IO - 结构体二进制读写 + +在操作系统实验中,经常需要将结构体数据以二进制格式写入文件并读回: + +```c +// 写入结构体数组 +struct student { + int id; + char name[20]; + float score; +}; +struct student stu[100]; +// ... 填充数据 ... + +int fd = open("student.dat", O_WRONLY|O_CREAT|O_TRUNC, 0666); +write(fd, stu, sizeof(struct student) * 100); +close(fd); + +// 读回结构体数组 +fd = open("student.dat", O_RDONLY); +read(fd, stu, sizeof(struct student) * 100); +close(fd); +``` + +**注意事项**: +- 结构体可能存在**内存对齐**填充,文件中的字节布局与内存一致 +- 跨平台时需注意字节序(大端/小端) +- 标准IO的fread/fwrite也可以进行结构体二进制读写 + +--- + +## 💻 动手实践 + +### 示例1:逐字节文件拷贝(fcopy1.c) + +最基础的文件拷贝,逐字节读写,理解read/write的基本用法: + +```c +// fcopy1.c - 逐字节文件拷贝 +#include "wrapper.h" +int main() +{ + char c; + int in, out; + in = Open("file.in", O_RDONLY, 0); // 以只读方式打开源文件 + out = Open("file.out", O_WRONLY|O_CREAT, 0666); // 以写方式创建目标文件 + while (Read(in, &c, 1) == 1) // 每次读1字节 + Write(out, &c, 1); // 每次写1字节 + Close(in); // 关闭源文件 + Close(out); // 关闭目标文件 + exit(0); +} +``` + +**编译运行**: +```bash +gcc -o fcopy1 fcopy1.c -L. -lwrapper +echo "Hello, UNIX IO!" > file.in +./fcopy1 +cat file.out # 输出: Hello, UNIX IO! +``` + +**关键点**: +- `Open`、`Read`、`Write`、`Close` 是wrapper.h提供的带错误检查的包装函数 +- `O_CREAT` 标志需要提供第三个参数(文件权限) +- 逐字节拷贝效率很低,实际应用应使用缓冲区 + +### 示例2:块缓冲文件拷贝(fcopy2.c) + +改用1024字节的缓冲区,大幅提升性能: + +```c +// fcopy2.c - 块缓冲文件拷贝 +#include "wrapper.h" +int main() +{ + char block[1024]; // 1024字节缓冲区 + int in, out; + int nread; + in = Open("file.in", O_RDONLY, 0); + out = Open("file.out", O_WRONLY|O_CREAT, 0666); + while ((nread = Read(in, block, sizeof(block))) > 0) // 每次读最多1024字节 + Write(out, block, nread); // 写入实际读到的字节数 + Close(in); + Close(out); + exit(0); +} +``` + +**关键点**: +- `Read` 返回实际读取的字节数(可能小于请求值) +- 最后一次读取可能不满1024字节,需要用 `nread` 控制写入量 +- 磁盘文件通常4KB对齐,使用4096字节缓冲区效率更高 + +### 示例3:lseek随机访问(lseek1.c) + +演示文件随机读写: + +```c +// lseek1.c - 使用lseek进行随机访问 +#include "wrapper.h" +int main() +{ + char s1[6], s2[6]; + int fd; + fd = Open("infile", O_RDWR, 0); + lseek(fd, 10, SEEK_SET); // 定位到文件开头偏移10字节处 + Read(fd, s1, 5); // 读取5个字节 + s1[5] = '\0'; + printf("Read string: %s\n", s1); // 打印读到的内容 + + strcpy(s2, "12345"); + lseek(fd, -5, SEEK_CUR); // 从当前位置回退5字节 + Write(fd, s2, 5); // 覆写5字节 + close(fd); + exit(0); +} +``` + +**执行过程图示**: +``` +文件内容: ABCDEFGHIJ0123456789... + 0123456789(偏移) + +第1步: lseek(fd, 10, SEEK_SET) → 偏移=10 +第2步: Read(fd, s1, 5) → s1="01234",偏移=15 +第3步: lseek(fd, -5, SEEK_CUR) → 偏移=10 +第4步: Write(fd, s2, 5) → 写入"12345",偏移=15 + +结果: ABCDEFGHIJ123456789... +``` + +### 示例4:mmap内存映射(mmap1.c) + +将整个文件映射到内存,通过指针直接读取: + +```c +// mmap1.c - 内存映射文件读取 +#include "wrapper.h" +void main() +{ + int fd = open("test.file", 0); // 打开文件 + struct stat statbuf; + char *start; + char buf[2] = {0}; + int ret = 0; + fstat(fd, &statbuf); // 获取文件大小 + start = mmap(NULL, statbuf.st_size, // 映射整个文件 + PROT_READ, MAP_PRIVATE, fd, 0); + do { + *buf = start[ret++]; // 像访问数组一样读取 + } while(ret < statbuf.st_size); +} +``` + +**编译运行**: +```bash +gcc -o mmap1 mmap1.c -L. -lwrapper +./mmap1 +``` + +**关键点**: +- `fstat()` 获取文件大小,确定映射长度 +- `PROT_READ` 表示只读映射,写入会触发段错误 +- `MAP_PRIVATE` 表示私有映射(写时复制),修改不会影响原文件 +- 访问映射区域时触发缺页中断,内核按需加载文件数据 + +### 示例5:dup2实现I/O重定向(dup2.c) + +演示如何将stdout重定向到文件,再恢复: + +```c +// dup2.c - dup2实现I/O重定向 +#include "wrapper.h" +int main(void) +{ + int fd, save_fd; + char msg[] = "This is a test\n"; + fd = Open("somefile", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR); + save_fd = dup(STDOUT_FILENO); // 保存原始stdout(fd=1)到新fd + dup2(fd, STDOUT_FILENO); // 将stdout重定向到文件 + Close(fd); // 关闭原fd(不影响已复制的fd=1) + Write(STDOUT_FILENO, msg, strlen(msg)); // 写入文件 + dup2(save_fd, STDOUT_FILENO); // 恢复stdout为终端 + Write(STDOUT_FILENO, msg, strlen(msg)); // 写入终端 + Close(save_fd); + return 0; +} +``` + +**预期输出**: +``` +终端显示: This is a test +somefile内容: This is a test +``` + +**关键点**: +- `dup(STDOUT_FILENO)` 保存原始stdout的副本 +- `dup2(fd, STDOUT_FILENO)` 原子地关闭fd=1并复制fd到1 +- 恢复时再次用 `dup2` 将保存的副本写回fd=1 +- Shell实现 `>` 重定向的原理与此相同 + +### 示例6:标准IO与UNIX IO对比 + +通过对比 `read1.c` 和 `fread1.c` 理解缓冲差异: + +```c +// read1.c - UNIX IO(无缓冲,每次read都是系统调用) +#include "wrapper.h" +void main() +{ + int fd = open("test.file", O_RDONLY); + char buf[2] = {0}; + int ret = 0; + do { + ret = read(fd, buf, 1); // 每读1字节都触发一次系统调用 + } while(ret); +} +``` + +```c +// fread1.c - 标准IO(有用户缓冲区,减少系统调用次数) +#include "wrapper.h" +void main() +{ + FILE *pf = fopen("test.file", "r"); + char buf[2] = {0}; + int ret = 0; + do { + ret = fread(buf, 1, 1, pf); // 用户缓冲区存在时不需要系统调用 + } while(ret); +} +``` + +**性能对比**:读取同一个100KB的文件: +- `read1.c`:约102,400次系统调用 +- `fread1.c`:约128次系统调用(默认缓冲区8KB) + +### 示例7:文件描述符分配测试(fdtest1.c) + +```c +// fdtest1.c - 观察文件描述符的分配顺序 +#include "wrapper.h" +int main() +{ + int fd1, fd2, fd3; + fd1 = Open("f1", O_RDWR|O_CREAT, 0777); + fd2 = Open("f2", O_RDWR|O_CREAT, 0777); + fd3 = Open("f3", O_RDWR|O_CREAT, 0777); + printf("fd1=%d fd2=%d fd3=%d\n", fd1, fd2, fd3); + Close(fd1); + Close(fd2); + Close(fd3); +} +``` + +**预期输出**: +``` +fd1=3 fd2=4 fd3=5 +``` + +**关键点**:0/1/2被stdin/stdout/stderr占用,新文件从3开始分配。 + +--- + +## 🔗 知识关联 +- 文件描述符的系统调用通过 [[01_系统运行机制]] 中的陷入指令进入内核态执行 +- 编译链接这些程序需要 [[03_C语言编程基础]] 中的gcc、Makefile知识 +- 文件的物理存储方式由 [[05_磁盘空间管理]] 中的FAT/NTFS/Ext2决定 +- 实验要求见 [[实验01_IO编程]] + +--- + +## 📝 思考题 + +1. **概念理解题**:文件描述符、文件表、v-node表三者的关系是什么?同一个文件被同一个进程打开两次和被两个不同进程各打开一次,有什么区别? +2. **代码分析题**:以下代码的输出是什么?为什么? + ```c + fd = Open("test", O_RDWR|O_CREAT|O_TRUNC, 0666); + Write(fd, "hello", 5); + lseek(fd, 0, SEEK_SET); + Write(fd, "world", 5); + ``` +3. **应用题**:如何用dup2实现命令 `ls | grep .c` 的管道功能?需要哪些系统调用? +4. **性能题**:为什么fcopy2.c比fcopy1.c快得多?如果将缓冲区从1024改为4096,性能会如何变化? + +--- + +## 📚 扩展阅读 +- 《深入理解计算机系统》第10章:系统级I/O +- 《UNIX环境高级编程》第3章:文件I/O +- 《Linux编程》第4章相关源码:`实例源代码/chap4/` diff --git a/操作系统/05_磁盘空间管理/05_磁盘空间管理.md b/操作系统/05_磁盘空间管理/05_磁盘空间管理.md index e5f2df5..27c5526 100644 --- a/操作系统/05_磁盘空间管理/05_磁盘空间管理.md +++ b/操作系统/05_磁盘空间管理/05_磁盘空间管理.md @@ -1,595 +1,595 @@ -# 第05讲:磁盘空间管理 - -> 🎯 **本节目标**:理解磁盘空间的外存组织方式,掌握FAT/NTFS/Ext2文件系统的结构,理解空闲空间管理和磁盘容错机制 - -## 📋 前置知识 -- [[04_文件IO编程]] — 文件读写的基本概念 -- [[01_系统运行机制]] — 存储层次结构 - ---- - -## 🤔 为什么需要这个? - -当你保存一个4GB的电影文件时,操作系统需要决定:把文件存在磁盘的哪些位置?如何记录哪些空间已用、哪些空闲?如果某个磁盘块坏了怎么办?不同的组织方式会直接影响文件的读写速度和磁盘空间利用率。 - -**生活比喻**: -- **连续分配**就像电影院的连续座位:一整排坐在一起,找人很快,但如果有零散空位就浪费了 -- **链接分配**就像寻宝游戏:每个地点告诉你下一个地点在哪,灵活但不能直接跳到第N个 -- **索引分配**就像书的目录:通过目录直接找到对应页码,高效且灵活 - ---- - -## 📖 核心概念 - -### 1. 外存组织方式 - -文件在磁盘上的存放方式有三种基本策略: - -```mermaid -graph TD - A[外存组织方式] --> B[连续组织 -顺序文件] - A --> C[链接组织 -隐式链接/FAT] - A --> D[索引组织 -单级/多级/混合] - - B --> B1["优点:顺序和随机访问都快"] - B --> B2["缺点:外部碎片,不能动态增长"] - C --> C1["优点:消除了外部碎片"] - C --> C2["缺点:不能高效随机访问"] - D --> D1["优点:支持随机访问,无外部碎片"] - D --> D2["缺点:索引块有开销"] - - style A fill:#e1f5fe -``` - -#### 连续组织(顺序文件) - -```mermaid -graph LR - A[文件目录] -->|"起始块号=2, 长度=3"| B[磁盘块2] - B --> C[磁盘块3] - C --> D[磁盘块4] - - style B fill:#e8f5e9 - style C fill:#e8f5e9 - style D fill:#e8f5e9 -``` - -- 文件占用一组**连续的磁盘块** -- 目录项只需记录:起始块号 + 长度 -- 支持顺序访问和随机访问(直接计算偏移) -- 问题:外部碎片严重,文件不能动态增长 - -#### 链接组织 - -**隐式链接**:每个磁盘块末尾存储指向下一个块的指针。 - -```mermaid -graph LR - A[目录] -->|"起始块号=2"| B[块2] -->|"指针→5"| C[块5] -->|"指针→8"| D[块8] -->|"指针→EOF"| E[结束] - - style B fill:#e1f5fe - style C fill:#e1f5fe - style D fill:#e1f5fe -``` - -- 优点:消除了外部碎片,文件可以动态增长 -- 缺点:只能顺序访问,指针占用存储空间,可靠性差(一个指针损坏后续全部丢失) - -**显式链接(FAT)**:将所有块的链接指针集中存放在一张**文件分配表(FAT)**中。 - -```mermaid -graph TD - subgraph 文件分配表FAT - F0["0: —"] - F1["1: —"] - F2["2: 5"] - F3["3: —"] - F4["4: —"] - F5["5: 8"] - F6["6: —"] - F7["7: —"] - F8["8: EOF"] - end - - subgraph 磁盘块 - D2[块2] - D5[块5] - D8[块8] - end - - D2 -.->|"FAT[2]=5"| D5 - D5 -.->|"FAT[5]=8"| D8 - D8 -.->|"FAT[8]=EOF"| STOP[结束] - - style F2 fill:#ffcdd2 - style F5 fill:#ffcdd2 - style F8 fill:#ffcdd2 -``` - -- FAT常驻内存,随机访问时只需查表,不需要读磁盘 -- 比隐式链接更可靠,但也更占内存 - -#### 索引组织 - -为每个文件建立一个**索引块**,集中存储所有数据块的块号: - -```mermaid -graph TD - A[文件目录] -->|"索引块号=10"| B[索引块] - B -->|"指针0"| C[块2] - B -->|"指针1"| D[块5] - B -->|"指针2"| E[块8] - B -->|"指针3"| F[块12] - - style B fill:#fff3e0 - style C fill:#e1f5fe - style D fill:#e1f5fe - style E fill:#e1f5fe - style F fill:#e1f5fe -``` - -- 支持随机访问:访问第N个数据块,直接查索引块的第N个指针 -- 无外部碎片 -- 缺点:索引块本身占用空间,大文件需要多级索引 - -**三种方式对比**: - -| 特性 | 连续组织 | 链接组织 | 索引组织 | -|------|----------|----------|----------| -| 顺序访问 | 快 | 快 | 快 | -| 随机访问 | 快(直接计算) | 慢(需遍历链) | 快(查索引表) | -| 外部碎片 | 严重 | 无 | 无 | -| 文件增长 | 困难 | 容易 | 容易 | -| 可靠性 | 高 | 低(指针损坏) | 高 | - -### 2. FAT文件系统 - -FAT(File Allocation Table)是Windows早期广泛使用的文件系统。 - -```mermaid -graph TD - subgraph FAT磁盘布局 - A[引导扇区] --> B[FAT1] - B --> C[FAT2 备份] - C --> D[根目录区] - D --> E[数据区] - end - - style A fill:#ffcdd2 - style B fill:#fff3e0 - style C fill:#fff3e0 - style D fill:#e1f5fe - style E fill:#e8f5e9 -``` - -**簇(Cluster)**:FAT文件系统的最小分配单位,由若干连续扇区组成。 - -| FAT版本 | 最大分区 | 簇大小 | FAT表项位数 | -|---------|---------|--------|-------------| -| FAT12 | 2MB | 512B~4KB | 12位 | -| FAT16 | 2GB | 2KB~32KB | 16位 | -| FAT32 | 2TB | 4KB~32KB | 32位(实际28位) | - -**FAT表结构**:每个簇在FAT表中占一个表项,表项内容的含义: - -| 表项值 | 含义 | -|--------|------| -| 0 | 空闲簇 | -| 2~N | 下一个簇的簇号 | -| FF8H~FFFH (FAT12) | 文件结束标记 | -| FFF8H~FFFFH (FAT16) | 文件结束标记 | -| 0FFFFFF8H~0FFFFFFFH (FAT32) | 文件结束标记 | -| 其他特殊值 | 坏簇标记 | - -**FAT16的问题**:每个分区最多65536个簇,簇最小2KB时最大分区为2GB。小文件也会浪费一个簇的空间(簇内碎片)。 - -### 3. NTFS文件系统 - -NTFS(New Technology File System)是Windows NT系列的现代文件系统。 - -```mermaid -graph TD - subgraph NTFS磁盘布局 - A[引导扇区 -MBR/分区表] --> B[主控文件表MFT] - B --> C[MFT副本] - C --> D[系统文件区] - D --> E[用户数据区] - end - - style A fill:#ffcdd2 - style B fill:#fff3e0 - style C fill:#fff3e0 - style D fill:#e1f5fe - style E fill:#e8f5e9 -``` - -**关键特性**: - -| 特性 | 说明 | -|------|------| -| 64位磁盘地址 | 支持超大分区(理论2^64字节) | -| MFT主控文件表 | 核心数据结构,每个文件/目录对应一条MFT记录 | -| LCN逻辑簇号 | 从分区开头算起的绝对簇号 | -| VCN虚拟簇号 | 文件内部的相对簇号(从0开始) | -| 日志文件 | 记录文件操作日志,崩溃后可恢复一致性 | -| 文件加密 | 支持EFS加密文件系统 | -| 文件压缩 | 支持透明压缩 | -| 硬链接 | 多个文件名指向同一个MFT记录 | - -**MFT结构**:每条MFT记录通常1KB,包含多个属性(文件名、时间戳、数据内容等)。小文件的数据直接存储在MFT记录中(驻留属性),无需额外数据块。 - -### 4. Ext2文件系统 - -Ext2是Linux的经典文件系统,采用**块组**组织磁盘空间。 - -#### 磁盘总体布局 - -```mermaid -graph LR - subgraph "Ext2磁盘布局" - A["引导块 -Block 0"] --> B["块组0"] - B --> C["块组1"] - C --> D["块组2"] - D --> E["..."] - end - - style A fill:#ffcdd2 -``` - -#### 块组内部结构 - -```mermaid -graph TD - subgraph "单个块组的结构" - A["超级块 Super Block -文件系统全局信息"] --> B["块组描述符表 -所有块组的描述信息"] - B --> C["数据块位图 -记录哪些块已用"] - C --> D["inode位图 -记录哪些inode已用"] - D --> E["inode表 -所有inode的数组"] - E --> F["数据块 -存放文件内容"] - end - - style A fill:#ffcdd2 - style B fill:#ffcdd2 - style C fill:#fff3e0 - style D fill:#fff3e0 - style E fill:#e1f5fe - style F fill:#e8f5e9 -``` - -**超级块**(Super Block):存储整个文件系统的元数据 -- 块大小、inode总数、块总数、空闲块数、空闲inode数 -- 挂载计数、上次检查时间等 - -**块组描述符**(Group Descriptor):描述每个块组的状态 -- 本块组的块位图位置、inode位图位置、inode表位置、空闲块数等 - -**块位图**(Block Bitmap):每个bit对应一个数据块,1=已用,0=空闲 - -**inode位图**(inode Bitmap):每个bit对应一个inode,1=已用,0=空闲 - -#### inode结构(128字节) - -```mermaid -graph TD - subgraph "inode 128字节" - A["模式/权限 2B"] --> B["所有者ID 2B"] - B --> C["文件大小 4B"] - C --> D["时间戳 12B -访问/修改/创建"] - D --> E["链接计数 2B"] - E --> F["数据块指针 60B"] - end - - subgraph "15个地址项(60字节)" - G["直接块指针 0~9 -10个 × 4B"] - H["一次间接指针 -1个 × 4B"] - I["二次间接指针 -1个 × 4B"] - J["三次间接指针 -1个 × 4B"] - end - - F --> G - F --> H - F --> I - F --> J - - style A fill:#e1f5fe - style G fill:#e8f5e9 - style H fill:#fff3e0 - style I fill:#ffcdd2 - style J fill:#fce4ec -``` - -#### 混合索引容量计算(重点) - -设盘块大小为**4KB**,盘块号占**4字节**,inode有**13个地址项**(10直接 + 1一次间接 + 1二次间接 + 1三次间接): - -每个盘块可存放盘块号个数: -$$4096 \div 4 = 1024 \text{ 个指针}$$ - -| 索引级别 | 计算过程 | 容量 | -|----------|----------|------| -| 直接块(10个) | 10 x 4KB | **40KB** | -| 一次间接 | 1024 x 4KB | **4MB** | -| 二次间接 | 1024 x 1024 x 4KB | **4GB** | -| 三次间接 | 1024 x 1024 x 1024 x 4KB | **4TB** | -| **总计** | | **约4TB** | - -```mermaid -graph TD - subgraph "混合索引寻址过程" - A["inode"] -->|"直接块指针0~9"| B["10个数据块 -共40KB"] - A -->|"一次间接指针"| C["索引块1 -1024个指针"] - C -->|"指针0~1023"| D["1024个数据块 -共4MB"] - A -->|"二次间接指针"| E["二级索引块 -1024个指针"] - E -->|"指针0~1023"| F["1024个一级索引块"] - F -->|"每个指向1024个数据块"| G["1024x1024个数据块 -共4GB"] - A -->|"三次间接指针"| H["三级索引块"] - H --> I["1024个二级索引块"] - I --> J["1024x1024个一级索引块"] - J --> K["1024^3个数据块 -共4TB"] - end - - style A fill:#ffcdd2 - style B fill:#e8f5e9 - style D fill:#e8f5e9 - style G fill:#e8f5e9 - style K fill:#e8f5e9 -``` - -**寻址示例**:假设要读取文件的第15000个字节(盘块大小4KB): -- 逻辑块号 = 15000 / 4096 = 3(第4个块,索引为3) -- 块内偏移 = 15000 % 4096 = 2660 -- 因为索引3 < 10,所以使用直接块指针[3]找到数据块 -- 在该数据块的偏移2660处读取数据 - -#### 目录项 ext2_dir_entry_2 - -目录在Ext2中也是文件,内容是一系列目录项: - -| 字段 | 大小 | 说明 | -|------|------|------| -| inode号 | 4字节 | 指向的inode编号 | -| rec_len | 2字节 | 本目录项总长度 | -| name_len | 1字节 | 文件名长度 | -| file_type | 1字节 | 文件类型(普通文件/目录/符号链接等) | -| name | 变长 | 文件名(不超过255字节) | - -### 5. HDFS - -HDFS(Hadoop Distributed File System)是大数据领域的分布式文件系统。 - -```mermaid -graph TD - subgraph HDFS架构 - NN["名称节点 NameNode -元数据管理 -文件→块映射 -块→数据节点映射"] - DN1["数据节点 DataNode1 -块1, 块3"] - DN2["数据节点 DataNode2 -块2, 块4"] - DN3["数据节点 DataNode3 -块1副本, 块5"] - end - - NN -->|"心跳/块报告"| DN1 - NN -->|"心跳/块报告"| DN2 - NN -->|"心跳/块报告"| DN3 - DN1 -->|"数据流"| CLIENT["客户端"] - - style NN fill:#ffcdd2 - style DN1 fill:#e1f5fe - style DN2 fill:#e1f5fe - style DN3 fill:#e1f5fe -``` - -**特点**: -- 文件被分割为固定大小的块(默认128MB),分布在多个数据节点上 -- 每个块默认3副本,分布在不同机架 -- 名称节点管理元数据(内存中),数据节点存储实际数据 -- 适合大文件顺序读取,不适合小文件和随机写入 - -### 6. 空闲空间管理 - -#### 空闲表法 - -用一张表记录所有空闲区的起始块号和长度: - -| 起始块号 | 空闲块数 | -|----------|----------| -| 2 | 3 | -| 10 | 5 | -| 20 | 2 | - -适用于连续分配方式,适合少量空闲区的情况。 - -#### 空闲链表法 - -将所有空闲磁盘块用指针链接成一个链表。分配时从链头取,回收时插入链尾。缺点是指针占用空间,分配效率低。 - -#### 位示图法 - -用一个**位图(bitmap)**记录每个磁盘块的使用状态,1=已用,0=空闲(或反之)。 - -``` -位示图示例(每行16位): -第0行: 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 → 块0~3已用 -第1行: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 → 块16~31空闲 -第2行: 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 → 块32~33已用 -``` - -**地址转换公式**: - -设每行有 **j** 位(通常为字长,如16位、32位),则第 **n** 个磁盘块对应位示图中: - -$$\text{行号 } i = n \div j$$ -$$\text{列号 } k = n \mod j$$ - -即: -$$n = j \times i + k$$ - -**经典公式**(每行16位时):$n = 16 \times i + j$ - -**位示图法示例**:设磁盘共200个块,每行16位,位示图需要 200/16 = 13 行。 - -要找到第75个空闲块:$i = 75 \div 16 = 4$,$j = 75 \mod 16 = 11$,即第4行第11列。 - -#### 成组链接法(UNIX) - -UNIX采用**成组链接法**管理空闲磁盘块,是空闲表法和空闲链表法的结合: - -```mermaid -graph TD - SB["超级块 -栈:存放当前组的空闲块号 -栈顶指针"] -->|"指向"| G1["空闲块组1 -栈底块存储下一组的信息"] - G1 -->|"下一组指针"| G2["空闲块组2"] - G2 -->|"下一组指针"| G3["空闲块组3"] - G3 -->|"下一组指针"| G4["更多组..."] - - style SB fill:#ffcdd2 - style G1 fill:#e8f5e9 - style G2 fill:#e8f5e9 - style G3 fill:#e8f5e9 -``` - -**分配过程**: -1. 从超级块的栈中弹出一个空闲块号 -2. 如果栈中只剩一个元素(它是下一组的指针),先将该组信息读入超级块,再弹出 -3. 更新超级块 - -**回收过程**: -1. 将回收的块号压入超级块的栈中 -2. 如果栈已满,将超级块中的栈信息写入回收块(成为新组),清空栈,将回收块号作为唯一元素 - -**优点**:分配和回收只需读写超级块(内存中),效率极高。 - -### 7. 磁盘IO优化 - -| 优化技术 | 原理 | -|----------|------| -| 磁盘高速缓存 | 在内存中开辟缓冲区缓存磁盘块,减少磁盘访问 | -| 提前读 | 顺序读取时,预先把后续块读入缓存 | -| 延迟写 | 先写入缓存,延迟到合适时机再写入磁盘 | -| 虚拟盘 | 用内存模拟磁盘(RAM Disk),速度极快但断电丢失 | - -### 8. RAID - -RAID(Redundant Array of Independent Disks)通过多块磁盘组合提高性能和可靠性。 - -```mermaid -graph TD - subgraph RAID0 条带化 - A0["数据块1"] --> S0[磁盘0] - A1["数据块2"] --> S1[磁盘1] - A2["数据块3"] --> S0 - A3["数据块4"] --> S1 - end - - subgraph RAID1 镜像 - B0["数据"] --> M0[磁盘0] - B0 --> M1[磁盘1 镜像] - end - - subgraph RAID5 分布式校验 - C0["数据A"] --> R0[磁盘0] - C1["数据B"] --> R1[磁盘1] - C2["P校验"] --> R2[磁盘2] - C3["数据C"] --> R3[磁盘3] - end - - style S0 fill:#e1f5fe - style S1 fill:#e1f5fe - style M0 fill:#e8f5e9 - style M1 fill:#fff3e0 - style R0 fill:#e1f5fe - style R1 fill:#e1f5fe - style R2 fill:#ffcdd2 - style R3 fill:#e1f5fe -``` - -| RAID级别 | 原理 | 冗余 | 最少磁盘 | 利用率 | 特点 | -|----------|------|------|----------|--------|------| -| RAID0 | 数据条带化分布 | 无 | 2 | 100% | 高性能,无容错 | -| RAID1 | 数据完全镜像 | 100% | 2 | 50% | 高可靠,成本高 | -| RAID3 | 位交叉+专用校验盘 | 1块校验盘 | 3 | (N-1)/N | 校验盘成瓶颈 | -| RAID5 | 块交叉+分布式校验 | 分布式校验 | 3 | (N-1)/N | 性能与可靠性平衡 | - -### 9. 磁盘容错 - -| 容错级别 | 技术 | 内容 | -|----------|------|------| -| SFT-I | 一级容错 | 双份目录和FAT表、热修复重定向(写入坏块时重定向到备用块) | -| SFT-II | 二级容错 | 磁盘镜像(同一控制器两个磁盘)、磁盘双工(不同控制器两个磁盘) | -| 集群容错 | 三级容错 | 多台服务器组成集群,一台故障其他接管 | - ---- - -## 💻 动手实践 - -### 查看文件系统信息 -```bash -# 查看磁盘使用情况 -df -h - -# 查看inode使用情况 -df -i - -# 查看文件系统类型和块大小 -tune2fs -l /dev/sda1 | grep -E "Block size|Inode count" - -# 查看文件的块分配 -stat filename - -# 查看文件的inode号 -ls -i filename -``` - ---- - -## 🔗 知识关联 -- 文件IO的read/write最终需要将数据写入 [[04_文件IO编程]] 中的物理磁盘 -- 磁盘调度算法在 [[17_IO系统]] 中有详细讲解 -- 文件系统是 [[13_存储管理基础]] 中存储管理的重要组成部分 -- 分页存储管理的思想与Ext2的块分配有相似之处,见 [[14_分页存储管理]] - ---- - -## 📝 思考题 - -1. **FAT表计算**:一个FAT16分区,每簇4KB,最多能管理多大的分区?为什么? -2. **Ext2容量计算**:如果盘块大小为1KB(而非4KB),盘块号占4字节,那么混合索引支持的最大文件是多少?(提示:每个间接块只能放256个指针) -3. **inode寻址**:给定盘块大小4KB,盘块号4字节,要读取文件偏移5GB处的数据,需要经过几级间接索引? -4. **成组链接法**:如果超级块栈最多容纳100个空闲块号,那么分配第101个空闲块时会发生什么? -5. **RAID选择**:一个视频监控系统需要大容量存储、持续写入、偶尔丢失可接受,应选择RAID几?为什么? - ---- - -## 📚 扩展阅读 -- 《计算机操作系统》(汤小丹)第5章:文件管理 -- 《操作系统概念》第11-12章:文件系统接口与实现 -- [ext2文件系统详解](https://www.nongnu.org/ext2-intro/) -- [RAID级别详解](https://www.techtarget.com/searchstorage/definition/RAID) +# 第05讲:磁盘空间管理 + +> 🎯 **本节目标**:理解磁盘空间的外存组织方式,掌握FAT/NTFS/Ext2文件系统的结构,理解空闲空间管理和磁盘容错机制 + +## 📋 前置知识 +- [[04_文件IO编程]] — 文件读写的基本概念 +- [[01_系统运行机制]] — 存储层次结构 + +--- + +## 🤔 为什么需要这个? + +当你保存一个4GB的电影文件时,操作系统需要决定:把文件存在磁盘的哪些位置?如何记录哪些空间已用、哪些空闲?如果某个磁盘块坏了怎么办?不同的组织方式会直接影响文件的读写速度和磁盘空间利用率。 + +**生活比喻**: +- **连续分配**就像电影院的连续座位:一整排坐在一起,找人很快,但如果有零散空位就浪费了 +- **链接分配**就像寻宝游戏:每个地点告诉你下一个地点在哪,灵活但不能直接跳到第N个 +- **索引分配**就像书的目录:通过目录直接找到对应页码,高效且灵活 + +--- + +## 📖 核心概念 + +### 1. 外存组织方式 + +文件在磁盘上的存放方式有三种基本策略: + +```mermaid +graph TD + A[外存组织方式] --> B[连续组织 +顺序文件] + A --> C[链接组织 +隐式链接/FAT] + A --> D[索引组织 +单级/多级/混合] + + B --> B1["优点:顺序和随机访问都快"] + B --> B2["缺点:外部碎片,不能动态增长"] + C --> C1["优点:消除了外部碎片"] + C --> C2["缺点:不能高效随机访问"] + D --> D1["优点:支持随机访问,无外部碎片"] + D --> D2["缺点:索引块有开销"] + + style A fill:#e1f5fe +``` + +#### 连续组织(顺序文件) + +```mermaid +graph LR + A[文件目录] -->|"起始块号=2, 长度=3"| B[磁盘块2] + B --> C[磁盘块3] + C --> D[磁盘块4] + + style B fill:#e8f5e9 + style C fill:#e8f5e9 + style D fill:#e8f5e9 +``` + +- 文件占用一组**连续的磁盘块** +- 目录项只需记录:起始块号 + 长度 +- 支持顺序访问和随机访问(直接计算偏移) +- 问题:外部碎片严重,文件不能动态增长 + +#### 链接组织 + +**隐式链接**:每个磁盘块末尾存储指向下一个块的指针。 + +```mermaid +graph LR + A[目录] -->|"起始块号=2"| B[块2] -->|"指针→5"| C[块5] -->|"指针→8"| D[块8] -->|"指针→EOF"| E[结束] + + style B fill:#e1f5fe + style C fill:#e1f5fe + style D fill:#e1f5fe +``` + +- 优点:消除了外部碎片,文件可以动态增长 +- 缺点:只能顺序访问,指针占用存储空间,可靠性差(一个指针损坏后续全部丢失) + +**显式链接(FAT)**:将所有块的链接指针集中存放在一张**文件分配表(FAT)**中。 + +```mermaid +graph TD + subgraph 文件分配表FAT + F0["0: —"] + F1["1: —"] + F2["2: 5"] + F3["3: —"] + F4["4: —"] + F5["5: 8"] + F6["6: —"] + F7["7: —"] + F8["8: EOF"] + end + + subgraph 磁盘块 + D2[块2] + D5[块5] + D8[块8] + end + + D2 -.->|"FAT[2]=5"| D5 + D5 -.->|"FAT[5]=8"| D8 + D8 -.->|"FAT[8]=EOF"| STOP[结束] + + style F2 fill:#ffcdd2 + style F5 fill:#ffcdd2 + style F8 fill:#ffcdd2 +``` + +- FAT常驻内存,随机访问时只需查表,不需要读磁盘 +- 比隐式链接更可靠,但也更占内存 + +#### 索引组织 + +为每个文件建立一个**索引块**,集中存储所有数据块的块号: + +```mermaid +graph TD + A[文件目录] -->|"索引块号=10"| B[索引块] + B -->|"指针0"| C[块2] + B -->|"指针1"| D[块5] + B -->|"指针2"| E[块8] + B -->|"指针3"| F[块12] + + style B fill:#fff3e0 + style C fill:#e1f5fe + style D fill:#e1f5fe + style E fill:#e1f5fe + style F fill:#e1f5fe +``` + +- 支持随机访问:访问第N个数据块,直接查索引块的第N个指针 +- 无外部碎片 +- 缺点:索引块本身占用空间,大文件需要多级索引 + +**三种方式对比**: + +| 特性 | 连续组织 | 链接组织 | 索引组织 | +|------|----------|----------|----------| +| 顺序访问 | 快 | 快 | 快 | +| 随机访问 | 快(直接计算) | 慢(需遍历链) | 快(查索引表) | +| 外部碎片 | 严重 | 无 | 无 | +| 文件增长 | 困难 | 容易 | 容易 | +| 可靠性 | 高 | 低(指针损坏) | 高 | + +### 2. FAT文件系统 + +FAT(File Allocation Table)是Windows早期广泛使用的文件系统。 + +```mermaid +graph TD + subgraph FAT磁盘布局 + A[引导扇区] --> B[FAT1] + B --> C[FAT2 备份] + C --> D[根目录区] + D --> E[数据区] + end + + style A fill:#ffcdd2 + style B fill:#fff3e0 + style C fill:#fff3e0 + style D fill:#e1f5fe + style E fill:#e8f5e9 +``` + +**簇(Cluster)**:FAT文件系统的最小分配单位,由若干连续扇区组成。 + +| FAT版本 | 最大分区 | 簇大小 | FAT表项位数 | +|---------|---------|--------|-------------| +| FAT12 | 2MB | 512B~4KB | 12位 | +| FAT16 | 2GB | 2KB~32KB | 16位 | +| FAT32 | 2TB | 4KB~32KB | 32位(实际28位) | + +**FAT表结构**:每个簇在FAT表中占一个表项,表项内容的含义: + +| 表项值 | 含义 | +|--------|------| +| 0 | 空闲簇 | +| 2~N | 下一个簇的簇号 | +| FF8H~FFFH (FAT12) | 文件结束标记 | +| FFF8H~FFFFH (FAT16) | 文件结束标记 | +| 0FFFFFF8H~0FFFFFFFH (FAT32) | 文件结束标记 | +| 其他特殊值 | 坏簇标记 | + +**FAT16的问题**:每个分区最多65536个簇,簇最小2KB时最大分区为2GB。小文件也会浪费一个簇的空间(簇内碎片)。 + +### 3. NTFS文件系统 + +NTFS(New Technology File System)是Windows NT系列的现代文件系统。 + +```mermaid +graph TD + subgraph NTFS磁盘布局 + A[引导扇区 +MBR/分区表] --> B[主控文件表MFT] + B --> C[MFT副本] + C --> D[系统文件区] + D --> E[用户数据区] + end + + style A fill:#ffcdd2 + style B fill:#fff3e0 + style C fill:#fff3e0 + style D fill:#e1f5fe + style E fill:#e8f5e9 +``` + +**关键特性**: + +| 特性 | 说明 | +|------|------| +| 64位磁盘地址 | 支持超大分区(理论2^64字节) | +| MFT主控文件表 | 核心数据结构,每个文件/目录对应一条MFT记录 | +| LCN逻辑簇号 | 从分区开头算起的绝对簇号 | +| VCN虚拟簇号 | 文件内部的相对簇号(从0开始) | +| 日志文件 | 记录文件操作日志,崩溃后可恢复一致性 | +| 文件加密 | 支持EFS加密文件系统 | +| 文件压缩 | 支持透明压缩 | +| 硬链接 | 多个文件名指向同一个MFT记录 | + +**MFT结构**:每条MFT记录通常1KB,包含多个属性(文件名、时间戳、数据内容等)。小文件的数据直接存储在MFT记录中(驻留属性),无需额外数据块。 + +### 4. Ext2文件系统 + +Ext2是Linux的经典文件系统,采用**块组**组织磁盘空间。 + +#### 磁盘总体布局 + +```mermaid +graph LR + subgraph "Ext2磁盘布局" + A["引导块 +Block 0"] --> B["块组0"] + B --> C["块组1"] + C --> D["块组2"] + D --> E["..."] + end + + style A fill:#ffcdd2 +``` + +#### 块组内部结构 + +```mermaid +graph TD + subgraph "单个块组的结构" + A["超级块 Super Block +文件系统全局信息"] --> B["块组描述符表 +所有块组的描述信息"] + B --> C["数据块位图 +记录哪些块已用"] + C --> D["inode位图 +记录哪些inode已用"] + D --> E["inode表 +所有inode的数组"] + E --> F["数据块 +存放文件内容"] + end + + style A fill:#ffcdd2 + style B fill:#ffcdd2 + style C fill:#fff3e0 + style D fill:#fff3e0 + style E fill:#e1f5fe + style F fill:#e8f5e9 +``` + +**超级块**(Super Block):存储整个文件系统的元数据 +- 块大小、inode总数、块总数、空闲块数、空闲inode数 +- 挂载计数、上次检查时间等 + +**块组描述符**(Group Descriptor):描述每个块组的状态 +- 本块组的块位图位置、inode位图位置、inode表位置、空闲块数等 + +**块位图**(Block Bitmap):每个bit对应一个数据块,1=已用,0=空闲 + +**inode位图**(inode Bitmap):每个bit对应一个inode,1=已用,0=空闲 + +#### inode结构(128字节) + +```mermaid +graph TD + subgraph "inode 128字节" + A["模式/权限 2B"] --> B["所有者ID 2B"] + B --> C["文件大小 4B"] + C --> D["时间戳 12B +访问/修改/创建"] + D --> E["链接计数 2B"] + E --> F["数据块指针 60B"] + end + + subgraph "15个地址项(60字节)" + G["直接块指针 0~9 +10个 × 4B"] + H["一次间接指针 +1个 × 4B"] + I["二次间接指针 +1个 × 4B"] + J["三次间接指针 +1个 × 4B"] + end + + F --> G + F --> H + F --> I + F --> J + + style A fill:#e1f5fe + style G fill:#e8f5e9 + style H fill:#fff3e0 + style I fill:#ffcdd2 + style J fill:#fce4ec +``` + +#### 混合索引容量计算(重点) + +设盘块大小为**4KB**,盘块号占**4字节**,inode有**13个地址项**(10直接 + 1一次间接 + 1二次间接 + 1三次间接): + +每个盘块可存放盘块号个数: +$$4096 \div 4 = 1024 \text{ 个指针}$$ + +| 索引级别 | 计算过程 | 容量 | +|----------|----------|------| +| 直接块(10个) | 10 x 4KB | **40KB** | +| 一次间接 | 1024 x 4KB | **4MB** | +| 二次间接 | 1024 x 1024 x 4KB | **4GB** | +| 三次间接 | 1024 x 1024 x 1024 x 4KB | **4TB** | +| **总计** | | **约4TB** | + +```mermaid +graph TD + subgraph "混合索引寻址过程" + A["inode"] -->|"直接块指针0~9"| B["10个数据块 +共40KB"] + A -->|"一次间接指针"| C["索引块1 +1024个指针"] + C -->|"指针0~1023"| D["1024个数据块 +共4MB"] + A -->|"二次间接指针"| E["二级索引块 +1024个指针"] + E -->|"指针0~1023"| F["1024个一级索引块"] + F -->|"每个指向1024个数据块"| G["1024x1024个数据块 +共4GB"] + A -->|"三次间接指针"| H["三级索引块"] + H --> I["1024个二级索引块"] + I --> J["1024x1024个一级索引块"] + J --> K["1024^3个数据块 +共4TB"] + end + + style A fill:#ffcdd2 + style B fill:#e8f5e9 + style D fill:#e8f5e9 + style G fill:#e8f5e9 + style K fill:#e8f5e9 +``` + +**寻址示例**:假设要读取文件的第15000个字节(盘块大小4KB): +- 逻辑块号 = 15000 / 4096 = 3(第4个块,索引为3) +- 块内偏移 = 15000 % 4096 = 2660 +- 因为索引3 < 10,所以使用直接块指针[3]找到数据块 +- 在该数据块的偏移2660处读取数据 + +#### 目录项 ext2_dir_entry_2 + +目录在Ext2中也是文件,内容是一系列目录项: + +| 字段 | 大小 | 说明 | +|------|------|------| +| inode号 | 4字节 | 指向的inode编号 | +| rec_len | 2字节 | 本目录项总长度 | +| name_len | 1字节 | 文件名长度 | +| file_type | 1字节 | 文件类型(普通文件/目录/符号链接等) | +| name | 变长 | 文件名(不超过255字节) | + +### 5. HDFS + +HDFS(Hadoop Distributed File System)是大数据领域的分布式文件系统。 + +```mermaid +graph TD + subgraph HDFS架构 + NN["名称节点 NameNode +元数据管理 +文件→块映射 +块→数据节点映射"] + DN1["数据节点 DataNode1 +块1, 块3"] + DN2["数据节点 DataNode2 +块2, 块4"] + DN3["数据节点 DataNode3 +块1副本, 块5"] + end + + NN -->|"心跳/块报告"| DN1 + NN -->|"心跳/块报告"| DN2 + NN -->|"心跳/块报告"| DN3 + DN1 -->|"数据流"| CLIENT["客户端"] + + style NN fill:#ffcdd2 + style DN1 fill:#e1f5fe + style DN2 fill:#e1f5fe + style DN3 fill:#e1f5fe +``` + +**特点**: +- 文件被分割为固定大小的块(默认128MB),分布在多个数据节点上 +- 每个块默认3副本,分布在不同机架 +- 名称节点管理元数据(内存中),数据节点存储实际数据 +- 适合大文件顺序读取,不适合小文件和随机写入 + +### 6. 空闲空间管理 + +#### 空闲表法 + +用一张表记录所有空闲区的起始块号和长度: + +| 起始块号 | 空闲块数 | +|----------|----------| +| 2 | 3 | +| 10 | 5 | +| 20 | 2 | + +适用于连续分配方式,适合少量空闲区的情况。 + +#### 空闲链表法 + +将所有空闲磁盘块用指针链接成一个链表。分配时从链头取,回收时插入链尾。缺点是指针占用空间,分配效率低。 + +#### 位示图法 + +用一个**位图(bitmap)**记录每个磁盘块的使用状态,1=已用,0=空闲(或反之)。 + +``` +位示图示例(每行16位): +第0行: 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 → 块0~3已用 +第1行: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 → 块16~31空闲 +第2行: 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 → 块32~33已用 +``` + +**地址转换公式**: + +设每行有 **j** 位(通常为字长,如16位、32位),则第 **n** 个磁盘块对应位示图中: + +$$\text{行号 } i = n \div j$$ +$$\text{列号 } k = n \mod j$$ + +即: +$$n = j \times i + k$$ + +**经典公式**(每行16位时):$n = 16 \times i + j$ + +**位示图法示例**:设磁盘共200个块,每行16位,位示图需要 200/16 = 13 行。 + +要找到第75个空闲块:$i = 75 \div 16 = 4$,$j = 75 \mod 16 = 11$,即第4行第11列。 + +#### 成组链接法(UNIX) + +UNIX采用**成组链接法**管理空闲磁盘块,是空闲表法和空闲链表法的结合: + +```mermaid +graph TD + SB["超级块 +栈:存放当前组的空闲块号 +栈顶指针"] -->|"指向"| G1["空闲块组1 +栈底块存储下一组的信息"] + G1 -->|"下一组指针"| G2["空闲块组2"] + G2 -->|"下一组指针"| G3["空闲块组3"] + G3 -->|"下一组指针"| G4["更多组..."] + + style SB fill:#ffcdd2 + style G1 fill:#e8f5e9 + style G2 fill:#e8f5e9 + style G3 fill:#e8f5e9 +``` + +**分配过程**: +1. 从超级块的栈中弹出一个空闲块号 +2. 如果栈中只剩一个元素(它是下一组的指针),先将该组信息读入超级块,再弹出 +3. 更新超级块 + +**回收过程**: +1. 将回收的块号压入超级块的栈中 +2. 如果栈已满,将超级块中的栈信息写入回收块(成为新组),清空栈,将回收块号作为唯一元素 + +**优点**:分配和回收只需读写超级块(内存中),效率极高。 + +### 7. 磁盘IO优化 + +| 优化技术 | 原理 | +|----------|------| +| 磁盘高速缓存 | 在内存中开辟缓冲区缓存磁盘块,减少磁盘访问 | +| 提前读 | 顺序读取时,预先把后续块读入缓存 | +| 延迟写 | 先写入缓存,延迟到合适时机再写入磁盘 | +| 虚拟盘 | 用内存模拟磁盘(RAM Disk),速度极快但断电丢失 | + +### 8. RAID + +RAID(Redundant Array of Independent Disks)通过多块磁盘组合提高性能和可靠性。 + +```mermaid +graph TD + subgraph RAID0 条带化 + A0["数据块1"] --> S0[磁盘0] + A1["数据块2"] --> S1[磁盘1] + A2["数据块3"] --> S0 + A3["数据块4"] --> S1 + end + + subgraph RAID1 镜像 + B0["数据"] --> M0[磁盘0] + B0 --> M1[磁盘1 镜像] + end + + subgraph RAID5 分布式校验 + C0["数据A"] --> R0[磁盘0] + C1["数据B"] --> R1[磁盘1] + C2["P校验"] --> R2[磁盘2] + C3["数据C"] --> R3[磁盘3] + end + + style S0 fill:#e1f5fe + style S1 fill:#e1f5fe + style M0 fill:#e8f5e9 + style M1 fill:#fff3e0 + style R0 fill:#e1f5fe + style R1 fill:#e1f5fe + style R2 fill:#ffcdd2 + style R3 fill:#e1f5fe +``` + +| RAID级别 | 原理 | 冗余 | 最少磁盘 | 利用率 | 特点 | +|----------|------|------|----------|--------|------| +| RAID0 | 数据条带化分布 | 无 | 2 | 100% | 高性能,无容错 | +| RAID1 | 数据完全镜像 | 100% | 2 | 50% | 高可靠,成本高 | +| RAID3 | 位交叉+专用校验盘 | 1块校验盘 | 3 | (N-1)/N | 校验盘成瓶颈 | +| RAID5 | 块交叉+分布式校验 | 分布式校验 | 3 | (N-1)/N | 性能与可靠性平衡 | + +### 9. 磁盘容错 + +| 容错级别 | 技术 | 内容 | +|----------|------|------| +| SFT-I | 一级容错 | 双份目录和FAT表、热修复重定向(写入坏块时重定向到备用块) | +| SFT-II | 二级容错 | 磁盘镜像(同一控制器两个磁盘)、磁盘双工(不同控制器两个磁盘) | +| 集群容错 | 三级容错 | 多台服务器组成集群,一台故障其他接管 | + +--- + +## 💻 动手实践 + +### 查看文件系统信息 +```bash +# 查看磁盘使用情况 +df -h + +# 查看inode使用情况 +df -i + +# 查看文件系统类型和块大小 +tune2fs -l /dev/sda1 | grep -E "Block size|Inode count" + +# 查看文件的块分配 +stat filename + +# 查看文件的inode号 +ls -i filename +``` + +--- + +## 🔗 知识关联 +- 文件IO的read/write最终需要将数据写入 [[04_文件IO编程]] 中的物理磁盘 +- 磁盘调度算法在 [[17_IO系统]] 中有详细讲解 +- 文件系统是 [[13_存储管理基础]] 中存储管理的重要组成部分 +- 分页存储管理的思想与Ext2的块分配有相似之处,见 [[14_分页存储管理]] + +--- + +## 📝 思考题 + +1. **FAT表计算**:一个FAT16分区,每簇4KB,最多能管理多大的分区?为什么? +2. **Ext2容量计算**:如果盘块大小为1KB(而非4KB),盘块号占4字节,那么混合索引支持的最大文件是多少?(提示:每个间接块只能放256个指针) +3. **inode寻址**:给定盘块大小4KB,盘块号4字节,要读取文件偏移5GB处的数据,需要经过几级间接索引? +4. **成组链接法**:如果超级块栈最多容纳100个空闲块号,那么分配第101个空闲块时会发生什么? +5. **RAID选择**:一个视频监控系统需要大容量存储、持续写入、偶尔丢失可接受,应选择RAID几?为什么? + +--- + +## 📚 扩展阅读 +- 《计算机操作系统》(汤小丹)第5章:文件管理 +- 《操作系统概念》第11-12章:文件系统接口与实现 +- [ext2文件系统详解](https://www.nongnu.org/ext2-intro/) +- [RAID级别详解](https://www.techtarget.com/searchstorage/definition/RAID) diff --git a/操作系统/06_进程控制/06_进程控制.md b/操作系统/06_进程控制/06_进程控制.md index 3b04f32..4707cea 100644 --- a/操作系统/06_进程控制/06_进程控制.md +++ b/操作系统/06_进程控制/06_进程控制.md @@ -1,868 +1,868 @@ -# 第06讲:进程控制 - -> **本节目标**:理解进程的概念与生命周期,掌握 `fork()`、`exec()`、`wait()`、`exit()` 等进程控制系统调用,理解信号机制与僵尸进程,最终能实现一个简易 shell 和 daemon 进程。 - -## 前置知识 - -- [[03_C语言编程基础]] -- C 语言指针、数组、字符串操作 -- [[07_多线程编程]] -- 线程与进程的对比 - ---- - -## 一、进程概念 - -### 1.1 什么是进程 - -**进程(Process)** 是程序的一次执行实例。程序是静态的代码文件,进程是动态的执行过程。当操作系统将程序加载到内存并开始执行时,就创建了一个进程。 - -每个进程拥有独立的地址空间,包含以下组成部分: - -```mermaid -graph TD - P[进程] --> T[代码段 Text] - P --> D[数据段 Data] - P --> H[堆 Heap] - P --> S[栈 Stack] - P --> PCB[PCB 进程控制块] - - T -->|存放| T1[程序指令] - D -->|存放| D1[全局变量/静态变量] - H -->|动态分配| H1[malloc/free] - S -->|自动管理| S1[局部变量/函数调用] - PCB -->|记录| PCB1[PID/状态/寄存器/文件描述符] - - style P fill:#fff3e0 - style PCB fill:#e1f5fe -``` - -### 1.2 进程 vs 程序 - -| 对比维度 | 程序(Program) | 进程(Process) | -|----------|-----------------|-----------------| -| 本质 | 静态的代码文件 | 动态的执行过程 | -| 存储位置 | 硬盘 | 内存 | -| 生命周期 | 长期保存 | 有创建、运行、终止 | -| 对应关系 | 一个程序可对应多个进程 | 一个进程只对应一个程序 | -| 包含内容 | 代码 + 数据 | 代码 + 数据 + 堆栈 + PCB | - -### 1.3 进程状态 - -```mermaid -stateDiagram-v2 - [*] --> 创建: fork() - 创建 --> 就绪: 分配资源 - 就绪 --> 运行: 调度器选中 - 运行 --> 就绪: 时间片用完 - 运行 --> 阻塞: 等待I/O - 阻塞 --> 就绪: I/O完成 - 运行 --> 终止: exit() - 终止 --> [*] - - 就绪: Ready - 运行: Running - 阻塞: Blocked/Waiting - 终止: Terminated/Zombie -``` - -### 1.4 进程标识 - -每个进程都有唯一的 **PID**(Process ID)。常用函数: - -```c -pid_t getpid(); // 获取当前进程的 PID -pid_t getppid(); // 获取父进程的 PID -``` - ---- - -## 二、fork() -- 创建子进程 - -### 2.1 基本原理 - -`fork()` 是 Linux 中创建新进程的唯一方式。调用 `fork()` 后,操作系统会创建一个与父进程几乎完全相同的子进程。子进程获得父进程的代码段、数据段、堆栈的**副本**。 - -**关键特性**:`fork()` 被调用一次,但**返回两次**: -- 父进程中返回子进程的 PID(正整数) -- 子进程中返回 0 -- 如果出错返回 -1 - -```mermaid -graph TD - A["父进程调用 fork()"] --> B{操作系统创建子进程} - B --> C["父进程:返回子进程 PID(>0)"] - B --> D["子进程:返回 0"] - C --> E["父子进程各自独立执行"] - D --> E - - style A fill:#e1f5fe - style C fill:#fff3e0 - style D fill:#e8f5e9 -``` - -### 2.2 fork 基础示例 - -参考 `实例源代码/chap5/fork1.c`: - -```c -#include "wrapper.h" -int main() -{ - pid_t pid; - int x = 1; - - pid = fork(); - if (pid == 0) { // 子进程执行这段代码 - x = x + 1; - printf("child: x=%d\n", x); // 输出 child: x=2 - } - - if (pid > 0) { // 父进程执行这段代码 - x = x - 1; - printf("parent: x=%d\n", x); // 输出 parent: x=0 - } - sleep(10); // 让父子进程都执行完代码 -} -``` - -**关键理解**: -- `fork()` 后,父子进程拥有**独立的变量副本** -- 子进程修改 `x` 不影响父进程的 `x`,反之亦然 -- 父子进程的执行顺序是不确定的,取决于调度器 - -### 2.3 多次 fork 的进程数计算 - -参考 `实例源代码/chap5/fork3.c`: - -```c -#include -#include -#include -int main() -{ - fork(); // 第1次:2个进程 - fork(); // 第2次:4个进程 - fork(); // 第3次:8个进程 - printf("hello \n"); - sleep(10); - return; -} -``` - -**进程树分析**:n 次 `fork()` 会产生 2^n 个进程。上面的代码执行 3 次 `fork()`,最终产生 2^3 = 8 个进程,每个进程打印一次 "hello"。 - -```mermaid -graph TD - P0["P0 (原始进程)"] -->|第1次fork| P1["P1"] - P0 -->|继续| P0a["P0"] - P0a -->|第2次fork| P2["P2"] - P0a -->|继续| P0b["P0"] - P1 -->|第2次fork| P3["P3"] - P1 -->|继续| P1a["P1"] - P0b -->|第3次fork| P4["P4"] - P1a -->|第3次fork| P5["P5"] - P2 -->|第3次fork| P6["P6"] - P3 -->|第3次fork| P7["P7"] - - style P0 fill:#ffcdd2 - style P1 fill:#e1f5fe - style P2 fill:#e1f5fe - style P3 fill:#e1f5fe - style P4 fill:#e8f5e9 - style P5 fill:#e8f5e9 - style P6 fill:#e8f5e9 - style P7 fill:#e8f5e9 -``` - -### 2.4 fork 的复杂示例 - -参考 `实例源代码/chap5/fork2.c`: - -```c -#include -#include -#include -int main() -{ - int pid; - pid = fork(); // 创建 P1 - pid = fork(); // P0 和 P1 各创建一个子进程 - if (pid > 0) fork(); // pid>0 的进程再创建一个子进程 - printf("hello \n"); - exit(0); -} -``` - -**分析**:需要追踪每个进程的 `pid` 值来确定哪些进程会执行第三次 `fork()`。最终打印 6 个 "hello"。 - -### 2.5 fork 的注意事项 - -| 要点 | 说明 | -|------|------| -| 返回值判断 | 必须用 `if-else` 分别处理父子进程 | -| 资源复制 | 子进程获得父进程的副本(写时复制 COW 优化) | -| 执行顺序 | 父子进程执行顺序不确定 | -| 文件描述符 | 子进程继承父进程打开的文件描述符 | -| 必须 exit | 子进程处理完后必须调用 `exit()` 终止 | - ---- - -## 三、exec 族函数 -- 替换进程映像 - -### 3.1 基本原理 - -`exec` 族函数用一个新的程序**替换**当前进程的代码段、数据段和堆栈。进程的 PID 不变,但执行的代码完全改变。`exec` 调用成功后**不会返回**,只有出错时才返回 -1。 - -```mermaid -graph LR - A["当前进程
PID=1234
执行 myprogram"] -->|execvp("ps", args)| B["同一进程
PID=1234
执行 ps 命令"] - B --> C["原来的代码段
被完全替换"] - - style A fill:#e1f5fe - style B fill:#e8f5e9 -``` - -### 3.2 exec 族函数对比 - -| 函数 | 参数形式 | 路径搜索 | 环境变量 | -|------|----------|----------|----------| -| `execl(path, arg0, ..., NULL)` | 列表 | 完整路径 | 继承父进程 | -| `execlp(file, arg0, ..., NULL)` | 列表 | 搜索 PATH | 继承父进程 | -| `execle(path, arg0, ..., NULL, envp)` | 列表 | 完整路径 | 自定义 | -| `execv(path, argv)` | 数组 | 完整路径 | 继承父进程 | -| `execvp(file, argv)` | 数组 | 搜索 PATH | 继承父进程 | - -**记忆技巧**: -- `l` = list(参数列表),`v` = vector(参数数组) -- `p` = path(搜索 PATH 环境变量),`e` = environment(自定义环境变量) - -### 3.3 exec 示例 - -参考 `实例源代码/chap5/exec1.c`: - -```c -#include "wrapper.h" -int main(void) -{ - char *arg[] = {"ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL}; - execvp("ps", arg); // 用 ps 命令替换当前进程 - perror("exec ps"); // 如果 execvp 返回,说明出错 - exit(1); -} -``` - -**运行结果**:程序执行后变成了 `ps` 命令,显示当前系统的进程信息。 - -### 3.4 fork + exec 组合 - -在实际应用中,`fork()` 和 `exec()` 通常配合使用: - -```mermaid -sequenceDiagram - participant 父进程 - participant 子进程 - participant 新程序 - - 父进程->>子进程: fork() - Note over 子进程: 子进程是父进程的副本 - 子进程->>新程序: execvp("ls", args) - Note over 新程序: 子进程的代码被 ls 替换 - 新程序-->>父进程: 执行完毕 - 父进程->>父进程: wait() 回收子进程 -``` - -这种模式是 shell 执行命令的核心机制:先 `fork` 创建子进程,再在子进程中 `exec` 执行新程序。 - ---- - -## 四、wait/waitpid -- 等待子进程 - -### 4.1 为什么需要 wait - -父进程创建子进程后,需要等待子进程结束并回收其资源。如果不调用 `wait`,子进程终止后会变成**僵尸进程**(详见第六节)。 - -### 4.2 wait 与 waitpid - -| 函数 | 说明 | -|------|------| -| `wait(&status)` | 等待任意一个子进程结束 | -| `waitpid(pid, &status, options)` | 可指定等待的子进程 | -| `WIFEXITED(status)` | 判断子进程是否正常退出 | -| `WEXITSTATUS(status)` | 获取子进程的退出状态 | - -**waitpid 的 pid 参数**: -- `pid > 0`:等待指定 PID 的子进程 -- `pid = -1`:等待任意子进程(等同于 `wait`) -- `pid = 0`:等待同一进程组的任意子进程 -- `pid < -1`:等待进程组 ID 为 |pid| 的任意子进程 - -### 4.3 waitpid 示例 - -参考 `实例源代码/chap5/waitpid1.c`: - -```c -#include "wrapper.h" -#define N 2 - -int main() -{ - int status, i; - pid_t pid; - - // 父进程创建 N 个子进程 - for (i = 0; i < 2; i++) - if ((pid = fork()) == 0) // 子进程 - exit(100 + i); // 以不同状态退出 - - // 父进程按任意顺序等待所有子进程 - while ((pid = waitpid(-1, &status, 0)) > 0) { - if (WIFEXITED(status)) - printf("child %d terminated normally with exit status=%d\n", - pid, WEXITSTATUS(status)); - else - printf("child %d terminated abnormally\n", pid); - } - - // 所有子进程已结束,waitpid 返回 -1,errno 为 ECHILD - if (errno != ECHILD) - perror("waitpid error"); - - exit(0); -} -``` - -**输出示例**: -``` -child 1235 terminated normally with exit status=100 -child 1236 terminated normally with exit status=101 -``` - ---- - -## 五、exit/_exit -- 进程终止 - -### 5.1 两种终止方式 - -| 函数 | 头文件 | 行为 | -|------|--------|------| -| `exit(status)` | `` | 执行清理工作(刷新缓冲区、调用 atexit 注册的函数),然后终止 | -| `_exit(status)` | `` | 立即终止,不执行任何清理 | - -### 5.2 exit 的清理过程 - -```mermaid -graph TD - A["调用 exit(status)"] --> B["执行 atexit() 注册的清理函数"] - B --> C["刷新 stdio 缓冲区
(fclose 所有打开的流)"] - C --> D["调用 _exit(status)"] - D --> E["内核回收进程资源"] - - style A fill:#e1f5fe - style E fill:#ffcdd2 -``` - -### 5.3 退出状态 - -参考 `实例源代码/chap5/exitstatus.c`: - -```c -#include -int main() { exit(100); } -``` - -父进程通过 `waitpid` 的 `WEXITSTATUS(status)` 宏获取子进程的退出状态值(0-255)。 - ---- - -## 六、僵尸进程 - -### 6.1 什么是僵尸进程 - -当子进程终止后,如果父进程没有调用 `wait()` 或 `waitpid()` 回收子进程的退出状态,子进程的进程控制块(PCB)仍然保留在系统中,成为**僵尸进程(Zombie Process)**。 - -### 6.2 僵尸进程的产生 - -参考 `实例源代码/chap5/zombie.c`: - -```c -#include -#include -#include -#include -#include - -int main() -{ - pid_t pid; - if ((pid = fork()) < 0) { - perror("fork failed"); - exit(1); - } else if (pid == 0) { - printf("child...\n"); // 子进程打印后退出 - } else { - printf("parent...\n"); - while (1); // 父进程不调用 wait(),子进程成为僵尸 - } - return 0; -} -``` - -### 6.3 僵尸进程的生命周期 - -```mermaid -sequenceDiagram - participant 父进程 - participant 子进程 - participant 操作系统内核 - - 父进程->>子进程: fork() 创建子进程 - 子进程->>子进程: 执行任务 - 子进程->>操作系统内核: exit() 终止 - 操作系统内核->>父进程: 发送 SIGCHLD 信号 - Note over 父进程: 父进程没有调用 wait() - Note over 操作系统内核: 子进程的 PCB 无法释放 - Note over 操作系统内核: 子进程成为僵尸进程 - Note over 操作系统内核: 僵尸进程占用 PID 资源 -``` - -### 6.4 僵尸进程的危害与解决 - -| 危害 | 说明 | -|------|------| -| 占用 PID | 僵尸进程的 PID 无法被复用 | -| 资源泄漏 | 虽然不占内存,但 PCB 信息占用内核资源 | -| 累积效应 | 大量僵尸进程可耗尽 PID 资源 | - -**解决方法**: -1. 父进程调用 `wait()` 或 `waitpid()` 回收子进程 -2. 注册 `SIGCHLD` 信号处理函数,在其中调用 `waitpid()` -3. 父进程先退出,让 init 进程(PID=1)自动回收孤儿进程 - ---- - -## 七、信号机制 - -### 7.1 信号的概念 - -信号是操作系统通知进程发生了某种事件的一种**异步通信机制**。进程可以注册信号处理函数来响应特定信号。 - -```mermaid -graph LR - SRC["信号来源"] --> SIG["信号"] - SIG --> PROC["目标进程"] - - SRC --> S1["用户输入 Ctrl+C"] - SRC --> S2["kill 命令"] - SRC --> S3["子进程终止"] - SRC --> S4["定时器超时"] - SRC --> S5["非法内存访问"] - - PROC --> H1["执行信号处理函数"] - PROC --> H2["忽略信号"] - PROC --> H3["执行默认动作"] - - style SIG fill:#fff3e0 - style SRC fill:#e1f5fe - style PROC fill:#e8f5e9 -``` - -### 7.2 常见信号 - -| 信号 | 编号 | 默认动作 | 说明 | -|------|:----:|----------|------| -| `SIGINT` | 2 | 终止 | 用户按 Ctrl+C | -| `SIGKILL` | 9 | 终止 | 强制终止(**不可捕获**) | -| `SIGTERM` | 15 | 终止 | 请求终止(可捕获) | -| `SIGCHLD` | 17 | 忽略 | 子进程状态改变 | -| `SIGALRM` | 14 | 终止 | 定时器超时 | -| `SIGSEGV` | 11 | 终止+core | 段错误 | -| `SIGSTOP` | 19 | 停止 | 暂停进程(**不可捕获**) | -| `SIGCONT` | 18 | 继续 | 恢复被暂停的进程 | - -### 7.3 signal() 注册信号处理函数 - -参考 `实例源代码/chap5/signal1.c`: - -```c -#include "wrapper.h" - -void handler1(int sig) -{ - pid_t pid; - if ((pid = waitpid(-1, NULL, 0)) < 0) - perror("waitpid error"); - printf("Handler reaped child %d\n", (int)pid); - sleep(2); - return; -} - -int main() -{ - int i, n; - char buf[MAXBUF]; - - // 注册 SIGCHLD 信号处理函数 - if (signal(SIGCHLD, handler1) == SIG_ERR) - perror("signal error"); - - // 父进程创建 3 个子进程 - for (i = 0; i < 3; i++) { - if (fork() == 0) { - printf("Hello from child %d\n", (int)getpid()); - sleep(1); - exit(0); - } - } - - // 父进程等待终端输入 - if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0) - perror("read"); - - printf("Parent processing input\n"); - while (1); - exit(0); -} -``` - -**信号处理流程**: - -```mermaid -sequenceDiagram - participant 父进程 - participant 子进程1 - participant 子进程2 - participant 子进程3 - participant 信号处理函数 - - 父进程->>信号处理函数: signal(SIGCHLD, handler1) - 父进程->>子进程1: fork() - 父进程->>子进程2: fork() - 父进程->>子进程3: fork() - - 子进程1->>父进程: exit() 触发 SIGCHLD - 父进程->>信号处理函数: 调用 handler1() - 信号处理函数->>信号处理函数: waitpid() 回收子进程 - - 子进程2->>父进程: exit() 触发 SIGCHLD - 父进程->>信号处理函数: 调用 handler1() - 信号处理函数->>信号处理函数: waitpid() 回收子进程 - - 子进程3->>父进程: exit() 触发 SIGCHLD - 父进程->>信号处理函数: 调用 handler1() - 信号处理函数->>信号处理函数: waitpid() 回收子进程 -``` - -### 7.4 SIGALRM 定时器 - -参考 `实例源代码/chap5/alarm.c`: - -```c -#include "wrapper.h" - -void handler(int sig) -{ - static int beeps = 0; - printf("BEEP\n"); - if (++beeps < 5) - alarm(1); // 1秒后再次触发 SIGALRM - else { - printf("BOOM!\n"); - exit(0); - } -} - -int main() -{ - signal(SIGALRM, handler); // 注册 SIGALRM 处理函数 - alarm(1); // 1秒后触发第一次 SIGALRM - while (1); // 等待信号 - exit(0); -} -``` - -**输出**:每隔 1 秒打印一次 "BEEP",第 5 次后打印 "BOOM!" 并退出。 - ---- - -## 八、Shell 的实现 - -### 8.1 Shell 的工作原理 - -Shell 是一个命令解释器,其核心逻辑可以用一个循环概括: - -```mermaid -graph TD - A["打印提示符 %"] --> B["读取用户输入命令"] - B --> C{"命令是否为空?"} - C -->|是| A - C -->|否| D{"是否为内置命令?"} - D -->|是 exit| E["退出 shell"] - D -->|是其他| F["执行内置命令"] - F --> A - D -->|否| G["fork() 创建子进程"] - G --> H["子进程: execvp() 执行命令"] - G --> I["父进程: waitpid() 等待子进程"] - H --> A - I --> A - - style A fill:#e1f5fe - style E fill:#ffcdd2 - style G fill:#e8f5e9 -``` - -### 8.2 shellex.c 核心实现 - -参考 `实例源代码/chap5/shellex.c`,这是一个完整的简易 shell 实现: - -```c -#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 (!builtin_command(argv)) { - 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); - } - return; -} - -// 判断内置命令(如 exit) -int builtin_command(char **argv) -{ - if (!strcmp(argv[0], "exit")) - exit(0); - if (!strcmp(argv[0], "&")) - return 1; - return 0; -} - -int main() -{ - char cmdline[MAXLINE]; - while (1) { - printf("%% "); // 打印提示符 - fgets(cmdline, MAXLINE, stdin); // 读取命令 - if (feof(stdin)) exit(0); - execute(cmdline); // 执行命令 - } -} -``` - -### 8.3 Shell 执行流程图 - -```mermaid -sequenceDiagram - participant 用户 - participant Shell主进程 - participant 子进程 - participant 操作系统 - - loop Shell 主循环 - 用户->>Shell主进程: 输入 "ls -l" - Shell主进程->>Shell主进程: parseline() 解析命令 - Shell主进程->>子进程: fork() 创建子进程 - 子进程->>操作系统: execvp("ls", ["ls", "-l", NULL]) - Note over 操作系统: ls 程序替换子进程 - 操作系统-->>子进程: ls 执行完毕 - 子进程->>Shell主进程: exit() - Shell主进程->>Shell主进程: waitpid() 回收子进程 - Shell主进程-->>用户: 显示结果,打印提示符 - end -``` - -### 8.4 前台进程与后台进程 - -- **前台进程**:Shell 调用 `waitpid()` 等待子进程结束后才继续接受输入 -- **后台进程**:Shell 不等待,直接打印子进程 PID 并继续接受输入(命令末尾加 `&`) - -### 8.5 管道与重定向 - -实验中的 `task52.c` 实现了管道和重定向功能: - -**输出重定向**:使用 `dup2()` 将标准输出重定向到文件 -```c -int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); -dup2(fd, STDOUT_FILENO); // 将 STDOUT 重定向到文件 -close(fd); -execvp(args[0], args); // 执行命令,输出写入文件 -``` - -**管道**:使用 `pipe()` 创建管道,连接两个进程的输入输出 -```c -int pipefd[2]; -pipe(pipefd); // pipefd[0]=读端, pipefd[1]=写端 - -// 子进程1: 写入管道 -dup2(pipefd[1], STDOUT_FILENO); -close(pipefd[0]); -execvp(cmd1_args[0], cmd1_args); - -// 子进程2: 从管道读取 -dup2(pipefd[0], STDIN_FILENO); -close(pipefd[1]); -execvp(cmd2_args[0], cmd2_args); -``` - ---- - -## 九、Daemon 进程 - -### 9.1 什么是 Daemon - -Daemon(守护进程)是在后台运行的特殊进程,通常在系统启动时创建,一直运行到系统关闭。常见的 daemon 有:`sshd`(SSH 服务)、`httpd`(Web 服务)、`crond`(定时任务)等。 - -### 9.2 Daemon 的特点 - -- 没有控制终端(不与任何终端关联) -- 在后台运行 -- 父进程是 init(PID=1) -- 通常以 `d` 结尾命名 - -### 9.3 创建 Daemon 的五步法 - -参考 `实例源代码/chap5/daemon.c`: - -```c -int init_daemon(void) -{ - pid_t pid; - int i; - - // 第1步:fork() + 父进程退出,脱离终端控制 - pid = fork(); - if (pid == -1) return -1; - else if (pid != 0) exit(EXIT_SUCCESS); - - // 第2步:setsid() 创建新会话,成为会话首进程 - if (setsid() == -1) return -1; - - // 第3步:(可选) 再次 fork(),确保不会重新获得控制终端 - // daemon.c 中省略了这一步,但 task53.c 中包含 - - // 第4步:chdir("/") 避免占用可卸载的文件系统 - if (chdir("/") == -1) return -1; - - // 第5步:关闭所有打开的文件描述符,重定向 0/1/2 到 /dev/null - for (i = 0; i < NR_OPEN; i++) close(i); - open("/dev/null", O_RDWR); // stdin -> /dev/null - dup(0); // stdout -> /dev/null - dup(0); // stderr -> /dev/null - return 0; -} -``` - -**五步法流程图**: - -```mermaid -graph TD - A["第1步: fork() + 父进程 exit()"] --> B["子进程成为孤儿进程
被 init 收养"] - B --> C["第2步: setsid()"] - C --> D["创建新会话
成为会话首进程
脱离控制终端"] - D --> E["第3步: fork() + 父进程 exit()"] - E --> F["确保不会重新获得
控制终端"] - F --> G["第4步: chdir('/')"] - G --> H["避免占用可卸载的
文件系统"] - H --> I["第5步: 关闭 0/1/2
重定向到 /dev/null"] - I --> J["Daemon 创建完成
在后台运行"] - - style A fill:#e1f5fe - style J fill:#e8f5e9 -``` - -### 9.4 Daemon 文件监控实例 - -实验中的 `task53.c` 实现了一个 daemon 文件监控程序: -1. 创建守护进程 -2. 每隔 5 分钟读取目标文件内容,计算 hash 值 -3. 与上一次的 hash 值对比 -4. 如果文件被篡改,将事件记录到日志文件 - ---- - -## 十、实验任务概览 - -本讲对应 [[实验02_进程控制]],包含以下任务: - -| 任务 | 文件 | 内容 | 核心知识点 | -|------|------|------|------------| -| 任务一 | task51.c | 创建进程族亲结构(p1 -> p11, p12 -> p121, p122) | fork() + wait() + 进程树 | -| 任务二 | task52.c | 简单 shell 实现(命令解析、重定向、管道) | fork() + exec() + dup2() + pipe() | -| 任务三 | task53.c | daemon 文件监控(hash 值检测篡改) | daemon 五步法 + 文件 I/O | -| 任务四 | task54.c | 信号机制管理子进程(create/kill/ps/exit) | signal() + kill() + SIGCHLD | - ---- - -## 知识关联 - -- 进程状态转换在处理机调度中有更详细的讨论,参见相关课件 -- 信号机制与进程间通信密切相关,参见 [[08_进程间通信]] -- Shell 的并发版本(多进程并发服务器)在网络编程中会深入讨论 -- 线程可以看作轻量级进程,参见 [[07_多线程编程]] - ---- - -## 思考题 - -1. **fork 返回值的设计**:为什么 `fork()` 要返回两次而不是只返回一次?如果子进程不知道自己的 PID,可以用什么方式获取? -2. **exec 的不可逆性**:为什么 `exec()` 成功后不返回?如果 `exec()` 执行失败会怎样? -3. **僵尸进程的危害**:如果一个服务器程序不断创建子进程但从不 `wait`,最终会怎样?如何用 `SIGCHLD` 信号处理函数避免僵尸进程? -4. **shell 的本质**:shell 执行 `ls -l` 命令时,为什么需要 `fork()` + `exec()` 两个步骤?只用 `exec()` 行不行? -5. **daemon 的双重 fork**:为什么 daemon 创建通常要 `fork()` 两次?只 `fork()` 一次有什么问题? - ---- - -## 扩展阅读 - -- 《UNIX环境高级编程》第8章:进程控制 -- 《深入理解计算机系统》第8章:异常控制流 -- 《Linux/UNIX系统编程手册》第24-27章:进程的创建、终止、监控 +# 第06讲:进程控制 + +> **本节目标**:理解进程的概念与生命周期,掌握 `fork()`、`exec()`、`wait()`、`exit()` 等进程控制系统调用,理解信号机制与僵尸进程,最终能实现一个简易 shell 和 daemon 进程。 + +## 前置知识 + +- [[03_C语言编程基础]] -- C 语言指针、数组、字符串操作 +- [[07_多线程编程]] -- 线程与进程的对比 + +--- + +## 一、进程概念 + +### 1.1 什么是进程 + +**进程(Process)** 是程序的一次执行实例。程序是静态的代码文件,进程是动态的执行过程。当操作系统将程序加载到内存并开始执行时,就创建了一个进程。 + +每个进程拥有独立的地址空间,包含以下组成部分: + +```mermaid +graph TD + P[进程] --> T[代码段 Text] + P --> D[数据段 Data] + P --> H[堆 Heap] + P --> S[栈 Stack] + P --> PCB[PCB 进程控制块] + + T -->|存放| T1[程序指令] + D -->|存放| D1[全局变量/静态变量] + H -->|动态分配| H1[malloc/free] + S -->|自动管理| S1[局部变量/函数调用] + PCB -->|记录| PCB1[PID/状态/寄存器/文件描述符] + + style P fill:#fff3e0 + style PCB fill:#e1f5fe +``` + +### 1.2 进程 vs 程序 + +| 对比维度 | 程序(Program) | 进程(Process) | +|----------|-----------------|-----------------| +| 本质 | 静态的代码文件 | 动态的执行过程 | +| 存储位置 | 硬盘 | 内存 | +| 生命周期 | 长期保存 | 有创建、运行、终止 | +| 对应关系 | 一个程序可对应多个进程 | 一个进程只对应一个程序 | +| 包含内容 | 代码 + 数据 | 代码 + 数据 + 堆栈 + PCB | + +### 1.3 进程状态 + +```mermaid +stateDiagram-v2 + [*] --> 创建: fork() + 创建 --> 就绪: 分配资源 + 就绪 --> 运行: 调度器选中 + 运行 --> 就绪: 时间片用完 + 运行 --> 阻塞: 等待I/O + 阻塞 --> 就绪: I/O完成 + 运行 --> 终止: exit() + 终止 --> [*] + + 就绪: Ready + 运行: Running + 阻塞: Blocked/Waiting + 终止: Terminated/Zombie +``` + +### 1.4 进程标识 + +每个进程都有唯一的 **PID**(Process ID)。常用函数: + +```c +pid_t getpid(); // 获取当前进程的 PID +pid_t getppid(); // 获取父进程的 PID +``` + +--- + +## 二、fork() -- 创建子进程 + +### 2.1 基本原理 + +`fork()` 是 Linux 中创建新进程的唯一方式。调用 `fork()` 后,操作系统会创建一个与父进程几乎完全相同的子进程。子进程获得父进程的代码段、数据段、堆栈的**副本**。 + +**关键特性**:`fork()` 被调用一次,但**返回两次**: +- 父进程中返回子进程的 PID(正整数) +- 子进程中返回 0 +- 如果出错返回 -1 + +```mermaid +graph TD + A["父进程调用 fork()"] --> B{操作系统创建子进程} + B --> C["父进程:返回子进程 PID(>0)"] + B --> D["子进程:返回 0"] + C --> E["父子进程各自独立执行"] + D --> E + + style A fill:#e1f5fe + style C fill:#fff3e0 + style D fill:#e8f5e9 +``` + +### 2.2 fork 基础示例 + +参考 `实例源代码/chap5/fork1.c`: + +```c +#include "wrapper.h" +int main() +{ + pid_t pid; + int x = 1; + + pid = fork(); + if (pid == 0) { // 子进程执行这段代码 + x = x + 1; + printf("child: x=%d\n", x); // 输出 child: x=2 + } + + if (pid > 0) { // 父进程执行这段代码 + x = x - 1; + printf("parent: x=%d\n", x); // 输出 parent: x=0 + } + sleep(10); // 让父子进程都执行完代码 +} +``` + +**关键理解**: +- `fork()` 后,父子进程拥有**独立的变量副本** +- 子进程修改 `x` 不影响父进程的 `x`,反之亦然 +- 父子进程的执行顺序是不确定的,取决于调度器 + +### 2.3 多次 fork 的进程数计算 + +参考 `实例源代码/chap5/fork3.c`: + +```c +#include +#include +#include +int main() +{ + fork(); // 第1次:2个进程 + fork(); // 第2次:4个进程 + fork(); // 第3次:8个进程 + printf("hello \n"); + sleep(10); + return; +} +``` + +**进程树分析**:n 次 `fork()` 会产生 2^n 个进程。上面的代码执行 3 次 `fork()`,最终产生 2^3 = 8 个进程,每个进程打印一次 "hello"。 + +```mermaid +graph TD + P0["P0 (原始进程)"] -->|第1次fork| P1["P1"] + P0 -->|继续| P0a["P0"] + P0a -->|第2次fork| P2["P2"] + P0a -->|继续| P0b["P0"] + P1 -->|第2次fork| P3["P3"] + P1 -->|继续| P1a["P1"] + P0b -->|第3次fork| P4["P4"] + P1a -->|第3次fork| P5["P5"] + P2 -->|第3次fork| P6["P6"] + P3 -->|第3次fork| P7["P7"] + + style P0 fill:#ffcdd2 + style P1 fill:#e1f5fe + style P2 fill:#e1f5fe + style P3 fill:#e1f5fe + style P4 fill:#e8f5e9 + style P5 fill:#e8f5e9 + style P6 fill:#e8f5e9 + style P7 fill:#e8f5e9 +``` + +### 2.4 fork 的复杂示例 + +参考 `实例源代码/chap5/fork2.c`: + +```c +#include +#include +#include +int main() +{ + int pid; + pid = fork(); // 创建 P1 + pid = fork(); // P0 和 P1 各创建一个子进程 + if (pid > 0) fork(); // pid>0 的进程再创建一个子进程 + printf("hello \n"); + exit(0); +} +``` + +**分析**:需要追踪每个进程的 `pid` 值来确定哪些进程会执行第三次 `fork()`。最终打印 6 个 "hello"。 + +### 2.5 fork 的注意事项 + +| 要点 | 说明 | +|------|------| +| 返回值判断 | 必须用 `if-else` 分别处理父子进程 | +| 资源复制 | 子进程获得父进程的副本(写时复制 COW 优化) | +| 执行顺序 | 父子进程执行顺序不确定 | +| 文件描述符 | 子进程继承父进程打开的文件描述符 | +| 必须 exit | 子进程处理完后必须调用 `exit()` 终止 | + +--- + +## 三、exec 族函数 -- 替换进程映像 + +### 3.1 基本原理 + +`exec` 族函数用一个新的程序**替换**当前进程的代码段、数据段和堆栈。进程的 PID 不变,但执行的代码完全改变。`exec` 调用成功后**不会返回**,只有出错时才返回 -1。 + +```mermaid +graph LR + A["当前进程
PID=1234
执行 myprogram"] -->|execvp("ps", args)| B["同一进程
PID=1234
执行 ps 命令"] + B --> C["原来的代码段
被完全替换"] + + style A fill:#e1f5fe + style B fill:#e8f5e9 +``` + +### 3.2 exec 族函数对比 + +| 函数 | 参数形式 | 路径搜索 | 环境变量 | +|------|----------|----------|----------| +| `execl(path, arg0, ..., NULL)` | 列表 | 完整路径 | 继承父进程 | +| `execlp(file, arg0, ..., NULL)` | 列表 | 搜索 PATH | 继承父进程 | +| `execle(path, arg0, ..., NULL, envp)` | 列表 | 完整路径 | 自定义 | +| `execv(path, argv)` | 数组 | 完整路径 | 继承父进程 | +| `execvp(file, argv)` | 数组 | 搜索 PATH | 继承父进程 | + +**记忆技巧**: +- `l` = list(参数列表),`v` = vector(参数数组) +- `p` = path(搜索 PATH 环境变量),`e` = environment(自定义环境变量) + +### 3.3 exec 示例 + +参考 `实例源代码/chap5/exec1.c`: + +```c +#include "wrapper.h" +int main(void) +{ + char *arg[] = {"ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL}; + execvp("ps", arg); // 用 ps 命令替换当前进程 + perror("exec ps"); // 如果 execvp 返回,说明出错 + exit(1); +} +``` + +**运行结果**:程序执行后变成了 `ps` 命令,显示当前系统的进程信息。 + +### 3.4 fork + exec 组合 + +在实际应用中,`fork()` 和 `exec()` 通常配合使用: + +```mermaid +sequenceDiagram + participant 父进程 + participant 子进程 + participant 新程序 + + 父进程->>子进程: fork() + Note over 子进程: 子进程是父进程的副本 + 子进程->>新程序: execvp("ls", args) + Note over 新程序: 子进程的代码被 ls 替换 + 新程序-->>父进程: 执行完毕 + 父进程->>父进程: wait() 回收子进程 +``` + +这种模式是 shell 执行命令的核心机制:先 `fork` 创建子进程,再在子进程中 `exec` 执行新程序。 + +--- + +## 四、wait/waitpid -- 等待子进程 + +### 4.1 为什么需要 wait + +父进程创建子进程后,需要等待子进程结束并回收其资源。如果不调用 `wait`,子进程终止后会变成**僵尸进程**(详见第六节)。 + +### 4.2 wait 与 waitpid + +| 函数 | 说明 | +|------|------| +| `wait(&status)` | 等待任意一个子进程结束 | +| `waitpid(pid, &status, options)` | 可指定等待的子进程 | +| `WIFEXITED(status)` | 判断子进程是否正常退出 | +| `WEXITSTATUS(status)` | 获取子进程的退出状态 | + +**waitpid 的 pid 参数**: +- `pid > 0`:等待指定 PID 的子进程 +- `pid = -1`:等待任意子进程(等同于 `wait`) +- `pid = 0`:等待同一进程组的任意子进程 +- `pid < -1`:等待进程组 ID 为 |pid| 的任意子进程 + +### 4.3 waitpid 示例 + +参考 `实例源代码/chap5/waitpid1.c`: + +```c +#include "wrapper.h" +#define N 2 + +int main() +{ + int status, i; + pid_t pid; + + // 父进程创建 N 个子进程 + for (i = 0; i < 2; i++) + if ((pid = fork()) == 0) // 子进程 + exit(100 + i); // 以不同状态退出 + + // 父进程按任意顺序等待所有子进程 + while ((pid = waitpid(-1, &status, 0)) > 0) { + if (WIFEXITED(status)) + printf("child %d terminated normally with exit status=%d\n", + pid, WEXITSTATUS(status)); + else + printf("child %d terminated abnormally\n", pid); + } + + // 所有子进程已结束,waitpid 返回 -1,errno 为 ECHILD + if (errno != ECHILD) + perror("waitpid error"); + + exit(0); +} +``` + +**输出示例**: +``` +child 1235 terminated normally with exit status=100 +child 1236 terminated normally with exit status=101 +``` + +--- + +## 五、exit/_exit -- 进程终止 + +### 5.1 两种终止方式 + +| 函数 | 头文件 | 行为 | +|------|--------|------| +| `exit(status)` | `` | 执行清理工作(刷新缓冲区、调用 atexit 注册的函数),然后终止 | +| `_exit(status)` | `` | 立即终止,不执行任何清理 | + +### 5.2 exit 的清理过程 + +```mermaid +graph TD + A["调用 exit(status)"] --> B["执行 atexit() 注册的清理函数"] + B --> C["刷新 stdio 缓冲区
(fclose 所有打开的流)"] + C --> D["调用 _exit(status)"] + D --> E["内核回收进程资源"] + + style A fill:#e1f5fe + style E fill:#ffcdd2 +``` + +### 5.3 退出状态 + +参考 `实例源代码/chap5/exitstatus.c`: + +```c +#include +int main() { exit(100); } +``` + +父进程通过 `waitpid` 的 `WEXITSTATUS(status)` 宏获取子进程的退出状态值(0-255)。 + +--- + +## 六、僵尸进程 + +### 6.1 什么是僵尸进程 + +当子进程终止后,如果父进程没有调用 `wait()` 或 `waitpid()` 回收子进程的退出状态,子进程的进程控制块(PCB)仍然保留在系统中,成为**僵尸进程(Zombie Process)**。 + +### 6.2 僵尸进程的产生 + +参考 `实例源代码/chap5/zombie.c`: + +```c +#include +#include +#include +#include +#include + +int main() +{ + pid_t pid; + if ((pid = fork()) < 0) { + perror("fork failed"); + exit(1); + } else if (pid == 0) { + printf("child...\n"); // 子进程打印后退出 + } else { + printf("parent...\n"); + while (1); // 父进程不调用 wait(),子进程成为僵尸 + } + return 0; +} +``` + +### 6.3 僵尸进程的生命周期 + +```mermaid +sequenceDiagram + participant 父进程 + participant 子进程 + participant 操作系统内核 + + 父进程->>子进程: fork() 创建子进程 + 子进程->>子进程: 执行任务 + 子进程->>操作系统内核: exit() 终止 + 操作系统内核->>父进程: 发送 SIGCHLD 信号 + Note over 父进程: 父进程没有调用 wait() + Note over 操作系统内核: 子进程的 PCB 无法释放 + Note over 操作系统内核: 子进程成为僵尸进程 + Note over 操作系统内核: 僵尸进程占用 PID 资源 +``` + +### 6.4 僵尸进程的危害与解决 + +| 危害 | 说明 | +|------|------| +| 占用 PID | 僵尸进程的 PID 无法被复用 | +| 资源泄漏 | 虽然不占内存,但 PCB 信息占用内核资源 | +| 累积效应 | 大量僵尸进程可耗尽 PID 资源 | + +**解决方法**: +1. 父进程调用 `wait()` 或 `waitpid()` 回收子进程 +2. 注册 `SIGCHLD` 信号处理函数,在其中调用 `waitpid()` +3. 父进程先退出,让 init 进程(PID=1)自动回收孤儿进程 + +--- + +## 七、信号机制 + +### 7.1 信号的概念 + +信号是操作系统通知进程发生了某种事件的一种**异步通信机制**。进程可以注册信号处理函数来响应特定信号。 + +```mermaid +graph LR + SRC["信号来源"] --> SIG["信号"] + SIG --> PROC["目标进程"] + + SRC --> S1["用户输入 Ctrl+C"] + SRC --> S2["kill 命令"] + SRC --> S3["子进程终止"] + SRC --> S4["定时器超时"] + SRC --> S5["非法内存访问"] + + PROC --> H1["执行信号处理函数"] + PROC --> H2["忽略信号"] + PROC --> H3["执行默认动作"] + + style SIG fill:#fff3e0 + style SRC fill:#e1f5fe + style PROC fill:#e8f5e9 +``` + +### 7.2 常见信号 + +| 信号 | 编号 | 默认动作 | 说明 | +|------|:----:|----------|------| +| `SIGINT` | 2 | 终止 | 用户按 Ctrl+C | +| `SIGKILL` | 9 | 终止 | 强制终止(**不可捕获**) | +| `SIGTERM` | 15 | 终止 | 请求终止(可捕获) | +| `SIGCHLD` | 17 | 忽略 | 子进程状态改变 | +| `SIGALRM` | 14 | 终止 | 定时器超时 | +| `SIGSEGV` | 11 | 终止+core | 段错误 | +| `SIGSTOP` | 19 | 停止 | 暂停进程(**不可捕获**) | +| `SIGCONT` | 18 | 继续 | 恢复被暂停的进程 | + +### 7.3 signal() 注册信号处理函数 + +参考 `实例源代码/chap5/signal1.c`: + +```c +#include "wrapper.h" + +void handler1(int sig) +{ + pid_t pid; + if ((pid = waitpid(-1, NULL, 0)) < 0) + perror("waitpid error"); + printf("Handler reaped child %d\n", (int)pid); + sleep(2); + return; +} + +int main() +{ + int i, n; + char buf[MAXBUF]; + + // 注册 SIGCHLD 信号处理函数 + if (signal(SIGCHLD, handler1) == SIG_ERR) + perror("signal error"); + + // 父进程创建 3 个子进程 + for (i = 0; i < 3; i++) { + if (fork() == 0) { + printf("Hello from child %d\n", (int)getpid()); + sleep(1); + exit(0); + } + } + + // 父进程等待终端输入 + if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0) + perror("read"); + + printf("Parent processing input\n"); + while (1); + exit(0); +} +``` + +**信号处理流程**: + +```mermaid +sequenceDiagram + participant 父进程 + participant 子进程1 + participant 子进程2 + participant 子进程3 + participant 信号处理函数 + + 父进程->>信号处理函数: signal(SIGCHLD, handler1) + 父进程->>子进程1: fork() + 父进程->>子进程2: fork() + 父进程->>子进程3: fork() + + 子进程1->>父进程: exit() 触发 SIGCHLD + 父进程->>信号处理函数: 调用 handler1() + 信号处理函数->>信号处理函数: waitpid() 回收子进程 + + 子进程2->>父进程: exit() 触发 SIGCHLD + 父进程->>信号处理函数: 调用 handler1() + 信号处理函数->>信号处理函数: waitpid() 回收子进程 + + 子进程3->>父进程: exit() 触发 SIGCHLD + 父进程->>信号处理函数: 调用 handler1() + 信号处理函数->>信号处理函数: waitpid() 回收子进程 +``` + +### 7.4 SIGALRM 定时器 + +参考 `实例源代码/chap5/alarm.c`: + +```c +#include "wrapper.h" + +void handler(int sig) +{ + static int beeps = 0; + printf("BEEP\n"); + if (++beeps < 5) + alarm(1); // 1秒后再次触发 SIGALRM + else { + printf("BOOM!\n"); + exit(0); + } +} + +int main() +{ + signal(SIGALRM, handler); // 注册 SIGALRM 处理函数 + alarm(1); // 1秒后触发第一次 SIGALRM + while (1); // 等待信号 + exit(0); +} +``` + +**输出**:每隔 1 秒打印一次 "BEEP",第 5 次后打印 "BOOM!" 并退出。 + +--- + +## 八、Shell 的实现 + +### 8.1 Shell 的工作原理 + +Shell 是一个命令解释器,其核心逻辑可以用一个循环概括: + +```mermaid +graph TD + A["打印提示符 %"] --> B["读取用户输入命令"] + B --> C{"命令是否为空?"} + C -->|是| A + C -->|否| D{"是否为内置命令?"} + D -->|是 exit| E["退出 shell"] + D -->|是其他| F["执行内置命令"] + F --> A + D -->|否| G["fork() 创建子进程"] + G --> H["子进程: execvp() 执行命令"] + G --> I["父进程: waitpid() 等待子进程"] + H --> A + I --> A + + style A fill:#e1f5fe + style E fill:#ffcdd2 + style G fill:#e8f5e9 +``` + +### 8.2 shellex.c 核心实现 + +参考 `实例源代码/chap5/shellex.c`,这是一个完整的简易 shell 实现: + +```c +#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 (!builtin_command(argv)) { + 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); + } + return; +} + +// 判断内置命令(如 exit) +int builtin_command(char **argv) +{ + if (!strcmp(argv[0], "exit")) + exit(0); + if (!strcmp(argv[0], "&")) + return 1; + return 0; +} + +int main() +{ + char cmdline[MAXLINE]; + while (1) { + printf("%% "); // 打印提示符 + fgets(cmdline, MAXLINE, stdin); // 读取命令 + if (feof(stdin)) exit(0); + execute(cmdline); // 执行命令 + } +} +``` + +### 8.3 Shell 执行流程图 + +```mermaid +sequenceDiagram + participant 用户 + participant Shell主进程 + participant 子进程 + participant 操作系统 + + loop Shell 主循环 + 用户->>Shell主进程: 输入 "ls -l" + Shell主进程->>Shell主进程: parseline() 解析命令 + Shell主进程->>子进程: fork() 创建子进程 + 子进程->>操作系统: execvp("ls", ["ls", "-l", NULL]) + Note over 操作系统: ls 程序替换子进程 + 操作系统-->>子进程: ls 执行完毕 + 子进程->>Shell主进程: exit() + Shell主进程->>Shell主进程: waitpid() 回收子进程 + Shell主进程-->>用户: 显示结果,打印提示符 + end +``` + +### 8.4 前台进程与后台进程 + +- **前台进程**:Shell 调用 `waitpid()` 等待子进程结束后才继续接受输入 +- **后台进程**:Shell 不等待,直接打印子进程 PID 并继续接受输入(命令末尾加 `&`) + +### 8.5 管道与重定向 + +实验中的 `task52.c` 实现了管道和重定向功能: + +**输出重定向**:使用 `dup2()` 将标准输出重定向到文件 +```c +int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); +dup2(fd, STDOUT_FILENO); // 将 STDOUT 重定向到文件 +close(fd); +execvp(args[0], args); // 执行命令,输出写入文件 +``` + +**管道**:使用 `pipe()` 创建管道,连接两个进程的输入输出 +```c +int pipefd[2]; +pipe(pipefd); // pipefd[0]=读端, pipefd[1]=写端 + +// 子进程1: 写入管道 +dup2(pipefd[1], STDOUT_FILENO); +close(pipefd[0]); +execvp(cmd1_args[0], cmd1_args); + +// 子进程2: 从管道读取 +dup2(pipefd[0], STDIN_FILENO); +close(pipefd[1]); +execvp(cmd2_args[0], cmd2_args); +``` + +--- + +## 九、Daemon 进程 + +### 9.1 什么是 Daemon + +Daemon(守护进程)是在后台运行的特殊进程,通常在系统启动时创建,一直运行到系统关闭。常见的 daemon 有:`sshd`(SSH 服务)、`httpd`(Web 服务)、`crond`(定时任务)等。 + +### 9.2 Daemon 的特点 + +- 没有控制终端(不与任何终端关联) +- 在后台运行 +- 父进程是 init(PID=1) +- 通常以 `d` 结尾命名 + +### 9.3 创建 Daemon 的五步法 + +参考 `实例源代码/chap5/daemon.c`: + +```c +int init_daemon(void) +{ + pid_t pid; + int i; + + // 第1步:fork() + 父进程退出,脱离终端控制 + pid = fork(); + if (pid == -1) return -1; + else if (pid != 0) exit(EXIT_SUCCESS); + + // 第2步:setsid() 创建新会话,成为会话首进程 + if (setsid() == -1) return -1; + + // 第3步:(可选) 再次 fork(),确保不会重新获得控制终端 + // daemon.c 中省略了这一步,但 task53.c 中包含 + + // 第4步:chdir("/") 避免占用可卸载的文件系统 + if (chdir("/") == -1) return -1; + + // 第5步:关闭所有打开的文件描述符,重定向 0/1/2 到 /dev/null + for (i = 0; i < NR_OPEN; i++) close(i); + open("/dev/null", O_RDWR); // stdin -> /dev/null + dup(0); // stdout -> /dev/null + dup(0); // stderr -> /dev/null + return 0; +} +``` + +**五步法流程图**: + +```mermaid +graph TD + A["第1步: fork() + 父进程 exit()"] --> B["子进程成为孤儿进程
被 init 收养"] + B --> C["第2步: setsid()"] + C --> D["创建新会话
成为会话首进程
脱离控制终端"] + D --> E["第3步: fork() + 父进程 exit()"] + E --> F["确保不会重新获得
控制终端"] + F --> G["第4步: chdir('/')"] + G --> H["避免占用可卸载的
文件系统"] + H --> I["第5步: 关闭 0/1/2
重定向到 /dev/null"] + I --> J["Daemon 创建完成
在后台运行"] + + style A fill:#e1f5fe + style J fill:#e8f5e9 +``` + +### 9.4 Daemon 文件监控实例 + +实验中的 `task53.c` 实现了一个 daemon 文件监控程序: +1. 创建守护进程 +2. 每隔 5 分钟读取目标文件内容,计算 hash 值 +3. 与上一次的 hash 值对比 +4. 如果文件被篡改,将事件记录到日志文件 + +--- + +## 十、实验任务概览 + +本讲对应 [[实验02_进程控制]],包含以下任务: + +| 任务 | 文件 | 内容 | 核心知识点 | +|------|------|------|------------| +| 任务一 | task51.c | 创建进程族亲结构(p1 -> p11, p12 -> p121, p122) | fork() + wait() + 进程树 | +| 任务二 | task52.c | 简单 shell 实现(命令解析、重定向、管道) | fork() + exec() + dup2() + pipe() | +| 任务三 | task53.c | daemon 文件监控(hash 值检测篡改) | daemon 五步法 + 文件 I/O | +| 任务四 | task54.c | 信号机制管理子进程(create/kill/ps/exit) | signal() + kill() + SIGCHLD | + +--- + +## 知识关联 + +- 进程状态转换在处理机调度中有更详细的讨论,参见相关课件 +- 信号机制与进程间通信密切相关,参见 [[08_进程间通信]] +- Shell 的并发版本(多进程并发服务器)在网络编程中会深入讨论 +- 线程可以看作轻量级进程,参见 [[07_多线程编程]] + +--- + +## 思考题 + +1. **fork 返回值的设计**:为什么 `fork()` 要返回两次而不是只返回一次?如果子进程不知道自己的 PID,可以用什么方式获取? +2. **exec 的不可逆性**:为什么 `exec()` 成功后不返回?如果 `exec()` 执行失败会怎样? +3. **僵尸进程的危害**:如果一个服务器程序不断创建子进程但从不 `wait`,最终会怎样?如何用 `SIGCHLD` 信号处理函数避免僵尸进程? +4. **shell 的本质**:shell 执行 `ls -l` 命令时,为什么需要 `fork()` + `exec()` 两个步骤?只用 `exec()` 行不行? +5. **daemon 的双重 fork**:为什么 daemon 创建通常要 `fork()` 两次?只 `fork()` 一次有什么问题? + +--- + +## 扩展阅读 + +- 《UNIX环境高级编程》第8章:进程控制 +- 《深入理解计算机系统》第8章:异常控制流 +- 《Linux/UNIX系统编程手册》第24-27章:进程的创建、终止、监控 diff --git a/操作系统/06_进程控制/06_进程控制_深入.md b/操作系统/06_进程控制/06_进程控制_深入.md index 1de2eea..659f088 100644 --- a/操作系统/06_进程控制/06_进程控制_深入.md +++ b/操作系统/06_进程控制/06_进程控制_深入.md @@ -1,315 +1,315 @@ -# 第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/) +# 第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/) diff --git a/操作系统/07_多线程编程/07_多线程编程.md b/操作系统/07_多线程编程/07_多线程编程.md index eee58e9..597019a 100644 --- a/操作系统/07_多线程编程/07_多线程编程.md +++ b/操作系统/07_多线程编程/07_多线程编程.md @@ -1,840 +1,840 @@ -# 第07讲:多线程编程 - -> **本节目标**:理解线程的概念与优势,掌握 POSIX 线程 API(pthread),理解竞态条件的成因与解决方案(互斥锁、信号量),能够实现并行计算和生产者-消费者模型。 - -## 前置知识 - -- [[06_进程控制]] -- 进程的概念与创建 -- [[08_进程间通信]] -- 进程间通信方式(与线程通信对比) - ---- - -## 一、线程概念 - -### 1.1 什么是线程 - -**线程(Thread)** 是进程内的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的地址空间,但每个线程拥有自己独立的栈和寄存器状态。 - -**生活比喻**: -- **进程** = 一家公司(独立的办公室、财务系统、人员编制) -- **线程** = 公司里的员工(共享办公室和财务系统,但各有各的工作任务) - -### 1.2 进程 vs 线程 - -```mermaid -graph TB - subgraph "多进程模型" - P1["进程1"] --> M1["独立的地址空间"] - P2["进程2"] --> M2["独立的地址空间"] - M1 -.->|"IPC 通信
(管道/共享内存)"| M2 - end - - subgraph "多线程模型" - T1["线程1"] --> SM["共享的地址空间"] - T2["线程2"] --> SM - T3["线程3"] --> SM - SM --> S1["代码段 (共享)"] - SM --> S2["数据段 (共享)"] - SM --> S3["堆 (共享)"] - SM --> S4["栈 (各自独立)"] - end - - style P1 fill:#e1f5fe - style P2 fill:#e1f5fe - style T1 fill:#e8f5e9 - style T2 fill:#e8f5e9 - style T3 fill:#e8f5e9 - style SM fill:#fff3e0 -``` - -| 对比维度 | 进程 | 线程 | -|----------|------|------| -| 地址空间 | 独立(互不可见) | 共享(可见彼此的变量) | -| 创建开销 | 大(复制页表、地址空间) | 小(只创建栈和寄存器) | -| 切换开销 | 大(切换页表、刷新 TLB) | 小(只切换栈指针和寄存器) | -| 通信方式 | 需要 IPC 机制(管道、共享内存等) | 直接读写共享变量 | -| 安全性 | 高(地址空间隔离) | 低(需要同步机制保护) | -| 典型应用 | 独立的程序(如浏览器和音乐播放器) | 同一程序内的并发任务 | - -### 1.3 线程的共享与私有 - -| 资源 | 共享/私有 | 说明 | -|------|-----------|------| -| 代码段 | 共享 | 所有线程执行相同的代码 | -| 数据段(全局变量) | 共享 | 所有线程可见同一份全局变量 | -| 堆(malloc 分配的内存) | 共享 | 任一线程分配的内存,其他线程可访问 | -| 栈 | **私有** | 每个线程有自己的栈,存放局部变量 | -| 寄存器状态 | **私有** | 每个线程有自己的程序计数器、栈指针等 | -| 信号处理 | 共享 | 信号发送给进程,任意线程可处理 | - ---- - -## 二、POSIX 线程 API - -### 2.1 核心函数 - -| 函数 | 作用 | 说明 | -|------|------|------| -| `pthread_create()` | 创建新线程 | 类似 `fork()`,但开销小得多 | -| `pthread_join()` | 等待线程结束 | 类似 `wait()`,阻塞调用线程 | -| `pthread_exit()` | 终止当前线程 | 类似 `exit()`,但只终止当前线程 | -| `pthread_self()` | 获取当前线程 ID | 类似 `getpid()` | -| `pthread_detach()` | 分离线程 | 线程结束后自动释放资源 | -| `pthread_cancel()` | 取消线程 | 请求终止另一个线程 | - -### 2.2 pthread_create 详解 - -```c -int pthread_create( - pthread_t *tid, // 输出参数:新线程的 ID - const pthread_attr_t *attr, // 线程属性(通常传 NULL) - void *(*start_routine)(void*), // 线程函数(新线程从这里开始执行) - void *arg // 传递给线程函数的参数 -); -// 返回值:成功返回 0,失败返回错误码 -``` - -```mermaid -graph LR - A["主线程调用
pthread_create()"] --> B["操作系统创建新线程"] - B --> C["新线程执行
start_routine(arg)"] - C --> D["线程函数返回"] - D --> E["主线程调用
pthread_join() 回收"] - - style A fill:#e1f5fe - style C fill:#e8f5e9 - style E fill:#fff3e0 -``` - -### 2.3 线程创建示例 - -参考 `实例源代码/chap6/pthread1.c`: - -```c -#include "wrapper.h" -void *peertask(void *vargp); - -int main() -{ - pthread_t tid; - int i; - - pthread_create(&tid, NULL, peertask, NULL); // 创建新线程 - - for (i = 0; i < 2; i++) { - printf("main thread looped %d times\n", i); - usleep(10); - } - - pthread_join(tid, NULL); // 等待新线程结束 - exit(0); -} - -void *peertask(void *vargp) // 线程函数 -{ - int i; - for (i = 0; i < 4; i++) { - printf("peer thread looped %d times\n", i); - usleep(10); - } - return NULL; -} -``` - -**输出示例**(顺序不确定,因为主线程和新线程并发执行): -``` -main thread looped 0 times -peer thread looped 0 times -main thread looped 1 times -peer thread looped 1 times -peer thread looped 2 times -peer thread looped 3 times -``` - -**关键点**: -- `pthread_create` 创建新线程后,两个线程**并发执行** -- `pthread_join` 阻塞主线程,直到新线程结束 -- 输出顺序取决于调度器,每次运行可能不同 -- 编译时需要加 `-lpthread` 链接 pthread 库 - ---- - -## 三、线程安全问题 -- 竞态条件 - -### 3.1 什么是竞态条件 - -**竞态条件(Race Condition)** 是指多个线程同时访问和修改共享变量,最终结果取决于线程执行的精确时序,导致程序行为不可预测。 - -### 3.2 竞态条件演示 - -参考 `实例源代码/chap6/threadrace.c`: - -```c -#include "wrapper.h" -#define N 4 - -void *thread(void *vargp); - -int main() -{ - pthread_t tid[N]; - int i; - - for (i = 0; i < N; i++) - pthread_create(&tid[i], NULL, thread, &i); // 传递 i 的地址 - - for (i = 0; i < N; i++) - pthread_join(tid[i], NULL); - exit(0); -} - -void *thread(void *vargp) -{ - usleep(1); - int myid = *((int *)vargp); // 读取线程 ID - printf("Hello from thread %d\n", myid); - return NULL; -} -``` - -**问题分析**:所有线程共享同一个变量 `i` 的地址。当线程读取 `*vargp` 时,`i` 的值可能已经被主线程的循环修改了。 - -**可能出现的输出**: -``` -Hello from thread 2 -Hello from thread 2 -Hello from thread 3 -Hello from thread 3 -``` - -### 3.3 竞态条件的时序分析 - -```mermaid -sequenceDiagram - participant 主线程 - participant 线程0 - participant 线程1 - participant 线程2 - participant 线程3 - - 主线程->>线程0: pthread_create(&i) - 主线程->>主线程: i++ (i=1) - 主线程->>线程1: pthread_create(&i) - 主线程->>主线程: i++ (i=2) - 主线程->>线程2: pthread_create(&i) - 主线程->>主线程: i++ (i=3) - 主线程->>线程3: pthread_create(&i) - - Note over 线程0: 读取 *vargp,但 i 已经是 3 - Note over 线程1: 读取 *vargp,i 也是 3 - Note over 线程2: 读取 *vargp,i 也是 3 - Note over 线程3: 读取 *vargp,i 是 3 - - 线程0-->>主线程: Hello from thread 3 (错误!) - 线程1-->>主线程: Hello from thread 3 (错误!) - 线程2-->>主线程: Hello from thread 3 (错误!) - 线程3-->>主线程: Hello from thread 3 -``` - -### 3.4 经典竞态:badcount.c - -参考 `实例源代码/chap6/badcount.c`: - -```c -#include "wrapper.h" -int count = 0; - -void *increase(void *arg) { - unsigned int i, niters = (unsigned int)arg; - for (i = 0; i < niters; i++) - count++; // 非原子操作! - return NULL; -} - -void *decrease(void *arg) { - unsigned int i, niters = (unsigned int)arg; - for (i = 0; i < niters; i++) - count--; // 非原子操作! - return NULL; -} - -int main(int argc, char **argv) { - unsigned int niters = atoll(argv[1]); - pthread_t tid1, tid2; - pthread_create(&tid1, NULL, increase, (void*)niters); - pthread_create(&tid2, NULL, decrease, (void*)niters); - pthread_join(tid1, NULL); - pthread_join(tid2, NULL); - - if (count != 0) - printf("Error! cnt=%d\n", count); // 期望为0,但实际可能不为0 - else - printf("Correct cnt=%d\n", count); - exit(0); -} -``` - -**为什么 `count++` 和 `count--` 不是原子操作?** - -```mermaid -graph LR - subgraph "count++ 的三条指令" - A1["LOAD count -> 寄存器"] --> A2["寄存器 = 寄存器 + 1"] - A2 --> A3["STORE 寄存器 -> count"] - end - - subgraph "count-- 的三条指令" - B1["LOAD count -> 寄存器"] --> B2["寄存器 = 寄存器 - 1"] - B2 --> B3["STORE 寄存器 -> count"] - end - - style A1 fill:#e1f5fe - style A2 fill:#fff3e0 - style A3 fill:#ffcdd2 - style B1 fill:#e1f5fe - style B2 fill:#fff3e0 - style B3 fill:#ffcdd2 -``` - -当两个线程的 LOAD/STORE 指令交错执行时,就会出现丢失更新的问题。 - ---- - -## 四、互斥锁(Mutex) - -### 4.1 互斥锁的原理 - -互斥锁(Mutex)保证同一时刻只有一个线程可以进入**临界区**(访问共享资源的代码段)。其他试图获取已锁定的互斥锁的线程会被阻塞,直到锁被释放。 - -```mermaid -sequenceDiagram - participant 线程1 - participant 互斥锁 - participant 线程2 - participant 共享变量 - - 线程1->>互斥锁: pthread_mutex_lock() - Note over 互斥锁: 锁状态:已锁定 - 线程1->>共享变量: 读取 count (0) - - 线程2->>互斥锁: pthread_mutex_lock() - Note over 互斥锁: 锁已被占用,线程2 阻塞 - - 线程1->>共享变量: count = count + 1 = 1 - 线程1->>互斥锁: pthread_mutex_unlock() - Note over 互斥锁: 锁状态:已解锁 - 互斥锁-->>线程2: 被唤醒,获得锁 - - 线程2->>共享变量: 读取 count (1) - 线程2->>共享变量: count = count - 1 = 0 - 线程2->>互斥锁: pthread_mutex_unlock() - - Note over 共享变量: count = 0 (正确!) -``` - -### 4.2 互斥锁 API - -| 函数 | 作用 | -|------|------| -| `pthread_mutex_init()` | 初始化互斥锁 | -| `pthread_mutex_lock()` | 加锁(阻塞等待) | -| `pthread_mutex_trylock()` | 尝试加锁(非阻塞) | -| `pthread_mutex_unlock()` | 解锁 | -| `pthread_mutex_destroy()` | 销毁互斥锁 | - -**初始化方式**: -```c -// 方式1:静态初始化 -pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; - -// 方式2:动态初始化 -pthread_mutex_t mutex; -pthread_mutex_init(&mutex, NULL); -``` - -### 4.3 互斥锁修复竞态 - -参考 `实例源代码/chap6/mutex1.c`: - -```c -// mutex1.c - 使用互斥锁保护共享变量 -#include "wrapper.h" -int count = 0; -pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; - -void *thread_fun(void *no) { - pthread_mutex_lock(&mutex); // 加锁 -- 进入临界区 - count++; - printf("thread %d, count = %d\n", (int)no, count); - pthread_mutex_unlock(&mutex); // 解锁 -- 离开临界区 -} - -int main() { - pthread_t thread_id1, thread_id2; - Pthread_create(&thread_id1, NULL, thread_fun, (void*)1); - Pthread_create(&thread_id2, NULL, thread_fun, (void*)2); - Pthread_join(thread_id1, NULL); - Pthread_join(thread_id2, NULL); - Pthread_exit(0); -} -``` - -**输出**(保证正确): -``` -thread 1, count = 1 -thread 2, count = 2 -``` - -### 4.4 另一种修复方式:避免共享 - -参考 `实例源代码/chap6/norace.c`,通过为每个线程分配独立的内存来避免共享: - -```c -// norace.c - 使用动态分配避免竞态 -for (i = 0; i < N; i++) { - ptr = malloc(sizeof(int)); // 为每个线程分配独立的内存 - *ptr = i; - pthread_create(&tid[i], NULL, thread, ptr); -} -``` - -**原理**:每个线程拥有自己独立的 `int` 变量,不再共享 `i`,从根本上消除了竞态条件。 - ---- - -## 五、信号量(Semaphore) - -### 5.1 信号量的概念 - -信号量是一种比互斥锁更通用的同步机制,由 Dijkstra 提出。信号量是一个非负整数计数器,支持两个原子操作: - -- **P 操作**(`sem_wait`):计数器减 1,如果计数器 < 0 则阻塞 -- **V 操作**(`sem_post`):计数器加 1,如果有等待者则唤醒 - -```mermaid -graph LR - subgraph "信号量 S" - S["S = 计数器"] - end - - P["sem_wait (P操作)
S--"] -->|S >= 0| OK["允许通过"] - P -->|S < 0| BLOCK["阻塞等待"] - - V["sem_post (V操作)
S++"] --> WAKE["唤醒等待者"] - - style S fill:#fff3e0 - style OK fill:#e8f5e9 - style BLOCK fill:#ffcdd2 -``` - -### 5.2 信号量 API - -| 函数 | 作用 | -|------|------| -| `sem_init(&sem, pshared, value)` | 初始化信号量,value 为初始值 | -| `sem_wait(&sem)` | P 操作:等待信号量(S--,若 S<0 则阻塞) | -| `sem_post(&sem)` | V 操作:释放信号量(S++,唤醒等待者) | -| `sem_getvalue(&sem, &val)` | 获取信号量当前值 | -| `sem_destroy(&sem)` | 销毁信号量 | - -### 5.3 互斥锁 vs 信号量 - -| 特性 | 互斥锁 (Mutex) | 信号量 (Semaphore) | -|------|----------------|-------------------| -| 计数范围 | 0 或 1(二值) | 0 到 N(计数) | -| 用途 | 互斥(保护临界区) | 同步(协调执行顺序) | -| 加锁/解锁者 | 谁加锁谁解锁 | 一个线程 sem_wait,另一个 sem_post | -| 典型场景 | 保护共享变量 | 生产者-消费者、资源池 | - -### 5.4 生产者-消费者问题 - -生产者-消费者是经典的同步问题:生产者向缓冲区放入数据,消费者从缓冲区取出数据。需要解决: -- 缓冲区满时生产者必须等待 -- 缓冲区空时消费者必须等待 -- 同一时刻只有一个线程访问缓冲区 - -**使用信号量解决**: - -```c -#include - -#define N 10 // 缓冲区大小 -int buf[N]; -int in = 0, out = 0; - -sem_t mutex; // 互斥信号量,初值 1 -sem_t empty; // 空槽位数,初值 N -sem_t full; // 满槽位数,初值 0 - -void *producer(void *arg) { - while (1) { - int item = produce_item(); - sem_wait(&empty); // 等待空槽位 (empty--) - sem_wait(&mutex); // 进入临界区 - buf[in] = item; - in = (in + 1) % N; - sem_post(&mutex); // 离开临界区 - sem_post(&full); // 增加满槽位 (full++) - } -} - -void *consumer(void *arg) { - while (1) { - sem_wait(&full); // 等待满槽位 (full--) - sem_wait(&mutex); // 进入临界区 - int item = buf[out]; - out = (out + 1) % N; - sem_post(&mutex); // 离开临界区 - sem_post(&empty); // 增加空槽位 (empty++) - consume_item(item); - } -} -``` - -**生产者-消费者流程图**: - -```mermaid -graph TD - subgraph "生产者" - P1["生产数据"] --> P2["sem_wait(empty)
等待空槽位"] - P2 --> P3["sem_wait(mutex)
进入临界区"] - P3 --> P4["放入缓冲区"] - P4 --> P5["sem_post(mutex)
离开临界区"] - P5 --> P6["sem_post(full)
通知消费者"] - P6 --> P1 - end - - subgraph "消费者" - C1["sem_wait(full)
等待满槽位"] --> C2["sem_wait(mutex)
进入临界区"] - C2 --> C3["取出数据"] - C3 --> C4["sem_post(mutex)
离开临界区"] - C4 --> C5["sem_post(empty)
通知生产者"] - C5 --> C6["处理数据"] - C6 --> C1 - end - - P6 -.->|"full++ 唤醒"| C1 - C5 -.->|"empty++ 唤醒"| P2 - - style P1 fill:#e1f5fe - style C6 fill:#e8f5e9 -``` - -### 5.5 实验任务:修复 badcount.c - -实验中的 `task62.c` 要求使用信号量修复 `badcount.c` 的竞态条件: - -```c -#include -sem_t mutex; -sem_init(&mutex, 0, 1); // 互斥信号量,初值 1 - -void *increase(void *arg) { - for (i = 0; i < niters; i++) { - sem_wait(&mutex); // P 操作 - count++; - sem_post(&mutex); // V 操作 - } -} - -void *decrease(void *arg) { - for (i = 0; i < niters; i++) { - sem_wait(&mutex); // P 操作 - count--; - sem_post(&mutex); // V 操作 - } -} -``` - ---- - -## 六、并行计算 - -### 6.1 并行求和问题 - -参考 `实例源代码/chap6/psum64.c`,将一个大数组的求和任务分配给多个线程并行计算: - -```c -#include "wrapper.h" -#define MAXTHREADS 32 - -unsigned long long psum[MAXTHREADS]; // 每个线程的部分和 -unsigned long long nelems_per_thread; // 每个线程处理的元素数 - -void *sum(void *vargp) { - int myid = *((int *)vargp); - unsigned long long start = myid * nelems_per_thread; - unsigned long long end = start + nelems_per_thread; - unsigned long long i, lsum = 0; - - for (i = start; i < end; i++) - lsum += i; - - psum[myid] = lsum; // 将部分和存入全局数组 - return NULL; -} - -int main(int argc, char **argv) { - unsigned long long i, nelems, nthreads, result = 0; - pthread_t tid[MAXTHREADS]; - int myid[MAXTHREADS]; - - nthreads = atoi(argv[1]); - nelems = (1L << atoi(argv[2])); // 2^log_nelems - nelems_per_thread = nelems / nthreads; - - // 创建线程 - for (i = 0; i < nthreads; i++) { - myid[i] = i; - pthread_create(&tid[i], NULL, sum, &myid[i]); - } - for (i = 0; i < nthreads; i++) - pthread_join(tid[i], NULL); - - // 汇总部分和 - for (i = 0; i < nthreads; i++) - result += psum[i]; - - // 验证结果 - if (result == (nelems * (nelems - 1) / 2)) - printf("Correct Result=%lld\n", result); - else - printf("Error: result=%lld\n", result); - - exit(0); -} -``` - -**并行求和示意图**: - -```mermaid -graph TD - subgraph "数据划分" - DATA["0, 1, 2, 3, 4, 5, 6, 7, ..."] - DATA --> T0["线程0: 0 ~ N/p-1"] - DATA --> T1["线程1: N/p ~ 2N/p-1"] - DATA --> T2["线程2: 2N/p ~ 3N/p-1"] - DATA --> T3["线程3: 3N/p ~ N-1"] - end - - T0 --> S0["psum[0]"] - T1 --> S1["psum[1]"] - T2 --> S2["psum[2]"] - T3 --> S3["psum[3]"] - - S0 --> SUM["result = psum[0]+psum[1]+psum[2]+psum[3]"] - S1 --> SUM - S2 --> SUM - S3 --> SUM - - style DATA fill:#e1f5fe - style SUM fill:#e8f5e9 -``` - -### 6.2 加速比与效率 - -**加速比(Speedup)**:S_p = T_1 / T_p -- T_1:单线程执行时间 -- T_p:p 个线程并行执行时间 -- 理想加速比 S_p = p(线性加速) - -**效率(Efficiency)**:E_p = S_p / p = T_1 / (p * T_p) -- 理想效率 E_p = 1(100%) -- 实际效率 < 1,因为存在线程创建、同步、通信等开销 - -```mermaid -graph LR - subgraph "加速比曲线" - X["线程数 p"] --> Y["加速比 Sp"] - IDEAL["理想: Sp=p"] -.-> Y - ACTUAL["实际: Sp Y - end -``` - -### 6.3 实验任务:测量加速比 - -实验中的 `task64.c` 要求: -1. 修改 `psum64.c`,添加计时功能 -2. 分别用 1、2、4、8 个线程运行 -3. 计算每个配置的加速比和效率 -4. 分析为什么加速比不是线性的 - -**预期结果**: - -| 线程数 | 执行时间 | 加速比 | 效率 | -|:------:|:--------:|:------:|:----:| -| 1 | T1 | 1.0 | 100% | -| 2 | T2 | ~1.8 | ~90% | -| 4 | T4 | ~3.2 | ~80% | -| 8 | T8 | ~5.5 | ~69% | - ---- - -## 七、fork vs pthread_create 性能对比 - -### 7.1 为什么线程更快 - -`fork()` 创建进程时需要复制父进程的整个地址空间(虽然有写时复制优化),而 `pthread_create()` 只需要分配一个新的栈和少量元数据。 - -```mermaid -graph LR - subgraph "fork() 创建进程" - F1["复制页表"] --> F2["复制地址空间
(写时复制)"] - F2 --> F3["创建新的 PCB"] - F3 --> F4["开销大: ~ms 级"] - end - - subgraph "pthread_create() 创建线程" - T1["分配栈空间"] --> T2["设置线程属性"] - T2 --> T3["创建线程元数据"] - T3 --> T4["开销小: ~us 级"] - end - - style F4 fill:#ffcdd2 - style T4 fill:#e8f5e9 -``` - -### 7.2 实验任务:测量创建时间 - -实验中的 `task66.c` 要求分别测量 `fork()` 和 `pthread_create()` 的创建时间: - -```c -// 测量 fork 创建时间 -clock_gettime(CLOCK_MONOTONIC, &start); -for (i = 0; i < N; i++) { - if (fork() == 0) exit(0); // 子进程立即退出 - wait(NULL); -} -clock_gettime(CLOCK_MONOTONIC, &end); - -// 测量 pthread_create 创建时间 -clock_gettime(CLOCK_MONOTONIC, &start); -for (i = 0; i < N; i++) { - pthread_create(&tid, NULL, dummy, NULL); - pthread_join(tid, NULL); -} -clock_gettime(CLOCK_MONOTONIC, &end); -``` - -**预期结果**:`pthread_create` 通常比 `fork` 快 10-100 倍。 - ---- - -## 八、动态线程池 - -### 8.1 为什么需要线程池 - -频繁创建和销毁线程会带来较大开销。线程池预先创建一组线程,当有任务到来时从池中取出一个线程执行,执行完毕后线程归还池中,避免反复创建销毁。 - -### 8.2 sbuf_t 带缓冲区的线程池 - -实验中的 `task67.c` 实现了一个动态线程池,核心数据结构 `sbuf_t`: - -```c -typedef struct { - int *buf; // 缓冲区数组 - int n; // 缓冲区容量 - int front; // 队首索引 - int rear; // 队尾索引 - sem_t mutex; // 互斥访问 - sem_t slots; // 空槽位信号量 - sem_t items; // 满槽位信号量 -} sbuf_t; -``` - -**动态调整策略**: -- **缓冲区满时翻倍**:`buf = realloc(buf, 2 * n)` -- **缓冲区空时减半**:`buf = realloc(buf, n / 2)` - -```mermaid -graph TD - subgraph "线程池架构" - MAIN["主线程
接收任务"] -->|"放入缓冲区"| BUF["sbuf_t 缓冲区"] - BUF -->|"取出任务"| W1["工作线程1"] - BUF -->|"取出任务"| W2["工作线程2"] - BUF -->|"取出任务"| W3["工作线程3"] - end - - subgraph "动态调整" - FULL["缓冲区满"] -->|"realloc"| DOUBLE["容量翻倍"] - EMPTY["缓冲区空"] -->|"realloc"| HALF["容量减半"] - end - - style MAIN fill:#e1f5fe - style BUF fill:#fff3e0 - style W1 fill:#e8f5e9 - style W2 fill:#e8f5e9 - style W3 fill:#e8f5e9 -``` - ---- - -## 九、线程安全的数据结构 - -### 9.1 tickets 售票问题 - -参考 `实例源代码/chap6/tickets1.c`(有竞态条件)和 `tickets2.c`(使用互斥锁修复): - -```c -// tickets2.c - 使用互斥锁保护售票 -int tickets = 10; -pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; - -void *counter(void *no) { - while (tickets > 0) { - pthread_mutex_lock(&mutex); // 加锁 - if (tickets > 0) { // 双重检查 - printf("柜台%d 卖出一张票,票号为%d\n", (int)no, tickets); - usleep(1); - tickets--; - } - pthread_mutex_unlock(&mutex); // 解锁 - usleep(1); - } -} -``` - -**关键点**:`while` 条件检查和 `if` 条件检查都需要在锁内进行(双重检查),否则可能出现超卖。 - ---- - -## 十、实验任务概览 - -本讲对应 [[实验03_多线程编程]],包含以下任务: - -| 任务 | 文件 | 内容 | 核心知识点 | -|------|------|------|------------| -| 任务一 | task61.c | 3 个对等线程打印姓名/学号/时间 | pthread_create/join | -| 任务二 | task62.c | 用信号量修复 badcount.c 的竞态条件 | sem_wait/sem_post | -| 任务三 | task63.c | k 个生产者 + m 个消费者,信号量同步 | 生产者-消费者模型 | -| 任务四 | task64.c | psum64.c 并行求和,测量不同线程数的加速比 | 并行计算 + 性能分析 | -| 任务五 | task66.c | 测量 fork vs pthread_create 时间 | 进程/线程创建开销对比 | -| 任务六 | task67.c | 动态线程池(sbuf_t 缓冲区满翻倍/空减半) | 线程池 + 动态数据结构 | - ---- - -## 知识关联 - -- 线程是轻量级进程,理解 [[06_进程控制]] 中的 fork 有助于理解线程的优势 -- 线程间的同步问题在死锁章节中会进一步讨论 -- 生产者-消费者模型在 [[08_进程间通信]] 中也有管道和共享内存的实现方式 -- 线程池在并发网络服务器中是核心组件 - ---- - -## 思考题 - -1. **互斥锁 vs 信号量**:互斥锁和二值信号量(初值为 1 的信号量)看起来功能相同,它们有什么本质区别?什么时候必须用信号量而不能用互斥锁? -2. **死锁的产生**:如果一个线程对已经加锁的互斥锁再次调用 `pthread_mutex_lock()`,会发生什么?如果两个线程分别持有对方需要的锁呢? -3. **线程安全的函数**:为什么 `printf()` 是线程安全的,而 `count++` 不是?如何判断一个函数是否线程安全? -4. **并行计算的瓶颈**:为什么 p 个线程的加速比通常达不到理想的 p 倍?哪些因素限制了并行加速? -5. **fork vs pthread**:在什么场景下应该用多进程而不是多线程?多进程模型有什么优势? - ---- - -## 扩展阅读 - -- 《UNIX环境高级编程》第11章:线程 -- 《深入理解计算机系统》第12章:并发编程 -- 《POSIX 多线程编程指南》 -- [Pthreads Tutorial (LLNL)](https://computing.llnl.gov/tutorials/pthreads/) +# 第07讲:多线程编程 + +> **本节目标**:理解线程的概念与优势,掌握 POSIX 线程 API(pthread),理解竞态条件的成因与解决方案(互斥锁、信号量),能够实现并行计算和生产者-消费者模型。 + +## 前置知识 + +- [[06_进程控制]] -- 进程的概念与创建 +- [[08_进程间通信]] -- 进程间通信方式(与线程通信对比) + +--- + +## 一、线程概念 + +### 1.1 什么是线程 + +**线程(Thread)** 是进程内的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的地址空间,但每个线程拥有自己独立的栈和寄存器状态。 + +**生活比喻**: +- **进程** = 一家公司(独立的办公室、财务系统、人员编制) +- **线程** = 公司里的员工(共享办公室和财务系统,但各有各的工作任务) + +### 1.2 进程 vs 线程 + +```mermaid +graph TB + subgraph "多进程模型" + P1["进程1"] --> M1["独立的地址空间"] + P2["进程2"] --> M2["独立的地址空间"] + M1 -.->|"IPC 通信
(管道/共享内存)"| M2 + end + + subgraph "多线程模型" + T1["线程1"] --> SM["共享的地址空间"] + T2["线程2"] --> SM + T3["线程3"] --> SM + SM --> S1["代码段 (共享)"] + SM --> S2["数据段 (共享)"] + SM --> S3["堆 (共享)"] + SM --> S4["栈 (各自独立)"] + end + + style P1 fill:#e1f5fe + style P2 fill:#e1f5fe + style T1 fill:#e8f5e9 + style T2 fill:#e8f5e9 + style T3 fill:#e8f5e9 + style SM fill:#fff3e0 +``` + +| 对比维度 | 进程 | 线程 | +|----------|------|------| +| 地址空间 | 独立(互不可见) | 共享(可见彼此的变量) | +| 创建开销 | 大(复制页表、地址空间) | 小(只创建栈和寄存器) | +| 切换开销 | 大(切换页表、刷新 TLB) | 小(只切换栈指针和寄存器) | +| 通信方式 | 需要 IPC 机制(管道、共享内存等) | 直接读写共享变量 | +| 安全性 | 高(地址空间隔离) | 低(需要同步机制保护) | +| 典型应用 | 独立的程序(如浏览器和音乐播放器) | 同一程序内的并发任务 | + +### 1.3 线程的共享与私有 + +| 资源 | 共享/私有 | 说明 | +|------|-----------|------| +| 代码段 | 共享 | 所有线程执行相同的代码 | +| 数据段(全局变量) | 共享 | 所有线程可见同一份全局变量 | +| 堆(malloc 分配的内存) | 共享 | 任一线程分配的内存,其他线程可访问 | +| 栈 | **私有** | 每个线程有自己的栈,存放局部变量 | +| 寄存器状态 | **私有** | 每个线程有自己的程序计数器、栈指针等 | +| 信号处理 | 共享 | 信号发送给进程,任意线程可处理 | + +--- + +## 二、POSIX 线程 API + +### 2.1 核心函数 + +| 函数 | 作用 | 说明 | +|------|------|------| +| `pthread_create()` | 创建新线程 | 类似 `fork()`,但开销小得多 | +| `pthread_join()` | 等待线程结束 | 类似 `wait()`,阻塞调用线程 | +| `pthread_exit()` | 终止当前线程 | 类似 `exit()`,但只终止当前线程 | +| `pthread_self()` | 获取当前线程 ID | 类似 `getpid()` | +| `pthread_detach()` | 分离线程 | 线程结束后自动释放资源 | +| `pthread_cancel()` | 取消线程 | 请求终止另一个线程 | + +### 2.2 pthread_create 详解 + +```c +int pthread_create( + pthread_t *tid, // 输出参数:新线程的 ID + const pthread_attr_t *attr, // 线程属性(通常传 NULL) + void *(*start_routine)(void*), // 线程函数(新线程从这里开始执行) + void *arg // 传递给线程函数的参数 +); +// 返回值:成功返回 0,失败返回错误码 +``` + +```mermaid +graph LR + A["主线程调用
pthread_create()"] --> B["操作系统创建新线程"] + B --> C["新线程执行
start_routine(arg)"] + C --> D["线程函数返回"] + D --> E["主线程调用
pthread_join() 回收"] + + style A fill:#e1f5fe + style C fill:#e8f5e9 + style E fill:#fff3e0 +``` + +### 2.3 线程创建示例 + +参考 `实例源代码/chap6/pthread1.c`: + +```c +#include "wrapper.h" +void *peertask(void *vargp); + +int main() +{ + pthread_t tid; + int i; + + pthread_create(&tid, NULL, peertask, NULL); // 创建新线程 + + for (i = 0; i < 2; i++) { + printf("main thread looped %d times\n", i); + usleep(10); + } + + pthread_join(tid, NULL); // 等待新线程结束 + exit(0); +} + +void *peertask(void *vargp) // 线程函数 +{ + int i; + for (i = 0; i < 4; i++) { + printf("peer thread looped %d times\n", i); + usleep(10); + } + return NULL; +} +``` + +**输出示例**(顺序不确定,因为主线程和新线程并发执行): +``` +main thread looped 0 times +peer thread looped 0 times +main thread looped 1 times +peer thread looped 1 times +peer thread looped 2 times +peer thread looped 3 times +``` + +**关键点**: +- `pthread_create` 创建新线程后,两个线程**并发执行** +- `pthread_join` 阻塞主线程,直到新线程结束 +- 输出顺序取决于调度器,每次运行可能不同 +- 编译时需要加 `-lpthread` 链接 pthread 库 + +--- + +## 三、线程安全问题 -- 竞态条件 + +### 3.1 什么是竞态条件 + +**竞态条件(Race Condition)** 是指多个线程同时访问和修改共享变量,最终结果取决于线程执行的精确时序,导致程序行为不可预测。 + +### 3.2 竞态条件演示 + +参考 `实例源代码/chap6/threadrace.c`: + +```c +#include "wrapper.h" +#define N 4 + +void *thread(void *vargp); + +int main() +{ + pthread_t tid[N]; + int i; + + for (i = 0; i < N; i++) + pthread_create(&tid[i], NULL, thread, &i); // 传递 i 的地址 + + for (i = 0; i < N; i++) + pthread_join(tid[i], NULL); + exit(0); +} + +void *thread(void *vargp) +{ + usleep(1); + int myid = *((int *)vargp); // 读取线程 ID + printf("Hello from thread %d\n", myid); + return NULL; +} +``` + +**问题分析**:所有线程共享同一个变量 `i` 的地址。当线程读取 `*vargp` 时,`i` 的值可能已经被主线程的循环修改了。 + +**可能出现的输出**: +``` +Hello from thread 2 +Hello from thread 2 +Hello from thread 3 +Hello from thread 3 +``` + +### 3.3 竞态条件的时序分析 + +```mermaid +sequenceDiagram + participant 主线程 + participant 线程0 + participant 线程1 + participant 线程2 + participant 线程3 + + 主线程->>线程0: pthread_create(&i) + 主线程->>主线程: i++ (i=1) + 主线程->>线程1: pthread_create(&i) + 主线程->>主线程: i++ (i=2) + 主线程->>线程2: pthread_create(&i) + 主线程->>主线程: i++ (i=3) + 主线程->>线程3: pthread_create(&i) + + Note over 线程0: 读取 *vargp,但 i 已经是 3 + Note over 线程1: 读取 *vargp,i 也是 3 + Note over 线程2: 读取 *vargp,i 也是 3 + Note over 线程3: 读取 *vargp,i 是 3 + + 线程0-->>主线程: Hello from thread 3 (错误!) + 线程1-->>主线程: Hello from thread 3 (错误!) + 线程2-->>主线程: Hello from thread 3 (错误!) + 线程3-->>主线程: Hello from thread 3 +``` + +### 3.4 经典竞态:badcount.c + +参考 `实例源代码/chap6/badcount.c`: + +```c +#include "wrapper.h" +int count = 0; + +void *increase(void *arg) { + unsigned int i, niters = (unsigned int)arg; + for (i = 0; i < niters; i++) + count++; // 非原子操作! + return NULL; +} + +void *decrease(void *arg) { + unsigned int i, niters = (unsigned int)arg; + for (i = 0; i < niters; i++) + count--; // 非原子操作! + return NULL; +} + +int main(int argc, char **argv) { + unsigned int niters = atoll(argv[1]); + pthread_t tid1, tid2; + pthread_create(&tid1, NULL, increase, (void*)niters); + pthread_create(&tid2, NULL, decrease, (void*)niters); + pthread_join(tid1, NULL); + pthread_join(tid2, NULL); + + if (count != 0) + printf("Error! cnt=%d\n", count); // 期望为0,但实际可能不为0 + else + printf("Correct cnt=%d\n", count); + exit(0); +} +``` + +**为什么 `count++` 和 `count--` 不是原子操作?** + +```mermaid +graph LR + subgraph "count++ 的三条指令" + A1["LOAD count -> 寄存器"] --> A2["寄存器 = 寄存器 + 1"] + A2 --> A3["STORE 寄存器 -> count"] + end + + subgraph "count-- 的三条指令" + B1["LOAD count -> 寄存器"] --> B2["寄存器 = 寄存器 - 1"] + B2 --> B3["STORE 寄存器 -> count"] + end + + style A1 fill:#e1f5fe + style A2 fill:#fff3e0 + style A3 fill:#ffcdd2 + style B1 fill:#e1f5fe + style B2 fill:#fff3e0 + style B3 fill:#ffcdd2 +``` + +当两个线程的 LOAD/STORE 指令交错执行时,就会出现丢失更新的问题。 + +--- + +## 四、互斥锁(Mutex) + +### 4.1 互斥锁的原理 + +互斥锁(Mutex)保证同一时刻只有一个线程可以进入**临界区**(访问共享资源的代码段)。其他试图获取已锁定的互斥锁的线程会被阻塞,直到锁被释放。 + +```mermaid +sequenceDiagram + participant 线程1 + participant 互斥锁 + participant 线程2 + participant 共享变量 + + 线程1->>互斥锁: pthread_mutex_lock() + Note over 互斥锁: 锁状态:已锁定 + 线程1->>共享变量: 读取 count (0) + + 线程2->>互斥锁: pthread_mutex_lock() + Note over 互斥锁: 锁已被占用,线程2 阻塞 + + 线程1->>共享变量: count = count + 1 = 1 + 线程1->>互斥锁: pthread_mutex_unlock() + Note over 互斥锁: 锁状态:已解锁 + 互斥锁-->>线程2: 被唤醒,获得锁 + + 线程2->>共享变量: 读取 count (1) + 线程2->>共享变量: count = count - 1 = 0 + 线程2->>互斥锁: pthread_mutex_unlock() + + Note over 共享变量: count = 0 (正确!) +``` + +### 4.2 互斥锁 API + +| 函数 | 作用 | +|------|------| +| `pthread_mutex_init()` | 初始化互斥锁 | +| `pthread_mutex_lock()` | 加锁(阻塞等待) | +| `pthread_mutex_trylock()` | 尝试加锁(非阻塞) | +| `pthread_mutex_unlock()` | 解锁 | +| `pthread_mutex_destroy()` | 销毁互斥锁 | + +**初始化方式**: +```c +// 方式1:静态初始化 +pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; + +// 方式2:动态初始化 +pthread_mutex_t mutex; +pthread_mutex_init(&mutex, NULL); +``` + +### 4.3 互斥锁修复竞态 + +参考 `实例源代码/chap6/mutex1.c`: + +```c +// mutex1.c - 使用互斥锁保护共享变量 +#include "wrapper.h" +int count = 0; +pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; + +void *thread_fun(void *no) { + pthread_mutex_lock(&mutex); // 加锁 -- 进入临界区 + count++; + printf("thread %d, count = %d\n", (int)no, count); + pthread_mutex_unlock(&mutex); // 解锁 -- 离开临界区 +} + +int main() { + pthread_t thread_id1, thread_id2; + Pthread_create(&thread_id1, NULL, thread_fun, (void*)1); + Pthread_create(&thread_id2, NULL, thread_fun, (void*)2); + Pthread_join(thread_id1, NULL); + Pthread_join(thread_id2, NULL); + Pthread_exit(0); +} +``` + +**输出**(保证正确): +``` +thread 1, count = 1 +thread 2, count = 2 +``` + +### 4.4 另一种修复方式:避免共享 + +参考 `实例源代码/chap6/norace.c`,通过为每个线程分配独立的内存来避免共享: + +```c +// norace.c - 使用动态分配避免竞态 +for (i = 0; i < N; i++) { + ptr = malloc(sizeof(int)); // 为每个线程分配独立的内存 + *ptr = i; + pthread_create(&tid[i], NULL, thread, ptr); +} +``` + +**原理**:每个线程拥有自己独立的 `int` 变量,不再共享 `i`,从根本上消除了竞态条件。 + +--- + +## 五、信号量(Semaphore) + +### 5.1 信号量的概念 + +信号量是一种比互斥锁更通用的同步机制,由 Dijkstra 提出。信号量是一个非负整数计数器,支持两个原子操作: + +- **P 操作**(`sem_wait`):计数器减 1,如果计数器 < 0 则阻塞 +- **V 操作**(`sem_post`):计数器加 1,如果有等待者则唤醒 + +```mermaid +graph LR + subgraph "信号量 S" + S["S = 计数器"] + end + + P["sem_wait (P操作)
S--"] -->|S >= 0| OK["允许通过"] + P -->|S < 0| BLOCK["阻塞等待"] + + V["sem_post (V操作)
S++"] --> WAKE["唤醒等待者"] + + style S fill:#fff3e0 + style OK fill:#e8f5e9 + style BLOCK fill:#ffcdd2 +``` + +### 5.2 信号量 API + +| 函数 | 作用 | +|------|------| +| `sem_init(&sem, pshared, value)` | 初始化信号量,value 为初始值 | +| `sem_wait(&sem)` | P 操作:等待信号量(S--,若 S<0 则阻塞) | +| `sem_post(&sem)` | V 操作:释放信号量(S++,唤醒等待者) | +| `sem_getvalue(&sem, &val)` | 获取信号量当前值 | +| `sem_destroy(&sem)` | 销毁信号量 | + +### 5.3 互斥锁 vs 信号量 + +| 特性 | 互斥锁 (Mutex) | 信号量 (Semaphore) | +|------|----------------|-------------------| +| 计数范围 | 0 或 1(二值) | 0 到 N(计数) | +| 用途 | 互斥(保护临界区) | 同步(协调执行顺序) | +| 加锁/解锁者 | 谁加锁谁解锁 | 一个线程 sem_wait,另一个 sem_post | +| 典型场景 | 保护共享变量 | 生产者-消费者、资源池 | + +### 5.4 生产者-消费者问题 + +生产者-消费者是经典的同步问题:生产者向缓冲区放入数据,消费者从缓冲区取出数据。需要解决: +- 缓冲区满时生产者必须等待 +- 缓冲区空时消费者必须等待 +- 同一时刻只有一个线程访问缓冲区 + +**使用信号量解决**: + +```c +#include + +#define N 10 // 缓冲区大小 +int buf[N]; +int in = 0, out = 0; + +sem_t mutex; // 互斥信号量,初值 1 +sem_t empty; // 空槽位数,初值 N +sem_t full; // 满槽位数,初值 0 + +void *producer(void *arg) { + while (1) { + int item = produce_item(); + sem_wait(&empty); // 等待空槽位 (empty--) + sem_wait(&mutex); // 进入临界区 + buf[in] = item; + in = (in + 1) % N; + sem_post(&mutex); // 离开临界区 + sem_post(&full); // 增加满槽位 (full++) + } +} + +void *consumer(void *arg) { + while (1) { + sem_wait(&full); // 等待满槽位 (full--) + sem_wait(&mutex); // 进入临界区 + int item = buf[out]; + out = (out + 1) % N; + sem_post(&mutex); // 离开临界区 + sem_post(&empty); // 增加空槽位 (empty++) + consume_item(item); + } +} +``` + +**生产者-消费者流程图**: + +```mermaid +graph TD + subgraph "生产者" + P1["生产数据"] --> P2["sem_wait(empty)
等待空槽位"] + P2 --> P3["sem_wait(mutex)
进入临界区"] + P3 --> P4["放入缓冲区"] + P4 --> P5["sem_post(mutex)
离开临界区"] + P5 --> P6["sem_post(full)
通知消费者"] + P6 --> P1 + end + + subgraph "消费者" + C1["sem_wait(full)
等待满槽位"] --> C2["sem_wait(mutex)
进入临界区"] + C2 --> C3["取出数据"] + C3 --> C4["sem_post(mutex)
离开临界区"] + C4 --> C5["sem_post(empty)
通知生产者"] + C5 --> C6["处理数据"] + C6 --> C1 + end + + P6 -.->|"full++ 唤醒"| C1 + C5 -.->|"empty++ 唤醒"| P2 + + style P1 fill:#e1f5fe + style C6 fill:#e8f5e9 +``` + +### 5.5 实验任务:修复 badcount.c + +实验中的 `task62.c` 要求使用信号量修复 `badcount.c` 的竞态条件: + +```c +#include +sem_t mutex; +sem_init(&mutex, 0, 1); // 互斥信号量,初值 1 + +void *increase(void *arg) { + for (i = 0; i < niters; i++) { + sem_wait(&mutex); // P 操作 + count++; + sem_post(&mutex); // V 操作 + } +} + +void *decrease(void *arg) { + for (i = 0; i < niters; i++) { + sem_wait(&mutex); // P 操作 + count--; + sem_post(&mutex); // V 操作 + } +} +``` + +--- + +## 六、并行计算 + +### 6.1 并行求和问题 + +参考 `实例源代码/chap6/psum64.c`,将一个大数组的求和任务分配给多个线程并行计算: + +```c +#include "wrapper.h" +#define MAXTHREADS 32 + +unsigned long long psum[MAXTHREADS]; // 每个线程的部分和 +unsigned long long nelems_per_thread; // 每个线程处理的元素数 + +void *sum(void *vargp) { + int myid = *((int *)vargp); + unsigned long long start = myid * nelems_per_thread; + unsigned long long end = start + nelems_per_thread; + unsigned long long i, lsum = 0; + + for (i = start; i < end; i++) + lsum += i; + + psum[myid] = lsum; // 将部分和存入全局数组 + return NULL; +} + +int main(int argc, char **argv) { + unsigned long long i, nelems, nthreads, result = 0; + pthread_t tid[MAXTHREADS]; + int myid[MAXTHREADS]; + + nthreads = atoi(argv[1]); + nelems = (1L << atoi(argv[2])); // 2^log_nelems + nelems_per_thread = nelems / nthreads; + + // 创建线程 + for (i = 0; i < nthreads; i++) { + myid[i] = i; + pthread_create(&tid[i], NULL, sum, &myid[i]); + } + for (i = 0; i < nthreads; i++) + pthread_join(tid[i], NULL); + + // 汇总部分和 + for (i = 0; i < nthreads; i++) + result += psum[i]; + + // 验证结果 + if (result == (nelems * (nelems - 1) / 2)) + printf("Correct Result=%lld\n", result); + else + printf("Error: result=%lld\n", result); + + exit(0); +} +``` + +**并行求和示意图**: + +```mermaid +graph TD + subgraph "数据划分" + DATA["0, 1, 2, 3, 4, 5, 6, 7, ..."] + DATA --> T0["线程0: 0 ~ N/p-1"] + DATA --> T1["线程1: N/p ~ 2N/p-1"] + DATA --> T2["线程2: 2N/p ~ 3N/p-1"] + DATA --> T3["线程3: 3N/p ~ N-1"] + end + + T0 --> S0["psum[0]"] + T1 --> S1["psum[1]"] + T2 --> S2["psum[2]"] + T3 --> S3["psum[3]"] + + S0 --> SUM["result = psum[0]+psum[1]+psum[2]+psum[3]"] + S1 --> SUM + S2 --> SUM + S3 --> SUM + + style DATA fill:#e1f5fe + style SUM fill:#e8f5e9 +``` + +### 6.2 加速比与效率 + +**加速比(Speedup)**:S_p = T_1 / T_p +- T_1:单线程执行时间 +- T_p:p 个线程并行执行时间 +- 理想加速比 S_p = p(线性加速) + +**效率(Efficiency)**:E_p = S_p / p = T_1 / (p * T_p) +- 理想效率 E_p = 1(100%) +- 实际效率 < 1,因为存在线程创建、同步、通信等开销 + +```mermaid +graph LR + subgraph "加速比曲线" + X["线程数 p"] --> Y["加速比 Sp"] + IDEAL["理想: Sp=p"] -.-> Y + ACTUAL["实际: Sp Y + end +``` + +### 6.3 实验任务:测量加速比 + +实验中的 `task64.c` 要求: +1. 修改 `psum64.c`,添加计时功能 +2. 分别用 1、2、4、8 个线程运行 +3. 计算每个配置的加速比和效率 +4. 分析为什么加速比不是线性的 + +**预期结果**: + +| 线程数 | 执行时间 | 加速比 | 效率 | +|:------:|:--------:|:------:|:----:| +| 1 | T1 | 1.0 | 100% | +| 2 | T2 | ~1.8 | ~90% | +| 4 | T4 | ~3.2 | ~80% | +| 8 | T8 | ~5.5 | ~69% | + +--- + +## 七、fork vs pthread_create 性能对比 + +### 7.1 为什么线程更快 + +`fork()` 创建进程时需要复制父进程的整个地址空间(虽然有写时复制优化),而 `pthread_create()` 只需要分配一个新的栈和少量元数据。 + +```mermaid +graph LR + subgraph "fork() 创建进程" + F1["复制页表"] --> F2["复制地址空间
(写时复制)"] + F2 --> F3["创建新的 PCB"] + F3 --> F4["开销大: ~ms 级"] + end + + subgraph "pthread_create() 创建线程" + T1["分配栈空间"] --> T2["设置线程属性"] + T2 --> T3["创建线程元数据"] + T3 --> T4["开销小: ~us 级"] + end + + style F4 fill:#ffcdd2 + style T4 fill:#e8f5e9 +``` + +### 7.2 实验任务:测量创建时间 + +实验中的 `task66.c` 要求分别测量 `fork()` 和 `pthread_create()` 的创建时间: + +```c +// 测量 fork 创建时间 +clock_gettime(CLOCK_MONOTONIC, &start); +for (i = 0; i < N; i++) { + if (fork() == 0) exit(0); // 子进程立即退出 + wait(NULL); +} +clock_gettime(CLOCK_MONOTONIC, &end); + +// 测量 pthread_create 创建时间 +clock_gettime(CLOCK_MONOTONIC, &start); +for (i = 0; i < N; i++) { + pthread_create(&tid, NULL, dummy, NULL); + pthread_join(tid, NULL); +} +clock_gettime(CLOCK_MONOTONIC, &end); +``` + +**预期结果**:`pthread_create` 通常比 `fork` 快 10-100 倍。 + +--- + +## 八、动态线程池 + +### 8.1 为什么需要线程池 + +频繁创建和销毁线程会带来较大开销。线程池预先创建一组线程,当有任务到来时从池中取出一个线程执行,执行完毕后线程归还池中,避免反复创建销毁。 + +### 8.2 sbuf_t 带缓冲区的线程池 + +实验中的 `task67.c` 实现了一个动态线程池,核心数据结构 `sbuf_t`: + +```c +typedef struct { + int *buf; // 缓冲区数组 + int n; // 缓冲区容量 + int front; // 队首索引 + int rear; // 队尾索引 + sem_t mutex; // 互斥访问 + sem_t slots; // 空槽位信号量 + sem_t items; // 满槽位信号量 +} sbuf_t; +``` + +**动态调整策略**: +- **缓冲区满时翻倍**:`buf = realloc(buf, 2 * n)` +- **缓冲区空时减半**:`buf = realloc(buf, n / 2)` + +```mermaid +graph TD + subgraph "线程池架构" + MAIN["主线程
接收任务"] -->|"放入缓冲区"| BUF["sbuf_t 缓冲区"] + BUF -->|"取出任务"| W1["工作线程1"] + BUF -->|"取出任务"| W2["工作线程2"] + BUF -->|"取出任务"| W3["工作线程3"] + end + + subgraph "动态调整" + FULL["缓冲区满"] -->|"realloc"| DOUBLE["容量翻倍"] + EMPTY["缓冲区空"] -->|"realloc"| HALF["容量减半"] + end + + style MAIN fill:#e1f5fe + style BUF fill:#fff3e0 + style W1 fill:#e8f5e9 + style W2 fill:#e8f5e9 + style W3 fill:#e8f5e9 +``` + +--- + +## 九、线程安全的数据结构 + +### 9.1 tickets 售票问题 + +参考 `实例源代码/chap6/tickets1.c`(有竞态条件)和 `tickets2.c`(使用互斥锁修复): + +```c +// tickets2.c - 使用互斥锁保护售票 +int tickets = 10; +pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; + +void *counter(void *no) { + while (tickets > 0) { + pthread_mutex_lock(&mutex); // 加锁 + if (tickets > 0) { // 双重检查 + printf("柜台%d 卖出一张票,票号为%d\n", (int)no, tickets); + usleep(1); + tickets--; + } + pthread_mutex_unlock(&mutex); // 解锁 + usleep(1); + } +} +``` + +**关键点**:`while` 条件检查和 `if` 条件检查都需要在锁内进行(双重检查),否则可能出现超卖。 + +--- + +## 十、实验任务概览 + +本讲对应 [[实验03_多线程编程]],包含以下任务: + +| 任务 | 文件 | 内容 | 核心知识点 | +|------|------|------|------------| +| 任务一 | task61.c | 3 个对等线程打印姓名/学号/时间 | pthread_create/join | +| 任务二 | task62.c | 用信号量修复 badcount.c 的竞态条件 | sem_wait/sem_post | +| 任务三 | task63.c | k 个生产者 + m 个消费者,信号量同步 | 生产者-消费者模型 | +| 任务四 | task64.c | psum64.c 并行求和,测量不同线程数的加速比 | 并行计算 + 性能分析 | +| 任务五 | task66.c | 测量 fork vs pthread_create 时间 | 进程/线程创建开销对比 | +| 任务六 | task67.c | 动态线程池(sbuf_t 缓冲区满翻倍/空减半) | 线程池 + 动态数据结构 | + +--- + +## 知识关联 + +- 线程是轻量级进程,理解 [[06_进程控制]] 中的 fork 有助于理解线程的优势 +- 线程间的同步问题在死锁章节中会进一步讨论 +- 生产者-消费者模型在 [[08_进程间通信]] 中也有管道和共享内存的实现方式 +- 线程池在并发网络服务器中是核心组件 + +--- + +## 思考题 + +1. **互斥锁 vs 信号量**:互斥锁和二值信号量(初值为 1 的信号量)看起来功能相同,它们有什么本质区别?什么时候必须用信号量而不能用互斥锁? +2. **死锁的产生**:如果一个线程对已经加锁的互斥锁再次调用 `pthread_mutex_lock()`,会发生什么?如果两个线程分别持有对方需要的锁呢? +3. **线程安全的函数**:为什么 `printf()` 是线程安全的,而 `count++` 不是?如何判断一个函数是否线程安全? +4. **并行计算的瓶颈**:为什么 p 个线程的加速比通常达不到理想的 p 倍?哪些因素限制了并行加速? +5. **fork vs pthread**:在什么场景下应该用多进程而不是多线程?多进程模型有什么优势? + +--- + +## 扩展阅读 + +- 《UNIX环境高级编程》第11章:线程 +- 《深入理解计算机系统》第12章:并发编程 +- 《POSIX 多线程编程指南》 +- [Pthreads Tutorial (LLNL)](https://computing.llnl.gov/tutorials/pthreads/) diff --git a/操作系统/08_进程间通信/08_进程间通信.md b/操作系统/08_进程间通信/08_进程间通信.md index 0d515a7..8261dfc 100644 --- a/操作系统/08_进程间通信/08_进程间通信.md +++ b/操作系统/08_进程间通信/08_进程间通信.md @@ -1,406 +1,406 @@ -# 第08讲:进程间通信 - -> 🎯 **本节目标**:掌握管道、消息队列、共享内存等进程间通信方式 - -## 📋 前置知识 -- [[06_进程控制]] — 进程的基本概念 -- [[06_进程控制_深入]] — fork 和 exec 的工作原理 - ---- - -## 🤔 为什么需要这个? - -进程之间是相互隔离的,每个进程有自己的地址空间。但有时候进程需要协作: -- Shell 需要将 `ls` 的输出传给 `grep` -- 浏览器需要与下载管理器通信 -- 数据库需要与应用程序交互 - -**进程间通信(IPC)** 就是解决这个问题的。 - -**生活比喻**: -- **管道** = 对讲机(单向通信) -- **消息队列** = 邮箱(异步通信) -- **共享内存** = 共享白板(最快的通信方式) - ---- - -## 📖 核心概念 - -### 1. IPC 方式概览 - -```mermaid -graph TD - A[进程间通信 IPC] --> B[管道 Pipe] - A --> C[消息队列 Message Queue] - A --> D[共享内存 Shared Memory] - A --> E[信号 Signal] - A --> F[信号量 Semaphore] - A --> G[套接字 Socket] - - B --> B1[单向通信] - C --> C1[异步通信] - D --> D1[最快] - E --> E1[异步通知] - F --> F1[同步控制] - G --> G1[网络通信] - - style A fill:#e1f5fe - style D fill:#e8f5e9 -``` - -**对比**: -| 方式 | 优点 | 缺点 | 适用场景 | -|------|------|------|----------| -| 管道 | 简单、易用 | 单向、只能父子进程 | Shell 命令组合 | -| 消息队列 | 异步、可按类型读取 | 有大小限制 | 任务分发 | -| 共享内存 | 最快 | 需要同步机制 | 大量数据交换 | -| 信号 | 异步通知 | 只能传递信号编号 | 事件通知 | -| 信号量 | 同步控制 | 不能传递数据 | 互斥、同步 | -| 套接字 | 跨网络 | 开销大 | 网络通信 | - -### 2. 管道(Pipe) - -管道是最古老的 IPC 方式,用于有亲缘关系的进程之间: - -```mermaid -graph LR - A[写端 fd[1]] -->|数据流| B[读端 fd[0]] - - style A fill:#e8f5e9 - style B fill:#e1f5fe -``` - -**特点**: -- **单向**:只能从一端写,另一端读 -- **有亲缘关系**:通常用于父子进程 -- **自带同步**:读端空时阻塞,写端满时阻塞 - -### 3. 命名管道(FIFO) - -命名管道让没有亲缘关系的进程也能通信: - -```mermaid -graph LR - A[进程1] -->|写入| B[/tmp/my_fifo] - B -->|读取| C[进程2] - - style B fill:#fff3e0 -``` - -**特点**: -- 有文件名,存在于文件系统中 -- 任意进程都可以打开 -- 使用方法与普通文件相同 - -### 4. 消息队列 - -消息队列是一种异步通信方式: - -```mermaid -graph LR - A[发送方 msgsnd] -->|消息| B[消息队列] - B -->|消息| C[接收方 msgrcv] - - style B fill:#fff3e0 -``` - -**消息结构**: -```c -struct msgbuf { - long mtype; // 消息类型 - char mtext[512]; // 消息内容 -}; -``` - -**优势**: -- 可以按类型读取消息 -- 异步通信,不需要同步 -- 可以设置优先级 - -### 5. 共享内存 - -共享内存是最快的 IPC 方式: - -```mermaid -graph TD - A[进程1] -->|读写| B[共享内存区域] - C[进程2] -->|读写| B - - style B fill:#e8f5e9 -``` - -**工作流程**: -1. 创建共享内存段 -2. 将共享内存映射到进程地址空间 -3. 直接读写共享内存 -4. 使用完毕后分离 - -**注意**:共享内存本身不提供同步机制,需要配合信号量使用。 - ---- - -## 💻 动手实践 - -### 示例1:使用管道通信 - -```c -// pipe1.c - 管道通信示例 -#include "wrapper.h" - -int main() { - int count; - int fds[2]; // fds[0]=读端, fds[1]=写端 - const char some_data[] = "1234567890"; - char buffer[BUFSIZ + 1]; - - memset(buffer, '\0', sizeof(buffer)); - - // 创建管道 - pipe(fds); - - // 向管道写入数据 - count = Write(fds[1], (void *)some_data, strlen(some_data)); - printf("Wrote %d bytes\n", count); - - // 从管道读取数据 - count = Read(fds[0], (void *)buffer, BUFSIZ); - printf("Read %d bytes: %s\n", count, buffer); - - exit(EXIT_SUCCESS); -} -``` - -**编译运行**: -```bash -gcc -o pipe1 pipe1.c -L. -lwrapper -./pipe1 -``` - -**预期输出**: -``` -Wrote 10 bytes -Read 10 bytes: 1234567890 -``` - -### 示例2:创建命名管道 - -```c -// fifo1.c - 创建命名管道 -#include -#include -#include -#include -#include - -int main() { - int res = mkfifo("/tmp/my_fifo", 0777); - if (res == 0) - printf("FIFO created\n"); - exit(EXIT_SUCCESS); -} -``` - -**编译运行**: -```bash -gcc -o fifo1 fifo1.c -./fifo1 -ls -l /tmp/my_fifo -``` - -**使用命名管道**: -```bash -# 终端1:写入数据 -echo "Hello FIFO" > /tmp/my_fifo - -# 终端2:读取数据 -cat /tmp/my_fifo -``` - -### 示例3:共享内存通信 - -```c -// shmwrite.c - 写入共享内存 -#include "wrapper.h" - -int main(int argc, char *argv[]) { - int shmid; - key_t key; - void *shmptr; - - if (argc <= 1) { - fprintf(stderr, "请以 ./shmwrite 形式运行\n"); - exit(2); - } - - // 将参数转换成十六进制数作为 key - sscanf(argv[1], "%x", &key); - - // 创建共享内存 - shmid = Shmget(key, 4096, IPC_CREAT | 0644); - - // 将共享内存映射到进程地址空间 - shmptr = Shmat(shmid, 0, 0); - - // 写入数据 - memcpy(shmptr, argv[2], strlen(argv[2]) + 1); - - // 分离共享内存 - Shmdt(shmptr); - exit(0); -} -``` - -```c -// shmread.c - 读取共享内存 -#include "wrapper.h" - -int main(int argc, char *argv[]) { - int shmid; - key_t key; - void *shmptr; - - if (argc <= 1) { - fprintf(stderr, "请以 ./shmread 形式运行\n"); - exit(2); - } - - sscanf(argv[1], "%x", &key); - - // 获取已存在的共享内存 - shmid = Shmget(key, 4096, IPC_CREAT | 0644); - - // 映射共享内存 - shmptr = Shmat(shmid, 0, 0); - - // 读取数据 - printf("%s\n", (char *)shmptr); - - // 分离共享内存 - Shmdt(shmptr); - exit(0); -} -``` - -**编译运行**: -```bash -gcc -o shmwrite shmwrite.c -L. -lwrapper -gcc -o shmread shmread.c -L. -lwrapper - -# 写入数据 -./shmwrite 0x12345678 "Hello Shared Memory!" - -# 读取数据 -./shmread 0x12345678 -``` - -**预期输出**: -``` -Hello Shared Memory! -``` - -### 示例4:消息队列通信 - -```c -// msgsnd1.c - 发送消息 -#include "wrapper.h" - -typedef struct MESSAGE { - int mtype; - char mtext[512]; -} mymsg, *pmymsg; - -int main(int argc, char *argv[]) { - int msqid; - key_t key; - mymsg msginfo; - - if (argc != 3) { - fprintf(stderr, "使用方法: msgsnd1 \n"); - exit(2); - } - - sscanf(argv[1], "%x", &key); - - // 获取消息队列 - msqid = Msgget(key, 0644); - - // 设置消息类型和内容 - msginfo.mtype = 1; - memcpy(&msginfo.mtext, argv[2], strlen(argv[2]) + 1); - - // 发送消息 - Msgsnd(msqid, (pmymsg)&msginfo, strlen(msginfo.mtext) + 1, 0); - printf("you send a message \"%s\" to msq %d\n", argv[1], msqid); - return 0; -} -``` - -```c -// msgrcv1.c - 接收消息 -#include "wrapper.h" - -typedef struct MESSAGE { - int mtype; - char mtext[512]; -} mymsg, *pmymsg; - -int main(int argc, char *argv[]) { - int msqid; - key_t key; - mymsg msginfo; - - if (argc != 2) { - fprintf(stderr, "使用方法: msgrcv1 \n"); - exit(2); - } - - sscanf(argv[1], "%x", &key); - - // 获取消息队列 - msqid = Msgget(key, 0644); - - // 接收消息(类型为1) - msgrcv(msqid, (pmymsg)&msginfo, 512, 1, 0); - printf("%s\n", msginfo.mtext); - return 0; -} -``` - -**编译运行**: -```bash -gcc -o msgsnd1 msgsnd1.c -L. -lwrapper -gcc -o msgrcv1 msgrcv1.c -L. -lwrapper - -# 发送消息 -./msgsnd1 0x12345678 "Hello Message Queue!" - -# 接收消息 -./msgrcv1 0x12345678 -``` - -**预期输出**: -``` -Hello Message Queue! -``` - ---- - -## 🔗 知识关联 -- 管道在 Shell 中广泛使用,详见 [[06_进程控制_深入]] -- 共享内存的同步需要信号量,详见 [[07_多线程编程]] -- 套接字是网络通信的基础,详见 [[09_网络编程]] - ---- - -## 📝 思考题 - -1. **管道的局限性**:为什么管道只能用于有亲缘关系的进程? -2. **共享内存的速度优势**:为什么共享内存比管道快? -3. **消息队列 vs 管道**:在什么场景下消息队列比管道更合适? - ---- - -## 📚 扩展阅读 -- 《UNIX环境高级编程》第15章:进程间通信 -- 《深入理解计算机系统》第10章:系统级I/O -- [Linux IPC 编程](https://www.tldp.org/LDP/tlk/ipc/ipc.html) +# 第08讲:进程间通信 + +> 🎯 **本节目标**:掌握管道、消息队列、共享内存等进程间通信方式 + +## 📋 前置知识 +- [[06_进程控制]] — 进程的基本概念 +- [[06_进程控制_深入]] — fork 和 exec 的工作原理 + +--- + +## 🤔 为什么需要这个? + +进程之间是相互隔离的,每个进程有自己的地址空间。但有时候进程需要协作: +- Shell 需要将 `ls` 的输出传给 `grep` +- 浏览器需要与下载管理器通信 +- 数据库需要与应用程序交互 + +**进程间通信(IPC)** 就是解决这个问题的。 + +**生活比喻**: +- **管道** = 对讲机(单向通信) +- **消息队列** = 邮箱(异步通信) +- **共享内存** = 共享白板(最快的通信方式) + +--- + +## 📖 核心概念 + +### 1. IPC 方式概览 + +```mermaid +graph TD + A[进程间通信 IPC] --> B[管道 Pipe] + A --> C[消息队列 Message Queue] + A --> D[共享内存 Shared Memory] + A --> E[信号 Signal] + A --> F[信号量 Semaphore] + A --> G[套接字 Socket] + + B --> B1[单向通信] + C --> C1[异步通信] + D --> D1[最快] + E --> E1[异步通知] + F --> F1[同步控制] + G --> G1[网络通信] + + style A fill:#e1f5fe + style D fill:#e8f5e9 +``` + +**对比**: +| 方式 | 优点 | 缺点 | 适用场景 | +|------|------|------|----------| +| 管道 | 简单、易用 | 单向、只能父子进程 | Shell 命令组合 | +| 消息队列 | 异步、可按类型读取 | 有大小限制 | 任务分发 | +| 共享内存 | 最快 | 需要同步机制 | 大量数据交换 | +| 信号 | 异步通知 | 只能传递信号编号 | 事件通知 | +| 信号量 | 同步控制 | 不能传递数据 | 互斥、同步 | +| 套接字 | 跨网络 | 开销大 | 网络通信 | + +### 2. 管道(Pipe) + +管道是最古老的 IPC 方式,用于有亲缘关系的进程之间: + +```mermaid +graph LR + A[写端 fd[1]] -->|数据流| B[读端 fd[0]] + + style A fill:#e8f5e9 + style B fill:#e1f5fe +``` + +**特点**: +- **单向**:只能从一端写,另一端读 +- **有亲缘关系**:通常用于父子进程 +- **自带同步**:读端空时阻塞,写端满时阻塞 + +### 3. 命名管道(FIFO) + +命名管道让没有亲缘关系的进程也能通信: + +```mermaid +graph LR + A[进程1] -->|写入| B[/tmp/my_fifo] + B -->|读取| C[进程2] + + style B fill:#fff3e0 +``` + +**特点**: +- 有文件名,存在于文件系统中 +- 任意进程都可以打开 +- 使用方法与普通文件相同 + +### 4. 消息队列 + +消息队列是一种异步通信方式: + +```mermaid +graph LR + A[发送方 msgsnd] -->|消息| B[消息队列] + B -->|消息| C[接收方 msgrcv] + + style B fill:#fff3e0 +``` + +**消息结构**: +```c +struct msgbuf { + long mtype; // 消息类型 + char mtext[512]; // 消息内容 +}; +``` + +**优势**: +- 可以按类型读取消息 +- 异步通信,不需要同步 +- 可以设置优先级 + +### 5. 共享内存 + +共享内存是最快的 IPC 方式: + +```mermaid +graph TD + A[进程1] -->|读写| B[共享内存区域] + C[进程2] -->|读写| B + + style B fill:#e8f5e9 +``` + +**工作流程**: +1. 创建共享内存段 +2. 将共享内存映射到进程地址空间 +3. 直接读写共享内存 +4. 使用完毕后分离 + +**注意**:共享内存本身不提供同步机制,需要配合信号量使用。 + +--- + +## 💻 动手实践 + +### 示例1:使用管道通信 + +```c +// pipe1.c - 管道通信示例 +#include "wrapper.h" + +int main() { + int count; + int fds[2]; // fds[0]=读端, fds[1]=写端 + const char some_data[] = "1234567890"; + char buffer[BUFSIZ + 1]; + + memset(buffer, '\0', sizeof(buffer)); + + // 创建管道 + pipe(fds); + + // 向管道写入数据 + count = Write(fds[1], (void *)some_data, strlen(some_data)); + printf("Wrote %d bytes\n", count); + + // 从管道读取数据 + count = Read(fds[0], (void *)buffer, BUFSIZ); + printf("Read %d bytes: %s\n", count, buffer); + + exit(EXIT_SUCCESS); +} +``` + +**编译运行**: +```bash +gcc -o pipe1 pipe1.c -L. -lwrapper +./pipe1 +``` + +**预期输出**: +``` +Wrote 10 bytes +Read 10 bytes: 1234567890 +``` + +### 示例2:创建命名管道 + +```c +// fifo1.c - 创建命名管道 +#include +#include +#include +#include +#include + +int main() { + int res = mkfifo("/tmp/my_fifo", 0777); + if (res == 0) + printf("FIFO created\n"); + exit(EXIT_SUCCESS); +} +``` + +**编译运行**: +```bash +gcc -o fifo1 fifo1.c +./fifo1 +ls -l /tmp/my_fifo +``` + +**使用命名管道**: +```bash +# 终端1:写入数据 +echo "Hello FIFO" > /tmp/my_fifo + +# 终端2:读取数据 +cat /tmp/my_fifo +``` + +### 示例3:共享内存通信 + +```c +// shmwrite.c - 写入共享内存 +#include "wrapper.h" + +int main(int argc, char *argv[]) { + int shmid; + key_t key; + void *shmptr; + + if (argc <= 1) { + fprintf(stderr, "请以 ./shmwrite 形式运行\n"); + exit(2); + } + + // 将参数转换成十六进制数作为 key + sscanf(argv[1], "%x", &key); + + // 创建共享内存 + shmid = Shmget(key, 4096, IPC_CREAT | 0644); + + // 将共享内存映射到进程地址空间 + shmptr = Shmat(shmid, 0, 0); + + // 写入数据 + memcpy(shmptr, argv[2], strlen(argv[2]) + 1); + + // 分离共享内存 + Shmdt(shmptr); + exit(0); +} +``` + +```c +// shmread.c - 读取共享内存 +#include "wrapper.h" + +int main(int argc, char *argv[]) { + int shmid; + key_t key; + void *shmptr; + + if (argc <= 1) { + fprintf(stderr, "请以 ./shmread 形式运行\n"); + exit(2); + } + + sscanf(argv[1], "%x", &key); + + // 获取已存在的共享内存 + shmid = Shmget(key, 4096, IPC_CREAT | 0644); + + // 映射共享内存 + shmptr = Shmat(shmid, 0, 0); + + // 读取数据 + printf("%s\n", (char *)shmptr); + + // 分离共享内存 + Shmdt(shmptr); + exit(0); +} +``` + +**编译运行**: +```bash +gcc -o shmwrite shmwrite.c -L. -lwrapper +gcc -o shmread shmread.c -L. -lwrapper + +# 写入数据 +./shmwrite 0x12345678 "Hello Shared Memory!" + +# 读取数据 +./shmread 0x12345678 +``` + +**预期输出**: +``` +Hello Shared Memory! +``` + +### 示例4:消息队列通信 + +```c +// msgsnd1.c - 发送消息 +#include "wrapper.h" + +typedef struct MESSAGE { + int mtype; + char mtext[512]; +} mymsg, *pmymsg; + +int main(int argc, char *argv[]) { + int msqid; + key_t key; + mymsg msginfo; + + if (argc != 3) { + fprintf(stderr, "使用方法: msgsnd1 \n"); + exit(2); + } + + sscanf(argv[1], "%x", &key); + + // 获取消息队列 + msqid = Msgget(key, 0644); + + // 设置消息类型和内容 + msginfo.mtype = 1; + memcpy(&msginfo.mtext, argv[2], strlen(argv[2]) + 1); + + // 发送消息 + Msgsnd(msqid, (pmymsg)&msginfo, strlen(msginfo.mtext) + 1, 0); + printf("you send a message \"%s\" to msq %d\n", argv[1], msqid); + return 0; +} +``` + +```c +// msgrcv1.c - 接收消息 +#include "wrapper.h" + +typedef struct MESSAGE { + int mtype; + char mtext[512]; +} mymsg, *pmymsg; + +int main(int argc, char *argv[]) { + int msqid; + key_t key; + mymsg msginfo; + + if (argc != 2) { + fprintf(stderr, "使用方法: msgrcv1 \n"); + exit(2); + } + + sscanf(argv[1], "%x", &key); + + // 获取消息队列 + msqid = Msgget(key, 0644); + + // 接收消息(类型为1) + msgrcv(msqid, (pmymsg)&msginfo, 512, 1, 0); + printf("%s\n", msginfo.mtext); + return 0; +} +``` + +**编译运行**: +```bash +gcc -o msgsnd1 msgsnd1.c -L. -lwrapper +gcc -o msgrcv1 msgrcv1.c -L. -lwrapper + +# 发送消息 +./msgsnd1 0x12345678 "Hello Message Queue!" + +# 接收消息 +./msgrcv1 0x12345678 +``` + +**预期输出**: +``` +Hello Message Queue! +``` + +--- + +## 🔗 知识关联 +- 管道在 Shell 中广泛使用,详见 [[06_进程控制_深入]] +- 共享内存的同步需要信号量,详见 [[07_多线程编程]] +- 套接字是网络通信的基础,详见 [[09_网络编程]] + +--- + +## 📝 思考题 + +1. **管道的局限性**:为什么管道只能用于有亲缘关系的进程? +2. **共享内存的速度优势**:为什么共享内存比管道快? +3. **消息队列 vs 管道**:在什么场景下消息队列比管道更合适? + +--- + +## 📚 扩展阅读 +- 《UNIX环境高级编程》第15章:进程间通信 +- 《深入理解计算机系统》第10章:系统级I/O +- [Linux IPC 编程](https://www.tldp.org/LDP/tlk/ipc/ipc.html) diff --git a/操作系统/09_网络编程基础/09_网络编程基础.md b/操作系统/09_网络编程基础/09_网络编程基础.md index bc58e14..dbfb25e 100644 --- a/操作系统/09_网络编程基础/09_网络编程基础.md +++ b/操作系统/09_网络编程基础/09_网络编程基础.md @@ -1,296 +1,296 @@ -# 第09讲:网络编程 - -> 🎯 **本节目标**:掌握 Socket 编程,理解客户端-服务器模型 - -## 📋 前置知识 -- [[04_文件IO编程]] — 文件描述符的概念 -- [[06_进程控制]] — 进程创建 - ---- - -## 🤔 为什么需要这个? - -你每天都在使用网络:浏览网页、发送消息、观看视频。但你有没有想过: -- 浏览器是怎么从服务器获取网页的? -- 两台电脑之间是怎么通信的? - -**网络编程**就是让你能够编写这样的程序。 - -**生活比喻**: -- **Socket** = 电话插座 -- **服务器** = 客服中心(等待来电) -- **客户端** = 拨打电话的用户 -- **端口** = 分机号 - ---- - -## 📖 核心概念 - -### 1. 客户端-服务器模型 - -```mermaid -sequenceDiagram - participant 客户端 - participant 服务器 - - 服务器->>服务器: socket() 创建套接字 - 服务器->>服务器: bind() 绑定地址 - 服务器->>服务器: listen() 监听连接 - 服务器->>服务器: accept() 等待连接 - - 客户端->>客户端: socket() 创建套接字 - 客户端->>服务器: connect() 发起连接 - - 服务器->>客户端: 连接建立 - - 客户端->>服务器: send() 发送数据 - 服务器->>客户端: recv() 接收数据 - 服务器->>客户端: send() 发送响应 - 客户端->>服务器: recv() 接收响应 - - 客户端->>客户端: close() 关闭连接 - 服务器->>服务器: close() 关闭连接 -``` - -### 2. Socket 编程流程 - -```mermaid -graph TD - subgraph 服务器端 - A1[socket] --> A2[bind] - A2 --> A3[listen] - A3 --> A4[accept] - A4 --> A5[read/write] - A5 --> A6[close] - end - subgraph 客户端 - B1[socket] --> B2[connect] - B2 --> B3[write/read] - B3 --> B4[close] - end - - style A1 fill:#e8f5e9 - style B1 fill:#e1f5fe -``` - -**核心函数**: -| 函数 | 作用 | 服务器/客户端 | -|------|------|:---:| -| `socket()` | 创建套接字 | 都需要 | -| `bind()` | 绑定地址和端口 | 服务器 | -| `listen()` | 开始监听 | 服务器 | -| `accept()` | 接受连接 | 服务器 | -| `connect()` | 发起连接 | 客户端 | -| `send()` | 发送数据 | 都需要 | -| `recv()` | 接收数据 | 都需要 | -| `close()` | 关闭连接 | 都需要 | - -### 3. IP 地址与端口 - -```mermaid -graph LR - A[计算机] -->|IP地址| B[192.168.1.100] - A -->|端口| C[:80] - B --> D[唯一标识一台电脑] - C --> E[唯一标识一个服务] - - style B fill:#e1f5fe - style C fill:#e8f5e9 -``` - -**常见端口**: -| 端口 | 服务 | 说明 | -|:---:|------|------| -| 22 | SSH | 远程登录 | -| 80 | HTTP | 网页浏览 | -| 443 | HTTPS | 安全网页 | -| 3306 | MySQL | 数据库 | -| 8080 | HTTP备用 | 开发常用 | - -### 4. 字节序 - -不同 CPU 存储多字节数据的方式不同: - -```mermaid -graph LR - A[0x12345678] --> B[大端序 Big-endian] - A --> C[小端序 Little-endian] - - B --> B1[12 34 56 78] - C --> C1[78 56 34 12] - - style B fill:#e1f5fe - style C fill:#e8f5e9 -``` - -**网络字节序**:大端序(Big-endian) - -**转换函数**: -```c -htonl() // host to network long -htons() // host to network short -ntohl() // network to host long -ntohs() // network to host short -``` - -### 5. DNS 域名解析 - -```mermaid -graph LR - A[www.example.com] -->|DNS查询| B[DNS服务器] - B -->|返回IP| C[93.184.216.34] - C -->|连接| D[Web服务器] - - style A fill:#e1f5fe - style C fill:#e8f5e9 -``` - ---- - -## 💻 动手实践 - -### 示例1:查询主机信息 - -```c -// hostinfo.c - 查询主机信息 -#include "wrapper.h" - -int main(int argc, char **argv) { - char **pp; - struct in_addr addr; - struct hostent *hostp; - - if (argc != 2) { - fprintf(stderr, "usage: %s \n", argv[0]); - exit(0); - } - - // 判断是域名还是IP地址 - if (inet_aton(argv[1], &addr) != 0) - hostp = Gethostbyaddr((const char *)&addr, sizeof(addr), AF_INET); - else - hostp = Gethostbyname(argv[1]); - - // 打印主机信息 - printf("official hostname: %s\n", hostp->h_name); - - for (pp = hostp->h_aliases; *pp != NULL; pp++) - printf("alias: %s\n", *pp); - - for (pp = hostp->h_addr_list; *pp != NULL; pp++) { - addr.s_addr = *((unsigned int *)*pp); - printf("address: %s\n", inet_ntoa(addr)); - } - exit(0); -} -``` - -**编译运行**: -```bash -gcc -o hostinfo hostinfo.c -L. -lwrapper -./hostinfo www.baidu.com -``` - -**预期输出**: -``` -official hostname: www.a.shifen.com -alias: www.baidu.com -address: 110.242.68.66 -address: 110.242.68.3 -``` - -### 示例2:TCP 服务器(大小写转换) - -```c -// toggle.c - TCP 服务器 -#include "wrapper.h" - -void toggle(int conn_sock) { - size_t n; - int i; - char buf[MAXLINE]; - - while ((n = recv(conn_sock, buf, MAXLINE, 0)) > 0) { - printf("toggle server received %d bytes\n", n); - - // 转换大小写 - for (i = 0; i < n; i++) - if (isupper(buf[i])) - buf[i] = tolower(buf[i]); - else if (islower(buf[i])) - buf[i] = toupper(buf[i]); - - send(conn_sock, buf, n, 0); - } -} -``` - -### 示例3:TCP 客户端 - -```c -// togglec.c - TCP 客户端 -#include "wrapper.h" - -int main(int argc, char **argv) { - int client_sock, port; - char *host, buf[MAXLINE]; - rio_t rio; - - if (argc != 3) { - fprintf(stderr, "usage: %s \n", argv[0]); - exit(1); - } - host = argv[1]; - port = atoi(argv[2]); - - // 连接服务器 - client_sock = open_client_sock(host, port); - - // 从标准输入读取,发送到服务器,接收响应 - while (fgets(buf, MAXLINE, stdin) != NULL) { - send(client_sock, buf, strlen(buf), 0); - recv(client_sock, buf, MAXLINE, 0); - fputs(buf, stdout); - } - - close(client_sock); - exit(0); -} -``` - -**编译运行**: -```bash -# 编译 -gcc -o toggle toggle.c -L. -lwrapper -gcc -o togglec togglec.c -L. -lwrapper - -# 终端1:启动服务器 -./toggle 8080 - -# 终端2:启动客户端 -./togglec localhost 8080 -Hello World # 输入 -HELLO WORLD # 输出(大小写转换) -``` - ---- - -## 🔗 知识关联 -- Socket 是文件描述符,详见 [[04_文件IO编程]] -- 并发服务器在 [[10_并发服务器]] 中有详细讲解 -- 网络编程在 [[实验05_Linux网络通信编程]] 中有实践练习 - ---- - -## 📝 思考题 - -1. **TCP vs UDP**:什么时候用 TCP,什么时候用 UDP? -2. **为什么需要字节序转换?** 如果不转换会怎样? -3. **服务器为什么需要 bind()?** 客户端为什么不需要? - ---- - -## 📚 扩展阅读 -- 《UNIX网络编程》第1卷:套接字联网API -- [Beej's Guide to Network Programming](https://beej.us/guide/bgnet/) -- [Socket 编程详解](https://www.cs.rpi.edu/~moorthy/Courses/os98/Pggrams/socket.html) +# 第09讲:网络编程 + +> 🎯 **本节目标**:掌握 Socket 编程,理解客户端-服务器模型 + +## 📋 前置知识 +- [[04_文件IO编程]] — 文件描述符的概念 +- [[06_进程控制]] — 进程创建 + +--- + +## 🤔 为什么需要这个? + +你每天都在使用网络:浏览网页、发送消息、观看视频。但你有没有想过: +- 浏览器是怎么从服务器获取网页的? +- 两台电脑之间是怎么通信的? + +**网络编程**就是让你能够编写这样的程序。 + +**生活比喻**: +- **Socket** = 电话插座 +- **服务器** = 客服中心(等待来电) +- **客户端** = 拨打电话的用户 +- **端口** = 分机号 + +--- + +## 📖 核心概念 + +### 1. 客户端-服务器模型 + +```mermaid +sequenceDiagram + participant 客户端 + participant 服务器 + + 服务器->>服务器: socket() 创建套接字 + 服务器->>服务器: bind() 绑定地址 + 服务器->>服务器: listen() 监听连接 + 服务器->>服务器: accept() 等待连接 + + 客户端->>客户端: socket() 创建套接字 + 客户端->>服务器: connect() 发起连接 + + 服务器->>客户端: 连接建立 + + 客户端->>服务器: send() 发送数据 + 服务器->>客户端: recv() 接收数据 + 服务器->>客户端: send() 发送响应 + 客户端->>服务器: recv() 接收响应 + + 客户端->>客户端: close() 关闭连接 + 服务器->>服务器: close() 关闭连接 +``` + +### 2. Socket 编程流程 + +```mermaid +graph TD + subgraph 服务器端 + A1[socket] --> A2[bind] + A2 --> A3[listen] + A3 --> A4[accept] + A4 --> A5[read/write] + A5 --> A6[close] + end + subgraph 客户端 + B1[socket] --> B2[connect] + B2 --> B3[write/read] + B3 --> B4[close] + end + + style A1 fill:#e8f5e9 + style B1 fill:#e1f5fe +``` + +**核心函数**: +| 函数 | 作用 | 服务器/客户端 | +|------|------|:---:| +| `socket()` | 创建套接字 | 都需要 | +| `bind()` | 绑定地址和端口 | 服务器 | +| `listen()` | 开始监听 | 服务器 | +| `accept()` | 接受连接 | 服务器 | +| `connect()` | 发起连接 | 客户端 | +| `send()` | 发送数据 | 都需要 | +| `recv()` | 接收数据 | 都需要 | +| `close()` | 关闭连接 | 都需要 | + +### 3. IP 地址与端口 + +```mermaid +graph LR + A[计算机] -->|IP地址| B[192.168.1.100] + A -->|端口| C[:80] + B --> D[唯一标识一台电脑] + C --> E[唯一标识一个服务] + + style B fill:#e1f5fe + style C fill:#e8f5e9 +``` + +**常见端口**: +| 端口 | 服务 | 说明 | +|:---:|------|------| +| 22 | SSH | 远程登录 | +| 80 | HTTP | 网页浏览 | +| 443 | HTTPS | 安全网页 | +| 3306 | MySQL | 数据库 | +| 8080 | HTTP备用 | 开发常用 | + +### 4. 字节序 + +不同 CPU 存储多字节数据的方式不同: + +```mermaid +graph LR + A[0x12345678] --> B[大端序 Big-endian] + A --> C[小端序 Little-endian] + + B --> B1[12 34 56 78] + C --> C1[78 56 34 12] + + style B fill:#e1f5fe + style C fill:#e8f5e9 +``` + +**网络字节序**:大端序(Big-endian) + +**转换函数**: +```c +htonl() // host to network long +htons() // host to network short +ntohl() // network to host long +ntohs() // network to host short +``` + +### 5. DNS 域名解析 + +```mermaid +graph LR + A[www.example.com] -->|DNS查询| B[DNS服务器] + B -->|返回IP| C[93.184.216.34] + C -->|连接| D[Web服务器] + + style A fill:#e1f5fe + style C fill:#e8f5e9 +``` + +--- + +## 💻 动手实践 + +### 示例1:查询主机信息 + +```c +// hostinfo.c - 查询主机信息 +#include "wrapper.h" + +int main(int argc, char **argv) { + char **pp; + struct in_addr addr; + struct hostent *hostp; + + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + exit(0); + } + + // 判断是域名还是IP地址 + if (inet_aton(argv[1], &addr) != 0) + hostp = Gethostbyaddr((const char *)&addr, sizeof(addr), AF_INET); + else + hostp = Gethostbyname(argv[1]); + + // 打印主机信息 + printf("official hostname: %s\n", hostp->h_name); + + for (pp = hostp->h_aliases; *pp != NULL; pp++) + printf("alias: %s\n", *pp); + + for (pp = hostp->h_addr_list; *pp != NULL; pp++) { + addr.s_addr = *((unsigned int *)*pp); + printf("address: %s\n", inet_ntoa(addr)); + } + exit(0); +} +``` + +**编译运行**: +```bash +gcc -o hostinfo hostinfo.c -L. -lwrapper +./hostinfo www.baidu.com +``` + +**预期输出**: +``` +official hostname: www.a.shifen.com +alias: www.baidu.com +address: 110.242.68.66 +address: 110.242.68.3 +``` + +### 示例2:TCP 服务器(大小写转换) + +```c +// toggle.c - TCP 服务器 +#include "wrapper.h" + +void toggle(int conn_sock) { + size_t n; + int i; + char buf[MAXLINE]; + + while ((n = recv(conn_sock, buf, MAXLINE, 0)) > 0) { + printf("toggle server received %d bytes\n", n); + + // 转换大小写 + for (i = 0; i < n; i++) + if (isupper(buf[i])) + buf[i] = tolower(buf[i]); + else if (islower(buf[i])) + buf[i] = toupper(buf[i]); + + send(conn_sock, buf, n, 0); + } +} +``` + +### 示例3:TCP 客户端 + +```c +// togglec.c - TCP 客户端 +#include "wrapper.h" + +int main(int argc, char **argv) { + int client_sock, port; + char *host, buf[MAXLINE]; + rio_t rio; + + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + exit(1); + } + host = argv[1]; + port = atoi(argv[2]); + + // 连接服务器 + client_sock = open_client_sock(host, port); + + // 从标准输入读取,发送到服务器,接收响应 + while (fgets(buf, MAXLINE, stdin) != NULL) { + send(client_sock, buf, strlen(buf), 0); + recv(client_sock, buf, MAXLINE, 0); + fputs(buf, stdout); + } + + close(client_sock); + exit(0); +} +``` + +**编译运行**: +```bash +# 编译 +gcc -o toggle toggle.c -L. -lwrapper +gcc -o togglec togglec.c -L. -lwrapper + +# 终端1:启动服务器 +./toggle 8080 + +# 终端2:启动客户端 +./togglec localhost 8080 +Hello World # 输入 +HELLO WORLD # 输出(大小写转换) +``` + +--- + +## 🔗 知识关联 +- Socket 是文件描述符,详见 [[04_文件IO编程]] +- 并发服务器在 [[10_并发服务器]] 中有详细讲解 +- 网络编程在 [[实验05_Linux网络通信编程]] 中有实践练习 + +--- + +## 📝 思考题 + +1. **TCP vs UDP**:什么时候用 TCP,什么时候用 UDP? +2. **为什么需要字节序转换?** 如果不转换会怎样? +3. **服务器为什么需要 bind()?** 客户端为什么不需要? + +--- + +## 📚 扩展阅读 +- 《UNIX网络编程》第1卷:套接字联网API +- [Beej's Guide to Network Programming](https://beej.us/guide/bgnet/) +- [Socket 编程详解](https://www.cs.rpi.edu/~moorthy/Courses/os98/Pggrams/socket.html) diff --git a/操作系统/10_并发服务器/10_并发服务器.md b/操作系统/10_并发服务器/10_并发服务器.md index c7d6136..120f017 100644 --- a/操作系统/10_并发服务器/10_并发服务器.md +++ b/操作系统/10_并发服务器/10_并发服务器.md @@ -1,395 +1,395 @@ -# 第10讲:并发网络服务器 - -> 🎯 **本节目标**:掌握多进程、多线程、I/O 多路复用三种并发服务器模型 - -## 📋 前置知识 -- [[09_网络编程]] — Socket 编程基础 -- [[06_进程控制]] — 进程创建 -- [[07_多线程编程]] — 线程创建 - ---- - -## 🤔 为什么需要这个? - -上一讲的服务器一次只能服务一个客户端。如果有 100 个用户同时访问网站,第 100 个用户必须等前 99 个都处理完才能得到响应。 - -**并发服务器**就是解决这个问题的——让服务器能够同时服务多个客户端。 - -**生活比喻**: -- **迭代服务器** = 一个服务员一次只服务一桌客人 -- **多进程服务器** = 每来一桌客人就招一个新服务员 -- **多线程服务器** = 一个服务员同时照看多桌客人 -- **I/O 多路复用** = 服务员轮流查看哪桌客人需要服务 - ---- - -## 📖 核心概念 - -### 1. 三种并发模型 - -```mermaid -graph TD - A[并发服务器模型] --> B[多进程模型] - A --> C[多线程模型] - A --> D[I/O 多路复用] - - B --> B1[每连接一个进程] - B --> B2[进程间隔离] - B --> B3[开销大] - - C --> C1[每连接一个线程] - C --> C2[共享内存] - C --> C3[开销较小] - - D --> D1[单线程处理多连接] - D --> D2[select/poll/epoll] - D --> D3[开销最小] - - style B fill:#ffcdd2 - style C fill:#fff3e0 - style D fill:#e8f5e9 -``` - -**对比**: -| 模型 | 优点 | 缺点 | 适用场景 | -|------|------|------|----------| -| 多进程 | 隔离性好 | 开销大 | 连接数少 | -| 多线程 | 开销较小 | 需要同步 | 连接数中等 | -| I/O 多路复用 | 开销最小 | 编程复杂 | 连接数多 | - -### 2. 多进程模型 - -```mermaid -sequenceDiagram - participant 主进程 - participant 子进程1 - participant 子进程2 - participant 客户端 - - 主进程->>主进程: accept() 等待连接 - 客户端->>主进程: 连接请求 - 主进程->>子进程1: fork() 创建子进程 - 子进程1->>客户端: 处理请求 - 主进程->>主进程: 继续 accept() - - 客户端->>主进程: 新连接请求 - 主进程->>子进程2: fork() 创建子进程 - 子进程2->>客户端: 处理请求 -``` - -**特点**: -- 每个连接一个独立进程 -- 进程间完全隔离 -- 进程创建和销毁开销大 - -### 3. 多线程模型 - -```mermaid -sequenceDiagram - participant 主线程 - participant 工作线程1 - participant 工作线程2 - participant 客户端 - - 主线程->>主线程: accept() 等待连接 - 客户端->>主线程: 连接请求 - 主线程->>工作线程1: pthread_create() - 工作线程1->>客户端: 处理请求 - 主线程->>主线程: 继续 accept() - - 客户端->>主线程: 新连接请求 - 主线程->>工作线程2: pthread_create() - 工作线程2->>客户端: 处理请求 -``` - -**特点**: -- 每个连接一个线程 -- 线程共享进程资源 -- 需要注意线程安全 - -### 4. I/O 多路复用(select) - -```mermaid -graph TD - A[主循环] --> B[select 监听所有 fd] - B --> C{哪个 fd 就绪?} - C -->|监听套接字| D[accept 新连接] - C -->|客户端套接字| E[处理请求] - D --> A - E --> A - - style B fill:#fff3e0 -``` - -**select 的工作原理**: -1. 将所有需要监听的文件描述符放入集合 -2. 调用 `select()` 等待任意一个就绪 -3. 遍历集合,处理就绪的描述符 -4. 重复步骤 1 - -**fd_set 操作**: -```c -fd_set read_set; -FD_ZERO(&read_set); // 清空集合 -FD_SET(fd1, &read_set); // 添加 fd1 -FD_SET(fd2, &read_set); // 添加 fd2 -select(maxfd+1, &read_set, NULL, NULL, NULL); // 等待 -if (FD_ISSET(fd1, &read_set)) // 检查 fd1 是否就绪 - // 处理 fd1 -``` - -### 5. 线程池模型 - -```mermaid -graph TD - A[主线程] -->|accept| B[任务队列] - B --> C[工作线程1] - B --> D[工作线程2] - B --> E[工作线程3] - C -->|处理完毕| B - D -->|处理完毕| B - E -->|处理完毕| B - - style B fill:#fff3e0 -``` - -**优势**: -- 避免频繁创建销毁线程 -- 控制并发数量 -- 提高资源利用率 - ---- - -## 💻 动手实践 - -### 示例1:多线程并发服务器 - -```c -// togglest.c - 多线程并发服务器 -#include "wrapper.h" - -void toggle(int conn_sock); -void *serve_client(void *vargp); - -int main(int argc, char **argv) { - int listen_sock, conn_sock, port, *conn_sock_p; - struct sockaddr_in clientaddr; - struct hostent *hp; - char *haddrp; - socklen_t clientlen = sizeof(struct sockaddr_in); - pthread_t tid; - - if (argc != 2) { - fprintf(stderr, "usage: %s \n", argv[0]); - exit(1); - } - port = atoi(argv[1]); - - listen_sock = open_listen_sock(port); - - while (1) { - conn_sock_p = malloc(sizeof(int)); - *conn_sock_p = accept(listen_sock, (SA *)&clientaddr, &clientlen); - - // 获取客户端信息 - hp = Gethostbyaddr((const char *)&clientaddr.sin_addr.s_addr, - sizeof(clientaddr.sin_addr.s_addr), AF_INET); - haddrp = inet_ntoa(clientaddr.sin_addr); - printf("server connected to %s (%s)\n", hp->h_name, haddrp); - - // 创建新线程处理客户端 - pthread_create(&tid, NULL, serve_client, conn_sock_p); - } -} - -void *serve_client(void *vargp) { - int conn_sock = *((int *)vargp); - pthread_detach(pthread_self()); // 分离线程 - free(vargp); - toggle(conn_sock); - close(conn_sock); - return NULL; -} -``` - -**编译运行**: -```bash -gcc -o togglest togglest.c -L. -lwrapper -lpthread - -# 终端1:启动服务器 -./togglest 8080 - -# 终端2-4:启动多个客户端 -./togglec localhost 8080 -``` - -### 示例2:I/O 多路复用服务器 - -```c -// toggless1.c - select 多路复用服务器 -#include "wrapper.h" - -void toggle(int conn_sock); -void read_input(void); - -int main(int argc, char **argv) { - int listen_sock, conn_sock, port; - socklen_t clientlen = sizeof(struct sockaddr_in); - struct sockaddr_in clientaddr; - fd_set read_set, ready_set; - - if (argc != 2) { - fprintf(stderr, "usage: %s \n", argv[0]); - exit(1); - } - port = atoi(argv[1]); - listen_sock = open_listen_sock(port); - - // 初始化 fd_set - FD_ZERO(&read_set); - FD_SET(STDIN_FILENO, &read_set); // 监听标准输入 - FD_SET(listen_sock, &read_set); // 监听套接字 - - while (1) { - ready_set = read_set; - select(listen_sock + 1, &ready_set, NULL, NULL, NULL); - - // 检查标准输入 - if (FD_ISSET(STDIN_FILENO, &ready_set)) - read_input(); - - // 检查新连接 - if (FD_ISSET(listen_sock, &ready_set)) { - conn_sock = accept(listen_sock, (SA *)&clientaddr, &clientlen); - toggle(conn_sock); - close(conn_sock); - } - } -} - -void read_input(void) { - char buf[MAXLINE]; - if (!fgets(buf, MAXLINE, stdin)) - exit(0); - printf("%s", buf); -} -``` - -### 示例3:连接池服务器 - -```c -// toggless2.c - 连接池服务器 -#include "wrapper.h" - -typedef struct { - int maxfd; - fd_set read_set; - fd_set ready_set; - int nready; - int maxi; - int client_sock[FD_SETSIZE]; -} sock_pool; - -void init_sock_pool(int listen_sock, sock_pool *pool); -void add_sock(int conn_sock, sock_pool *pool); -void serve_clients(sock_pool *pool); - -int main(int argc, char **argv) { - int listen_sock, conn_sock, port; - socklen_t clientlen = sizeof(struct sockaddr_in); - struct sockaddr_in clientaddr; - static sock_pool pool; - - if (argc != 2) { - fprintf(stderr, "usage: %s \n", argv[0]); - exit(1); - } - port = atoi(argv[1]); - - listen_sock = open_listen_sock(port); - init_sock_pool(listen_sock, &pool); - - while (1) { - pool.ready_set = pool.read_set; - pool.nready = select(pool.maxfd + 1, &pool.ready_set, NULL, NULL, NULL); - - // 新连接 - if (FD_ISSET(listen_sock, &pool.ready_set)) { - conn_sock = accept(listen_sock, (SA *)&clientaddr, &clientlen); - add_sock(conn_sock, &pool); - } - - // 处理客户端请求 - serve_clients(&pool); - } -} - -void init_sock_pool(int listen_sock, sock_pool *p) { - int i; - p->maxi = -1; - for (i = 0; i < FD_SETSIZE; i++) - p->client_sock[i] = -1; - p->maxfd = listen_sock; - FD_ZERO(&p->read_set); - FD_SET(listen_sock, &p->read_set); -} - -void add_sock(int conn_sock, sock_pool *p) { - int i; - p->nready--; - for (i = 0; i < FD_SETSIZE; i++) - if (p->client_sock[i] < 0) { - p->client_sock[i] = conn_sock; - FD_SET(conn_sock, &p->read_set); - if (conn_sock > p->maxfd) - p->maxfd = conn_sock; - if (i > p->maxi) - p->maxi = i; - break; - } - if (i == FD_SETSIZE) - perror("add_sock error: Too many clients"); -} - -void serve_clients(sock_pool *p) { - int i, conn_sock, n; - char buf[MAXLINE]; - - for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) { - conn_sock = p->client_sock[i]; - if ((conn_sock > 0) && (FD_ISSET(conn_sock, &p->ready_set))) { - p->nready--; - if ((n = recv(conn_sock, buf, MAXLINE, 0)) != 0) { - printf("Server received %d bytes on fd %d\n", n, conn_sock); - send(conn_sock, buf, n, 0); - } else { - close(conn_sock); - FD_CLR(conn_sock, &p->read_set); - p->client_sock[i] = -1; - } - } - } -} -``` - ---- - -## 🔗 知识关联 -- 多进程模型在 [[06_进程控制]] 中有详细讲解 -- 多线程模型在 [[07_多线程编程]] 中有详细讲解 -- I/O 多路复用在 [[17_IO系统]] 中有更深入的讨论 - ---- - -## 📝 思考题 - -1. **为什么 select 有 FD_SETSIZE 限制?** 如何突破这个限制? -2. **多进程 vs 多线程**:在什么情况下多进程比多线程更合适? -3. **epoll 的优势**:为什么 Linux 推荐使用 epoll 而不是 select? - ---- - -## 📚 扩展阅读 -- 《UNIX网络编程》第1卷:第6章、第16章 -- [epoll 详解](https://man7.org/linux/man-pages/man7/epoll.7.html) -- [高性能网络编程](https://www.zhihu.com/question/28594409) +# 第10讲:并发网络服务器 + +> 🎯 **本节目标**:掌握多进程、多线程、I/O 多路复用三种并发服务器模型 + +## 📋 前置知识 +- [[09_网络编程]] — Socket 编程基础 +- [[06_进程控制]] — 进程创建 +- [[07_多线程编程]] — 线程创建 + +--- + +## 🤔 为什么需要这个? + +上一讲的服务器一次只能服务一个客户端。如果有 100 个用户同时访问网站,第 100 个用户必须等前 99 个都处理完才能得到响应。 + +**并发服务器**就是解决这个问题的——让服务器能够同时服务多个客户端。 + +**生活比喻**: +- **迭代服务器** = 一个服务员一次只服务一桌客人 +- **多进程服务器** = 每来一桌客人就招一个新服务员 +- **多线程服务器** = 一个服务员同时照看多桌客人 +- **I/O 多路复用** = 服务员轮流查看哪桌客人需要服务 + +--- + +## 📖 核心概念 + +### 1. 三种并发模型 + +```mermaid +graph TD + A[并发服务器模型] --> B[多进程模型] + A --> C[多线程模型] + A --> D[I/O 多路复用] + + B --> B1[每连接一个进程] + B --> B2[进程间隔离] + B --> B3[开销大] + + C --> C1[每连接一个线程] + C --> C2[共享内存] + C --> C3[开销较小] + + D --> D1[单线程处理多连接] + D --> D2[select/poll/epoll] + D --> D3[开销最小] + + style B fill:#ffcdd2 + style C fill:#fff3e0 + style D fill:#e8f5e9 +``` + +**对比**: +| 模型 | 优点 | 缺点 | 适用场景 | +|------|------|------|----------| +| 多进程 | 隔离性好 | 开销大 | 连接数少 | +| 多线程 | 开销较小 | 需要同步 | 连接数中等 | +| I/O 多路复用 | 开销最小 | 编程复杂 | 连接数多 | + +### 2. 多进程模型 + +```mermaid +sequenceDiagram + participant 主进程 + participant 子进程1 + participant 子进程2 + participant 客户端 + + 主进程->>主进程: accept() 等待连接 + 客户端->>主进程: 连接请求 + 主进程->>子进程1: fork() 创建子进程 + 子进程1->>客户端: 处理请求 + 主进程->>主进程: 继续 accept() + + 客户端->>主进程: 新连接请求 + 主进程->>子进程2: fork() 创建子进程 + 子进程2->>客户端: 处理请求 +``` + +**特点**: +- 每个连接一个独立进程 +- 进程间完全隔离 +- 进程创建和销毁开销大 + +### 3. 多线程模型 + +```mermaid +sequenceDiagram + participant 主线程 + participant 工作线程1 + participant 工作线程2 + participant 客户端 + + 主线程->>主线程: accept() 等待连接 + 客户端->>主线程: 连接请求 + 主线程->>工作线程1: pthread_create() + 工作线程1->>客户端: 处理请求 + 主线程->>主线程: 继续 accept() + + 客户端->>主线程: 新连接请求 + 主线程->>工作线程2: pthread_create() + 工作线程2->>客户端: 处理请求 +``` + +**特点**: +- 每个连接一个线程 +- 线程共享进程资源 +- 需要注意线程安全 + +### 4. I/O 多路复用(select) + +```mermaid +graph TD + A[主循环] --> B[select 监听所有 fd] + B --> C{哪个 fd 就绪?} + C -->|监听套接字| D[accept 新连接] + C -->|客户端套接字| E[处理请求] + D --> A + E --> A + + style B fill:#fff3e0 +``` + +**select 的工作原理**: +1. 将所有需要监听的文件描述符放入集合 +2. 调用 `select()` 等待任意一个就绪 +3. 遍历集合,处理就绪的描述符 +4. 重复步骤 1 + +**fd_set 操作**: +```c +fd_set read_set; +FD_ZERO(&read_set); // 清空集合 +FD_SET(fd1, &read_set); // 添加 fd1 +FD_SET(fd2, &read_set); // 添加 fd2 +select(maxfd+1, &read_set, NULL, NULL, NULL); // 等待 +if (FD_ISSET(fd1, &read_set)) // 检查 fd1 是否就绪 + // 处理 fd1 +``` + +### 5. 线程池模型 + +```mermaid +graph TD + A[主线程] -->|accept| B[任务队列] + B --> C[工作线程1] + B --> D[工作线程2] + B --> E[工作线程3] + C -->|处理完毕| B + D -->|处理完毕| B + E -->|处理完毕| B + + style B fill:#fff3e0 +``` + +**优势**: +- 避免频繁创建销毁线程 +- 控制并发数量 +- 提高资源利用率 + +--- + +## 💻 动手实践 + +### 示例1:多线程并发服务器 + +```c +// togglest.c - 多线程并发服务器 +#include "wrapper.h" + +void toggle(int conn_sock); +void *serve_client(void *vargp); + +int main(int argc, char **argv) { + int listen_sock, conn_sock, port, *conn_sock_p; + struct sockaddr_in clientaddr; + struct hostent *hp; + char *haddrp; + socklen_t clientlen = sizeof(struct sockaddr_in); + pthread_t tid; + + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + exit(1); + } + port = atoi(argv[1]); + + listen_sock = open_listen_sock(port); + + while (1) { + conn_sock_p = malloc(sizeof(int)); + *conn_sock_p = accept(listen_sock, (SA *)&clientaddr, &clientlen); + + // 获取客户端信息 + hp = Gethostbyaddr((const char *)&clientaddr.sin_addr.s_addr, + sizeof(clientaddr.sin_addr.s_addr), AF_INET); + haddrp = inet_ntoa(clientaddr.sin_addr); + printf("server connected to %s (%s)\n", hp->h_name, haddrp); + + // 创建新线程处理客户端 + pthread_create(&tid, NULL, serve_client, conn_sock_p); + } +} + +void *serve_client(void *vargp) { + int conn_sock = *((int *)vargp); + pthread_detach(pthread_self()); // 分离线程 + free(vargp); + toggle(conn_sock); + close(conn_sock); + return NULL; +} +``` + +**编译运行**: +```bash +gcc -o togglest togglest.c -L. -lwrapper -lpthread + +# 终端1:启动服务器 +./togglest 8080 + +# 终端2-4:启动多个客户端 +./togglec localhost 8080 +``` + +### 示例2:I/O 多路复用服务器 + +```c +// toggless1.c - select 多路复用服务器 +#include "wrapper.h" + +void toggle(int conn_sock); +void read_input(void); + +int main(int argc, char **argv) { + int listen_sock, conn_sock, port; + socklen_t clientlen = sizeof(struct sockaddr_in); + struct sockaddr_in clientaddr; + fd_set read_set, ready_set; + + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + exit(1); + } + port = atoi(argv[1]); + listen_sock = open_listen_sock(port); + + // 初始化 fd_set + FD_ZERO(&read_set); + FD_SET(STDIN_FILENO, &read_set); // 监听标准输入 + FD_SET(listen_sock, &read_set); // 监听套接字 + + while (1) { + ready_set = read_set; + select(listen_sock + 1, &ready_set, NULL, NULL, NULL); + + // 检查标准输入 + if (FD_ISSET(STDIN_FILENO, &ready_set)) + read_input(); + + // 检查新连接 + if (FD_ISSET(listen_sock, &ready_set)) { + conn_sock = accept(listen_sock, (SA *)&clientaddr, &clientlen); + toggle(conn_sock); + close(conn_sock); + } + } +} + +void read_input(void) { + char buf[MAXLINE]; + if (!fgets(buf, MAXLINE, stdin)) + exit(0); + printf("%s", buf); +} +``` + +### 示例3:连接池服务器 + +```c +// toggless2.c - 连接池服务器 +#include "wrapper.h" + +typedef struct { + int maxfd; + fd_set read_set; + fd_set ready_set; + int nready; + int maxi; + int client_sock[FD_SETSIZE]; +} sock_pool; + +void init_sock_pool(int listen_sock, sock_pool *pool); +void add_sock(int conn_sock, sock_pool *pool); +void serve_clients(sock_pool *pool); + +int main(int argc, char **argv) { + int listen_sock, conn_sock, port; + socklen_t clientlen = sizeof(struct sockaddr_in); + struct sockaddr_in clientaddr; + static sock_pool pool; + + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + exit(1); + } + port = atoi(argv[1]); + + listen_sock = open_listen_sock(port); + init_sock_pool(listen_sock, &pool); + + while (1) { + pool.ready_set = pool.read_set; + pool.nready = select(pool.maxfd + 1, &pool.ready_set, NULL, NULL, NULL); + + // 新连接 + if (FD_ISSET(listen_sock, &pool.ready_set)) { + conn_sock = accept(listen_sock, (SA *)&clientaddr, &clientlen); + add_sock(conn_sock, &pool); + } + + // 处理客户端请求 + serve_clients(&pool); + } +} + +void init_sock_pool(int listen_sock, sock_pool *p) { + int i; + p->maxi = -1; + for (i = 0; i < FD_SETSIZE; i++) + p->client_sock[i] = -1; + p->maxfd = listen_sock; + FD_ZERO(&p->read_set); + FD_SET(listen_sock, &p->read_set); +} + +void add_sock(int conn_sock, sock_pool *p) { + int i; + p->nready--; + for (i = 0; i < FD_SETSIZE; i++) + if (p->client_sock[i] < 0) { + p->client_sock[i] = conn_sock; + FD_SET(conn_sock, &p->read_set); + if (conn_sock > p->maxfd) + p->maxfd = conn_sock; + if (i > p->maxi) + p->maxi = i; + break; + } + if (i == FD_SETSIZE) + perror("add_sock error: Too many clients"); +} + +void serve_clients(sock_pool *p) { + int i, conn_sock, n; + char buf[MAXLINE]; + + for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) { + conn_sock = p->client_sock[i]; + if ((conn_sock > 0) && (FD_ISSET(conn_sock, &p->ready_set))) { + p->nready--; + if ((n = recv(conn_sock, buf, MAXLINE, 0)) != 0) { + printf("Server received %d bytes on fd %d\n", n, conn_sock); + send(conn_sock, buf, n, 0); + } else { + close(conn_sock); + FD_CLR(conn_sock, &p->read_set); + p->client_sock[i] = -1; + } + } + } +} +``` + +--- + +## 🔗 知识关联 +- 多进程模型在 [[06_进程控制]] 中有详细讲解 +- 多线程模型在 [[07_多线程编程]] 中有详细讲解 +- I/O 多路复用在 [[17_IO系统]] 中有更深入的讨论 + +--- + +## 📝 思考题 + +1. **为什么 select 有 FD_SETSIZE 限制?** 如何突破这个限制? +2. **多进程 vs 多线程**:在什么情况下多进程比多线程更合适? +3. **epoll 的优势**:为什么 Linux 推荐使用 epoll 而不是 select? + +--- + +## 📚 扩展阅读 +- 《UNIX网络编程》第1卷:第6章、第16章 +- [epoll 详解](https://man7.org/linux/man-pages/man7/epoll.7.html) +- [高性能网络编程](https://www.zhihu.com/question/28594409) diff --git a/操作系统/11_处理机调度/11_处理机调度.md b/操作系统/11_处理机调度/11_处理机调度.md index ae26484..3f2322f 100644 --- a/操作系统/11_处理机调度/11_处理机调度.md +++ b/操作系统/11_处理机调度/11_处理机调度.md @@ -1,705 +1,705 @@ -# 11 处理机调度 - -> **课程**:操作系统 -> **关联章节**:[[06_进程控制]] | [[12_死锁]] - ---- - -## 一、处理器调度的概念 - -### 1.1 什么是调度 - -处理器调度的核心任务是将 **CPU 时间** 公平、合理地分配给系统中的各个进程。其本质是一种**资源分配决策**——决定在某一时刻,哪个进程获得 CPU 的使用权。 - -> **类比**:学校排课——教室(CPU)有限,多个班级(进程)都要使用,需要教务处(调度器)制定合理的课表。 - -### 1.2 调度的基本原则 - -| 原则 | 说明 | -|------|------| -| **公平性** | 每个进程都能获得合理的 CPU 时间 | -| **高效性** | CPU 利用率尽可能高 | -| **响应性** | 交互式进程能快速得到响应 | -| **吞吐量** | 单位时间内完成的进程数尽可能多 | - ---- - -## 二、作业(Job)的概念 - -### 2.1 作业与作业步 - -- **作业(Job)**:用户提交给系统的一项**完整工作**,是系统进行资源分配的基本单位。 -- **作业步(Job Step)**:作业中若干个**既独立又相互关联**的加工步骤。每个作业步完成一项特定的处理任务。 - -``` -作业示例:编写并运行一个C程序 - 作业步1:编辑(edit)→ 生成源代码文件 - 作业步2:编译(compile)→ 生成目标文件 - 作业步3:链接(link)→ 生成可执行文件 - 作业步4:运行(run)→ 输出结果 -``` - -### 2.2 作业的状态转换 - -```mermaid -stateDiagram-v2 - [*] --> 后备 : 用户提交作业 - 后备 --> 运行 : 高级调度(被选中) - 运行 --> 完成 : 作业执行结束 - 完成 --> [*] : 撤离系统 - - state 运行 { - [*] --> 就绪 : 进程创建 - 就绪 --> 执行 : 低级调度(被选中) - 执行 --> 就绪 : 时间片用完 - 执行 --> 阻塞 : 等待I/O - 阻塞 --> 就绪 : I/O完成 - } -``` - -### 2.3 作业控制块(JCB) - -作业控制块是作业在系统中存在的唯一标识,包含: - -- 作业名、作业状态 -- 资源需求(内存大小、外设类型等) -- 优先级、提交时间 -- 作业步信息 - ---- - -## 三、三级调度体系 - -```mermaid -graph TB - subgraph 外存 - A[后备队列
作业池] - end - - subgraph 内存 - B[就绪队列] - C[阻塞队列] - D[CPU 执行] - end - - A -->|高级调度
长程调度
作业调度| B - B -->|低级调度
短程调度
进程调度| D - D -->|时间片用完/等待事件| B - D -->|等待I/O| C - C -->|I/O完成| B - - B -->|中级调度
交换调度
内存调度| A - - style A fill:#f9f,stroke:#333 - style D fill:#ff9,stroke:#333 -``` - -| 调度级别 | 别名 | 频率 | 功能 | -|----------|------|------|------| -| **高级调度** | 作业调度 / 长程调度 | 最低 | 从后备队列选择作业调入内存 | -| **中级调度** | 交换调度 / 内存调度 | 中等 | 将暂时不用的进程换出到外存(挂起) | -| **低级调度** | 进程调度 / 短程调度 | 最高 | 从就绪队列选择进程分配 CPU | - ---- - -## 四、调度时机 - -### 4.1 何时触发调度 - -```mermaid -flowchart TD - A{发生什么事件?} --> B[进程结束] - A --> C[进程等待事件
如I/O请求] - A --> D[时间片用完] - A --> E[新进程产生] - A --> F[等待的事件已发生
如I/O完成] - - B --> G[执行调度] - C --> G - D --> G - E --> G - F --> G - - G --> H[选择下一个
运行的进程] - - style G fill:#ff9,stroke:#333 -``` - -| 调度时机 | 说明 | 触发场景 | -|----------|------|----------| -| 进程结束 | 运行进程执行完毕 | 正常退出 | -| 等待事件 | 进程因 I/O 等阻塞 | 读磁盘、等待键盘输入 | -| 时间片用完 | 当前进程的时间配额耗尽 | 时钟中断 | -| 新进程产生 | 新进程进入就绪队列 | fork() 创建子进程 | -| 等待事件已发生 | 阻塞进程变就绪 | I/O 中断 | - ---- - -## 五、调度方式 - -### 5.1 非抢占式调度 - -进程**自愿放弃** CPU,系统不会强制剥夺。只有当进程结束、阻塞或主动让出时才调度。 - -- **优点**:实现简单,系统开销小 -- **缺点**:响应时间不可预测,不适合交互式系统 - -### 5.2 抢占式调度 - -系统**强制剥夺**当前运行进程的 CPU,分配给其他进程。 - -- **优点**:响应快,适合分时/实时系统 -- **缺点**:实现复杂,需要保存/恢复现场,开销较大 - -```mermaid -graph LR - subgraph 非抢占式 - A[进程A运行] -->|自愿放弃| B[调度] - B --> C[进程B运行] - end - - subgraph 抢占式 - D[进程A运行] -->|强制剥夺| E[调度] - E --> F[进程B运行] - end - - style E fill:#f96,stroke:#333 -``` - ---- - -## 六、调度算法的设计目标 - -| 系统类型 | 主要目标 | 关键指标 | -|----------|----------|----------| -| **批处理系统** | 周转时间短、吞吐量高 | 平均周转时间、吞吐量 | -| **分时系统** | 响应时间快、均衡性 | 响应时间、公平性 | -| **实时系统** | 满足截止时间、可预测性 | 截止时间满足率 | - -### 关键性能指标 - -$$\text{周转时间} = \text{完成时间} - \text{提交时间}$$ - -$$\text{等待时间} = \text{周转时间} - \text{运行时间}$$ - -$$\text{带权周转时间} = \frac{\text{周转时间}}{\text{运行时间}}$$ - ---- - -## 七、批处理调度算法 - -### 7.1 FCFS — 先来先服务 - -**First Come First Served**:按进程到达就绪队列的**先后顺序**依次调度。 - -#### 示例 - -假设三个进程 P1、P2、P3 几乎同时到达: - -| 进程 | 运行时间 | -|------|----------| -| P1 | 24 | -| P2 | 3 | -| P3 | 3 | - -**按 P1→P2→P3 顺序执行:** - -``` -时间轴(Gantt图): -|--------P1(24)--------|--P2(3)--|--P3(3)--| -0 24 27 30 -``` - -| 进程 | 完成时间 | 周转时间 | 等待时间 | -|------|----------|----------|----------| -| P1 | 24 | 24 | 0 | -| P2 | 27 | 27 | 24 | -| P3 | 30 | 30 | 27 | -| **平均** | | **27** | **17** | - -**按 P2→P3→P1 顺序执行(短作业在前):** - -``` -时间轴(Gantt图): -|--P2(3)--|--P3(3)--|--------P1(24)--------| -0 3 6 30 -``` - -| 进程 | 完成时间 | 周转时间 | 等待时间 | -|------|----------|----------|----------| -| P2 | 3 | 3 | 0 | -| P3 | 6 | 6 | 3 | -| P1 | 30 | 30 | 6 | -| **平均** | | **13** | **3** | - -> **结论**:FCFS 对短作业非常不利,平均等待时间从 17 降到仅 3(仅调整顺序)。 - -### 7.2 SJF — 短作业优先 - -**Shortest Job First**:选择**估计运行时间最短**的进程优先执行。 - -#### 两种变体 - -```mermaid -graph TD - A[SJF 短作业优先] --> B[非抢占式 SJF] - A --> C[抢占式 SJF
即 SRTF] - - B --> D[进程开始执行后
不会被中断] - C --> E[新进程到达时
比较剩余时间
更短则抢占] - - style C fill:#f96,stroke:#333 -``` - -#### 示例(非抢占式 SJF) - -| 进程 | 到达时间 | 运行时间 | -|------|----------|----------| -| P1 | 0 | 7 | -| P2 | 2 | 4 | -| P3 | 4 | 1 | -| P4 | 5 | 4 | - -``` -Gantt图(非抢占SJF): -|--P1(7)--|-P3(1)-|---P2(4)---|---P4(4)---| -0 7 8 12 16 -``` - -- t=0 时只有 P1,P1 先执行 -- t=7 时 P2、P3、P4 都已到达,选最短的 P3(运行时间 1) -- t=8 时选 P2(运行时间 4) -- t=12 时选 P4 - -#### 示例(抢占式 SRTF) - -| 进程 | 到达时间 | 运行时间 | -|------|----------|----------| -| P1 | 0 | 7 | -| P2 | 2 | 4 | -| P3 | 4 | 1 | -| P4 | 5 | 4 | - -``` -Gantt图(抢占式SRTF): -|--P1--|P1|P3|---P2---|---P4---|--P1--| -0 2 4 5 9 13 16 - -时间线: -0-2: P1运行(剩5) -2: P2到达(需4) < P1剩余(5), P2抢占 -2-4: P2运行(剩2) -4: P3到达(需1) < P2剩余(2), P3抢占 -4-5: P3完成 -5: P4到达(需4) = P2剩余(2), P2先到 -5-7: P2完成(剩2) -7: P4(需4) vs P1(剩5), P4更短 -7-11: P4完成 -11-16: P1完成(剩5) -``` - -| 进程 | 完成时间 | 周转时间 | 等待时间 | -|------|----------|----------|----------| -| P1 | 16 | 16 | 9 | -| P2 | 9 | 7 | 3 | -| P3 | 5 | 1 | 0 | -| P4 | 13 | 8 | 4 | -| **平均** | | **8** | **4** | - -#### SJF 的优缺点 - -| 优点 | 缺点 | -|------|------| -| 平均等待时间最小(理论上最优) | 对长作业不利,可能产生**饥饿** | -| 吞吐量高 | 需要预知运行时间(难以精确估计) | - -### 7.3 HRRF — 响应比高优先 - -**Highest Response Ratio First**:FCFS 和 SJF 的折衷方案。 - -$$\text{响应比} = 1 + \frac{\text{等待时间}}{\text{估计运行时间}}$$ - -- 短作业:运行时间小,响应比容易变高 → 兼顾 SJF -- 长作业:随着等待时间增长,响应比逐渐增大 → 避免饥饿 - -```mermaid -flowchart LR - A[选择响应比最高的进程] --> B{响应比 = 1 + 等待时间/运行时间} - B --> C[短作业:等待时间相同时
运行时间短 → 响应比高] - B --> D[长作业:等待时间越长
响应比越高 → 不会饥饿] -``` - -#### 示例 - -| 进程 | 到达时间 | 运行时间 | -|------|----------|----------| -| P1 | 0 | 20 | -| P2 | 0 | 5 | -| P3 | 0 | 10 | - -t=0: 三个进程同时到达,计算响应比: -- P1: 1 + 0/20 = 1 -- P2: 1 + 0/5 = 1 -- P3: 1 + 0/10 = 1 - -全部相同,按 FCFS 选 P1(运行时间 20)。 - -t=20: P1 完成,计算剩余进程响应比: -- P2: 1 + 20/5 = 5 -- P3: 1 + 20/10 = 3 - -选 P2(响应比 5),P2 执行到 t=25。 - -t=25: 选 P3(响应比 1+25/10=3.5),P3 执行到 t=35。 - -### 7.4 优先级调度 - -**Priority Scheduling**:为每个进程分配优先级,选择优先级最高的进程执行。 - -| 分类方式 | 说明 | -|----------|------| -| **静态优先级** | 创建时确定,运行期间不变 | -| **动态优先级** | 运行期间根据情况调整 | -| **抢占式** | 高优先级进程到达时可抢占当前进程 | -| **非抢占式** | 等当前进程完成后才重新调度 | - ---- - -## 八、分时调度算法 - -### 8.1 RR — 时间片轮转 - -**Round Robin**:就绪队列中的进程按**先来先服务**依次执行,每个进程每次最多运行一个**时间片**(quantum)的时间。 - -#### 示例 - -4 个进程,时间片 q = 4: - -| 进程 | 到达时间 | 运行时间 | -|------|----------|----------| -| P1 | 0 | 24 | -| P2 | 0 | 3 | -| P3 | 0 | 3 | -| P4 | 0 | 6 | - -``` -Gantt图(时间片q=4): -|P1(4)|P2(3)|P3(3)|P4(4)|P1(4)|P4(2)|P1(4)|P1(4)|P1(4)|P1(4)| -0 4 7 10 14 18 20 24 28 32 36 - -执行顺序: -0-4: P1执行(剩20) -4-7: P2执行(完成) ← P2只需3 < 时间片4 -7-10: P3执行(完成) ← P3只需3 < 时间片4 -10-14: P4执行(剩2) -14-18: P1执行(剩16) -18-20: P4执行(完成) ← P4剩2 < 时间片4 -20-24: P1执行(剩12) -24-28: P1执行(剩8) -28-32: P1执行(剩4) -32-36: P1执行(完成) -``` - -| 进程 | 完成时间 | 周转时间 | 等待时间 | -|------|----------|----------|----------| -| P1 | 36 | 36 | 12 | -| P2 | 7 | 7 | 4 | -| P3 | 10 | 10 | 7 | -| P4 | 20 | 20 | 14 | -| **平均** | | **18.25** | **9.25** | - -#### 时间片大小的影响 - -```mermaid -graph TD - A[时间片 q 的选择] --> B[q 太大] - A --> C[q 太小] - A --> D[q 适中] - - B --> E[退化为 FCFS
响应时间变长] - C --> F[进程切换频繁
系统开销过大] - D --> G[兼顾响应性和效率
通常 10~100ms] - - style B fill:#f99,stroke:#333 - style C fill:#f99,stroke:#333 - style D fill:#9f9,stroke:#333 -``` - -#### 时间片选择示例 - -> 10 个进程,进程切换开销为 10ms。 -> -> - 时间片 q = 200ms:每个进程切换一次,总切换时间 = 10×10ms = 100ms -> - 开销比 = 100 / (10×200) = **5%** → 可接受 -> - 时间片 q = 100ms:每个进程切换约2次,总切换时间 = 20×10ms = 200ms -> - 开销比 = 200 / (10×200) = **10%** → 开销偏高 - -### 8.2 多级反馈队列(MFQ) - -**Multi-level Feedback Queue**:综合多种调度算法优点的经典调度算法。 - -```mermaid -graph TB - subgraph MFQ多级反馈队列 - A[新进程到达] --> B[队列1
优先级最高
时间片最小 如4ms] - B -->|时间片用完
未完成| C[队列2
优先级中等
时间片中等 如8ms] - C -->|时间片用完
未完成| D[队列3
优先级最低
时间片最大 如16ms] - end - - B -->|完成| E[进程结束] - C -->|完成| E - D -->|完成| E - - B -->|等待I/O| F[阻塞] - F -->|I/O完成| B - - style B fill:#9f9,stroke:#333 - style C fill:#ff9,stroke:#333 - style D fill:#f99,stroke:#333 -``` - -#### MFQ 的核心规则 - -1. **新进程**进入**最高优先级**队列(队列1) -2. 进程在当前队列用完时间片 → **降级**到下一优先级队列 -3. 高优先级队列**空**时,才调度低优先级队列 -4. 可设置**队列间调度策略**:固定优先级 或 按时间比例分配 - -| 特点 | 说明 | -|------|------| -| 自适应 | I/O 密集型进程常在高优先级队列(因时间片未用完就阻塞) | -| 周转时间短 | CPU 密集型进程最终会降到低优先级队列,用大时间片执行 | -| 公平性 | 各类用户(交互型、批处理型)都能得到合理服务 | - ---- - -## 九、实时调度算法 - -### 9.1 EDF — 最早截止时间优先 - -**Earliest Deadline First**:截止时间越早的进程,优先级越高。 - -```mermaid -gantt - title EDF 调度示例 - dateFormat X - axisFormat %s - - section 进程 - P1(截止t=4) :a1, 0, 2 - P2(截止t=6) :a2, 2, 5 - P3(截止t=8) :a3, 5, 7 -``` - -| 进程 | 到达时间 | 运行时间 | 截止时间 | -|------|----------|----------|----------| -| P1 | 0 | 2 | 4 | -| P2 | 0 | 3 | 6 | -| P3 | 0 | 2 | 8 | - -调度过程: -1. t=0:P1(截止4) < P2(截止6) < P3(截止8) → 执行 P1 -2. t=2:P1 完成,P2(截止6) < P3(截止8) → 执行 P2 -3. t=5:P2 完成,执行 P3 -4. t=7:全部完成,均满足截止时间 - -### 9.2 LLF — 最低松弛度优先 - -**Least Laxity First**:选择**松弛度最小**的进程优先执行。 - -$$\text{松弛度} = \text{必须完成时间} - \text{本身运行时间} - \text{当前时间}$$ - -> 松弛度越小,说明该进程越"紧迫"。 - -#### 示例 - -| 进程 | 运行时间 | 截止时间 | -|------|----------|----------| -| P1 | 2 | 8 | -| P2 | 4 | 12 | - -t=0 时刻: -- P1 松弛度 = 8 - 2 - 0 = **6** -- P2 松弛度 = 12 - 4 - 0 = **8** - -P1 松弛度更小,先执行 P1。 - ---- - -## 十、优先级倒置问题 - -### 10.1 问题描述 - -**优先级倒置(Priority Inversion)**:高优先级进程被低优先级进程间接阻塞的现象。 - -```mermaid -sequenceDiagram - participant H as 高优先级进程H - participant M as 中优先级进程M - participant L as 低优先级进程L - participant R as 共享资源R - - L->>R: 获得资源R(加锁) - H->>R: 请求资源R → 阻塞! - Note over H: H必须等待L释放R - M->>M: M到达,优先级高于L - Note over M: M抢占L执行 - Note over H: H被M间接阻塞!
优先级倒置发生! - M->>M: M执行完毕 - L->>R: 释放资源R - H->>R: 获得资源R,继续执行 -``` - -### 10.2 解决方案——优先级继承 - -**Priority Inheritance Protocol**:当高优先级进程因共享资源阻塞时,**临时提升**持有该资源的低优先级进程的优先级,使其尽快完成并释放资源。 - -```mermaid -sequenceDiagram - participant H as 高优先级进程H - participant L as 低优先级进程L(提升优先级) - participant M as 中优先级进程M - participant R as 共享资源R - - L->>R: 获得资源R - H->>R: 请求资源R → 阻塞 - Note over L: L的优先级临时提升为H的优先级 - Note over M: M到达,但L优先级已提升 - Note over M: M无法抢占L! - L->>R: 释放资源R - Note over L: L优先级恢复原值 - H->>R: 获得资源R,继续执行 -``` - ---- - -## 十一、Linux 调度器 - -### 11.1 CFS — 完全公平调度器 - -**Completely Fair Scheduler**:Linux 默认调度器,基于**虚拟运行时间(vruntime)**实现公平调度。 - -核心思想:**vruntime 最小的进程优先执行**。 - -$$\text{vruntime} += \text{实际运行时间} \times \frac{\text{nice 值基准权重}}{\text{该进程权重}}$$ - -```mermaid -graph TD - A[CFS调度器] --> B{选择 vruntime
最小的进程} - B --> C[红黑树组织
就绪进程] - C --> D[最左叶子节点
= vruntime 最小] - D --> E[执行该进程] - E --> F[更新 vruntime] - F --> C - - style D fill:#9f9,stroke:#333 -``` - -#### CFS 特点 - -| 特性 | 说明 | -|------|------| -| 无固定时间片 | 根据进程权重和系统负载动态计算 | -| 红黑树结构 | O(log n) 插入/查找,高效调度 | -| 公平性保证 | nice 值越低(优先级越高),vruntime 增长越慢 | -| 自动适配 | I/O 密集型进程 vruntime 自然较小,获得更多 CPU | - -### 11.2 调度器类层次 - -```mermaid -graph TB - A[调度器类优先级] --> B[Stop Task
最高优先级
停止特定CPU] - A --> C[Real-Time
实时调度类] - A --> D[Fair
CFS 调度类
普通进程] - A --> E[Idle Task
最低优先级
空闲任务] - - B --> C --> D --> E - - style B fill:#f66,stroke:#333 - style C fill:#f96,stroke:#333 - style D fill:#9f9,stroke:#333 - style E fill:#99f,stroke:#333 -``` - -**调度顺序**:Stop Task > Real-Time > Fair > Idle Task - -每次调度时,优先从高优先级调度类中选择进程。 - -### 11.3 实时进程调度策略 - -| 策略 | 说明 | -|------|------| -| **SCHED_FIFO** | 先来先服务。实时进程一旦获得 CPU,将一直执行直到主动放弃或被更高优先级进程抢占 | -| **SCHED_RR** | 时间片轮转。同优先级的实时进程轮流使用 CPU,时间片用完后回到队尾 | - ---- - -## 十二、调度算法对比总结 - -```mermaid -graph LR - subgraph 批处理算法 - FCFS[FCFS
简单公平] - SJF[SJF/SRTF
最优平均等待] - HRRF[HRRF
FCFS+SJF折衷] - PRIO[优先级调度
灵活控制] - end - - subgraph 分时算法 - RR[RR 轮转
公平响应] - MFQ[多级反馈队列
综合最优] - end - - subgraph 实时算法 - EDF[EDF
截止时间优先] - LLF[LLF
松弛度优先] - end - - FCFS -.->|缺点: 短作业等待久| SJF - SJF -.->|缺点: 长作业饥饿| HRRF - RR -.->|时间片选择关键| MFQ -``` - -| 算法 | 类型 | 抢占 | 优点 | 缺点 | -|------|------|------|------|------| -| FCFS | 批处理 | 否 | 简单公平 | 短作业等待时间长 | -| SJF | 批处理 | 否 | 最小平均等待 | 长作业饥饿 | -| SRTF | 批处理 | 是 | 更优的平均等待 | 长作业饥饿 | -| HRRF | 批处理 | 否 | 兼顾公平 | 需预估运行时间 | -| RR | 分时 | 是 | 响应快 | 时间片选择影响大 | -| MFQ | 分时 | 是 | 自适应各类进程 | 参数调整复杂 | -| EDF | 实时 | 是 | 利用率可达100% | 过载时不确定 | -| LLF | 实时 | 是 | 松弛度精确 | 频繁切换开销 | - ---- - -## 十三、本讲关键公式 - -$$\text{周转时间} = \text{完成时间} - \text{提交时间}$$ - -$$\text{等待时间} = \text{周转时间} - \text{运行时间}$$ - -$$\text{带权周转时间} = \frac{\text{周转时间}}{\text{运行时间}} \geq 1$$ - -$$\text{平均周转时间} = \frac{1}{n}\sum_{i=1}^{n}T_i$$ - -$$\text{响应比} = 1 + \frac{\text{等待时间}}{\text{估计运行时间}}$$ - -$$\text{松弛度} = \text{必须完成时间} - \text{本身运行时间} - \text{当前时间}$$ - -$$\text{时间片开销比} = \frac{\text{进程数} \times \text{切换时间}}{\text{进程数} \times \text{时间片}} = \frac{\text{切换时间}}{\text{时间片}}$$ - ---- - -## 十四、常见考点 - -1. **FCFS/SJF/RR 手工计算**:给定进程到达时间和运行时间,画 Gantt 图,计算平均等待时间和周转时间 -2. **时间片大小对 RR 的影响**:太大退化为 FCFS,太小切换开销大 -3. **响应比计算**:HRRF 的每一步调度决策 -4. **优先级倒置场景分析**及优先级继承原理 -5. **CFS 核心思想**:vruntime 最小优先 - ---- - -**上一章**:[[06_进程控制]] -**下一章**:[[12_死锁]] +# 11 处理机调度 + +> **课程**:操作系统 +> **关联章节**:[[06_进程控制]] | [[12_死锁]] + +--- + +## 一、处理器调度的概念 + +### 1.1 什么是调度 + +处理器调度的核心任务是将 **CPU 时间** 公平、合理地分配给系统中的各个进程。其本质是一种**资源分配决策**——决定在某一时刻,哪个进程获得 CPU 的使用权。 + +> **类比**:学校排课——教室(CPU)有限,多个班级(进程)都要使用,需要教务处(调度器)制定合理的课表。 + +### 1.2 调度的基本原则 + +| 原则 | 说明 | +|------|------| +| **公平性** | 每个进程都能获得合理的 CPU 时间 | +| **高效性** | CPU 利用率尽可能高 | +| **响应性** | 交互式进程能快速得到响应 | +| **吞吐量** | 单位时间内完成的进程数尽可能多 | + +--- + +## 二、作业(Job)的概念 + +### 2.1 作业与作业步 + +- **作业(Job)**:用户提交给系统的一项**完整工作**,是系统进行资源分配的基本单位。 +- **作业步(Job Step)**:作业中若干个**既独立又相互关联**的加工步骤。每个作业步完成一项特定的处理任务。 + +``` +作业示例:编写并运行一个C程序 + 作业步1:编辑(edit)→ 生成源代码文件 + 作业步2:编译(compile)→ 生成目标文件 + 作业步3:链接(link)→ 生成可执行文件 + 作业步4:运行(run)→ 输出结果 +``` + +### 2.2 作业的状态转换 + +```mermaid +stateDiagram-v2 + [*] --> 后备 : 用户提交作业 + 后备 --> 运行 : 高级调度(被选中) + 运行 --> 完成 : 作业执行结束 + 完成 --> [*] : 撤离系统 + + state 运行 { + [*] --> 就绪 : 进程创建 + 就绪 --> 执行 : 低级调度(被选中) + 执行 --> 就绪 : 时间片用完 + 执行 --> 阻塞 : 等待I/O + 阻塞 --> 就绪 : I/O完成 + } +``` + +### 2.3 作业控制块(JCB) + +作业控制块是作业在系统中存在的唯一标识,包含: + +- 作业名、作业状态 +- 资源需求(内存大小、外设类型等) +- 优先级、提交时间 +- 作业步信息 + +--- + +## 三、三级调度体系 + +```mermaid +graph TB + subgraph 外存 + A[后备队列
作业池] + end + + subgraph 内存 + B[就绪队列] + C[阻塞队列] + D[CPU 执行] + end + + A -->|高级调度
长程调度
作业调度| B + B -->|低级调度
短程调度
进程调度| D + D -->|时间片用完/等待事件| B + D -->|等待I/O| C + C -->|I/O完成| B + + B -->|中级调度
交换调度
内存调度| A + + style A fill:#f9f,stroke:#333 + style D fill:#ff9,stroke:#333 +``` + +| 调度级别 | 别名 | 频率 | 功能 | +|----------|------|------|------| +| **高级调度** | 作业调度 / 长程调度 | 最低 | 从后备队列选择作业调入内存 | +| **中级调度** | 交换调度 / 内存调度 | 中等 | 将暂时不用的进程换出到外存(挂起) | +| **低级调度** | 进程调度 / 短程调度 | 最高 | 从就绪队列选择进程分配 CPU | + +--- + +## 四、调度时机 + +### 4.1 何时触发调度 + +```mermaid +flowchart TD + A{发生什么事件?} --> B[进程结束] + A --> C[进程等待事件
如I/O请求] + A --> D[时间片用完] + A --> E[新进程产生] + A --> F[等待的事件已发生
如I/O完成] + + B --> G[执行调度] + C --> G + D --> G + E --> G + F --> G + + G --> H[选择下一个
运行的进程] + + style G fill:#ff9,stroke:#333 +``` + +| 调度时机 | 说明 | 触发场景 | +|----------|------|----------| +| 进程结束 | 运行进程执行完毕 | 正常退出 | +| 等待事件 | 进程因 I/O 等阻塞 | 读磁盘、等待键盘输入 | +| 时间片用完 | 当前进程的时间配额耗尽 | 时钟中断 | +| 新进程产生 | 新进程进入就绪队列 | fork() 创建子进程 | +| 等待事件已发生 | 阻塞进程变就绪 | I/O 中断 | + +--- + +## 五、调度方式 + +### 5.1 非抢占式调度 + +进程**自愿放弃** CPU,系统不会强制剥夺。只有当进程结束、阻塞或主动让出时才调度。 + +- **优点**:实现简单,系统开销小 +- **缺点**:响应时间不可预测,不适合交互式系统 + +### 5.2 抢占式调度 + +系统**强制剥夺**当前运行进程的 CPU,分配给其他进程。 + +- **优点**:响应快,适合分时/实时系统 +- **缺点**:实现复杂,需要保存/恢复现场,开销较大 + +```mermaid +graph LR + subgraph 非抢占式 + A[进程A运行] -->|自愿放弃| B[调度] + B --> C[进程B运行] + end + + subgraph 抢占式 + D[进程A运行] -->|强制剥夺| E[调度] + E --> F[进程B运行] + end + + style E fill:#f96,stroke:#333 +``` + +--- + +## 六、调度算法的设计目标 + +| 系统类型 | 主要目标 | 关键指标 | +|----------|----------|----------| +| **批处理系统** | 周转时间短、吞吐量高 | 平均周转时间、吞吐量 | +| **分时系统** | 响应时间快、均衡性 | 响应时间、公平性 | +| **实时系统** | 满足截止时间、可预测性 | 截止时间满足率 | + +### 关键性能指标 + +$$\text{周转时间} = \text{完成时间} - \text{提交时间}$$ + +$$\text{等待时间} = \text{周转时间} - \text{运行时间}$$ + +$$\text{带权周转时间} = \frac{\text{周转时间}}{\text{运行时间}}$$ + +--- + +## 七、批处理调度算法 + +### 7.1 FCFS — 先来先服务 + +**First Come First Served**:按进程到达就绪队列的**先后顺序**依次调度。 + +#### 示例 + +假设三个进程 P1、P2、P3 几乎同时到达: + +| 进程 | 运行时间 | +|------|----------| +| P1 | 24 | +| P2 | 3 | +| P3 | 3 | + +**按 P1→P2→P3 顺序执行:** + +``` +时间轴(Gantt图): +|--------P1(24)--------|--P2(3)--|--P3(3)--| +0 24 27 30 +``` + +| 进程 | 完成时间 | 周转时间 | 等待时间 | +|------|----------|----------|----------| +| P1 | 24 | 24 | 0 | +| P2 | 27 | 27 | 24 | +| P3 | 30 | 30 | 27 | +| **平均** | | **27** | **17** | + +**按 P2→P3→P1 顺序执行(短作业在前):** + +``` +时间轴(Gantt图): +|--P2(3)--|--P3(3)--|--------P1(24)--------| +0 3 6 30 +``` + +| 进程 | 完成时间 | 周转时间 | 等待时间 | +|------|----------|----------|----------| +| P2 | 3 | 3 | 0 | +| P3 | 6 | 6 | 3 | +| P1 | 30 | 30 | 6 | +| **平均** | | **13** | **3** | + +> **结论**:FCFS 对短作业非常不利,平均等待时间从 17 降到仅 3(仅调整顺序)。 + +### 7.2 SJF — 短作业优先 + +**Shortest Job First**:选择**估计运行时间最短**的进程优先执行。 + +#### 两种变体 + +```mermaid +graph TD + A[SJF 短作业优先] --> B[非抢占式 SJF] + A --> C[抢占式 SJF
即 SRTF] + + B --> D[进程开始执行后
不会被中断] + C --> E[新进程到达时
比较剩余时间
更短则抢占] + + style C fill:#f96,stroke:#333 +``` + +#### 示例(非抢占式 SJF) + +| 进程 | 到达时间 | 运行时间 | +|------|----------|----------| +| P1 | 0 | 7 | +| P2 | 2 | 4 | +| P3 | 4 | 1 | +| P4 | 5 | 4 | + +``` +Gantt图(非抢占SJF): +|--P1(7)--|-P3(1)-|---P2(4)---|---P4(4)---| +0 7 8 12 16 +``` + +- t=0 时只有 P1,P1 先执行 +- t=7 时 P2、P3、P4 都已到达,选最短的 P3(运行时间 1) +- t=8 时选 P2(运行时间 4) +- t=12 时选 P4 + +#### 示例(抢占式 SRTF) + +| 进程 | 到达时间 | 运行时间 | +|------|----------|----------| +| P1 | 0 | 7 | +| P2 | 2 | 4 | +| P3 | 4 | 1 | +| P4 | 5 | 4 | + +``` +Gantt图(抢占式SRTF): +|--P1--|P1|P3|---P2---|---P4---|--P1--| +0 2 4 5 9 13 16 + +时间线: +0-2: P1运行(剩5) +2: P2到达(需4) < P1剩余(5), P2抢占 +2-4: P2运行(剩2) +4: P3到达(需1) < P2剩余(2), P3抢占 +4-5: P3完成 +5: P4到达(需4) = P2剩余(2), P2先到 +5-7: P2完成(剩2) +7: P4(需4) vs P1(剩5), P4更短 +7-11: P4完成 +11-16: P1完成(剩5) +``` + +| 进程 | 完成时间 | 周转时间 | 等待时间 | +|------|----------|----------|----------| +| P1 | 16 | 16 | 9 | +| P2 | 9 | 7 | 3 | +| P3 | 5 | 1 | 0 | +| P4 | 13 | 8 | 4 | +| **平均** | | **8** | **4** | + +#### SJF 的优缺点 + +| 优点 | 缺点 | +|------|------| +| 平均等待时间最小(理论上最优) | 对长作业不利,可能产生**饥饿** | +| 吞吐量高 | 需要预知运行时间(难以精确估计) | + +### 7.3 HRRF — 响应比高优先 + +**Highest Response Ratio First**:FCFS 和 SJF 的折衷方案。 + +$$\text{响应比} = 1 + \frac{\text{等待时间}}{\text{估计运行时间}}$$ + +- 短作业:运行时间小,响应比容易变高 → 兼顾 SJF +- 长作业:随着等待时间增长,响应比逐渐增大 → 避免饥饿 + +```mermaid +flowchart LR + A[选择响应比最高的进程] --> B{响应比 = 1 + 等待时间/运行时间} + B --> C[短作业:等待时间相同时
运行时间短 → 响应比高] + B --> D[长作业:等待时间越长
响应比越高 → 不会饥饿] +``` + +#### 示例 + +| 进程 | 到达时间 | 运行时间 | +|------|----------|----------| +| P1 | 0 | 20 | +| P2 | 0 | 5 | +| P3 | 0 | 10 | + +t=0: 三个进程同时到达,计算响应比: +- P1: 1 + 0/20 = 1 +- P2: 1 + 0/5 = 1 +- P3: 1 + 0/10 = 1 + +全部相同,按 FCFS 选 P1(运行时间 20)。 + +t=20: P1 完成,计算剩余进程响应比: +- P2: 1 + 20/5 = 5 +- P3: 1 + 20/10 = 3 + +选 P2(响应比 5),P2 执行到 t=25。 + +t=25: 选 P3(响应比 1+25/10=3.5),P3 执行到 t=35。 + +### 7.4 优先级调度 + +**Priority Scheduling**:为每个进程分配优先级,选择优先级最高的进程执行。 + +| 分类方式 | 说明 | +|----------|------| +| **静态优先级** | 创建时确定,运行期间不变 | +| **动态优先级** | 运行期间根据情况调整 | +| **抢占式** | 高优先级进程到达时可抢占当前进程 | +| **非抢占式** | 等当前进程完成后才重新调度 | + +--- + +## 八、分时调度算法 + +### 8.1 RR — 时间片轮转 + +**Round Robin**:就绪队列中的进程按**先来先服务**依次执行,每个进程每次最多运行一个**时间片**(quantum)的时间。 + +#### 示例 + +4 个进程,时间片 q = 4: + +| 进程 | 到达时间 | 运行时间 | +|------|----------|----------| +| P1 | 0 | 24 | +| P2 | 0 | 3 | +| P3 | 0 | 3 | +| P4 | 0 | 6 | + +``` +Gantt图(时间片q=4): +|P1(4)|P2(3)|P3(3)|P4(4)|P1(4)|P4(2)|P1(4)|P1(4)|P1(4)|P1(4)| +0 4 7 10 14 18 20 24 28 32 36 + +执行顺序: +0-4: P1执行(剩20) +4-7: P2执行(完成) ← P2只需3 < 时间片4 +7-10: P3执行(完成) ← P3只需3 < 时间片4 +10-14: P4执行(剩2) +14-18: P1执行(剩16) +18-20: P4执行(完成) ← P4剩2 < 时间片4 +20-24: P1执行(剩12) +24-28: P1执行(剩8) +28-32: P1执行(剩4) +32-36: P1执行(完成) +``` + +| 进程 | 完成时间 | 周转时间 | 等待时间 | +|------|----------|----------|----------| +| P1 | 36 | 36 | 12 | +| P2 | 7 | 7 | 4 | +| P3 | 10 | 10 | 7 | +| P4 | 20 | 20 | 14 | +| **平均** | | **18.25** | **9.25** | + +#### 时间片大小的影响 + +```mermaid +graph TD + A[时间片 q 的选择] --> B[q 太大] + A --> C[q 太小] + A --> D[q 适中] + + B --> E[退化为 FCFS
响应时间变长] + C --> F[进程切换频繁
系统开销过大] + D --> G[兼顾响应性和效率
通常 10~100ms] + + style B fill:#f99,stroke:#333 + style C fill:#f99,stroke:#333 + style D fill:#9f9,stroke:#333 +``` + +#### 时间片选择示例 + +> 10 个进程,进程切换开销为 10ms。 +> +> - 时间片 q = 200ms:每个进程切换一次,总切换时间 = 10×10ms = 100ms +> - 开销比 = 100 / (10×200) = **5%** → 可接受 +> - 时间片 q = 100ms:每个进程切换约2次,总切换时间 = 20×10ms = 200ms +> - 开销比 = 200 / (10×200) = **10%** → 开销偏高 + +### 8.2 多级反馈队列(MFQ) + +**Multi-level Feedback Queue**:综合多种调度算法优点的经典调度算法。 + +```mermaid +graph TB + subgraph MFQ多级反馈队列 + A[新进程到达] --> B[队列1
优先级最高
时间片最小 如4ms] + B -->|时间片用完
未完成| C[队列2
优先级中等
时间片中等 如8ms] + C -->|时间片用完
未完成| D[队列3
优先级最低
时间片最大 如16ms] + end + + B -->|完成| E[进程结束] + C -->|完成| E + D -->|完成| E + + B -->|等待I/O| F[阻塞] + F -->|I/O完成| B + + style B fill:#9f9,stroke:#333 + style C fill:#ff9,stroke:#333 + style D fill:#f99,stroke:#333 +``` + +#### MFQ 的核心规则 + +1. **新进程**进入**最高优先级**队列(队列1) +2. 进程在当前队列用完时间片 → **降级**到下一优先级队列 +3. 高优先级队列**空**时,才调度低优先级队列 +4. 可设置**队列间调度策略**:固定优先级 或 按时间比例分配 + +| 特点 | 说明 | +|------|------| +| 自适应 | I/O 密集型进程常在高优先级队列(因时间片未用完就阻塞) | +| 周转时间短 | CPU 密集型进程最终会降到低优先级队列,用大时间片执行 | +| 公平性 | 各类用户(交互型、批处理型)都能得到合理服务 | + +--- + +## 九、实时调度算法 + +### 9.1 EDF — 最早截止时间优先 + +**Earliest Deadline First**:截止时间越早的进程,优先级越高。 + +```mermaid +gantt + title EDF 调度示例 + dateFormat X + axisFormat %s + + section 进程 + P1(截止t=4) :a1, 0, 2 + P2(截止t=6) :a2, 2, 5 + P3(截止t=8) :a3, 5, 7 +``` + +| 进程 | 到达时间 | 运行时间 | 截止时间 | +|------|----------|----------|----------| +| P1 | 0 | 2 | 4 | +| P2 | 0 | 3 | 6 | +| P3 | 0 | 2 | 8 | + +调度过程: +1. t=0:P1(截止4) < P2(截止6) < P3(截止8) → 执行 P1 +2. t=2:P1 完成,P2(截止6) < P3(截止8) → 执行 P2 +3. t=5:P2 完成,执行 P3 +4. t=7:全部完成,均满足截止时间 + +### 9.2 LLF — 最低松弛度优先 + +**Least Laxity First**:选择**松弛度最小**的进程优先执行。 + +$$\text{松弛度} = \text{必须完成时间} - \text{本身运行时间} - \text{当前时间}$$ + +> 松弛度越小,说明该进程越"紧迫"。 + +#### 示例 + +| 进程 | 运行时间 | 截止时间 | +|------|----------|----------| +| P1 | 2 | 8 | +| P2 | 4 | 12 | + +t=0 时刻: +- P1 松弛度 = 8 - 2 - 0 = **6** +- P2 松弛度 = 12 - 4 - 0 = **8** + +P1 松弛度更小,先执行 P1。 + +--- + +## 十、优先级倒置问题 + +### 10.1 问题描述 + +**优先级倒置(Priority Inversion)**:高优先级进程被低优先级进程间接阻塞的现象。 + +```mermaid +sequenceDiagram + participant H as 高优先级进程H + participant M as 中优先级进程M + participant L as 低优先级进程L + participant R as 共享资源R + + L->>R: 获得资源R(加锁) + H->>R: 请求资源R → 阻塞! + Note over H: H必须等待L释放R + M->>M: M到达,优先级高于L + Note over M: M抢占L执行 + Note over H: H被M间接阻塞!
优先级倒置发生! + M->>M: M执行完毕 + L->>R: 释放资源R + H->>R: 获得资源R,继续执行 +``` + +### 10.2 解决方案——优先级继承 + +**Priority Inheritance Protocol**:当高优先级进程因共享资源阻塞时,**临时提升**持有该资源的低优先级进程的优先级,使其尽快完成并释放资源。 + +```mermaid +sequenceDiagram + participant H as 高优先级进程H + participant L as 低优先级进程L(提升优先级) + participant M as 中优先级进程M + participant R as 共享资源R + + L->>R: 获得资源R + H->>R: 请求资源R → 阻塞 + Note over L: L的优先级临时提升为H的优先级 + Note over M: M到达,但L优先级已提升 + Note over M: M无法抢占L! + L->>R: 释放资源R + Note over L: L优先级恢复原值 + H->>R: 获得资源R,继续执行 +``` + +--- + +## 十一、Linux 调度器 + +### 11.1 CFS — 完全公平调度器 + +**Completely Fair Scheduler**:Linux 默认调度器,基于**虚拟运行时间(vruntime)**实现公平调度。 + +核心思想:**vruntime 最小的进程优先执行**。 + +$$\text{vruntime} += \text{实际运行时间} \times \frac{\text{nice 值基准权重}}{\text{该进程权重}}$$ + +```mermaid +graph TD + A[CFS调度器] --> B{选择 vruntime
最小的进程} + B --> C[红黑树组织
就绪进程] + C --> D[最左叶子节点
= vruntime 最小] + D --> E[执行该进程] + E --> F[更新 vruntime] + F --> C + + style D fill:#9f9,stroke:#333 +``` + +#### CFS 特点 + +| 特性 | 说明 | +|------|------| +| 无固定时间片 | 根据进程权重和系统负载动态计算 | +| 红黑树结构 | O(log n) 插入/查找,高效调度 | +| 公平性保证 | nice 值越低(优先级越高),vruntime 增长越慢 | +| 自动适配 | I/O 密集型进程 vruntime 自然较小,获得更多 CPU | + +### 11.2 调度器类层次 + +```mermaid +graph TB + A[调度器类优先级] --> B[Stop Task
最高优先级
停止特定CPU] + A --> C[Real-Time
实时调度类] + A --> D[Fair
CFS 调度类
普通进程] + A --> E[Idle Task
最低优先级
空闲任务] + + B --> C --> D --> E + + style B fill:#f66,stroke:#333 + style C fill:#f96,stroke:#333 + style D fill:#9f9,stroke:#333 + style E fill:#99f,stroke:#333 +``` + +**调度顺序**:Stop Task > Real-Time > Fair > Idle Task + +每次调度时,优先从高优先级调度类中选择进程。 + +### 11.3 实时进程调度策略 + +| 策略 | 说明 | +|------|------| +| **SCHED_FIFO** | 先来先服务。实时进程一旦获得 CPU,将一直执行直到主动放弃或被更高优先级进程抢占 | +| **SCHED_RR** | 时间片轮转。同优先级的实时进程轮流使用 CPU,时间片用完后回到队尾 | + +--- + +## 十二、调度算法对比总结 + +```mermaid +graph LR + subgraph 批处理算法 + FCFS[FCFS
简单公平] + SJF[SJF/SRTF
最优平均等待] + HRRF[HRRF
FCFS+SJF折衷] + PRIO[优先级调度
灵活控制] + end + + subgraph 分时算法 + RR[RR 轮转
公平响应] + MFQ[多级反馈队列
综合最优] + end + + subgraph 实时算法 + EDF[EDF
截止时间优先] + LLF[LLF
松弛度优先] + end + + FCFS -.->|缺点: 短作业等待久| SJF + SJF -.->|缺点: 长作业饥饿| HRRF + RR -.->|时间片选择关键| MFQ +``` + +| 算法 | 类型 | 抢占 | 优点 | 缺点 | +|------|------|------|------|------| +| FCFS | 批处理 | 否 | 简单公平 | 短作业等待时间长 | +| SJF | 批处理 | 否 | 最小平均等待 | 长作业饥饿 | +| SRTF | 批处理 | 是 | 更优的平均等待 | 长作业饥饿 | +| HRRF | 批处理 | 否 | 兼顾公平 | 需预估运行时间 | +| RR | 分时 | 是 | 响应快 | 时间片选择影响大 | +| MFQ | 分时 | 是 | 自适应各类进程 | 参数调整复杂 | +| EDF | 实时 | 是 | 利用率可达100% | 过载时不确定 | +| LLF | 实时 | 是 | 松弛度精确 | 频繁切换开销 | + +--- + +## 十三、本讲关键公式 + +$$\text{周转时间} = \text{完成时间} - \text{提交时间}$$ + +$$\text{等待时间} = \text{周转时间} - \text{运行时间}$$ + +$$\text{带权周转时间} = \frac{\text{周转时间}}{\text{运行时间}} \geq 1$$ + +$$\text{平均周转时间} = \frac{1}{n}\sum_{i=1}^{n}T_i$$ + +$$\text{响应比} = 1 + \frac{\text{等待时间}}{\text{估计运行时间}}$$ + +$$\text{松弛度} = \text{必须完成时间} - \text{本身运行时间} - \text{当前时间}$$ + +$$\text{时间片开销比} = \frac{\text{进程数} \times \text{切换时间}}{\text{进程数} \times \text{时间片}} = \frac{\text{切换时间}}{\text{时间片}}$$ + +--- + +## 十四、常见考点 + +1. **FCFS/SJF/RR 手工计算**:给定进程到达时间和运行时间,画 Gantt 图,计算平均等待时间和周转时间 +2. **时间片大小对 RR 的影响**:太大退化为 FCFS,太小切换开销大 +3. **响应比计算**:HRRF 的每一步调度决策 +4. **优先级倒置场景分析**及优先级继承原理 +5. **CFS 核心思想**:vruntime 最小优先 + +--- + +**上一章**:[[06_进程控制]] +**下一章**:[[12_死锁]] diff --git a/操作系统/12_死锁/12_死锁.md b/操作系统/12_死锁/12_死锁.md index a1c74b7..075bd10 100644 --- a/操作系统/12_死锁/12_死锁.md +++ b/操作系统/12_死锁/12_死锁.md @@ -1,376 +1,376 @@ -# 第12讲:死锁 - -> 🎯 **本节目标**:理解死锁的概念,掌握死锁的预防、避免和检测方法 - -## 📋 前置知识 -- [[07_多线程编程]] — 互斥锁和信号量 -- [[11_处理机调度]] — 调度的基本概念 - ---- - -## 🤔 为什么需要这个? - -想象这样一个场景: -- 线程 A 持有锁 1,等待锁 2 -- 线程 B 持有锁 2,等待锁 1 -- 两个线程互相等待,永远无法继续 - -这就是**死锁**——多个进程互相等待对方释放资源,导致所有进程都无法继续执行。 - -**生活比喻**: -- **死锁** = 两个人在狭窄的走廊相遇,谁都不肯让路,结果谁也过不去 - ---- - -## 📖 核心概念 - -### 1. 死锁的四个必要条件 - -```mermaid -graph TD - A[死锁四个条件] --> B[互斥条件] - A --> C[持有并等待] - A --> D[不可抢占] - A --> E[循环等待] - - B --> B1[资源一次只能被一个进程使用] - C --> C1[进程持有资源的同时请求新资源] - D --> D1[资源不能被强制剥夺] - E --> E1[存在进程的循环等待链] - - style A fill:#ffcdd2 -``` - -**必须同时满足这四个条件才会发生死锁**。 - -### 2. 资源分配图 - -```mermaid -graph LR - subgraph 进程 - P1[P1] - P2[P2] - end - subgraph 资源 - R1[R1] - R2[R2] - end - - R1 -->|分配| P1 - R2 -->|分配| P2 - P1 -->|请求| R2 - P2 -->|请求| R1 - - style P1 fill:#e1f5fe - style P2 fill:#e1f5fe - style R1 fill:#e8f5e9 - style R2 fill:#e8f5e9 -``` - -**图例**: -- 方框表示资源,圆圈表示进程 -- 资源→进程:已分配 -- 进程→资源:请求中 - -### 3. 死锁的处理策略 - -```mermaid -graph TD - A[死锁处理策略] --> B[死锁预防] - A --> C[死锁避免] - A --> D[死锁检测] - A --> E[死锁恢复] - - B --> B1[破坏四个条件之一] - C --> C1[银行家算法] - D --> D1[资源分配图] - E --> E1[终止进程] - - style B fill:#e8f5e9 - style C fill:#e1f5fe - style D fill:#fff3e0 - style E fill:#ffcdd2 -``` - -### 4. 死锁预防 - -破坏死锁的四个必要条件之一: - -| 条件 | 破坏方法 | 代价 | -|------|----------|------| -| 互斥 | 使用可共享资源 | 不总是可行 | -| 持有并等待 | 一次性申请所有资源 | 资源浪费 | -| 不可抢占 | 允许抢占资源 | 实现复杂 | -| 循环等待 | 按顺序申请资源 | 限制灵活性 | - -**资源有序分配法**: -```c -// 规定所有进程必须按编号顺序申请资源 -// 例如:先申请锁1,再申请锁2 - -pthread_mutex_lock(&mutex1); // 正确 -pthread_mutex_lock(&mutex2); - -// 而不是 -pthread_mutex_lock(&mutex2); // 可能导致死锁 -pthread_mutex_lock(&mutex1); -``` - -### 5. 银行家算法 - -```mermaid -graph TD - A[银行家算法] --> B[检查请求是否安全] - B --> C{安全?} - C -->|是| D[分配资源] - C -->|否| E[等待] - D --> F[更新数据结构] - E --> G[阻塞进程] - - style C fill:#fff3e0 - style D fill:#e8f5e9 - style E fill:#ffcdd2 -``` - -**安全状态**:存在一个安全序列,使得所有进程都能顺利完成 - -**数据结构**: -- `Available[]`:可用资源向量 -- `Max[][]`:最大需求矩阵 -- `Allocation[][]`:已分配矩阵 -- `Need[][]`:还需要的资源矩阵 - -**算法步骤**: -1. 检查请求是否超过需要 -2. 检查请求是否超过可用资源 -3. 尝试分配,检查是否安全 -4. 如果安全,正式分配;否则等待 - -### 6. 死锁检测 - -```mermaid -graph TD - A[构建资源分配图] --> B[寻找环路] - B --> C{有环路?} - C -->|是| D[可能存在死锁] - C -->|否| E[无死锁] - D --> F[进一步分析] - - style C fill:#fff3e0 - style D fill:#ffcdd2 - style E fill:#e8f5e9 -``` - -**检测算法**: -1. 构建资源分配图 -2. 使用深度优先搜索寻找环路 -3. 如果存在环路,可能存在死锁 - -### 7. 死锁恢复 - -```mermaid -graph TD - A[检测到死锁] --> B[选择终止进程] - B --> C[回滚操作] - C --> D[释放资源] - D --> E[唤醒等待进程] - - style A fill:#ffcdd2 - style E fill:#e8f5e9 -``` - -**恢复方法**: -- **终止所有死锁进程**:简单但代价大 -- **逐个终止进程**:直到死锁解除 -- **资源抢占**:强制剥夺资源 - ---- - -## 💻 动手实践 - -### 示例1:死锁演示 - -```c -// deadlock_demo.c - 死锁演示 -#include -#include -#include - -pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER; -pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER; - -void *thread1(void *arg) { - pthread_mutex_lock(&mutex1); // 获取锁1 - printf("Thread 1: 持有锁1,等待锁2...\n"); - sleep(1); // 等待,让线程2获取锁2 - pthread_mutex_lock(&mutex2); // 等待锁2(死锁!) - printf("Thread 1: 获取到锁2\n"); - - pthread_mutex_unlock(&mutex2); - pthread_mutex_unlock(&mutex1); - return NULL; -} - -void *thread2(void *arg) { - pthread_mutex_lock(&mutex2); // 获取锁2 - printf("Thread 2: 持有锁2,等待锁1...\n"); - sleep(1); // 等待,让线程1获取锁1 - pthread_mutex_lock(&mutex1); // 等待锁1(死锁!) - printf("Thread 2: 获取到锁1\n"); - - pthread_mutex_unlock(&mutex1); - pthread_mutex_unlock(&mutex2); - return NULL; -} - -int main() { - pthread_t tid1, tid2; - - pthread_create(&tid1, NULL, thread1, NULL); - pthread_create(&tid2, NULL, thread2, NULL); - - pthread_join(tid1, NULL); - pthread_join(tid2, NULL); - - printf("程序正常结束(如果这里能打印说明没有死锁)\n"); - return 0; -} -``` - -**编译运行**: -```bash -gcc -o deadlock deadlock_demo.c -lpthread -./deadlock -``` - -**预期结果**:程序会卡住,无法正常结束(死锁) - -### 示例2:避免死锁 - -```c -// no_deadlock.c - 避免死锁(按顺序获取锁) -#include -#include -#include - -pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER; -pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER; - -void *thread1(void *arg) { - pthread_mutex_lock(&mutex1); // 先获取锁1 - printf("Thread 1: 持有锁1\n"); - sleep(1); - pthread_mutex_lock(&mutex2); // 再获取锁2 - printf("Thread 1: 持有锁1和锁2\n"); - - pthread_mutex_unlock(&mutex2); - pthread_mutex_unlock(&mutex1); - return NULL; -} - -void *thread2(void *arg) { - pthread_mutex_lock(&mutex1); // 也先获取锁1(顺序一致) - printf("Thread 2: 持有锁1\n"); - sleep(1); - pthread_mutex_lock(&mutex2); // 再获取锁2 - printf("Thread 2: 持有锁1和锁2\n"); - - pthread_mutex_unlock(&mutex2); - pthread_mutex_unlock(&mutex1); - return NULL; -} - -int main() { - pthread_t tid1, tid2; - - pthread_create(&tid1, NULL, thread1, NULL); - pthread_create(&tid2, NULL, thread2, NULL); - - pthread_join(tid1, NULL); - pthread_join(tid2, NULL); - - printf("程序正常结束\n"); - return 0; -} -``` - -### 示例3:银行家算法模拟 - -```python -# banker.py - 银行家算法模拟 -def is_safe(available, max_need, allocation): - """检查系统是否处于安全状态""" - n = len(allocation) # 进程数 - m = len(available) # 资源类型数 - - # 计算 Need 矩阵 - need = [[max_need[i][j] - allocation[i][j] for j in range(m)] for i in range(n)] - - # 初始化工作向量和完成标志 - work = available.copy() - finish = [False] * n - safe_seq = [] - - while len(safe_seq) < n: - found = False - for i in range(n): - if not finish[i]: - # 检查是否可以分配 - if all(need[i][j] <= work[j] for j in range(m)): - # 模拟分配 - for j in range(m): - work[j] += allocation[i][j] - finish[i] = True - safe_seq.append(i) - found = True - - if not found: - return False, [] # 不安全 - - return True, safe_seq - -# 测试数据 -available = [3, 3, 2] -max_need = [ - [7, 5, 3], - [3, 2, 2], - [9, 0, 2], - [2, 2, 2], - [4, 3, 3] -] -allocation = [ - [0, 1, 0], - [2, 0, 0], - [3, 0, 2], - [2, 1, 1], - [0, 0, 2] -] - -safe, seq = is_safe(available, max_need, allocation) -if safe: - print(f"系统安全,安全序列: {seq}") -else: - print("系统不安全") -``` - ---- - -## 🔗 知识关联 -- 互斥锁在 [[07_多线程编程]] 中有详细讲解 -- 资源分配在 [[05_磁盘空间管理]] 中有类似概念 -- 银行家算法在 [[14_分页存储管理]] 中的页面置换有类似思想 - ---- - -## 📝 思考题 - -1. **为什么需要同时满足四个条件?** 如果只有三个条件满足会怎样? -2. **银行家算法的局限性**:为什么它在实际系统中很少使用? -3. **鸵鸟策略**:为什么有些系统选择忽略死锁问题? - ---- - -## 📚 扩展阅读 -- 《操作系统概念》第7章:死锁 -- 《现代操作系统》第6章:死锁 -- [死锁检测算法](https://www.geeksforgeeks.org/deadlock-detection-algorithm/) +# 第12讲:死锁 + +> 🎯 **本节目标**:理解死锁的概念,掌握死锁的预防、避免和检测方法 + +## 📋 前置知识 +- [[07_多线程编程]] — 互斥锁和信号量 +- [[11_处理机调度]] — 调度的基本概念 + +--- + +## 🤔 为什么需要这个? + +想象这样一个场景: +- 线程 A 持有锁 1,等待锁 2 +- 线程 B 持有锁 2,等待锁 1 +- 两个线程互相等待,永远无法继续 + +这就是**死锁**——多个进程互相等待对方释放资源,导致所有进程都无法继续执行。 + +**生活比喻**: +- **死锁** = 两个人在狭窄的走廊相遇,谁都不肯让路,结果谁也过不去 + +--- + +## 📖 核心概念 + +### 1. 死锁的四个必要条件 + +```mermaid +graph TD + A[死锁四个条件] --> B[互斥条件] + A --> C[持有并等待] + A --> D[不可抢占] + A --> E[循环等待] + + B --> B1[资源一次只能被一个进程使用] + C --> C1[进程持有资源的同时请求新资源] + D --> D1[资源不能被强制剥夺] + E --> E1[存在进程的循环等待链] + + style A fill:#ffcdd2 +``` + +**必须同时满足这四个条件才会发生死锁**。 + +### 2. 资源分配图 + +```mermaid +graph LR + subgraph 进程 + P1[P1] + P2[P2] + end + subgraph 资源 + R1[R1] + R2[R2] + end + + R1 -->|分配| P1 + R2 -->|分配| P2 + P1 -->|请求| R2 + P2 -->|请求| R1 + + style P1 fill:#e1f5fe + style P2 fill:#e1f5fe + style R1 fill:#e8f5e9 + style R2 fill:#e8f5e9 +``` + +**图例**: +- 方框表示资源,圆圈表示进程 +- 资源→进程:已分配 +- 进程→资源:请求中 + +### 3. 死锁的处理策略 + +```mermaid +graph TD + A[死锁处理策略] --> B[死锁预防] + A --> C[死锁避免] + A --> D[死锁检测] + A --> E[死锁恢复] + + B --> B1[破坏四个条件之一] + C --> C1[银行家算法] + D --> D1[资源分配图] + E --> E1[终止进程] + + style B fill:#e8f5e9 + style C fill:#e1f5fe + style D fill:#fff3e0 + style E fill:#ffcdd2 +``` + +### 4. 死锁预防 + +破坏死锁的四个必要条件之一: + +| 条件 | 破坏方法 | 代价 | +|------|----------|------| +| 互斥 | 使用可共享资源 | 不总是可行 | +| 持有并等待 | 一次性申请所有资源 | 资源浪费 | +| 不可抢占 | 允许抢占资源 | 实现复杂 | +| 循环等待 | 按顺序申请资源 | 限制灵活性 | + +**资源有序分配法**: +```c +// 规定所有进程必须按编号顺序申请资源 +// 例如:先申请锁1,再申请锁2 + +pthread_mutex_lock(&mutex1); // 正确 +pthread_mutex_lock(&mutex2); + +// 而不是 +pthread_mutex_lock(&mutex2); // 可能导致死锁 +pthread_mutex_lock(&mutex1); +``` + +### 5. 银行家算法 + +```mermaid +graph TD + A[银行家算法] --> B[检查请求是否安全] + B --> C{安全?} + C -->|是| D[分配资源] + C -->|否| E[等待] + D --> F[更新数据结构] + E --> G[阻塞进程] + + style C fill:#fff3e0 + style D fill:#e8f5e9 + style E fill:#ffcdd2 +``` + +**安全状态**:存在一个安全序列,使得所有进程都能顺利完成 + +**数据结构**: +- `Available[]`:可用资源向量 +- `Max[][]`:最大需求矩阵 +- `Allocation[][]`:已分配矩阵 +- `Need[][]`:还需要的资源矩阵 + +**算法步骤**: +1. 检查请求是否超过需要 +2. 检查请求是否超过可用资源 +3. 尝试分配,检查是否安全 +4. 如果安全,正式分配;否则等待 + +### 6. 死锁检测 + +```mermaid +graph TD + A[构建资源分配图] --> B[寻找环路] + B --> C{有环路?} + C -->|是| D[可能存在死锁] + C -->|否| E[无死锁] + D --> F[进一步分析] + + style C fill:#fff3e0 + style D fill:#ffcdd2 + style E fill:#e8f5e9 +``` + +**检测算法**: +1. 构建资源分配图 +2. 使用深度优先搜索寻找环路 +3. 如果存在环路,可能存在死锁 + +### 7. 死锁恢复 + +```mermaid +graph TD + A[检测到死锁] --> B[选择终止进程] + B --> C[回滚操作] + C --> D[释放资源] + D --> E[唤醒等待进程] + + style A fill:#ffcdd2 + style E fill:#e8f5e9 +``` + +**恢复方法**: +- **终止所有死锁进程**:简单但代价大 +- **逐个终止进程**:直到死锁解除 +- **资源抢占**:强制剥夺资源 + +--- + +## 💻 动手实践 + +### 示例1:死锁演示 + +```c +// deadlock_demo.c - 死锁演示 +#include +#include +#include + +pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER; +pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER; + +void *thread1(void *arg) { + pthread_mutex_lock(&mutex1); // 获取锁1 + printf("Thread 1: 持有锁1,等待锁2...\n"); + sleep(1); // 等待,让线程2获取锁2 + pthread_mutex_lock(&mutex2); // 等待锁2(死锁!) + printf("Thread 1: 获取到锁2\n"); + + pthread_mutex_unlock(&mutex2); + pthread_mutex_unlock(&mutex1); + return NULL; +} + +void *thread2(void *arg) { + pthread_mutex_lock(&mutex2); // 获取锁2 + printf("Thread 2: 持有锁2,等待锁1...\n"); + sleep(1); // 等待,让线程1获取锁1 + pthread_mutex_lock(&mutex1); // 等待锁1(死锁!) + printf("Thread 2: 获取到锁1\n"); + + pthread_mutex_unlock(&mutex1); + pthread_mutex_unlock(&mutex2); + return NULL; +} + +int main() { + pthread_t tid1, tid2; + + pthread_create(&tid1, NULL, thread1, NULL); + pthread_create(&tid2, NULL, thread2, NULL); + + pthread_join(tid1, NULL); + pthread_join(tid2, NULL); + + printf("程序正常结束(如果这里能打印说明没有死锁)\n"); + return 0; +} +``` + +**编译运行**: +```bash +gcc -o deadlock deadlock_demo.c -lpthread +./deadlock +``` + +**预期结果**:程序会卡住,无法正常结束(死锁) + +### 示例2:避免死锁 + +```c +// no_deadlock.c - 避免死锁(按顺序获取锁) +#include +#include +#include + +pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER; +pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER; + +void *thread1(void *arg) { + pthread_mutex_lock(&mutex1); // 先获取锁1 + printf("Thread 1: 持有锁1\n"); + sleep(1); + pthread_mutex_lock(&mutex2); // 再获取锁2 + printf("Thread 1: 持有锁1和锁2\n"); + + pthread_mutex_unlock(&mutex2); + pthread_mutex_unlock(&mutex1); + return NULL; +} + +void *thread2(void *arg) { + pthread_mutex_lock(&mutex1); // 也先获取锁1(顺序一致) + printf("Thread 2: 持有锁1\n"); + sleep(1); + pthread_mutex_lock(&mutex2); // 再获取锁2 + printf("Thread 2: 持有锁1和锁2\n"); + + pthread_mutex_unlock(&mutex2); + pthread_mutex_unlock(&mutex1); + return NULL; +} + +int main() { + pthread_t tid1, tid2; + + pthread_create(&tid1, NULL, thread1, NULL); + pthread_create(&tid2, NULL, thread2, NULL); + + pthread_join(tid1, NULL); + pthread_join(tid2, NULL); + + printf("程序正常结束\n"); + return 0; +} +``` + +### 示例3:银行家算法模拟 + +```python +# banker.py - 银行家算法模拟 +def is_safe(available, max_need, allocation): + """检查系统是否处于安全状态""" + n = len(allocation) # 进程数 + m = len(available) # 资源类型数 + + # 计算 Need 矩阵 + need = [[max_need[i][j] - allocation[i][j] for j in range(m)] for i in range(n)] + + # 初始化工作向量和完成标志 + work = available.copy() + finish = [False] * n + safe_seq = [] + + while len(safe_seq) < n: + found = False + for i in range(n): + if not finish[i]: + # 检查是否可以分配 + if all(need[i][j] <= work[j] for j in range(m)): + # 模拟分配 + for j in range(m): + work[j] += allocation[i][j] + finish[i] = True + safe_seq.append(i) + found = True + + if not found: + return False, [] # 不安全 + + return True, safe_seq + +# 测试数据 +available = [3, 3, 2] +max_need = [ + [7, 5, 3], + [3, 2, 2], + [9, 0, 2], + [2, 2, 2], + [4, 3, 3] +] +allocation = [ + [0, 1, 0], + [2, 0, 0], + [3, 0, 2], + [2, 1, 1], + [0, 0, 2] +] + +safe, seq = is_safe(available, max_need, allocation) +if safe: + print(f"系统安全,安全序列: {seq}") +else: + print("系统不安全") +``` + +--- + +## 🔗 知识关联 +- 互斥锁在 [[07_多线程编程]] 中有详细讲解 +- 资源分配在 [[05_磁盘空间管理]] 中有类似概念 +- 银行家算法在 [[14_分页存储管理]] 中的页面置换有类似思想 + +--- + +## 📝 思考题 + +1. **为什么需要同时满足四个条件?** 如果只有三个条件满足会怎样? +2. **银行家算法的局限性**:为什么它在实际系统中很少使用? +3. **鸵鸟策略**:为什么有些系统选择忽略死锁问题? + +--- + +## 📚 扩展阅读 +- 《操作系统概念》第7章:死锁 +- 《现代操作系统》第6章:死锁 +- [死锁检测算法](https://www.geeksforgeeks.org/deadlock-detection-algorithm/) diff --git a/操作系统/13_存储管理基础/13_存储管理基础.md b/操作系统/13_存储管理基础/13_存储管理基础.md index 477ac2b..ac957aa 100644 --- a/操作系统/13_存储管理基础/13_存储管理基础.md +++ b/操作系统/13_存储管理基础/13_存储管理基础.md @@ -1,303 +1,303 @@ -# 13. 存储管理基础 - -> **课程**: 操作系统 - 存储器管理 -> **核心内容**: 存储器层次结构、存储管理功能、程序编译链接与装入、地址空间 - ---- - -## 一、存储器层次结构 - -存储器按照速度和容量形成层次结构,越靠近CPU速度越快但容量越小、价格越高。 - -``` -速度递增 ↑ 容量递减 ↑ 价格递增 ↑ -┌─────────────────────────┐ -│ 寄存器(Register) │ ← 最快,纳秒级,几十~几百字节 -├─────────────────────────┤ -│ 高速缓存(Cache) │ ← L1/L2/L3,几MB -├─────────────────────────┤ -│ 主存(内存/DRAM) │ ← 几GB~几百GB,百纳秒级 -├─────────────────────────┤ -│ Flash/SSD │ ← 固态存储 -├─────────────────────────┤ -│ 磁盘缓存(Disk Cache) │ -├─────────────────────────┤ -│ 固定磁盘(HDD/SSD) │ ← 几百GB~几TB,毫秒级 -├─────────────────────────┤ -│ 可移动存储(U盘/光盘/磁带) │ ← 最慢,容量可很大 -└─────────────────────────┘ -``` - -**设计原则**: 利用**局部性原理**,将频繁访问的数据放在高速层次,较少访问的数据放在低速大容量层次。 - ---- - -## 二、存储管理的功能 - -操作系统存储管理需要实现以下五大核心功能: - -| 功能 | 说明 | -|------|------| -| **内存分配与回收** | 为进程分配所需内存,进程结束后回收内存 | -| **地址转换** | 将程序中的逻辑地址转换为物理地址([[14_分页存储管理\|分页]]、[[15_段式存储管理\|分段]]) | -| **内存共享** | 多个进程共享同一段代码(如共享库) | -| **内存保护** | 防止进程越界访问其他进程或内核的内存区域 | -| **内存扩充** | 通过虚拟存储技术,使程序可用空间大于实际物理内存([[16_虚拟存储器\|虚拟存储器]]) | - ---- - -## 三、程序的编译与链接过程 - -一个C语言源文件从编写到可执行,经历以下阶段: - -```mermaid -flowchart LR - A["hello.c
源文件"] -->|预处理| B["hello.i
预处理后"] - B -->|编译| C["hello.s
汇编文件"] - C -->|汇编| D["hello.o
目标文件(可重定位)"] - D -->|链接| E["hello / a.out
可执行文件"] - E -->|装入| F["内存中运行的进程"] - - style A fill:#e1f5fe - style C fill:#fff3e0 - style D fill:#fce4ec - style E fill:#e8f5e9 - style F fill:#f3e5f5 -``` - -### 各阶段说明 - -| 阶段 | 输入 | 输出 | 工具 | 说明 | -|------|------|------|------|------| -| **预处理** | `.c` | `.i` | cpp | 展开宏、头文件、条件编译 | -| **编译** | `.i` | `.s` | cc1 | 翻译为汇编语言 | -| **汇编** | `.s` | `.o` | as | 翻译为机器指令(可重定位目标文件) | -| **链接** | `.o` | 可执行文件 | ld | 合并节段、解析符号引用、重定位 | -| **装入** | 可执行文件 | 进程 | 加载器 | 将程序载入内存并创建进程 | - -### 查看目标文件的节段 - -使用 `objdump -h` 可以查看可执行文件或目标文件中的节段(Section)信息: - -```bash -gcc -c hello.c # 编译为可重定位目标文件 -objdump -h hello.o # 查看节段头信息 - -gcc hello.c -o hello # 链接为可执行文件 -objdump -h hello # 查看可执行文件的节段 -``` - ---- - -## 四、可执行文件结构 - -可执行文件在内存中从低地址到高地址的典型布局如下: - -``` -高地址 ┌──────────────────┐ - │ 命令行参数 │ - │ 和环境变量 │ - ├──────────────────┤ - │ 栈(Stack) │ ← 局部变量、函数调用帧,向下增长 ↓ - │ ↓ │ - │ │ - │ ↑ │ - │ 堆(Heap) │ ← malloc/new动态分配,向上增长 ↑ - ├──────────────────┤ - │ .bss 段 │ ← 未初始化的全局/静态变量(不占文件空间) - ├──────────────────┤ - │ .data 段 │ ← 已初始化的全局变量和静态变量 - ├──────────────────┤ - │ .rodata 段 │ ← 只读数据(如字符串常量) - ├──────────────────┤ - │ .text 段 │ ← 可执行代码(机器指令) -低地址 └──────────────────┘ -``` - -| 段名 | 内容 | 是否可写 | 说明 | -|------|------|---------|------| -| `.text` | 机器指令代码 | 只读/只执行 | 程序的可执行代码 | -| `.rodata` | 只读数据 | 只读 | 字符串常量、`const`变量 | -| `.data` | 已初始化全局变量 | 可读写 | 有初始值的全局和静态变量 | -| `.bss` | 未初始化全局变量 | 可读写 | 不占文件空间,装入时清零 | -| 堆(Heap) | 动态分配内存 | 可读写 | `malloc`/`new` 分配 | -| 栈(Stack) | 函数调用帧 | 可读写 | 局部变量、返回地址等 | - ---- - -## 五、逻辑地址与物理地址 - -### 核心概念 - -| 术语 | 别名 | 说明 | -|------|------|------| -| **逻辑地址** | 虚拟地址(VA)、相对地址 | 程序中使用的地址,从0开始编址 | -| **物理地址** | PA、绝对地址 | 内存硬件中的实际地址 | - -> **关键**: 程序中的地址(逻辑地址)**不等于**内存中的实际地址(物理地址)。地址转换由硬件([[01_系统运行机制#四、存储器管理硬件——MMU|MMU]])和操作系统配合完成。 - -### 逻辑地址空间与物理地址空间 - -```mermaid -flowchart TB - subgraph VA["逻辑地址空间 (虚拟)"] - direction TB - V0["地址 0"] - V1["..."] - V2["地址 2^v - 1"] - end - - subgraph PA["物理地址空间 (实际)"] - direction TB - P0["地址 0"] - P1["..."] - P2["地址 2^p - 1"] - end - - VA -->|"地址映射
(页表/段表)"| PA - - style VA fill:#e3f2fd,stroke:#1976d2 - style PA fill:#fff8e1,stroke:#f9a825 -``` - -- **虚拟地址空间**: 大小为 $2^v$ 字节,其中 $v$ 是虚拟地址的位数 -- **物理地址空间**: 大小为 $2^p$ 字节,其中 $p$ 是物理地址的位数 -- 通常 $v \geq p$(虚拟地址空间可以大于物理内存),这就是[[16_虚拟存储器|虚拟存储器]]的基础 - -### 引入逻辑地址的好处 - -1. **进程隔离**: 每个进程拥有独立的虚拟地址空间,互不干扰 -2. **提高内存利用率**: 可以使用[[16_虚拟存储器|虚拟存储器]]技术 -3. **内存保护**: 通过地址转换实现访问权限控制 - ---- - -## 六、内核空间与用户空间 - -操作系统将每个进程的虚拟地址空间划分为**用户空间**和**内核空间**两部分: - -```mermaid -block-beta - columns 1 - block:linux["Linux 进程地址空间 (4GB)"] - columns 1 - block:kernel_linux["内核空间 (高 1GB)"] - k1["内核代码、数据、内核栈等"] - end - block:user_linux["用户空间 (低 3GB)"] - u1["栈 ↓"] - u2["..."] - u3["堆 ↑"] - u4[".bss / .data / .rodata / .text"] - end - end - block:win["Windows 进程地址空间 (4GB)"] - columns 1 - block:kernel_win["内核空间 (高 2GB)"] - k2["内核代码、驱动等"] - end - block:user_win["用户空间 (低 2GB)"] - u5["用户程序空间"] - end - end -``` - -| 操作系统 | 用户空间 | 内核空间 | 说明 | -|---------|---------|---------|------| -| **Linux (32位)** | 0 ~ 3GB (低3GB) | 3GB ~ 4GB (高1GB) | 通过`PAGE_OFFSET`划分 | -| **Windows (32位)** | 0 ~ 2GB (低2GB) | 2GB ~ 4GB (高2GB) | 可通过`/3GB`启动参数调整为3:1 | - -**重要规则**: 用户态程序**不能**直接访问内核空间的地址,否则触发保护异常。内核态代码可以访问整个地址空间。 - ---- - -## 七、MMU 与内存保护 - -**MMU (Memory Management Unit)** 是CPU中负责地址转换和内存保护的硬件单元。 - -### 地址转换流程 - -```mermaid -flowchart LR - CPU["CPU 发出
虚拟地址(VA)"] --> MMU["MMU
地址转换"] - MMU --> PA["物理地址(PA)"] - PA --> MEM["访问内存"] - - style MMU fill:#ffcdd2,stroke:#c62828 -``` - -### 页表保护机制 - -MMU通过页表中的**U/S位**和CPU的**模式位**配合实现内存保护: - -| 页表U/S位 | 含义 | -|-----------|------| -| **U=0 (Supervisor)** | 内核态页面,只有内核可以访问 | -| **U=1 (User)** | 用户态页面,用户态和内核态均可访问 | - -| CPU模式 | 可访问的页面 | -|---------|-------------| -| **用户模式 (User mode)** | 只能访问 U=1 的页面 | -| **内核模式 (Kernel mode)** | 可以访问 U=0 和 U=1 的页面 | - -当用户态程序试图访问 U=0 的内核页面时,MMU会产生**保护异常(段错误/Segmentation Fault)**,终止该进程。 - ---- - -## 八、地址转换的主要方式 - -操作系统实现地址转换有多种方式,各有特点: - -| 方式 | 原理 | 优点 | 缺点 | -|------|------|------|------| -| **重定位寄存器(基址寄存器)** | PA = VA + 基址值 | 简单 | 程序必须连续存放 | -| **静态重定位** | 装入时一次性修改所有地址 | 无需硬件支持 | 装入后不能移动 | -| **动态重定位** | 执行时通过MMU实时转换 | 灵活,支持移动 | 需要硬件支持 | -| **[[14_分页存储管理\|分页]]** | 按页划分,通过页表映射 | 消除外部碎片 | 有内部碎片、页表开销 | -| **[[15_段式存储管理\|分段]]** | 按逻辑段划分,通过段表映射 | 符合程序逻辑 | 外部碎片问题 | - ---- - -## 九、小结 - -```mermaid -mindmap - root((存储管理基础)) - 存储层次 - 寄存器 - Cache - 主存 - Flash - 磁盘 - 管理功能 - 分配回收 - 地址转换 - 内存共享 - 内存保护 - 内存扩充 - 程序装入 - 编译链接 - 可执行文件结构 - 逻辑地址vs物理地址 - 地址空间 - 虚拟地址空间 - 物理地址空间 - 内核空间/用户空间 - MMU保护 - 页表U/S位 - CPU模式位 -``` - ---- - -## 关联笔记 - -- [[01_系统运行机制]] — CPU工作模式(用户态/内核态)与MMU硬件基础 -- [[14_分页存储管理]] — 分页式地址转换的详细实现 -- [[15_段式存储管理]] — 分段式地址转换 -- [[16_虚拟存储器]] — 虚拟存储器的实现原理 - ---- - -**上一讲**: [[01_系统运行机制]] -**下一讲**: [[14_分页存储管理]] +# 13. 存储管理基础 + +> **课程**: 操作系统 - 存储器管理 +> **核心内容**: 存储器层次结构、存储管理功能、程序编译链接与装入、地址空间 + +--- + +## 一、存储器层次结构 + +存储器按照速度和容量形成层次结构,越靠近CPU速度越快但容量越小、价格越高。 + +``` +速度递增 ↑ 容量递减 ↑ 价格递增 ↑ +┌─────────────────────────┐ +│ 寄存器(Register) │ ← 最快,纳秒级,几十~几百字节 +├─────────────────────────┤ +│ 高速缓存(Cache) │ ← L1/L2/L3,几MB +├─────────────────────────┤ +│ 主存(内存/DRAM) │ ← 几GB~几百GB,百纳秒级 +├─────────────────────────┤ +│ Flash/SSD │ ← 固态存储 +├─────────────────────────┤ +│ 磁盘缓存(Disk Cache) │ +├─────────────────────────┤ +│ 固定磁盘(HDD/SSD) │ ← 几百GB~几TB,毫秒级 +├─────────────────────────┤ +│ 可移动存储(U盘/光盘/磁带) │ ← 最慢,容量可很大 +└─────────────────────────┘ +``` + +**设计原则**: 利用**局部性原理**,将频繁访问的数据放在高速层次,较少访问的数据放在低速大容量层次。 + +--- + +## 二、存储管理的功能 + +操作系统存储管理需要实现以下五大核心功能: + +| 功能 | 说明 | +|------|------| +| **内存分配与回收** | 为进程分配所需内存,进程结束后回收内存 | +| **地址转换** | 将程序中的逻辑地址转换为物理地址([[14_分页存储管理\|分页]]、[[15_段式存储管理\|分段]]) | +| **内存共享** | 多个进程共享同一段代码(如共享库) | +| **内存保护** | 防止进程越界访问其他进程或内核的内存区域 | +| **内存扩充** | 通过虚拟存储技术,使程序可用空间大于实际物理内存([[16_虚拟存储器\|虚拟存储器]]) | + +--- + +## 三、程序的编译与链接过程 + +一个C语言源文件从编写到可执行,经历以下阶段: + +```mermaid +flowchart LR + A["hello.c
源文件"] -->|预处理| B["hello.i
预处理后"] + B -->|编译| C["hello.s
汇编文件"] + C -->|汇编| D["hello.o
目标文件(可重定位)"] + D -->|链接| E["hello / a.out
可执行文件"] + E -->|装入| F["内存中运行的进程"] + + style A fill:#e1f5fe + style C fill:#fff3e0 + style D fill:#fce4ec + style E fill:#e8f5e9 + style F fill:#f3e5f5 +``` + +### 各阶段说明 + +| 阶段 | 输入 | 输出 | 工具 | 说明 | +|------|------|------|------|------| +| **预处理** | `.c` | `.i` | cpp | 展开宏、头文件、条件编译 | +| **编译** | `.i` | `.s` | cc1 | 翻译为汇编语言 | +| **汇编** | `.s` | `.o` | as | 翻译为机器指令(可重定位目标文件) | +| **链接** | `.o` | 可执行文件 | ld | 合并节段、解析符号引用、重定位 | +| **装入** | 可执行文件 | 进程 | 加载器 | 将程序载入内存并创建进程 | + +### 查看目标文件的节段 + +使用 `objdump -h` 可以查看可执行文件或目标文件中的节段(Section)信息: + +```bash +gcc -c hello.c # 编译为可重定位目标文件 +objdump -h hello.o # 查看节段头信息 + +gcc hello.c -o hello # 链接为可执行文件 +objdump -h hello # 查看可执行文件的节段 +``` + +--- + +## 四、可执行文件结构 + +可执行文件在内存中从低地址到高地址的典型布局如下: + +``` +高地址 ┌──────────────────┐ + │ 命令行参数 │ + │ 和环境变量 │ + ├──────────────────┤ + │ 栈(Stack) │ ← 局部变量、函数调用帧,向下增长 ↓ + │ ↓ │ + │ │ + │ ↑ │ + │ 堆(Heap) │ ← malloc/new动态分配,向上增长 ↑ + ├──────────────────┤ + │ .bss 段 │ ← 未初始化的全局/静态变量(不占文件空间) + ├──────────────────┤ + │ .data 段 │ ← 已初始化的全局变量和静态变量 + ├──────────────────┤ + │ .rodata 段 │ ← 只读数据(如字符串常量) + ├──────────────────┤ + │ .text 段 │ ← 可执行代码(机器指令) +低地址 └──────────────────┘ +``` + +| 段名 | 内容 | 是否可写 | 说明 | +|------|------|---------|------| +| `.text` | 机器指令代码 | 只读/只执行 | 程序的可执行代码 | +| `.rodata` | 只读数据 | 只读 | 字符串常量、`const`变量 | +| `.data` | 已初始化全局变量 | 可读写 | 有初始值的全局和静态变量 | +| `.bss` | 未初始化全局变量 | 可读写 | 不占文件空间,装入时清零 | +| 堆(Heap) | 动态分配内存 | 可读写 | `malloc`/`new` 分配 | +| 栈(Stack) | 函数调用帧 | 可读写 | 局部变量、返回地址等 | + +--- + +## 五、逻辑地址与物理地址 + +### 核心概念 + +| 术语 | 别名 | 说明 | +|------|------|------| +| **逻辑地址** | 虚拟地址(VA)、相对地址 | 程序中使用的地址,从0开始编址 | +| **物理地址** | PA、绝对地址 | 内存硬件中的实际地址 | + +> **关键**: 程序中的地址(逻辑地址)**不等于**内存中的实际地址(物理地址)。地址转换由硬件([[01_系统运行机制#四、存储器管理硬件——MMU|MMU]])和操作系统配合完成。 + +### 逻辑地址空间与物理地址空间 + +```mermaid +flowchart TB + subgraph VA["逻辑地址空间 (虚拟)"] + direction TB + V0["地址 0"] + V1["..."] + V2["地址 2^v - 1"] + end + + subgraph PA["物理地址空间 (实际)"] + direction TB + P0["地址 0"] + P1["..."] + P2["地址 2^p - 1"] + end + + VA -->|"地址映射
(页表/段表)"| PA + + style VA fill:#e3f2fd,stroke:#1976d2 + style PA fill:#fff8e1,stroke:#f9a825 +``` + +- **虚拟地址空间**: 大小为 $2^v$ 字节,其中 $v$ 是虚拟地址的位数 +- **物理地址空间**: 大小为 $2^p$ 字节,其中 $p$ 是物理地址的位数 +- 通常 $v \geq p$(虚拟地址空间可以大于物理内存),这就是[[16_虚拟存储器|虚拟存储器]]的基础 + +### 引入逻辑地址的好处 + +1. **进程隔离**: 每个进程拥有独立的虚拟地址空间,互不干扰 +2. **提高内存利用率**: 可以使用[[16_虚拟存储器|虚拟存储器]]技术 +3. **内存保护**: 通过地址转换实现访问权限控制 + +--- + +## 六、内核空间与用户空间 + +操作系统将每个进程的虚拟地址空间划分为**用户空间**和**内核空间**两部分: + +```mermaid +block-beta + columns 1 + block:linux["Linux 进程地址空间 (4GB)"] + columns 1 + block:kernel_linux["内核空间 (高 1GB)"] + k1["内核代码、数据、内核栈等"] + end + block:user_linux["用户空间 (低 3GB)"] + u1["栈 ↓"] + u2["..."] + u3["堆 ↑"] + u4[".bss / .data / .rodata / .text"] + end + end + block:win["Windows 进程地址空间 (4GB)"] + columns 1 + block:kernel_win["内核空间 (高 2GB)"] + k2["内核代码、驱动等"] + end + block:user_win["用户空间 (低 2GB)"] + u5["用户程序空间"] + end + end +``` + +| 操作系统 | 用户空间 | 内核空间 | 说明 | +|---------|---------|---------|------| +| **Linux (32位)** | 0 ~ 3GB (低3GB) | 3GB ~ 4GB (高1GB) | 通过`PAGE_OFFSET`划分 | +| **Windows (32位)** | 0 ~ 2GB (低2GB) | 2GB ~ 4GB (高2GB) | 可通过`/3GB`启动参数调整为3:1 | + +**重要规则**: 用户态程序**不能**直接访问内核空间的地址,否则触发保护异常。内核态代码可以访问整个地址空间。 + +--- + +## 七、MMU 与内存保护 + +**MMU (Memory Management Unit)** 是CPU中负责地址转换和内存保护的硬件单元。 + +### 地址转换流程 + +```mermaid +flowchart LR + CPU["CPU 发出
虚拟地址(VA)"] --> MMU["MMU
地址转换"] + MMU --> PA["物理地址(PA)"] + PA --> MEM["访问内存"] + + style MMU fill:#ffcdd2,stroke:#c62828 +``` + +### 页表保护机制 + +MMU通过页表中的**U/S位**和CPU的**模式位**配合实现内存保护: + +| 页表U/S位 | 含义 | +|-----------|------| +| **U=0 (Supervisor)** | 内核态页面,只有内核可以访问 | +| **U=1 (User)** | 用户态页面,用户态和内核态均可访问 | + +| CPU模式 | 可访问的页面 | +|---------|-------------| +| **用户模式 (User mode)** | 只能访问 U=1 的页面 | +| **内核模式 (Kernel mode)** | 可以访问 U=0 和 U=1 的页面 | + +当用户态程序试图访问 U=0 的内核页面时,MMU会产生**保护异常(段错误/Segmentation Fault)**,终止该进程。 + +--- + +## 八、地址转换的主要方式 + +操作系统实现地址转换有多种方式,各有特点: + +| 方式 | 原理 | 优点 | 缺点 | +|------|------|------|------| +| **重定位寄存器(基址寄存器)** | PA = VA + 基址值 | 简单 | 程序必须连续存放 | +| **静态重定位** | 装入时一次性修改所有地址 | 无需硬件支持 | 装入后不能移动 | +| **动态重定位** | 执行时通过MMU实时转换 | 灵活,支持移动 | 需要硬件支持 | +| **[[14_分页存储管理\|分页]]** | 按页划分,通过页表映射 | 消除外部碎片 | 有内部碎片、页表开销 | +| **[[15_段式存储管理\|分段]]** | 按逻辑段划分,通过段表映射 | 符合程序逻辑 | 外部碎片问题 | + +--- + +## 九、小结 + +```mermaid +mindmap + root((存储管理基础)) + 存储层次 + 寄存器 + Cache + 主存 + Flash + 磁盘 + 管理功能 + 分配回收 + 地址转换 + 内存共享 + 内存保护 + 内存扩充 + 程序装入 + 编译链接 + 可执行文件结构 + 逻辑地址vs物理地址 + 地址空间 + 虚拟地址空间 + 物理地址空间 + 内核空间/用户空间 + MMU保护 + 页表U/S位 + CPU模式位 +``` + +--- + +## 关联笔记 + +- [[01_系统运行机制]] — CPU工作模式(用户态/内核态)与MMU硬件基础 +- [[14_分页存储管理]] — 分页式地址转换的详细实现 +- [[15_段式存储管理]] — 分段式地址转换 +- [[16_虚拟存储器]] — 虚拟存储器的实现原理 + +--- + +**上一讲**: [[01_系统运行机制]] +**下一讲**: [[14_分页存储管理]] diff --git a/操作系统/14_分页存储管理/14_分页存储管理.md b/操作系统/14_分页存储管理/14_分页存储管理.md index 35cbd4e..c1c4e07 100644 --- a/操作系统/14_分页存储管理/14_分页存储管理.md +++ b/操作系统/14_分页存储管理/14_分页存储管理.md @@ -1,597 +1,597 @@ -# 14. 分页存储管理 - -> **课程**: 操作系统 - 存储器管理 -> **核心内容**: 碎片问题、分页思想、地址结构、页表、地址变换、TLB快表、多级页表、倒转页表 - ---- - -## 前置知识 - -- [[13_存储管理基础]] — 存储器层次结构、逻辑地址与物理地址、MMU -- [[01_系统运行机制]] — CPU工作模式与硬件基础 - ---- - -## 一、碎片问题 - -在连续分配方式中,内存中会出现无法被利用的空闲区域,称为**碎片**。 - -| 碎片类型 | 出现位置 | 产生原因 | 对应分配方式 | -|---------|---------|---------|------------| -| **内碎片** (Internal Fragmentation) | 分配区域**内部** | 固定分区大小 > 进程实际需要 | 固定分区分配 | -| **外碎片** (External Fragmentation) | 分配区域**外部** | 空闲分区太小,无法满足任何请求 | 动态分区分配 | - -```mermaid -flowchart LR - subgraph 内碎片示意 - direction TB - A1["┌──────────────┐"] - A2["│ 进程实际数据 │"] - A3["│──────────────│"] - A4["│ 未使用空间 │ ← 内碎片"] - A5["└──────────────┘"] - end - - subgraph 外碎片示意 - direction TB - B1["┌────┐ ┌────┐ ┌────┐ ┌────┐"] - B2["│进程A│ │空闲│ │进程B│ │空闲│"] - B3["└────┘ └────┘ └────┘ └────┘"] - B4[" ↑外碎片 ↑外碎片"] - end -``` - -> **根本原因**: 无论是固定分区还是动态分区,都要求进程在内存中**连续存放**。要彻底解决碎片问题,必须放弃"连续"要求——这就是**分页**思想的核心出发点。 - ---- - -## 二、分页的基本思想 - -分页存储管理将**虚拟地址空间**和**物理内存**都划分为大小相等的小块: - -- 虚拟地址空间的每一块称为**虚拟页 (Virtual Page, VP)** -- 物理内存的每一块称为**物理页框 (Page Frame, PF)** - -常见的页面大小为 $2^k$ 字节:512B、1KB、2KB、**4KB**(最常用)。 - -```mermaid -flowchart TB - subgraph VA["虚拟地址空间 (进程视角)"] - direction TB - VP0["虚拟页 VP0"] - VP1["虚拟页 VP1"] - VP2["虚拟页 VP2"] - VP3["虚拟页 VP3"] - VP4["..."] - end - - subgraph PM["物理内存"] - direction TB - PF0["物理页框 PF0"] - PF1["物理页框 PF1"] - PF2["物理页框 PF2"] - PF3["物理页框 PF3"] - PF4["..."] - end - - VP0 -->|"页表映射"| PF2 - VP1 -->|"页表映射"| PF0 - VP2 -->|"不在内存"| DISK["外存(磁盘)"] - VP3 -->|"页表映射"| PF3 - - style VA fill:#e3f2fd,stroke:#1976d2 - style PM fill:#fff8e1,stroke:#f9a825 - style DISK fill:#fce4ec,stroke:#c62828 -``` - -**关键特性**: -- 虚拟页在物理内存中**不需要连续**存放 -- 每个虚拟页可以映射到任意一个空闲物理页框 -- 消除了外碎片(但每个页内可能有少量内碎片) - ---- - -## 三、地址结构 - -在分页系统中,虚拟地址被划分为两部分: - -``` -虚拟地址 VA (v+k 位) -┌─────────────────┬─────────────┐ -│ VPN (v位) │ VPO (k位) │ -│ 虚拟页号 │ 页内偏移 │ -└─────────────────┴─────────────┘ -``` - -**计算公式**: -- 页大小 = $2^k$ 字节 -- 虚拟页号 VPN = $\lfloor VA / 2^k \rfloor$(即高位部分) -- 页内偏移 VPO = $VA \mod 2^k$(即低 $k$ 位) - -物理地址同理: - -``` -物理地址 PA (p+k 位) -┌─────────────────┬─────────────┐ -│ PPN (p位) │ PPO (k位) │ -│ 物理页号 │ 页内偏移 │ -└─────────────────┴─────────────┘ -``` - -- 物理页号 PPN 由页表查得 -- **PPO = VPO**(页内偏移不变,直接复制) - -### 地址计算示例 - -> **例题**: 某系统虚拟地址 16 位,页面大小 4KB ($2^{12}$),求虚拟地址 VA = 0x3A6F 对应的 VPN 和 VPO。 - -- 页大小 $2^k = 2^{12}$,所以 $k = 12$,$v = 16 - 12 = 4$ -- VPN = $0x3A6F / 0x1000 = 0x3$(高4位:0011) -- VPO = $0x3A6F \mod 0x1000 = 0xA6F$(低12位) - -``` -VA = 0x3A6F = 0011 1010 0110 1111 - ──── ──────────────── - VPN VPO - (0x3) (0xA6F) -``` - ---- - -## 四、页表 - -页表是实现虚拟页到物理页框映射的核心数据结构。 - -### 页表结构 - -每个进程拥有**独立的页表**。页表以 VPN 为索引,每个页表项 (PTE) 包含: - -``` -页表 (以VPN为索引) -┌──────┬───────┬──────────────────────────────┐ -│ VPN │ PPN │ 控制位 │ -├──────┼───────┼──────────────────────────────┤ -│ 0 │ 5 │ V=1 D=0 R=1 W=1 U/S=1 │ -│ 1 │ 2 │ V=1 D=1 R=1 W=1 U/S=1 │ -│ 2 │ --- │ V=0 (不在内存) │ -│ 3 │ 8 │ V=1 D=0 R=1 W=0 U/S=1 │ -│ ... │ ... │ ... │ -└──────┴───────┴──────────────────────────────┘ -``` - -### 页表项 (PTE) 各字段 - -| 字段 | 含义 | 说明 | -|------|------|------| -| **有效位 (Valid/Present)** | 页面是否在内存中 | V=1:在内存;V=0:不在内存(缺页) | -| **修改位 (Dirty)** | 页面是否被写过 | D=1:被修改过,换出时需写回外存 | -| **引用位 (Reference)** | 页面是否被访问过 | 用于页面置换算法(如Clock、LRU近似) | -| **读/写权限 (R/W)** | 读写保护 | 只读页被写入时触发保护异常 | -| **用户/内核 (U/S)** | 访问权限 | U=0:仅内核可访问;U=1:用户可访问 | -| **物理页号 (PPN)** | 对应的物理页框号 | 与VPO拼接得到物理地址 | - ---- - -## 五、地址变换过程 - -### 基本地址变换流程 - -```mermaid -flowchart TD - A["CPU发出虚拟地址 VA"] --> B["从VA中提取VPN和VPO"] - B --> C{"TLB查找VPN"} - C -->|"TLB命中"| D["直接取出PPN"] - C -->|"TLB未命中"| E["访问页表
(页表基址寄存器PTBR + VPN × PTE大小)"] - E --> F{"页表项有效位?"} - F -->|"V=1"| G["取出PPN"] - F -->|"V=0"| H["**缺页中断**
操作系统处理"] - H --> I["从外存调入页面"] - I --> J["更新页表"] - J --> G - D --> K["物理地址 PA = PPN × 2^k + PPO"] - G --> K - K --> L["访问物理内存"] - - style H fill:#ffcdd2,stroke:#c62828 - style C fill:#e8f5e9,stroke:#2e7d32 -``` - -### 详细步骤 - -1. **提取地址字段**: 从虚拟地址 VA 中分离出 VPN 和 VPO -2. **查TLB**: 用 VPN 在 TLB 中查找(见第六节) -3. **查页表**: 若 TLB 未命中,用 **PTBR(页表基址寄存器)+ VPN** 定位页表项 -4. **检查有效位**: - - V=1:取出 PPN,与 PPO 拼接得到物理地址 - - V=0:触发**缺页中断**,OS 从外存调入页面 -5. **拼接物理地址**: PA = PPN | PPO(将 PPN 放高位,PPO 放低位) - -### 缺页中断处理流程 - -```mermaid -flowchart TD - A["发生缺页中断"] --> B["保存CPU现场"] - B --> C{"外存中找到该页?"} - C -->|"找到"| D{"内存有空闲页框?"} - C -->|"未找到"| E["终止进程
(非法访问)"] - D -->|"有空闲"| F["从外存读入该页"] - D -->|"无空闲"| G["执行页面置换算法
选择牺牲页"] - G --> H{"牺牲页Dirty=1?"} - H -->|"是"| I["将牺牲页写回外存"] - H -->|"否"| J["直接覆盖"] - I --> F - J --> F - F --> K["修改页表
设置V=1, PPN"] - K --> L["重新执行被中断的指令"] -``` - -### 地址变换计算示例 - -> **例题**: 某系统页面大小 1KB ($2^{10}$),虚拟地址 14 位。页表如下,求虚拟地址 VA=0x1A8F 对应的物理地址。 - -| VPN | PPN | Valid | -|-----|-----|-------| -| 0 | 3 | 1 | -| 1 | 7 | 1 | -| 2 | --- | 0 | -| 3 | 5 | 1 | -| 4 | 2 | 1 | -| 5 | 8 | 1 | -| 6 | 1 | 1 | - -**解题过程**: - -1. 页面大小 $2^{10}$,所以 $k=10$,$v=14-10=4$ -2. VA = 0x1A8F = **01 1010 1000 1111** (二进制) - - VPN = 高4位 = 0110 = **6** - - VPO = 低10位 = 10 1000 1111 = 0x28F -3. 查页表:VPN=6 对应 PPN=**1**,Valid=1 -4. PA = PPN | PPO = 1 × 2^{10} + 0x28F = 0x400 + 0x28F = **0x68F** - -``` -VA = 0x1A8F: 0110 1010001111 - VPN=6 VPO=0x28F - -查页表: VPN=6 → PPN=1 - -PA = 0001 1010001111 = 0x068F - PPN=1 PPO=0x28F -``` - ---- - -## 六、TLB 快表 - -### TLB 概述 - -**TLB (Translation Lookaside Buffer)** 是集成在 MMU 中的高速缓存,存储最近使用的页表项。 - -```mermaid -flowchart LR - CPU["CPU"] -->|"虚拟地址"| TLB{"TLB
(快表)"} - TLB -->|"命中
取出PPN"| PA["物理地址"] - TLB -->|"未命中"| PT["查页表
(内存)"] - PT -->|"PPN"| PA - PT -->|"更新TLB"| TLB - - style TLB fill:#e8f5e9,stroke:#2e7d32 - style PT fill:#fff3e0,stroke:#e65100 -``` - -### TLB 组织方式 - -| 方式 | 说明 | 特点 | -|------|------|------| -| **全相联** | VPN可以放在TLB的任意位置 | 灵活但查找慢,适合小容量TLB | -| **组相联** | VPN映射到固定的组(set),组内任意放置 | 折中方案,最常用 | -| **直接映射** | VPN映射到固定的TLB位置 | 最快但冲突多 | - -### TLB 性能分析 - -设 TLB 查找时间为 $\lambda$,内存访问时间为 $t$,TLB 命中率为 $a$: - -**无 TLB 时**:每次地址变换需要访问一次页表(内存)+ 一次数据访问(内存) - -$$EAT_{无TLB} = t + t = 2t$$ - -**有 TLB 时**: - -$$EAT = a(\lambda + t) + (1-a)(\lambda + t + t) = \lambda + t + (1-a) \cdot t$$ - -> **计算示例**: 设 $\lambda = 10$ns, $t = 100$ns, $a = 0.98$(98%命中率) -> -> $EAT = 10 + 100 + (1 - 0.98) \times 100 = 10 + 100 + 2 = 112$ ns -> -> 相比无TLB的 $2t = 200$ ns,性能提升了约 **44%**。 - ---- - -## 七、多级页表 - -### 为什么需要多级页表 - -对于 32 位系统,页面大小 4KB: -- 虚拟地址空间 = $2^{32}$ = 4GB -- 页数 = $2^{32} / 2^{12} = 2^{20}$ = 1M 个页 -- 每个页表项 4 字节 → 页表大小 = 1M × 4B = **4MB** - -4MB 的页表对于每个进程都太大了!而且页表必须连续存放。 - -### 二级页表结构 - -**核心思想**: 将页表本身也分页,用"页目录"来索引这些页表页。 - -``` -32位虚拟地址 (二级页表) -┌──────────────┬──────────────┬──────────────┐ -│ 页目录索引(10位)│ 页表索引(10位) │ 页内偏移(12位)│ -└──────────────┴──────────────┴──────────────┘ -``` - -```mermaid -flowchart LR - CR3["CR3
(页目录基址)"] --> PD["页目录
(1024项)"] - PD -->|"页目录项"| PT1["页表1
(1024项)"] - PD -->|"页目录项"| PT2["页表2
(1024项)"] - PD -->|"页目录项"| PT3["页表3
(1024项)"] - PT1 -->|"页表项+偏移"| PF1["物理页框"] - PT2 -->|"页表项+偏移"| PF2["物理页框"] - PT3 -->|"页表项+偏移"| PF3["物理页框"] - - style PD fill:#e3f2fd,stroke:#1976d2 - style PT1 fill:#fff3e0,stroke:#e65100 - style PT2 fill:#fff3e0,stroke:#e65100 - style PT3 fill:#fff3e0,stroke:#e65100 -``` - -### 多级页表的优势 - -- 页目录只需常驻内存(4KB),页表页按需创建 -- 未使用的虚拟地址区域**不需要分配页表页**,节省大量内存 -- 64 位系统通常使用 **3~5 级页表**(如 Linux 的 4 级页表:PGD→PUD→PMD→PTE→偏移) - -### 二级页表地址变换 - -1. 用 CR3 找到页目录基址 -2. 用**页目录索引**在页目录中找到页表页的物理地址 -3. 用**页表索引**在页表页中找到 PPN -4. PPN 与**页内偏移**拼接得到物理地址 - -> **注意**: 二级页表需要 **3 次内存访问**(页目录 + 页表 + 数据),比一级页表多一次。因此 TLB 的作用更加重要。 - ---- - -## 八、倒转页表 - -### 基本思想 - -传统页表以**虚拟页号**为索引,每个进程一张。倒转页表以**物理页框号**为索引,整个系统一张。 - -| 对比项 | 传统页表 | 倒转页表 | -|--------|---------|---------| -| 索引 | 虚拟页号 (VPN) | 物理页框号 (PFN) | -| 表项数 | 虚拟页数(可能很大) | 物理页框数(固定) | -| 进程数 | 每进程一张 | 全系统一张 | -| 查找方式 | 直接索引 | 需要搜索(或用Hash) | - -```mermaid -flowchart LR - subgraph 传统页表 - direction TB - T1["VPN 0 → PPN x"] - T2["VPN 1 → PPN y"] - T3["VPN 2 → PPN z"] - T4["..."] - end - - subgraph 倒转页表 - direction TB - I1["PFN 0 ← VPN a, 进程P1"] - I2["PFN 1 ← VPN b, 进程P2"] - I3["PFN 2 ← VPN c, 进程P1"] - I4["..."] - end -``` - -**优点**: 表大小与物理内存成正比,节省空间(尤其在 64 位系统) - -**缺点**: 查找需要搜索整个表(通常用 Hash 加速) - ---- - -## 九、内存保护 - -分页系统中通过以下机制实现内存保护: - -### 1. 越界保护 - -- 页表项中的有效位 V=0 表示该页不在内存,访问时触发缺页中断 -- 非法虚拟地址(超出进程地址空间范围)触发保护异常 - -### 2. 标志位保护 - -| 标志位 | 保护功能 | -|--------|---------| -| R/W | 读/写权限控制。只读页被写入时触发异常 | -| U/S | 用户/内核权限。用户态访问内核页时触发异常 | -| NX (No Execute) | 禁止执行位。数据页被当作代码执行时触发异常 | - -### 3. 键保护 - -- 每个物理页框有一个保护键 (Protection Key) -- 每个进程有一个键寄存器 -- 只有键匹配时才允许访问 -- Intel MPX/MPK 技术支持此机制 - ---- - -## 十、空闲页面管理 - -操作系统需要跟踪物理内存中哪些页框是空闲的: - -### 1. 位示图法 (Bitmap) - -用一个 bit 表示一个物理页框的状态:0=空闲,1=已分配。 - -``` -位示图示例 (假设16个物理页框): -位号: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 -状态: 1 1 0 1 0 0 1 1 0 1 0 0 1 1 0 1 - ↑ ↑ ↑ - 空闲 空闲 空闲 -``` - -- **优点**: 简单,查找连续空闲块方便 -- **缺点**: 位示图本身占用内存(物理内存 4GB、页大小 4KB → 位示图 128KB) - -### 2. 链表法 - -将所有空闲页框用链表串起来: - -``` -空闲链表: [PF3] → [PF5] → [PF8] → [PF12] → NULL -``` - -- **优点**: 实现简单,不额外占用大量空间 -- **缺点**: 查找连续空闲块需要遍历链表 - ---- - -## 十一、Nachos 页式存储管理代码 - -Nachos 教学操作系统中实现了基本的分页存储管理。以下是关键代码片段: - -### 核心数据结构 - -```cpp -// 页表项结构 (Machine/translate.h) -typedef struct { - int virtualPage; // 虚拟页号 (VPN) - int physicalPage; // 物理页号 (PPN) - bool valid; // 有效位 - bool readOnly; // 只读标志 - bool use; // 引用位 (用于LRU等算法) - bool dirty; // 修改位 -} TranslationEntry; -``` - -### 地址转换核心代码 - -```cpp -// Machine/translate.cc - translate() -// 将虚拟地址转换为物理地址 -int Machine::Translate(int virtAddr, int *physAddr, int size, bool writing) { - // 1. 计算VPN和偏移 - int vpn = virtAddr / PageSize; - int offset = virtAddr % PageSize; - - // 2. 查找页表 - TranslationEntry *entry = &pageTable[vpn]; - - // 3. 检查有效位 - if (!entry->valid) { - // 缺页处理 - return PageFaultException; - } - - // 4. 检查写权限 - if (writing && entry->readOnly) { - return ReadOnlyException; - } - - // 5. 计算物理地址 - *physAddr = entry->physicalPage * PageSize + offset; - - // 6. 更新引用位和修改位 - entry->use = true; - if (writing) entry->dirty = true; - - return NoException; -} -``` - -### 内存分配示例 - -```cpp -// AddrSpace/addrspace.cc - 进程地址空间初始化 -// 为进程分配物理页框 -void AddrSpace::InitRegisters() { - // 初始化页表 - pageTable = new TranslationEntry[numPages]; - for (int i = 0; i < numPages; i++) { - pageTable[i].virtualPage = i; - pageTable[i].physicalPage = bitMap->Find(); // 位示图分配 - pageTable[i].valid = true; - pageTable[i].readOnly = false; - pageTable[i].use = false; - pageTable[i].dirty = false; - } -} -``` - ---- - -## 十二、小结 - -```mermaid -mindmap - root((分页存储管理)) - 碎片问题 - 内碎片(固定分区) - 外碎片(动态分区) - 根因:连续存放 - 分页思想 - 虚拟页VP - 物理页框PF - 等大划分 - 地址结构 - VPN+VPO - PPN+PPO - 页内偏移不变 - 页表 - VPN→PPN映射 - 有效位/Dirty/Ref - 每进程独立 - 地址变换 - PTBR+VPN查页表 - 取PPN拼PPO - 缺页中断处理 - TLB快表 - 高速缓存页表项 - 命中率影响EAT - 多级页表 - 页目录+页表 - 节省内存 - 倒转页表 - 按物理块号索引 - 全系统一张 -``` - ---- - -## 思考题 - -1. **概念理解**: 为什么分页能消除外碎片但不能消除内碎片?内碎片平均浪费多少? - -2. **计算题**: 某系统页面大小 4KB,虚拟地址 20 位,物理地址 18 位。 - - 虚拟地址空间有多少页? - - 物理内存最大多少? - - 页表至少需要多少项? - -3. **TLB计算**: 设 TLB 查找时间 5ns,内存访问时间 80ns,要求有效访问时间不超过 100ns,TLB 命中率至少为多少? - -4. **多级页表**: 对于 64 位系统,为什么至少需要 3 级页表? - ---- - -## 关联笔记 - -- [[13_存储管理基础]] — 存储器层次结构与地址空间基础 -- [[15_段式存储管理]] — 分段式地址转换,段页式结合 -- [[16_虚拟存储器]] — 基于分页的虚拟存储器实现 -- [[11_处理机调度]] — 进程调度与缺页处理的关系 - ---- - -**上一讲**: [[13_存储管理基础]] -**下一讲**: [[15_段式存储管理]] +# 14. 分页存储管理 + +> **课程**: 操作系统 - 存储器管理 +> **核心内容**: 碎片问题、分页思想、地址结构、页表、地址变换、TLB快表、多级页表、倒转页表 + +--- + +## 前置知识 + +- [[13_存储管理基础]] — 存储器层次结构、逻辑地址与物理地址、MMU +- [[01_系统运行机制]] — CPU工作模式与硬件基础 + +--- + +## 一、碎片问题 + +在连续分配方式中,内存中会出现无法被利用的空闲区域,称为**碎片**。 + +| 碎片类型 | 出现位置 | 产生原因 | 对应分配方式 | +|---------|---------|---------|------------| +| **内碎片** (Internal Fragmentation) | 分配区域**内部** | 固定分区大小 > 进程实际需要 | 固定分区分配 | +| **外碎片** (External Fragmentation) | 分配区域**外部** | 空闲分区太小,无法满足任何请求 | 动态分区分配 | + +```mermaid +flowchart LR + subgraph 内碎片示意 + direction TB + A1["┌──────────────┐"] + A2["│ 进程实际数据 │"] + A3["│──────────────│"] + A4["│ 未使用空间 │ ← 内碎片"] + A5["└──────────────┘"] + end + + subgraph 外碎片示意 + direction TB + B1["┌────┐ ┌────┐ ┌────┐ ┌────┐"] + B2["│进程A│ │空闲│ │进程B│ │空闲│"] + B3["└────┘ └────┘ └────┘ └────┘"] + B4[" ↑外碎片 ↑外碎片"] + end +``` + +> **根本原因**: 无论是固定分区还是动态分区,都要求进程在内存中**连续存放**。要彻底解决碎片问题,必须放弃"连续"要求——这就是**分页**思想的核心出发点。 + +--- + +## 二、分页的基本思想 + +分页存储管理将**虚拟地址空间**和**物理内存**都划分为大小相等的小块: + +- 虚拟地址空间的每一块称为**虚拟页 (Virtual Page, VP)** +- 物理内存的每一块称为**物理页框 (Page Frame, PF)** + +常见的页面大小为 $2^k$ 字节:512B、1KB、2KB、**4KB**(最常用)。 + +```mermaid +flowchart TB + subgraph VA["虚拟地址空间 (进程视角)"] + direction TB + VP0["虚拟页 VP0"] + VP1["虚拟页 VP1"] + VP2["虚拟页 VP2"] + VP3["虚拟页 VP3"] + VP4["..."] + end + + subgraph PM["物理内存"] + direction TB + PF0["物理页框 PF0"] + PF1["物理页框 PF1"] + PF2["物理页框 PF2"] + PF3["物理页框 PF3"] + PF4["..."] + end + + VP0 -->|"页表映射"| PF2 + VP1 -->|"页表映射"| PF0 + VP2 -->|"不在内存"| DISK["外存(磁盘)"] + VP3 -->|"页表映射"| PF3 + + style VA fill:#e3f2fd,stroke:#1976d2 + style PM fill:#fff8e1,stroke:#f9a825 + style DISK fill:#fce4ec,stroke:#c62828 +``` + +**关键特性**: +- 虚拟页在物理内存中**不需要连续**存放 +- 每个虚拟页可以映射到任意一个空闲物理页框 +- 消除了外碎片(但每个页内可能有少量内碎片) + +--- + +## 三、地址结构 + +在分页系统中,虚拟地址被划分为两部分: + +``` +虚拟地址 VA (v+k 位) +┌─────────────────┬─────────────┐ +│ VPN (v位) │ VPO (k位) │ +│ 虚拟页号 │ 页内偏移 │ +└─────────────────┴─────────────┘ +``` + +**计算公式**: +- 页大小 = $2^k$ 字节 +- 虚拟页号 VPN = $\lfloor VA / 2^k \rfloor$(即高位部分) +- 页内偏移 VPO = $VA \mod 2^k$(即低 $k$ 位) + +物理地址同理: + +``` +物理地址 PA (p+k 位) +┌─────────────────┬─────────────┐ +│ PPN (p位) │ PPO (k位) │ +│ 物理页号 │ 页内偏移 │ +└─────────────────┴─────────────┘ +``` + +- 物理页号 PPN 由页表查得 +- **PPO = VPO**(页内偏移不变,直接复制) + +### 地址计算示例 + +> **例题**: 某系统虚拟地址 16 位,页面大小 4KB ($2^{12}$),求虚拟地址 VA = 0x3A6F 对应的 VPN 和 VPO。 + +- 页大小 $2^k = 2^{12}$,所以 $k = 12$,$v = 16 - 12 = 4$ +- VPN = $0x3A6F / 0x1000 = 0x3$(高4位:0011) +- VPO = $0x3A6F \mod 0x1000 = 0xA6F$(低12位) + +``` +VA = 0x3A6F = 0011 1010 0110 1111 + ──── ──────────────── + VPN VPO + (0x3) (0xA6F) +``` + +--- + +## 四、页表 + +页表是实现虚拟页到物理页框映射的核心数据结构。 + +### 页表结构 + +每个进程拥有**独立的页表**。页表以 VPN 为索引,每个页表项 (PTE) 包含: + +``` +页表 (以VPN为索引) +┌──────┬───────┬──────────────────────────────┐ +│ VPN │ PPN │ 控制位 │ +├──────┼───────┼──────────────────────────────┤ +│ 0 │ 5 │ V=1 D=0 R=1 W=1 U/S=1 │ +│ 1 │ 2 │ V=1 D=1 R=1 W=1 U/S=1 │ +│ 2 │ --- │ V=0 (不在内存) │ +│ 3 │ 8 │ V=1 D=0 R=1 W=0 U/S=1 │ +│ ... │ ... │ ... │ +└──────┴───────┴──────────────────────────────┘ +``` + +### 页表项 (PTE) 各字段 + +| 字段 | 含义 | 说明 | +|------|------|------| +| **有效位 (Valid/Present)** | 页面是否在内存中 | V=1:在内存;V=0:不在内存(缺页) | +| **修改位 (Dirty)** | 页面是否被写过 | D=1:被修改过,换出时需写回外存 | +| **引用位 (Reference)** | 页面是否被访问过 | 用于页面置换算法(如Clock、LRU近似) | +| **读/写权限 (R/W)** | 读写保护 | 只读页被写入时触发保护异常 | +| **用户/内核 (U/S)** | 访问权限 | U=0:仅内核可访问;U=1:用户可访问 | +| **物理页号 (PPN)** | 对应的物理页框号 | 与VPO拼接得到物理地址 | + +--- + +## 五、地址变换过程 + +### 基本地址变换流程 + +```mermaid +flowchart TD + A["CPU发出虚拟地址 VA"] --> B["从VA中提取VPN和VPO"] + B --> C{"TLB查找VPN"} + C -->|"TLB命中"| D["直接取出PPN"] + C -->|"TLB未命中"| E["访问页表
(页表基址寄存器PTBR + VPN × PTE大小)"] + E --> F{"页表项有效位?"} + F -->|"V=1"| G["取出PPN"] + F -->|"V=0"| H["**缺页中断**
操作系统处理"] + H --> I["从外存调入页面"] + I --> J["更新页表"] + J --> G + D --> K["物理地址 PA = PPN × 2^k + PPO"] + G --> K + K --> L["访问物理内存"] + + style H fill:#ffcdd2,stroke:#c62828 + style C fill:#e8f5e9,stroke:#2e7d32 +``` + +### 详细步骤 + +1. **提取地址字段**: 从虚拟地址 VA 中分离出 VPN 和 VPO +2. **查TLB**: 用 VPN 在 TLB 中查找(见第六节) +3. **查页表**: 若 TLB 未命中,用 **PTBR(页表基址寄存器)+ VPN** 定位页表项 +4. **检查有效位**: + - V=1:取出 PPN,与 PPO 拼接得到物理地址 + - V=0:触发**缺页中断**,OS 从外存调入页面 +5. **拼接物理地址**: PA = PPN | PPO(将 PPN 放高位,PPO 放低位) + +### 缺页中断处理流程 + +```mermaid +flowchart TD + A["发生缺页中断"] --> B["保存CPU现场"] + B --> C{"外存中找到该页?"} + C -->|"找到"| D{"内存有空闲页框?"} + C -->|"未找到"| E["终止进程
(非法访问)"] + D -->|"有空闲"| F["从外存读入该页"] + D -->|"无空闲"| G["执行页面置换算法
选择牺牲页"] + G --> H{"牺牲页Dirty=1?"} + H -->|"是"| I["将牺牲页写回外存"] + H -->|"否"| J["直接覆盖"] + I --> F + J --> F + F --> K["修改页表
设置V=1, PPN"] + K --> L["重新执行被中断的指令"] +``` + +### 地址变换计算示例 + +> **例题**: 某系统页面大小 1KB ($2^{10}$),虚拟地址 14 位。页表如下,求虚拟地址 VA=0x1A8F 对应的物理地址。 + +| VPN | PPN | Valid | +|-----|-----|-------| +| 0 | 3 | 1 | +| 1 | 7 | 1 | +| 2 | --- | 0 | +| 3 | 5 | 1 | +| 4 | 2 | 1 | +| 5 | 8 | 1 | +| 6 | 1 | 1 | + +**解题过程**: + +1. 页面大小 $2^{10}$,所以 $k=10$,$v=14-10=4$ +2. VA = 0x1A8F = **01 1010 1000 1111** (二进制) + - VPN = 高4位 = 0110 = **6** + - VPO = 低10位 = 10 1000 1111 = 0x28F +3. 查页表:VPN=6 对应 PPN=**1**,Valid=1 +4. PA = PPN | PPO = 1 × 2^{10} + 0x28F = 0x400 + 0x28F = **0x68F** + +``` +VA = 0x1A8F: 0110 1010001111 + VPN=6 VPO=0x28F + +查页表: VPN=6 → PPN=1 + +PA = 0001 1010001111 = 0x068F + PPN=1 PPO=0x28F +``` + +--- + +## 六、TLB 快表 + +### TLB 概述 + +**TLB (Translation Lookaside Buffer)** 是集成在 MMU 中的高速缓存,存储最近使用的页表项。 + +```mermaid +flowchart LR + CPU["CPU"] -->|"虚拟地址"| TLB{"TLB
(快表)"} + TLB -->|"命中
取出PPN"| PA["物理地址"] + TLB -->|"未命中"| PT["查页表
(内存)"] + PT -->|"PPN"| PA + PT -->|"更新TLB"| TLB + + style TLB fill:#e8f5e9,stroke:#2e7d32 + style PT fill:#fff3e0,stroke:#e65100 +``` + +### TLB 组织方式 + +| 方式 | 说明 | 特点 | +|------|------|------| +| **全相联** | VPN可以放在TLB的任意位置 | 灵活但查找慢,适合小容量TLB | +| **组相联** | VPN映射到固定的组(set),组内任意放置 | 折中方案,最常用 | +| **直接映射** | VPN映射到固定的TLB位置 | 最快但冲突多 | + +### TLB 性能分析 + +设 TLB 查找时间为 $\lambda$,内存访问时间为 $t$,TLB 命中率为 $a$: + +**无 TLB 时**:每次地址变换需要访问一次页表(内存)+ 一次数据访问(内存) + +$$EAT_{无TLB} = t + t = 2t$$ + +**有 TLB 时**: + +$$EAT = a(\lambda + t) + (1-a)(\lambda + t + t) = \lambda + t + (1-a) \cdot t$$ + +> **计算示例**: 设 $\lambda = 10$ns, $t = 100$ns, $a = 0.98$(98%命中率) +> +> $EAT = 10 + 100 + (1 - 0.98) \times 100 = 10 + 100 + 2 = 112$ ns +> +> 相比无TLB的 $2t = 200$ ns,性能提升了约 **44%**。 + +--- + +## 七、多级页表 + +### 为什么需要多级页表 + +对于 32 位系统,页面大小 4KB: +- 虚拟地址空间 = $2^{32}$ = 4GB +- 页数 = $2^{32} / 2^{12} = 2^{20}$ = 1M 个页 +- 每个页表项 4 字节 → 页表大小 = 1M × 4B = **4MB** + +4MB 的页表对于每个进程都太大了!而且页表必须连续存放。 + +### 二级页表结构 + +**核心思想**: 将页表本身也分页,用"页目录"来索引这些页表页。 + +``` +32位虚拟地址 (二级页表) +┌──────────────┬──────────────┬──────────────┐ +│ 页目录索引(10位)│ 页表索引(10位) │ 页内偏移(12位)│ +└──────────────┴──────────────┴──────────────┘ +``` + +```mermaid +flowchart LR + CR3["CR3
(页目录基址)"] --> PD["页目录
(1024项)"] + PD -->|"页目录项"| PT1["页表1
(1024项)"] + PD -->|"页目录项"| PT2["页表2
(1024项)"] + PD -->|"页目录项"| PT3["页表3
(1024项)"] + PT1 -->|"页表项+偏移"| PF1["物理页框"] + PT2 -->|"页表项+偏移"| PF2["物理页框"] + PT3 -->|"页表项+偏移"| PF3["物理页框"] + + style PD fill:#e3f2fd,stroke:#1976d2 + style PT1 fill:#fff3e0,stroke:#e65100 + style PT2 fill:#fff3e0,stroke:#e65100 + style PT3 fill:#fff3e0,stroke:#e65100 +``` + +### 多级页表的优势 + +- 页目录只需常驻内存(4KB),页表页按需创建 +- 未使用的虚拟地址区域**不需要分配页表页**,节省大量内存 +- 64 位系统通常使用 **3~5 级页表**(如 Linux 的 4 级页表:PGD→PUD→PMD→PTE→偏移) + +### 二级页表地址变换 + +1. 用 CR3 找到页目录基址 +2. 用**页目录索引**在页目录中找到页表页的物理地址 +3. 用**页表索引**在页表页中找到 PPN +4. PPN 与**页内偏移**拼接得到物理地址 + +> **注意**: 二级页表需要 **3 次内存访问**(页目录 + 页表 + 数据),比一级页表多一次。因此 TLB 的作用更加重要。 + +--- + +## 八、倒转页表 + +### 基本思想 + +传统页表以**虚拟页号**为索引,每个进程一张。倒转页表以**物理页框号**为索引,整个系统一张。 + +| 对比项 | 传统页表 | 倒转页表 | +|--------|---------|---------| +| 索引 | 虚拟页号 (VPN) | 物理页框号 (PFN) | +| 表项数 | 虚拟页数(可能很大) | 物理页框数(固定) | +| 进程数 | 每进程一张 | 全系统一张 | +| 查找方式 | 直接索引 | 需要搜索(或用Hash) | + +```mermaid +flowchart LR + subgraph 传统页表 + direction TB + T1["VPN 0 → PPN x"] + T2["VPN 1 → PPN y"] + T3["VPN 2 → PPN z"] + T4["..."] + end + + subgraph 倒转页表 + direction TB + I1["PFN 0 ← VPN a, 进程P1"] + I2["PFN 1 ← VPN b, 进程P2"] + I3["PFN 2 ← VPN c, 进程P1"] + I4["..."] + end +``` + +**优点**: 表大小与物理内存成正比,节省空间(尤其在 64 位系统) + +**缺点**: 查找需要搜索整个表(通常用 Hash 加速) + +--- + +## 九、内存保护 + +分页系统中通过以下机制实现内存保护: + +### 1. 越界保护 + +- 页表项中的有效位 V=0 表示该页不在内存,访问时触发缺页中断 +- 非法虚拟地址(超出进程地址空间范围)触发保护异常 + +### 2. 标志位保护 + +| 标志位 | 保护功能 | +|--------|---------| +| R/W | 读/写权限控制。只读页被写入时触发异常 | +| U/S | 用户/内核权限。用户态访问内核页时触发异常 | +| NX (No Execute) | 禁止执行位。数据页被当作代码执行时触发异常 | + +### 3. 键保护 + +- 每个物理页框有一个保护键 (Protection Key) +- 每个进程有一个键寄存器 +- 只有键匹配时才允许访问 +- Intel MPX/MPK 技术支持此机制 + +--- + +## 十、空闲页面管理 + +操作系统需要跟踪物理内存中哪些页框是空闲的: + +### 1. 位示图法 (Bitmap) + +用一个 bit 表示一个物理页框的状态:0=空闲,1=已分配。 + +``` +位示图示例 (假设16个物理页框): +位号: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 +状态: 1 1 0 1 0 0 1 1 0 1 0 0 1 1 0 1 + ↑ ↑ ↑ + 空闲 空闲 空闲 +``` + +- **优点**: 简单,查找连续空闲块方便 +- **缺点**: 位示图本身占用内存(物理内存 4GB、页大小 4KB → 位示图 128KB) + +### 2. 链表法 + +将所有空闲页框用链表串起来: + +``` +空闲链表: [PF3] → [PF5] → [PF8] → [PF12] → NULL +``` + +- **优点**: 实现简单,不额外占用大量空间 +- **缺点**: 查找连续空闲块需要遍历链表 + +--- + +## 十一、Nachos 页式存储管理代码 + +Nachos 教学操作系统中实现了基本的分页存储管理。以下是关键代码片段: + +### 核心数据结构 + +```cpp +// 页表项结构 (Machine/translate.h) +typedef struct { + int virtualPage; // 虚拟页号 (VPN) + int physicalPage; // 物理页号 (PPN) + bool valid; // 有效位 + bool readOnly; // 只读标志 + bool use; // 引用位 (用于LRU等算法) + bool dirty; // 修改位 +} TranslationEntry; +``` + +### 地址转换核心代码 + +```cpp +// Machine/translate.cc - translate() +// 将虚拟地址转换为物理地址 +int Machine::Translate(int virtAddr, int *physAddr, int size, bool writing) { + // 1. 计算VPN和偏移 + int vpn = virtAddr / PageSize; + int offset = virtAddr % PageSize; + + // 2. 查找页表 + TranslationEntry *entry = &pageTable[vpn]; + + // 3. 检查有效位 + if (!entry->valid) { + // 缺页处理 + return PageFaultException; + } + + // 4. 检查写权限 + if (writing && entry->readOnly) { + return ReadOnlyException; + } + + // 5. 计算物理地址 + *physAddr = entry->physicalPage * PageSize + offset; + + // 6. 更新引用位和修改位 + entry->use = true; + if (writing) entry->dirty = true; + + return NoException; +} +``` + +### 内存分配示例 + +```cpp +// AddrSpace/addrspace.cc - 进程地址空间初始化 +// 为进程分配物理页框 +void AddrSpace::InitRegisters() { + // 初始化页表 + pageTable = new TranslationEntry[numPages]; + for (int i = 0; i < numPages; i++) { + pageTable[i].virtualPage = i; + pageTable[i].physicalPage = bitMap->Find(); // 位示图分配 + pageTable[i].valid = true; + pageTable[i].readOnly = false; + pageTable[i].use = false; + pageTable[i].dirty = false; + } +} +``` + +--- + +## 十二、小结 + +```mermaid +mindmap + root((分页存储管理)) + 碎片问题 + 内碎片(固定分区) + 外碎片(动态分区) + 根因:连续存放 + 分页思想 + 虚拟页VP + 物理页框PF + 等大划分 + 地址结构 + VPN+VPO + PPN+PPO + 页内偏移不变 + 页表 + VPN→PPN映射 + 有效位/Dirty/Ref + 每进程独立 + 地址变换 + PTBR+VPN查页表 + 取PPN拼PPO + 缺页中断处理 + TLB快表 + 高速缓存页表项 + 命中率影响EAT + 多级页表 + 页目录+页表 + 节省内存 + 倒转页表 + 按物理块号索引 + 全系统一张 +``` + +--- + +## 思考题 + +1. **概念理解**: 为什么分页能消除外碎片但不能消除内碎片?内碎片平均浪费多少? + +2. **计算题**: 某系统页面大小 4KB,虚拟地址 20 位,物理地址 18 位。 + - 虚拟地址空间有多少页? + - 物理内存最大多少? + - 页表至少需要多少项? + +3. **TLB计算**: 设 TLB 查找时间 5ns,内存访问时间 80ns,要求有效访问时间不超过 100ns,TLB 命中率至少为多少? + +4. **多级页表**: 对于 64 位系统,为什么至少需要 3 级页表? + +--- + +## 关联笔记 + +- [[13_存储管理基础]] — 存储器层次结构与地址空间基础 +- [[15_段式存储管理]] — 分段式地址转换,段页式结合 +- [[16_虚拟存储器]] — 基于分页的虚拟存储器实现 +- [[11_处理机调度]] — 进程调度与缺页处理的关系 + +--- + +**上一讲**: [[13_存储管理基础]] +**下一讲**: [[15_段式存储管理]] diff --git a/操作系统/15_段式存储管理/15_段式存储管理.md b/操作系统/15_段式存储管理/15_段式存储管理.md index aed451a..aeb7d40 100644 --- a/操作系统/15_段式存储管理/15_段式存储管理.md +++ b/操作系统/15_段式存储管理/15_段式存储管理.md @@ -1,398 +1,398 @@ -# 15. 段式存储管理 - -> **课程**: 操作系统 - 存储器管理 -> **核心内容**: 分段引入原因、分段思想、地址结构、段表、地址变换、段页式存储管理 - ---- - -## 前置知识 - -- [[13_存储管理基础]] — 存储器层次结构、逻辑地址与物理地址 -- [[14_分页存储管理]] — 分页思想、页表、地址变换 - ---- - -## 一、为什么需要分段 - -[[14_分页存储管理|分页]]虽然解决了碎片问题,但在以下场景中存在不足: - -### 1. 信息共享不方便 - -分页按固定大小划分,不考虑程序的逻辑结构。如果要共享一段代码(如共享库函数),该代码可能跨越多个页面,其中某些页面还包含不需要共享的数据,导致共享粒度过粗。 - -``` -分页视角(按固定大小切分,不考虑逻辑含义): -┌────────┐ ┌────────┐ ┌────────┐ -│ 代码1 │ │ 代码2+ │ │ 数据 │ ← 一个逻辑模块跨了3个页 -│ │ │ 常量 │ │ │ 共享时会把不相关的内容也共享了 -└────────┘ └────────┘ └────────┘ -``` - -### 2. 动态链接问题 - -动态链接需要在运行时将目标模块装入内存并链接。分页系统中,模块的装入和链接以页为单位,不够灵活。 - -### 3. 程序员视角需求 - -程序员编写程序时,自然地将代码划分为**代码段、数据段、堆栈段**等逻辑模块。分页完全打乱了这种逻辑结构。 - -> **核心需求**: 地址空间的划分应该按照**逻辑意义**进行,而非固定大小。这就是分段的思想。 - ---- - -## 二、分段的基本思想 - -分段按照程序的**逻辑结构**将地址空间划分为若干个段: - -- 每个段有独立的**段名**和**段长** -- 每个段在内存中**连续存放** -- 不同段之间**不需要连续** - -```mermaid -flowchart TB - subgraph VA["进程虚拟地址空间"] - direction TB - S0["主程序段 (段0)"] - S1["子程序段 (段1)"] - S2["数据段 (段2)"] - S3["栈段 (段3)"] - end - - subgraph PM["物理内存"] - direction TB - M0["区域A"] - M1["区域B"] - M2["区域C"] - M3["区域D"] - M4["空闲"] - M5["区域E"] - end - - S0 -->|"段表映射"| M5 - S1 -->|"段表映射"| M0 - S2 -->|"段表映射"| M2 - S3 -->|"段表映射"| M3 - - style VA fill:#e3f2fd,stroke:#1976d2 - style PM fill:#fff8e1,stroke:#f9a825 -``` - -### 分段 vs 分页 - -| 对比项 | 分页 | 分段 | -|--------|------|------| -| **划分依据** | 固定大小(物理需要) | 逻辑意义(程序员视角) | -| **段/页大小** | 所有页等大 | 各段长度不同 | -| **地址空间** | 一维(VA直接计算VPN和偏移) | **二维**(段号 + 段内偏移) | -| **碎片类型** | 内碎片(页内浪费) | 外碎片(段间空闲区) | -| **共享** | 以页为粒度,较粗 | 以段为粒度,符合逻辑 | -| **动态增长** | 不方便 | 方便(如堆、栈段可动态扩展) | - ---- - -## 三、地址结构 - -分段系统的逻辑地址是**二维**的,由两部分组成: - -``` -逻辑地址 = (段号 S, 段内地址 d) -``` - -``` -逻辑地址表示: -┌──────────────┬────────────────────┐ -│ 段号 S │ 段内地址 d │ -│ (高位部分) │ (低位部分) │ -└──────────────┴────────────────────┘ - -注意: 不同于分页的VPN|VPO可以统一计算, -分段中 d 的取值范围取决于段长,各段不同。 -``` - -**地址表示示例**: - -- 分页地址 `(0x1A8F)` → 可直接算出 VPN=6, VPO=0x28F -- 分段地址 `(2, 0x100)` → 段号=2,偏移=0x100(需要查段表才知道该段的基址和长度) - ---- - -## 四、段表 - -### 段表结构 - -每个进程拥有一张**段表**,段表以**段号**为索引,每项包含: - -| 字段 | 含义 | -|------|------| -| **段号** | 段的编号(隐含在索引中) | -| **段长 (Limit)** | 该段的长度(字节数),用于越界检查 | -| **段基址 (Base)** | 该段在物理内存中的起始地址 | - -``` -段表示例: -┌──────┬────────────┬────────────────┐ -│ 段号 │ 段长(Limit) │ 基址(Base) │ -├──────┼────────────┼────────────────┤ -│ 0 │ 0x2000 │ 0x4000 │ ← 主程序: 在内存0x4000处, 长8KB -│ 1 │ 0x1000 │ 0x8000 │ ← 子程序: 在内存0x8000处, 长4KB -│ 2 │ 0x3000 │ 0xB000 │ ← 数据段: 在内存0xB000处, 长12KB -│ 3 │ 0x1800 │ 0x2000 │ ← 栈段: 在内存0x2000处, 长6KB -└──────┴────────────┴────────────────┘ -``` - -段表基址寄存器 **STBR** 指向段表在内存中的起始地址。 - ---- - -## 五、地址变换过程 - -### 变换流程 - -```mermaid -flowchart TD - A["逻辑地址 (S, d)"] --> B["用STBR找到段表基址"] - B --> C["定位段表项: 段表基址 + S × 段表项大小"] - C --> D{"d < Limit ?"} - D -->|"是"| E["物理地址 PA = Base + d"] - D -->|"否"| F["**越界中断**
(Segmentation Fault)"] - E --> G["访问物理内存"] - - style F fill:#ffcdd2,stroke:#c62828 - style D fill:#fff3e0,stroke:#e65100 -``` - -### 详细步骤 - -1. **提取段号和偏移**: 从逻辑地址中分离出段号 S 和段内地址 d -2. **查段表**: 用 STBR + S 定位到第 S 个段表项 -3. **越界检查**: 比较 d 与 Limit - - 若 d >= Limit,触发**越界中断**(Segmentation Fault) - - 若 d < Limit,继续 -4. **计算物理地址**: PA = Base + d -5. **访问内存**: 用物理地址访问实际内存 - -### 地址变换计算示例 - -> **例题**: 某分段系统,段表如下。求以下逻辑地址对应的物理地址: -> - (0, 0x1500) -> - (1, 0x2000) -> - (2, 0x0800) - -**解题过程**: - -| 逻辑地址 | S | d | Limit | 比较 | Base | PA = Base + d | 结果 | -|---------|---|---|-------|------|------|--------------|------| -| (0, 0x1500) | 0 | 0x1500 | 0x2000 | 0x1500 < 0x2000 ✓ | 0x4000 | 0x4000 + 0x1500 | **0x5500** | -| (1, 0x2000) | 1 | 0x2000 | 0x1000 | 0x2000 >= 0x1000 ✗ | — | — | **越界中断** | -| (2, 0x0800) | 2 | 0x0800 | 0x3000 | 0x0800 < 0x3000 ✓ | 0xB000 | 0xB000 + 0x0800 | **0xB800** | - ---- - -## 六、分段的优缺点 - -### 优点 - -| 优点 | 说明 | -|------|------| -| **符合程序逻辑** | 按代码、数据、栈等逻辑单元组织,便于理解和管理 | -| **便于共享** | 以段为单位共享,粒度合理(如共享整个代码段) | -| **便于动态链接** | 可以按段为单位进行动态链接和装入 | -| **支持动态增长** | 堆、栈等段可以独立扩展,不影响其他段 | -| **保护自然** | 每段可设置独立的访问权限(代码段只读、数据段可读写等) | - -### 缺点 - -| 缺点 | 说明 | -|------|------| -| **外碎片** | 段长不等,内存分配/回收后产生不连续的空闲区 | -| **段长限制** | 每段最大长度受地址结构限制 | -| **内存紧缩开销** | 消除外碎片需要移动段(类似动态分区的紧凑操作) | - ---- - -## 七、段页式存储管理 - -### 基本思想 - -**段页式** = 分段 + 分页,兼具两者的优点: - -- 先按**逻辑结构分段**(保留分段的优点:共享、保护、逻辑清晰) -- 再将每段**按固定大小分页**(保留分页的优点:消除外碎片) - -```mermaid -flowchart TB - subgraph VA["虚拟地址空间"] - direction TB - S0["段0 (主程序)"] - S1["段1 (子程序)"] - S2["段2 (数据)"] - end - - subgraph S0P["段0 内部分页"] - direction TB - P0["页0"] - P1["页1"] - P2["页2"] - end - - subgraph PM["物理内存"] - direction TB - F0["页框0"] - F1["页框1"] - F2["页框2"] - F3["页框3"] - end - - VA --> S0P - P0 --> F2 - P1 --> F0 - P2 --> F3 - - style VA fill:#e3f2fd,stroke:#1976d2 - style S0P fill:#f3e5f5,stroke:#7b1fa2 - style PM fill:#fff8e1,stroke:#f9a825 -``` - -### 段页式地址结构 - -逻辑地址由**三部分**组成: - -``` -段页式逻辑地址: -┌──────────────┬──────────────┬──────────────┐ -│ 段号 S │ 页号 P │ 页内偏移 d │ -└──────────────┴──────────────┴──────────────┘ -``` - -### 段页式地址变换 - -```mermaid -flowchart TD - A["逻辑地址 (S, P, d)"] --> B["1. 用STBR找到段表"] - B --> C["2. 用段号S查段表
得到该段的页表基址和段长"] - C --> D{"P < 该段页数?"} - D -->|"是"| E["3. 用页号P查页表
得到物理页框号 PPN"] - D -->|"否"| F["越界中断"] - E --> G["4. 物理地址 = PPN × 页大小 + d"] - G --> H["访问物理内存"] - - style F fill:#ffcdd2,stroke:#c62828 -``` - -### 段页式地址变换步骤 - -1. **查段表**: 用 STBR + S 找到第 S 个段表项,得到该段的**页表基址** -2. **查页表**: 用页表基址 + P 找到第 P 个页表项,得到 **PPN** -3. **拼接地址**: PA = PPN × 页大小 + d - -> **注意**: 段页式需要 **3 次内存访问**(段表 + 页表 + 数据),比纯分页多一次。TLB 的作用更加关键。 - -### 段页式地址变换计算示例 - -> **例题**: 某段页式系统,页面大小 4KB。段表如下,求逻辑地址 (1, 2, 0x100) 的物理地址。 - -**段表**: - -| 段号 | 页表基址 | 段长(页数) | -|------|---------|-----------| -| 0 | 0x8000 | 4 | -| 1 | 0xA000 | 6 | -| 2 | 0xC000 | 3 | - -**段1的页表** (基址 0xA000): - -| 页号 | PPN | -|------|-----| -| 0 | 5 | -| 1 | 2 | -| 2 | 8 | -| 3 | 1 | -| 4 | 7 | -| 5 | 3 | - -**解题过程**: - -1. S=1, P=2, d=0x100 -2. 查段表:段1的页表基址 = 0xA000,段长 = 6 页 -3. P=2 < 6,合法 -4. 查段1的页表:P=2 对应 PPN=**8** -5. PA = 8 × 4096 + 0x100 = 0x8000 + 0x100 = **0x8100** - ---- - -## 八、三种存储管理方式对比 - -| 对比项 | 纯分页 | 纯分段 | 段页式 | -|--------|--------|--------|--------| -| **划分依据** | 固定大小 | 逻辑结构 | 先逻辑后固定大小 | -| **地址维度** | 一维 | 二维 | 三维 | -| **碎片** | 内碎片 | 外碎片 | 内碎片 | -| **共享** | 以页为粒度 | 以段为粒度 | 以段为粒度 | -| **内存访问次数** | 2次(页表+数据) | 2次(段表+数据) | 3次(段表+页表+数据) | -| **代表系统** | Linux | 早期Multics | Intel x86(32位保护模式) | - ---- - -## 九、小结 - -```mermaid -mindmap - root((段式存储管理)) - 分段引入 - 信息共享不便 - 动态链接需求 - 逻辑结构需求 - 分段思想 - 按逻辑分段 - 代码段/数据段/栈段 - 各段独立地址空间 - 地址结构 - 二维: 段号+偏移 - 不同于分页的一维 - 段表 - 段号/段长/基址 - 越界检查 - STBR寄存器 - 地址变换 - 查段表→越界检查→基址+偏移 - PA = Base + d - 段页式 - 先分段再分页 - 三维地址: 段号+页号+偏移 - 兼具两者优点 -``` - ---- - -## 思考题 - -1. **概念理解**: 分段和分页的根本区别是什么?为什么说分段的地址空间是二维的? - -2. **地址变换**: 某分段系统有 4 个段,段表如下。求物理地址或判断是否越界: - - (0, 0x800) → ? - - (2, 0x5000) → ? - - (3, 0x2000) → ? - - | 段号 | 段长 | 基址 | - |------|------|------| - | 0 | 0x1000 | 0x5000 | - | 1 | 0x2000 | 0x8000 | - | 2 | 0x4000 | 0xA000 | - | 3 | 0x3000 | 0x2000 | - -3. **段页式**: 为什么段页式需要 3 次内存访问?TLB 如何缓解这个问题? - -4. **对比分析**: 在什么场景下分段优于分页?在什么场景下分页优于分段? - ---- - -## 关联笔记 - -- [[13_存储管理基础]] — 存储器层次结构与地址空间基础 -- [[14_分页存储管理]] — 分页思想、页表与地址变换 -- [[16_虚拟存储器]] — 基于分段的虚拟存储器实现 - ---- - -**上一讲**: [[14_分页存储管理]] -**下一讲**: [[16_虚拟存储器]] +# 15. 段式存储管理 + +> **课程**: 操作系统 - 存储器管理 +> **核心内容**: 分段引入原因、分段思想、地址结构、段表、地址变换、段页式存储管理 + +--- + +## 前置知识 + +- [[13_存储管理基础]] — 存储器层次结构、逻辑地址与物理地址 +- [[14_分页存储管理]] — 分页思想、页表、地址变换 + +--- + +## 一、为什么需要分段 + +[[14_分页存储管理|分页]]虽然解决了碎片问题,但在以下场景中存在不足: + +### 1. 信息共享不方便 + +分页按固定大小划分,不考虑程序的逻辑结构。如果要共享一段代码(如共享库函数),该代码可能跨越多个页面,其中某些页面还包含不需要共享的数据,导致共享粒度过粗。 + +``` +分页视角(按固定大小切分,不考虑逻辑含义): +┌────────┐ ┌────────┐ ┌────────┐ +│ 代码1 │ │ 代码2+ │ │ 数据 │ ← 一个逻辑模块跨了3个页 +│ │ │ 常量 │ │ │ 共享时会把不相关的内容也共享了 +└────────┘ └────────┘ └────────┘ +``` + +### 2. 动态链接问题 + +动态链接需要在运行时将目标模块装入内存并链接。分页系统中,模块的装入和链接以页为单位,不够灵活。 + +### 3. 程序员视角需求 + +程序员编写程序时,自然地将代码划分为**代码段、数据段、堆栈段**等逻辑模块。分页完全打乱了这种逻辑结构。 + +> **核心需求**: 地址空间的划分应该按照**逻辑意义**进行,而非固定大小。这就是分段的思想。 + +--- + +## 二、分段的基本思想 + +分段按照程序的**逻辑结构**将地址空间划分为若干个段: + +- 每个段有独立的**段名**和**段长** +- 每个段在内存中**连续存放** +- 不同段之间**不需要连续** + +```mermaid +flowchart TB + subgraph VA["进程虚拟地址空间"] + direction TB + S0["主程序段 (段0)"] + S1["子程序段 (段1)"] + S2["数据段 (段2)"] + S3["栈段 (段3)"] + end + + subgraph PM["物理内存"] + direction TB + M0["区域A"] + M1["区域B"] + M2["区域C"] + M3["区域D"] + M4["空闲"] + M5["区域E"] + end + + S0 -->|"段表映射"| M5 + S1 -->|"段表映射"| M0 + S2 -->|"段表映射"| M2 + S3 -->|"段表映射"| M3 + + style VA fill:#e3f2fd,stroke:#1976d2 + style PM fill:#fff8e1,stroke:#f9a825 +``` + +### 分段 vs 分页 + +| 对比项 | 分页 | 分段 | +|--------|------|------| +| **划分依据** | 固定大小(物理需要) | 逻辑意义(程序员视角) | +| **段/页大小** | 所有页等大 | 各段长度不同 | +| **地址空间** | 一维(VA直接计算VPN和偏移) | **二维**(段号 + 段内偏移) | +| **碎片类型** | 内碎片(页内浪费) | 外碎片(段间空闲区) | +| **共享** | 以页为粒度,较粗 | 以段为粒度,符合逻辑 | +| **动态增长** | 不方便 | 方便(如堆、栈段可动态扩展) | + +--- + +## 三、地址结构 + +分段系统的逻辑地址是**二维**的,由两部分组成: + +``` +逻辑地址 = (段号 S, 段内地址 d) +``` + +``` +逻辑地址表示: +┌──────────────┬────────────────────┐ +│ 段号 S │ 段内地址 d │ +│ (高位部分) │ (低位部分) │ +└──────────────┴────────────────────┘ + +注意: 不同于分页的VPN|VPO可以统一计算, +分段中 d 的取值范围取决于段长,各段不同。 +``` + +**地址表示示例**: + +- 分页地址 `(0x1A8F)` → 可直接算出 VPN=6, VPO=0x28F +- 分段地址 `(2, 0x100)` → 段号=2,偏移=0x100(需要查段表才知道该段的基址和长度) + +--- + +## 四、段表 + +### 段表结构 + +每个进程拥有一张**段表**,段表以**段号**为索引,每项包含: + +| 字段 | 含义 | +|------|------| +| **段号** | 段的编号(隐含在索引中) | +| **段长 (Limit)** | 该段的长度(字节数),用于越界检查 | +| **段基址 (Base)** | 该段在物理内存中的起始地址 | + +``` +段表示例: +┌──────┬────────────┬────────────────┐ +│ 段号 │ 段长(Limit) │ 基址(Base) │ +├──────┼────────────┼────────────────┤ +│ 0 │ 0x2000 │ 0x4000 │ ← 主程序: 在内存0x4000处, 长8KB +│ 1 │ 0x1000 │ 0x8000 │ ← 子程序: 在内存0x8000处, 长4KB +│ 2 │ 0x3000 │ 0xB000 │ ← 数据段: 在内存0xB000处, 长12KB +│ 3 │ 0x1800 │ 0x2000 │ ← 栈段: 在内存0x2000处, 长6KB +└──────┴────────────┴────────────────┘ +``` + +段表基址寄存器 **STBR** 指向段表在内存中的起始地址。 + +--- + +## 五、地址变换过程 + +### 变换流程 + +```mermaid +flowchart TD + A["逻辑地址 (S, d)"] --> B["用STBR找到段表基址"] + B --> C["定位段表项: 段表基址 + S × 段表项大小"] + C --> D{"d < Limit ?"} + D -->|"是"| E["物理地址 PA = Base + d"] + D -->|"否"| F["**越界中断**
(Segmentation Fault)"] + E --> G["访问物理内存"] + + style F fill:#ffcdd2,stroke:#c62828 + style D fill:#fff3e0,stroke:#e65100 +``` + +### 详细步骤 + +1. **提取段号和偏移**: 从逻辑地址中分离出段号 S 和段内地址 d +2. **查段表**: 用 STBR + S 定位到第 S 个段表项 +3. **越界检查**: 比较 d 与 Limit + - 若 d >= Limit,触发**越界中断**(Segmentation Fault) + - 若 d < Limit,继续 +4. **计算物理地址**: PA = Base + d +5. **访问内存**: 用物理地址访问实际内存 + +### 地址变换计算示例 + +> **例题**: 某分段系统,段表如下。求以下逻辑地址对应的物理地址: +> - (0, 0x1500) +> - (1, 0x2000) +> - (2, 0x0800) + +**解题过程**: + +| 逻辑地址 | S | d | Limit | 比较 | Base | PA = Base + d | 结果 | +|---------|---|---|-------|------|------|--------------|------| +| (0, 0x1500) | 0 | 0x1500 | 0x2000 | 0x1500 < 0x2000 ✓ | 0x4000 | 0x4000 + 0x1500 | **0x5500** | +| (1, 0x2000) | 1 | 0x2000 | 0x1000 | 0x2000 >= 0x1000 ✗ | — | — | **越界中断** | +| (2, 0x0800) | 2 | 0x0800 | 0x3000 | 0x0800 < 0x3000 ✓ | 0xB000 | 0xB000 + 0x0800 | **0xB800** | + +--- + +## 六、分段的优缺点 + +### 优点 + +| 优点 | 说明 | +|------|------| +| **符合程序逻辑** | 按代码、数据、栈等逻辑单元组织,便于理解和管理 | +| **便于共享** | 以段为单位共享,粒度合理(如共享整个代码段) | +| **便于动态链接** | 可以按段为单位进行动态链接和装入 | +| **支持动态增长** | 堆、栈等段可以独立扩展,不影响其他段 | +| **保护自然** | 每段可设置独立的访问权限(代码段只读、数据段可读写等) | + +### 缺点 + +| 缺点 | 说明 | +|------|------| +| **外碎片** | 段长不等,内存分配/回收后产生不连续的空闲区 | +| **段长限制** | 每段最大长度受地址结构限制 | +| **内存紧缩开销** | 消除外碎片需要移动段(类似动态分区的紧凑操作) | + +--- + +## 七、段页式存储管理 + +### 基本思想 + +**段页式** = 分段 + 分页,兼具两者的优点: + +- 先按**逻辑结构分段**(保留分段的优点:共享、保护、逻辑清晰) +- 再将每段**按固定大小分页**(保留分页的优点:消除外碎片) + +```mermaid +flowchart TB + subgraph VA["虚拟地址空间"] + direction TB + S0["段0 (主程序)"] + S1["段1 (子程序)"] + S2["段2 (数据)"] + end + + subgraph S0P["段0 内部分页"] + direction TB + P0["页0"] + P1["页1"] + P2["页2"] + end + + subgraph PM["物理内存"] + direction TB + F0["页框0"] + F1["页框1"] + F2["页框2"] + F3["页框3"] + end + + VA --> S0P + P0 --> F2 + P1 --> F0 + P2 --> F3 + + style VA fill:#e3f2fd,stroke:#1976d2 + style S0P fill:#f3e5f5,stroke:#7b1fa2 + style PM fill:#fff8e1,stroke:#f9a825 +``` + +### 段页式地址结构 + +逻辑地址由**三部分**组成: + +``` +段页式逻辑地址: +┌──────────────┬──────────────┬──────────────┐ +│ 段号 S │ 页号 P │ 页内偏移 d │ +└──────────────┴──────────────┴──────────────┘ +``` + +### 段页式地址变换 + +```mermaid +flowchart TD + A["逻辑地址 (S, P, d)"] --> B["1. 用STBR找到段表"] + B --> C["2. 用段号S查段表
得到该段的页表基址和段长"] + C --> D{"P < 该段页数?"} + D -->|"是"| E["3. 用页号P查页表
得到物理页框号 PPN"] + D -->|"否"| F["越界中断"] + E --> G["4. 物理地址 = PPN × 页大小 + d"] + G --> H["访问物理内存"] + + style F fill:#ffcdd2,stroke:#c62828 +``` + +### 段页式地址变换步骤 + +1. **查段表**: 用 STBR + S 找到第 S 个段表项,得到该段的**页表基址** +2. **查页表**: 用页表基址 + P 找到第 P 个页表项,得到 **PPN** +3. **拼接地址**: PA = PPN × 页大小 + d + +> **注意**: 段页式需要 **3 次内存访问**(段表 + 页表 + 数据),比纯分页多一次。TLB 的作用更加关键。 + +### 段页式地址变换计算示例 + +> **例题**: 某段页式系统,页面大小 4KB。段表如下,求逻辑地址 (1, 2, 0x100) 的物理地址。 + +**段表**: + +| 段号 | 页表基址 | 段长(页数) | +|------|---------|-----------| +| 0 | 0x8000 | 4 | +| 1 | 0xA000 | 6 | +| 2 | 0xC000 | 3 | + +**段1的页表** (基址 0xA000): + +| 页号 | PPN | +|------|-----| +| 0 | 5 | +| 1 | 2 | +| 2 | 8 | +| 3 | 1 | +| 4 | 7 | +| 5 | 3 | + +**解题过程**: + +1. S=1, P=2, d=0x100 +2. 查段表:段1的页表基址 = 0xA000,段长 = 6 页 +3. P=2 < 6,合法 +4. 查段1的页表:P=2 对应 PPN=**8** +5. PA = 8 × 4096 + 0x100 = 0x8000 + 0x100 = **0x8100** + +--- + +## 八、三种存储管理方式对比 + +| 对比项 | 纯分页 | 纯分段 | 段页式 | +|--------|--------|--------|--------| +| **划分依据** | 固定大小 | 逻辑结构 | 先逻辑后固定大小 | +| **地址维度** | 一维 | 二维 | 三维 | +| **碎片** | 内碎片 | 外碎片 | 内碎片 | +| **共享** | 以页为粒度 | 以段为粒度 | 以段为粒度 | +| **内存访问次数** | 2次(页表+数据) | 2次(段表+数据) | 3次(段表+页表+数据) | +| **代表系统** | Linux | 早期Multics | Intel x86(32位保护模式) | + +--- + +## 九、小结 + +```mermaid +mindmap + root((段式存储管理)) + 分段引入 + 信息共享不便 + 动态链接需求 + 逻辑结构需求 + 分段思想 + 按逻辑分段 + 代码段/数据段/栈段 + 各段独立地址空间 + 地址结构 + 二维: 段号+偏移 + 不同于分页的一维 + 段表 + 段号/段长/基址 + 越界检查 + STBR寄存器 + 地址变换 + 查段表→越界检查→基址+偏移 + PA = Base + d + 段页式 + 先分段再分页 + 三维地址: 段号+页号+偏移 + 兼具两者优点 +``` + +--- + +## 思考题 + +1. **概念理解**: 分段和分页的根本区别是什么?为什么说分段的地址空间是二维的? + +2. **地址变换**: 某分段系统有 4 个段,段表如下。求物理地址或判断是否越界: + - (0, 0x800) → ? + - (2, 0x5000) → ? + - (3, 0x2000) → ? + + | 段号 | 段长 | 基址 | + |------|------|------| + | 0 | 0x1000 | 0x5000 | + | 1 | 0x2000 | 0x8000 | + | 2 | 0x4000 | 0xA000 | + | 3 | 0x3000 | 0x2000 | + +3. **段页式**: 为什么段页式需要 3 次内存访问?TLB 如何缓解这个问题? + +4. **对比分析**: 在什么场景下分段优于分页?在什么场景下分页优于分段? + +--- + +## 关联笔记 + +- [[13_存储管理基础]] — 存储器层次结构与地址空间基础 +- [[14_分页存储管理]] — 分页思想、页表与地址变换 +- [[16_虚拟存储器]] — 基于分段的虚拟存储器实现 + +--- + +**上一讲**: [[14_分页存储管理]] +**下一讲**: [[16_虚拟存储器]] diff --git a/操作系统/16_虚拟存储器/16_虚拟存储器.md b/操作系统/16_虚拟存储器/16_虚拟存储器.md index f920f45..517d277 100644 --- a/操作系统/16_虚拟存储器/16_虚拟存储器.md +++ b/操作系统/16_虚拟存储器/16_虚拟存储器.md @@ -1,606 +1,606 @@ -# 16. 虚拟存储器 - -> **课程**: 操作系统 - 存储器管理 -> **核心内容**: 虚拟存储器思想、局部性原理、请求分页、页面置换算法、内存分配策略、抖动与工作集 - ---- - -## 前置知识 - -- [[14_分页存储管理]] — 分页思想、页表、地址变换、缺页中断 -- [[15_段式存储管理]] — 分段思想、段页式 -- [[11_处理机调度]] — 进程调度基本概念 - ---- - -## 一、虚拟存储器的基本思想 - -### 问题的提出 - -在传统的存储管理中,作业必须**全部装入内存**才能运行。这带来两个问题: -1. 作业大小超过物理内存时,无法运行 -2. 内存中同时驻留多个大程序时,内存不够用 - -### 虚拟存储器思想 - -**核心**: 程序员在比实际物理内存**大得多**的虚拟地址空间中编写程序,运行时只需将**部分页面**调入内存,其余留在外存。当访问到不在内存的页面时,再从外存调入。 - -```mermaid -flowchart LR - subgraph VA["虚拟地址空间 (很大)"] - direction TB - V0["页面0 ✓ 在内存"] - V1["页面1 ✓ 在内存"] - V2["页面2 ✗ 在外存"] - V3["页面3 ✓ 在内存"] - V4["页面4 ✗ 在外存"] - V5["..."] - end - - subgraph MEM["物理内存 (较小)"] - direction TB - M0["页框0"] - M1["页框1"] - M2["页框2"] - M3["页框3"] - end - - subgraph DISK["外存 (磁盘)"] - direction TB - D0["页面2"] - D1["页面4"] - D2["..."] - end - - V0 --> M0 - V1 --> M1 - V3 --> M3 - V2 -.->|"缺页时调入"| DISK - V4 -.->|"缺页时调入"| DISK - - style VA fill:#e3f2fd,stroke:#1976d2 - style MEM fill:#fff8e1,stroke:#f9a825 - style DISK fill:#fce4ec,stroke:#c62828 -``` - -### 实现基础 - -虚拟存储器的实现依赖两个关键技术: -1. **[[14_分页存储管理|分页]]或[[15_段式存储管理|分段]]**: 将程序划分为小块,可以部分装入 -2. **[[14_分页存储管理#五、地址变换过程|缺页中断]]**: 访问不在内存的页面时,由OS负责调入 - ---- - -## 二、局部性原理 - -虚拟存储器之所以可行,是因为程序运行时具有**局部性**——程序不会均匀地访问所有地址空间。 - -### 时间局部性 (Temporal Locality) - -> **含义**: 如果一条指令/数据被访问,那么在不久的将来它很可能**再次被访问**。 - -**典型场景**: -- 循环中的指令反复执行 -- 频繁访问的变量 -- 栈顶数据的反复操作 - -### 空间局部性 (Spatial Locality) - -> **含义**: 如果一个存储单元被访问,那么它**附近的单元**也很可能很快被访问。 - -**典型场景**: -- 顺序执行的指令 -- 数组的顺序遍历 -- 结构体成员的连续访问 - -### 局部性的实际影响:行优先 vs 列优先 - -以二维数组遍历为例,行优先和列优先的性能差异可达 **21.5 倍**: - -```c -// 二维数组: int a[1024][1024]; - -// 行优先遍历 (空间局部性好) -for (int i = 0; i < 1024; i++) - for (int j = 0; j < 1024; j++) - sum += a[i][j]; // 访问顺序: a[0][0], a[0][1], a[0][2], ... - // 连续内存访问,充分利用缓存行 - -// 列优先遍历 (空间局部性差) -for (int j = 0; j < 1024; j++) - for (int i = 0; i < 1024; i++) - sum += a[i][j]; // 访问顺序: a[0][0], a[1][0], a[2][0], ... - // 每次跳过一整行,频繁缺页/缓存失效 -``` - -```mermaid -flowchart LR - subgraph RowMajor["行优先遍历"] - direction LR - R1["a[0][0]"] --> R2["a[0][1]"] --> R3["a[0][2]"] --> R4["..."] - style R1 fill:#c8e6c9 - style R2 fill:#c8e6c9 - style R3 fill:#c8e6c9 - end - - subgraph ColMajor["列优先遍历"] - direction LR - C1["a[0][0]"] --> C2["a[1][0]"] --> C3["a[2][0]"] --> C4["..."] - style C1 fill:#ffcdd2 - style C2 fill:#ffcdd2 - style C3 fill:#ffcdd2 - end - - RowMajor ---|"性能差异约 21.5 倍"| ColMajor -``` - ---- - -## 三、请求分页 - -### 基本思想 - -**请求分页 (Demand Paging)** 是最常用的虚拟存储器实现方式: -- 页面只在**被访问时**才调入内存(而非预装入) -- 内存不足时,将某些页面**换出**到外存,腾出空间 - -### 页表项扩展 - -在[[14_分页存储管理#四、页表|基本分页]]的基础上,请求分页的页表项增加了**状态位**: - -| 字段 | 含义 | 说明 | -|------|------|------| -| **状态位 (Present/Valid)** | 页面是否在内存 | 1=在内存,0=不在内存 | -| **访问位 (Reference/Access)** | 页面是否被访问过 | 用于置换算法判断 | -| **修改位 (Dirty)** | 页面是否被写过 | Dirty=1 时换出需写回外存 | -| **外存地址** | 页面在外存中的位置 | 用于缺页时调入 | - -### 缺页中断处理 - -```mermaid -flowchart TD - A["CPU访问虚拟地址"] --> B{"页面在内存中?
(检查状态位)"} - B -->|"在内存"| C["正常地址变换,访问数据"] - B -->|"不在内存"| D["触发**缺页中断**"] - D --> E["保存CPU现场"] - E --> F{"外存中存在该页?"} - F -->|"不存在"| G["终止进程
Segmentation Fault"] - F -->|"存在"| H{"内存有空闲页框?"} - H -->|"有空闲"| I["从外存读入该页"] - H -->|"无空闲"| J["执行**页面置换算法**
选择牺牲页"] - J --> K{"牺牲页Dirty=1?"} - K -->|"是"| L["将牺牲页写回外存"] - K -->|"否"| M["直接覆盖"] - L --> I - M --> I - I --> N["更新页表:
状态位=1, PPN"] - N --> O["刷新TLB"] - O --> P["重新执行被中断的指令"] - - style D fill:#ffcdd2,stroke:#c62828 - style J fill:#fff3e0,stroke:#e65100 - style G fill:#ffcdd2,stroke:#c62828 -``` - ---- - -## 四、页面置换算法 - -当内存中没有空闲页框时,需要选择一个页面换出到外存,为新页面腾出空间。不同的选择策略就是**页面置换算法**。 - -> **示例参数**: 物理内存有 **3 个页框**,页面引用串为: -> **7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1** - -### 1. OPT (Optimal) 最佳置换算法 - -**策略**: 换出**将来最长时间不会被使用**的页面。 - -> OPT 是理论算法,因为无法预知未来的页面访问序列。它用于衡量其他算法的上限。 - -**执行过程**: - -| 访问序列 | 页框0 | 页框1 | 页框2 | 缺页? | 说明 | -|---------|-------|-------|-------|-------|------| -| **7** | 7 | - | - | 缺页 | 空闲页框,直接装入 | -| **0** | 7 | 0 | - | 缺页 | 空闲页框,直接装入 | -| **1** | 7 | 0 | 1 | 缺页 | 空闲页框,直接装入 | -| **2** | 7 | 0 | **2** | 缺页 | 换出1(最久后才用) | -| **0** | 7 | 0 | 2 | 命中 | 0已在内存 | -| **3** | **3** | 0 | 2 | 缺页 | 换出7(最久后才用) | -| **0** | 3 | 0 | 2 | 命中 | | -| **4** | 3 | 0 | **4** | 缺页 | 换出2(最久后才用) | -| **2** | 3 | **2** | 4 | 缺页 | 换出0(最久后才用) | -| **3** | 3 | 2 | 4 | 命中 | | -| **0** | 3 | 2 | **0** | 缺页 | 换出4(最久后才用) | -| **3** | 3 | 2 | 0 | 命中 | | -| **2** | 3 | 2 | 0 | 命中 | | -| **1** | **1** | 2 | 0 | 缺页 | 换出3(最久后才用) | -| **2** | 1 | 2 | 0 | 命中 | | -| **0** | 1 | 2 | 0 | 命中 | | -| **1** | 1 | 2 | 0 | 命中 | | -| **7** | 1 | **7** | 0 | 缺页 | 换出2(最久后才用) | -| **0** | 1 | 7 | 0 | 命中 | | -| **1** | 1 | 7 | 0 | 命中 | | - -**缺页次数: 9**,缺页率 = 9/20 = **45%** - -### 2. FIFO (First-In First-Out) 先进先出 - -**策略**: 换出**最早进入内存**的页面。 - -**执行过程**: - -| 访问序列 | 页框0 | 页框1 | 页框2 | 缺页? | 淘汰队列 | -|---------|-------|-------|-------|-------|---------| -| **7** | 7 | - | - | 缺页 | [7] | -| **0** | 7 | 0 | - | 缺页 | [7,0] | -| **1** | 7 | 0 | 1 | 缺页 | [7,0,1] | -| **2** | **2** | 0 | 1 | 缺页 | [0,1,2] 淘汰7 | -| **0** | 2 | 0 | 1 | 命中 | [0,1,2] | -| **3** | 2 | **3** | 1 | 缺页 | [1,2,3] 淘汰0 | -| **0** | 2 | 3 | **0** | 缺页 | [2,3,0] 淘汰1 | -| **4** | **4** | 3 | 0 | 缺页 | [3,0,4] 淘汰2 | -| **2** | 4 | **2** | 0 | 缺页 | [0,4,2] 淘汰3 | -| **3** | 4 | 2 | **3** | 缺页 | [4,2,3] 淘汰0 | -| **0** | **0** | 2 | 3 | 缺页 | [2,3,0] 淘汰4 | -| **3** | 0 | 2 | 3 | 命中 | [2,3,0] | -| **2** | 0 | 2 | 3 | 命中 | [2,3,0] | -| **1** | 0 | **1** | 3 | 缺页 | [3,0,1] 淘汰2 | -| **2** | 0 | 1 | **2** | 缺页 | [0,1,2] 淘汰3 | -| **0** | 0 | 1 | 2 | 命中 | [0,1,2] | -| **1** | 0 | 1 | 2 | 命中 | [0,1,2] | -| **7** | **7** | 1 | 2 | 缺页 | [1,2,7] 淘汰0 | -| **0** | 7 | **0** | 2 | 缺页 | [2,7,0] 淘汰1 | -| **1** | 7 | 0 | **1** | 缺页 | [7,0,1] 淘汰2 | - -**缺页次数: 15**,缺页率 = 15/20 = **75%** - -> **Belady 异常**: FIFO 算法在某些情况下,增加物理页框数反而会导致缺页率**上升**。例如本例中 4 个页框时缺页次数可能比 3 个页框时更多。这是 FIFO 算法的独特缺陷。 - -### 3. LRU (Least Recently Used) 最近最久未使用 - -**策略**: 换出**最近最长时间没有被访问**的页面。 - -**实现方式**: -- **栈法**: 用一个栈保存页面号,访问某页时将其移到栈顶。栈底就是最久未使用的页面 -- **计数器法**: 每个页面记录最后一次访问的时间,淘汰时间值最小的页面 - -**执行过程**: - -| 访问序列 | 页框0 | 页框1 | 页框2 | 缺页? | 说明 | -|---------|-------|-------|-------|-------|------| -| **7** | 7 | - | - | 缺页 | | -| **0** | 7 | 0 | - | 缺页 | | -| **1** | 7 | 0 | 1 | 缺页 | | -| **2** | 2 | 0 | 1 | 缺页 | 淘汰7(最久未用) | -| **0** | 2 | 0 | 1 | 命中 | 0被访问,更新时间戳 | -| **3** | 2 | 0 | 3 | 缺页 | 淘汰1(最久未用) | -| **0** | 2 | 0 | 3 | 命中 | | -| **4** | 4 | 0 | 3 | 缺页 | 淘汰2(最久未用) | -| **2** | 4 | 0 | 2 | 缺页 | 淘汰3(最久未用) | -| **3** | 4 | 3 | 2 | 缺页 | 淘汰0(最久未用) | -| **0** | 0 | 3 | 2 | 缺页 | 淘汰4(最久未用) | -| **3** | 0 | 3 | 2 | 命中 | | -| **2** | 0 | 3 | 2 | 命中 | | -| **1** | 0 | 3 | 1 | 缺页 | 淘汰2(最久未用) | -| **2** | 0 | 2 | 1 | 缺页 | 淘汰3(最久未用) | -| **0** | 0 | 2 | 1 | 命中 | | -| **1** | 0 | 2 | 1 | 命中 | | -| **7** | 0 | 2 | 7 | 缺页 | 淘汰1(最久未用) | -| **0** | 0 | 2 | 7 | 命中 | | -| **1** | 1 | 2 | 7 | 缺页 | 淘汰0(最久未用) | - -**缺页次数: 12**,缺页率 = 12/20 = **60%** - -### 4. Clock (时钟 / 近似LRU) - -**策略**: 将所有页面组成一个**循环链表**,每页有一个**访问位 R**。淘汰指针从当前位置开始扫描: -- R=1:将 R 置 0,指针前移(给第二次机会) -- R=0:淘汰该页 - -```mermaid -flowchart LR - subgraph Clock["时钟置换算法 (循环链表)"] - direction LR - P0["页0
R=1"] - P1["页1
R=0"] - P2["页2
R=1"] - P3["页3
R=0"] - end - - PTR["淘汰指针"] --> P1 - - P0 --> P1 - P1 --> P2 - P2 --> P3 - P3 --> P0 - - style P1 fill:#ffcdd2,stroke:#c62828 - style PTR fill:#fff3e0,stroke:#e65100 -``` - -**执行示例**(简化): -- 指针指向页1 (R=0) → 淘汰页1,装入新页,指针前移 -- 指针指向页2 (R=1) → R置0,指针前移 -- 指针指向页3 (R=0) → 淘汰页3 - -**改进型Clock**: 同时考虑 R 和 D (修改位),优先淘汰 R=0 且 D=0 的页面(不需要写回外存)。 - -| 优先级 | R | D | 淘汰代价 | -|--------|---|---|---------| -| 最高 | 0 | 0 | 未访问未修改,直接覆盖 | -| | 0 | 1 | 未访问但已修改,需写回 | -| | 1 | 0 | 已访问未修改,再给机会 | -| 最低 | 1 | 1 | 已访问已修改,最不想淘汰 | - -### 5. LFU (Least Frequently Used) 最少使用 - -**策略**: 换出**访问次数最少**的页面。 - -- 用计数器记录每个页面的访问次数 -- 淘汰计数器值最小的页面 -- **缺点**: 某页面过去被频繁访问但现在不再使用,仍难以被淘汰 -- **实现开销**: 需要为每个页面维护计数器 - -### 算法对比 - -| 算法 | 策略 | 优点 | 缺点 | 是否有Belady异常 | -|------|------|------|------|----------------| -| **OPT** | 淘汰将来最久不用的 | 缺页率最低(理论最优) | 无法实现 | 无 | -| **FIFO** | 淘汰最早进入的 | 实现简单 | 缺页率高,有Belady异常 | **有** | -| **LRU** | 淘汰最近最久未用的 | 接近OPT,效果好 | 实现开销大(需时间戳或栈) | 无 | -| **Clock** | 给访问位R=0的淘汰 | LRU的近似,开销小 | 效果略逊于LRU | 无 | -| **LFU** | 淘汰访问次数最少的 | 考虑历史频率 | 对访问模式变化响应慢 | 无 | - -### 算法效果对比(本例) - -``` -页面引用串: 7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1 (3个物理块) - -┌──────────┬──────────┬──────────┐ -│ 算法 │ 缺页次数 │ 缺页率 │ -├──────────┼──────────┼──────────┤ -│ OPT │ 9 │ 45% │ ← 理论最优 -│ LRU │ 12 │ 60% │ -│ FIFO │ 15 │ 75% │ ← 效果最差 -└──────────┴──────────┴──────────┘ -``` - ---- - -## 五、内存分配策略 - -### 物理页框分配方式 - -| 方式 | 说明 | -|------|------| -| **平均分配** | 将可用页框平均分配给所有进程 | -| **按比例分配** | 按进程大小占总需求的比例分配 | -| **考虑优先权** | 高优先级进程分配更多页框 | - -**按比例分配公式**: - -$$a_i = \frac{s_i}{\sum s_j} \times m$$ - -其中 $s_i$ 是进程 $i$ 的页面数,$m$ 是可用页框总数。 - -### 置换策略 - -| 策略 | 说明 | 特点 | -|------|------|------| -| **固定分配局部置换** | 每个进程固定页框数,只能在自己的页面中置换 | 公平,但可能浪费 | -| **可变分配全局置换** | 从全局空闲页框池中分配,可换出任何进程的页面 | 灵活,但可能影响其他进程 | -| **可变分配局部置换** | 进程页框数可调,但只能在自己的页面中置换 | 折中方案 | - ---- - -## 六、抖动与工作集 - -### 抖动 (Thrashing) - -当进程分配到的页框数**不足以**容纳其工作集时,会频繁发生缺页,CPU 大量时间花在页面置换上,利用率急剧下降——这种现象称为**抖动**。 - -```mermaid -flowchart TD - A["进程增多"] --> B["每个进程分到的页框减少"] - B --> C["缺页率上升"] - C --> D["CPU利用率下降"] - D --> E["调度器增加进程数
(以为CPU空闲)"] - E --> B - - style C fill:#ffcdd2,stroke:#c62828 - style D fill:#ffcdd2,stroke:#c62828 -``` - -CPU 利用率随进程数的变化: - -``` -CPU利用率 - ↑ -100%│ ╱‾‾‾‾╲ - │ ╱ ╲ - │ ╱ ╲ ← 抖动区域 - │ ╱ ╲ - │ ╱ ╲ - │╱ ╲ - └──────────────────────────→ 进程数 - ↑ - 最佳点 -``` - -### 工作集 (Working Set) - -**工作集**的定义:在最近 $\Delta$ 时间内,进程实际访问的页面集合。 - -$$W(t, \Delta) = \text{在时刻 } t-\Delta \text{ 到 } t \text{ 之间访问的页面集合}$$ - -**示例**: - -页面访问序列: ... 2, 6, 1, 5, 7, 7, 7, 2, ... - -设 $\Delta = 5$(最近 5 次访问),当前访问页面 2: -- 最近 5 次访问: {5, 7, 7, 7, 2} -- 工作集 = {2, 5, 7},大小 = 3 - -**工作集策略**: 为每个进程分配**不少于其工作集大小**的页框数,就可以避免抖动。 - -### 工作集模型的应用 - -```mermaid -flowchart TD - A["监控每个进程的工作集"] --> B{"总工作集 ≤ 物理页框数?"} - B -->|"是"| C["正常运行,不会抖动"] - B -->|"否"| D["可能发生抖动"] - D --> E["挂起某些进程
释放页框"] - E --> B - - style D fill:#ffcdd2,stroke:#c62828 - style C fill:#c8e6c9,stroke:#2e7d32 -``` - ---- - -## 七、请求分段 - -### 基本思想 - -**请求分段**是虚拟存储器在[[15_段式存储管理|分段]]基础上的实现: -- 段不需要全部装入内存 -- 访问到不在内存的段时,触发**缺段中断**,从外存调入 - -### 与请求分页的对比 - -| 对比项 | 请求分页 | 请求分段 | -|--------|---------|---------| -| 调入/调出单位 | 页面(固定大小) | 段(可变大小) | -| 碎片 | 内碎片 | 外碎片 | -| 共享 | 以页为粒度 | 以段为粒度 | -| 中断类型 | 缺页中断 | 缺段中断 | -| 实现复杂度 | 较简单 | 较复杂 | - ---- - -## 八、完整计算示例 - -### 综合例题 - -> 某系统采用请求分页存储管理,页面大小为 4KB,物理内存有 4 个页框。某进程的页面访问序列为:**0, 1, 4, 2, 0, 2, 6, 5, 1, 2, 3, 2, 1, 2, 6, 5, 2, 1, 3, 6**。分别用 FIFO 和 LRU 算法计算缺页次数。 - -**FIFO 算法**: - -| 访问 | 页框0 | 页框1 | 页框2 | 页框3 | 缺页? | -|------|-------|-------|-------|-------|-------| -| 0 | 0 | - | - | - | 缺页 | -| 1 | 0 | 1 | - | - | 缺页 | -| 4 | 0 | 1 | 4 | - | 缺页 | -| 2 | 0 | 1 | 4 | 2 | 缺页 | -| 0 | 0 | 1 | 4 | 2 | 命中 | -| 2 | 0 | 1 | 4 | 2 | 命中 | -| 6 | 6 | 1 | 4 | 2 | 缺页(淘汰0) | -| 5 | 6 | 5 | 4 | 2 | 缺页(淘汰1) | -| 1 | 6 | 5 | 1 | 2 | 缺页(淘汰4) | -| 2 | 6 | 5 | 1 | 2 | 命中 | -| 3 | 6 | 5 | 1 | 3 | 缺页(淘汰2) | -| 2 | 2 | 5 | 1 | 3 | 缺页(淘汰6) | -| 1 | 2 | 5 | 1 | 3 | 命中 | -| 2 | 2 | 5 | 1 | 3 | 命中 | -| 6 | 2 | 6 | 1 | 3 | 缺页(淘汰5) | -| 5 | 2 | 6 | 5 | 3 | 缺页(淘汰1) | -| 2 | 2 | 6 | 5 | 3 | 命中 | -| 1 | 2 | 6 | 5 | 1 | 缺页(淘汰3) | -| 3 | 3 | 6 | 5 | 1 | 缺页(淘汰2) | -| 6 | 3 | 6 | 5 | 1 | 命中 | - -**FIFO 缺页次数: 14** - -**LRU 算法**: - -| 访问 | 页框0 | 页框1 | 页框2 | 页框3 | 缺页? | -|------|-------|-------|-------|-------|-------| -| 0 | 0 | - | - | - | 缺页 | -| 1 | 0 | 1 | - | - | 缺页 | -| 4 | 0 | 1 | 4 | - | 缺页 | -| 2 | 0 | 1 | 4 | 2 | 缺页 | -| 0 | 0 | 1 | 4 | 2 | 命中 | -| 2 | 0 | 1 | 4 | 2 | 命中 | -| 6 | 0 | 1 | 6 | 2 | 缺页(淘汰4) | -| 5 | 0 | 5 | 6 | 2 | 缺页(淘汰1) | -| 1 | 1 | 5 | 6 | 2 | 缺页(淘汰0) | -| 2 | 1 | 5 | 6 | 2 | 命中 | -| 3 | 1 | 5 | 3 | 2 | 缺页(淘汰6) | -| 2 | 1 | 5 | 3 | 2 | 命中 | -| 1 | 1 | 5 | 3 | 2 | 命中 | -| 2 | 1 | 5 | 3 | 2 | 命中 | -| 6 | 1 | 5 | 3 | 6 | 缺页(淘汰2) | -| 5 | 1 | 5 | 3 | 6 | 命中 | -| 2 | 2 | 5 | 3 | 6 | 缺页(淘汰1) | -| 1 | 2 | 1 | 3 | 6 | 缺页(淘汰5) | -| 3 | 2 | 1 | 3 | 6 | 命中 | -| 6 | 2 | 1 | 3 | 6 | 命中 | - -**LRU 缺页次数: 12** - ---- - -## 九、小结 - -```mermaid -mindmap - root((虚拟存储器)) - 基本思想 - 虚拟空间>物理内存 - 部分装入 - 按需调入调出 - 局部性原理 - 时间局部性 - 空间局部性 - 行优先vs列优先 - 请求分页 - 页表扩展状态位 - 缺页中断处理 - 页面置换 - 置换算法 - OPT理论最优 - FIFO简单但有Belady异常 - LRU效果好但开销大 - Clock近似LRU - LFU最少使用 - 内存分配 - 平均/按比例/优先权 - 固定/可变分配 - 局部/全局置换 - 抖动与工作集 - 频繁缺页=抖动 - 工作集=近期访问页面集 - 帧数≥工作集大小 -``` - ---- - -## 思考题 - -1. **概念理解**: 为什么说虚拟存储器的基础是局部性原理?如果没有局部性,虚拟存储器还能工作吗? - -2. **算法对比**: 对于页面引用串 1,2,3,4,1,2,5,1,2,3,4,5(3个物理块),分别计算 OPT、FIFO、LRU 的缺页次数。是否存在Belady异常? - -3. **工作集**: 某进程页面访问序列为 2,6,1,5,7,7,7,2,5,1,3,1,5,7,2,5,设 $\Delta=5$,求各时刻的工作集大小。 - -4. **抖动分析**: 某系统物理内存可容纳 10 个工作集页面。目前有 5 个进程,工作集大小分别为 {3,2,2,1,3}。此时再增加一个工作集大小为 3 的进程,系统是否会发生抖动? - -5. **综合题**: 在请求分页系统中,页面大小 4KB,页表项 4 字节,虚拟地址 32 位,物理地址 32 位。 - - 一级页表有多大? - - 如果采用二级页表,页目录和页表各多大? - - 如果 TLB 命中率 95%,TLB 访问 10ns,内存访问 100ns,有效访问时间是多少? - ---- - -## 关联笔记 - -- [[14_分页存储管理]] — 分页基础、页表结构、地址变换 -- [[15_段式存储管理]] — 分段思想、段页式 -- [[11_处理机调度]] — 进程调度与抖动的关系 - ---- - -**上一讲**: [[15_段式存储管理]] -**目录**: [[00_课程导航]] +# 16. 虚拟存储器 + +> **课程**: 操作系统 - 存储器管理 +> **核心内容**: 虚拟存储器思想、局部性原理、请求分页、页面置换算法、内存分配策略、抖动与工作集 + +--- + +## 前置知识 + +- [[14_分页存储管理]] — 分页思想、页表、地址变换、缺页中断 +- [[15_段式存储管理]] — 分段思想、段页式 +- [[11_处理机调度]] — 进程调度基本概念 + +--- + +## 一、虚拟存储器的基本思想 + +### 问题的提出 + +在传统的存储管理中,作业必须**全部装入内存**才能运行。这带来两个问题: +1. 作业大小超过物理内存时,无法运行 +2. 内存中同时驻留多个大程序时,内存不够用 + +### 虚拟存储器思想 + +**核心**: 程序员在比实际物理内存**大得多**的虚拟地址空间中编写程序,运行时只需将**部分页面**调入内存,其余留在外存。当访问到不在内存的页面时,再从外存调入。 + +```mermaid +flowchart LR + subgraph VA["虚拟地址空间 (很大)"] + direction TB + V0["页面0 ✓ 在内存"] + V1["页面1 ✓ 在内存"] + V2["页面2 ✗ 在外存"] + V3["页面3 ✓ 在内存"] + V4["页面4 ✗ 在外存"] + V5["..."] + end + + subgraph MEM["物理内存 (较小)"] + direction TB + M0["页框0"] + M1["页框1"] + M2["页框2"] + M3["页框3"] + end + + subgraph DISK["外存 (磁盘)"] + direction TB + D0["页面2"] + D1["页面4"] + D2["..."] + end + + V0 --> M0 + V1 --> M1 + V3 --> M3 + V2 -.->|"缺页时调入"| DISK + V4 -.->|"缺页时调入"| DISK + + style VA fill:#e3f2fd,stroke:#1976d2 + style MEM fill:#fff8e1,stroke:#f9a825 + style DISK fill:#fce4ec,stroke:#c62828 +``` + +### 实现基础 + +虚拟存储器的实现依赖两个关键技术: +1. **[[14_分页存储管理|分页]]或[[15_段式存储管理|分段]]**: 将程序划分为小块,可以部分装入 +2. **[[14_分页存储管理#五、地址变换过程|缺页中断]]**: 访问不在内存的页面时,由OS负责调入 + +--- + +## 二、局部性原理 + +虚拟存储器之所以可行,是因为程序运行时具有**局部性**——程序不会均匀地访问所有地址空间。 + +### 时间局部性 (Temporal Locality) + +> **含义**: 如果一条指令/数据被访问,那么在不久的将来它很可能**再次被访问**。 + +**典型场景**: +- 循环中的指令反复执行 +- 频繁访问的变量 +- 栈顶数据的反复操作 + +### 空间局部性 (Spatial Locality) + +> **含义**: 如果一个存储单元被访问,那么它**附近的单元**也很可能很快被访问。 + +**典型场景**: +- 顺序执行的指令 +- 数组的顺序遍历 +- 结构体成员的连续访问 + +### 局部性的实际影响:行优先 vs 列优先 + +以二维数组遍历为例,行优先和列优先的性能差异可达 **21.5 倍**: + +```c +// 二维数组: int a[1024][1024]; + +// 行优先遍历 (空间局部性好) +for (int i = 0; i < 1024; i++) + for (int j = 0; j < 1024; j++) + sum += a[i][j]; // 访问顺序: a[0][0], a[0][1], a[0][2], ... + // 连续内存访问,充分利用缓存行 + +// 列优先遍历 (空间局部性差) +for (int j = 0; j < 1024; j++) + for (int i = 0; i < 1024; i++) + sum += a[i][j]; // 访问顺序: a[0][0], a[1][0], a[2][0], ... + // 每次跳过一整行,频繁缺页/缓存失效 +``` + +```mermaid +flowchart LR + subgraph RowMajor["行优先遍历"] + direction LR + R1["a[0][0]"] --> R2["a[0][1]"] --> R3["a[0][2]"] --> R4["..."] + style R1 fill:#c8e6c9 + style R2 fill:#c8e6c9 + style R3 fill:#c8e6c9 + end + + subgraph ColMajor["列优先遍历"] + direction LR + C1["a[0][0]"] --> C2["a[1][0]"] --> C3["a[2][0]"] --> C4["..."] + style C1 fill:#ffcdd2 + style C2 fill:#ffcdd2 + style C3 fill:#ffcdd2 + end + + RowMajor ---|"性能差异约 21.5 倍"| ColMajor +``` + +--- + +## 三、请求分页 + +### 基本思想 + +**请求分页 (Demand Paging)** 是最常用的虚拟存储器实现方式: +- 页面只在**被访问时**才调入内存(而非预装入) +- 内存不足时,将某些页面**换出**到外存,腾出空间 + +### 页表项扩展 + +在[[14_分页存储管理#四、页表|基本分页]]的基础上,请求分页的页表项增加了**状态位**: + +| 字段 | 含义 | 说明 | +|------|------|------| +| **状态位 (Present/Valid)** | 页面是否在内存 | 1=在内存,0=不在内存 | +| **访问位 (Reference/Access)** | 页面是否被访问过 | 用于置换算法判断 | +| **修改位 (Dirty)** | 页面是否被写过 | Dirty=1 时换出需写回外存 | +| **外存地址** | 页面在外存中的位置 | 用于缺页时调入 | + +### 缺页中断处理 + +```mermaid +flowchart TD + A["CPU访问虚拟地址"] --> B{"页面在内存中?
(检查状态位)"} + B -->|"在内存"| C["正常地址变换,访问数据"] + B -->|"不在内存"| D["触发**缺页中断**"] + D --> E["保存CPU现场"] + E --> F{"外存中存在该页?"} + F -->|"不存在"| G["终止进程
Segmentation Fault"] + F -->|"存在"| H{"内存有空闲页框?"} + H -->|"有空闲"| I["从外存读入该页"] + H -->|"无空闲"| J["执行**页面置换算法**
选择牺牲页"] + J --> K{"牺牲页Dirty=1?"} + K -->|"是"| L["将牺牲页写回外存"] + K -->|"否"| M["直接覆盖"] + L --> I + M --> I + I --> N["更新页表:
状态位=1, PPN"] + N --> O["刷新TLB"] + O --> P["重新执行被中断的指令"] + + style D fill:#ffcdd2,stroke:#c62828 + style J fill:#fff3e0,stroke:#e65100 + style G fill:#ffcdd2,stroke:#c62828 +``` + +--- + +## 四、页面置换算法 + +当内存中没有空闲页框时,需要选择一个页面换出到外存,为新页面腾出空间。不同的选择策略就是**页面置换算法**。 + +> **示例参数**: 物理内存有 **3 个页框**,页面引用串为: +> **7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1** + +### 1. OPT (Optimal) 最佳置换算法 + +**策略**: 换出**将来最长时间不会被使用**的页面。 + +> OPT 是理论算法,因为无法预知未来的页面访问序列。它用于衡量其他算法的上限。 + +**执行过程**: + +| 访问序列 | 页框0 | 页框1 | 页框2 | 缺页? | 说明 | +|---------|-------|-------|-------|-------|------| +| **7** | 7 | - | - | 缺页 | 空闲页框,直接装入 | +| **0** | 7 | 0 | - | 缺页 | 空闲页框,直接装入 | +| **1** | 7 | 0 | 1 | 缺页 | 空闲页框,直接装入 | +| **2** | 7 | 0 | **2** | 缺页 | 换出1(最久后才用) | +| **0** | 7 | 0 | 2 | 命中 | 0已在内存 | +| **3** | **3** | 0 | 2 | 缺页 | 换出7(最久后才用) | +| **0** | 3 | 0 | 2 | 命中 | | +| **4** | 3 | 0 | **4** | 缺页 | 换出2(最久后才用) | +| **2** | 3 | **2** | 4 | 缺页 | 换出0(最久后才用) | +| **3** | 3 | 2 | 4 | 命中 | | +| **0** | 3 | 2 | **0** | 缺页 | 换出4(最久后才用) | +| **3** | 3 | 2 | 0 | 命中 | | +| **2** | 3 | 2 | 0 | 命中 | | +| **1** | **1** | 2 | 0 | 缺页 | 换出3(最久后才用) | +| **2** | 1 | 2 | 0 | 命中 | | +| **0** | 1 | 2 | 0 | 命中 | | +| **1** | 1 | 2 | 0 | 命中 | | +| **7** | 1 | **7** | 0 | 缺页 | 换出2(最久后才用) | +| **0** | 1 | 7 | 0 | 命中 | | +| **1** | 1 | 7 | 0 | 命中 | | + +**缺页次数: 9**,缺页率 = 9/20 = **45%** + +### 2. FIFO (First-In First-Out) 先进先出 + +**策略**: 换出**最早进入内存**的页面。 + +**执行过程**: + +| 访问序列 | 页框0 | 页框1 | 页框2 | 缺页? | 淘汰队列 | +|---------|-------|-------|-------|-------|---------| +| **7** | 7 | - | - | 缺页 | [7] | +| **0** | 7 | 0 | - | 缺页 | [7,0] | +| **1** | 7 | 0 | 1 | 缺页 | [7,0,1] | +| **2** | **2** | 0 | 1 | 缺页 | [0,1,2] 淘汰7 | +| **0** | 2 | 0 | 1 | 命中 | [0,1,2] | +| **3** | 2 | **3** | 1 | 缺页 | [1,2,3] 淘汰0 | +| **0** | 2 | 3 | **0** | 缺页 | [2,3,0] 淘汰1 | +| **4** | **4** | 3 | 0 | 缺页 | [3,0,4] 淘汰2 | +| **2** | 4 | **2** | 0 | 缺页 | [0,4,2] 淘汰3 | +| **3** | 4 | 2 | **3** | 缺页 | [4,2,3] 淘汰0 | +| **0** | **0** | 2 | 3 | 缺页 | [2,3,0] 淘汰4 | +| **3** | 0 | 2 | 3 | 命中 | [2,3,0] | +| **2** | 0 | 2 | 3 | 命中 | [2,3,0] | +| **1** | 0 | **1** | 3 | 缺页 | [3,0,1] 淘汰2 | +| **2** | 0 | 1 | **2** | 缺页 | [0,1,2] 淘汰3 | +| **0** | 0 | 1 | 2 | 命中 | [0,1,2] | +| **1** | 0 | 1 | 2 | 命中 | [0,1,2] | +| **7** | **7** | 1 | 2 | 缺页 | [1,2,7] 淘汰0 | +| **0** | 7 | **0** | 2 | 缺页 | [2,7,0] 淘汰1 | +| **1** | 7 | 0 | **1** | 缺页 | [7,0,1] 淘汰2 | + +**缺页次数: 15**,缺页率 = 15/20 = **75%** + +> **Belady 异常**: FIFO 算法在某些情况下,增加物理页框数反而会导致缺页率**上升**。例如本例中 4 个页框时缺页次数可能比 3 个页框时更多。这是 FIFO 算法的独特缺陷。 + +### 3. LRU (Least Recently Used) 最近最久未使用 + +**策略**: 换出**最近最长时间没有被访问**的页面。 + +**实现方式**: +- **栈法**: 用一个栈保存页面号,访问某页时将其移到栈顶。栈底就是最久未使用的页面 +- **计数器法**: 每个页面记录最后一次访问的时间,淘汰时间值最小的页面 + +**执行过程**: + +| 访问序列 | 页框0 | 页框1 | 页框2 | 缺页? | 说明 | +|---------|-------|-------|-------|-------|------| +| **7** | 7 | - | - | 缺页 | | +| **0** | 7 | 0 | - | 缺页 | | +| **1** | 7 | 0 | 1 | 缺页 | | +| **2** | 2 | 0 | 1 | 缺页 | 淘汰7(最久未用) | +| **0** | 2 | 0 | 1 | 命中 | 0被访问,更新时间戳 | +| **3** | 2 | 0 | 3 | 缺页 | 淘汰1(最久未用) | +| **0** | 2 | 0 | 3 | 命中 | | +| **4** | 4 | 0 | 3 | 缺页 | 淘汰2(最久未用) | +| **2** | 4 | 0 | 2 | 缺页 | 淘汰3(最久未用) | +| **3** | 4 | 3 | 2 | 缺页 | 淘汰0(最久未用) | +| **0** | 0 | 3 | 2 | 缺页 | 淘汰4(最久未用) | +| **3** | 0 | 3 | 2 | 命中 | | +| **2** | 0 | 3 | 2 | 命中 | | +| **1** | 0 | 3 | 1 | 缺页 | 淘汰2(最久未用) | +| **2** | 0 | 2 | 1 | 缺页 | 淘汰3(最久未用) | +| **0** | 0 | 2 | 1 | 命中 | | +| **1** | 0 | 2 | 1 | 命中 | | +| **7** | 0 | 2 | 7 | 缺页 | 淘汰1(最久未用) | +| **0** | 0 | 2 | 7 | 命中 | | +| **1** | 1 | 2 | 7 | 缺页 | 淘汰0(最久未用) | + +**缺页次数: 12**,缺页率 = 12/20 = **60%** + +### 4. Clock (时钟 / 近似LRU) + +**策略**: 将所有页面组成一个**循环链表**,每页有一个**访问位 R**。淘汰指针从当前位置开始扫描: +- R=1:将 R 置 0,指针前移(给第二次机会) +- R=0:淘汰该页 + +```mermaid +flowchart LR + subgraph Clock["时钟置换算法 (循环链表)"] + direction LR + P0["页0
R=1"] + P1["页1
R=0"] + P2["页2
R=1"] + P3["页3
R=0"] + end + + PTR["淘汰指针"] --> P1 + + P0 --> P1 + P1 --> P2 + P2 --> P3 + P3 --> P0 + + style P1 fill:#ffcdd2,stroke:#c62828 + style PTR fill:#fff3e0,stroke:#e65100 +``` + +**执行示例**(简化): +- 指针指向页1 (R=0) → 淘汰页1,装入新页,指针前移 +- 指针指向页2 (R=1) → R置0,指针前移 +- 指针指向页3 (R=0) → 淘汰页3 + +**改进型Clock**: 同时考虑 R 和 D (修改位),优先淘汰 R=0 且 D=0 的页面(不需要写回外存)。 + +| 优先级 | R | D | 淘汰代价 | +|--------|---|---|---------| +| 最高 | 0 | 0 | 未访问未修改,直接覆盖 | +| | 0 | 1 | 未访问但已修改,需写回 | +| | 1 | 0 | 已访问未修改,再给机会 | +| 最低 | 1 | 1 | 已访问已修改,最不想淘汰 | + +### 5. LFU (Least Frequently Used) 最少使用 + +**策略**: 换出**访问次数最少**的页面。 + +- 用计数器记录每个页面的访问次数 +- 淘汰计数器值最小的页面 +- **缺点**: 某页面过去被频繁访问但现在不再使用,仍难以被淘汰 +- **实现开销**: 需要为每个页面维护计数器 + +### 算法对比 + +| 算法 | 策略 | 优点 | 缺点 | 是否有Belady异常 | +|------|------|------|------|----------------| +| **OPT** | 淘汰将来最久不用的 | 缺页率最低(理论最优) | 无法实现 | 无 | +| **FIFO** | 淘汰最早进入的 | 实现简单 | 缺页率高,有Belady异常 | **有** | +| **LRU** | 淘汰最近最久未用的 | 接近OPT,效果好 | 实现开销大(需时间戳或栈) | 无 | +| **Clock** | 给访问位R=0的淘汰 | LRU的近似,开销小 | 效果略逊于LRU | 无 | +| **LFU** | 淘汰访问次数最少的 | 考虑历史频率 | 对访问模式变化响应慢 | 无 | + +### 算法效果对比(本例) + +``` +页面引用串: 7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1 (3个物理块) + +┌──────────┬──────────┬──────────┐ +│ 算法 │ 缺页次数 │ 缺页率 │ +├──────────┼──────────┼──────────┤ +│ OPT │ 9 │ 45% │ ← 理论最优 +│ LRU │ 12 │ 60% │ +│ FIFO │ 15 │ 75% │ ← 效果最差 +└──────────┴──────────┴──────────┘ +``` + +--- + +## 五、内存分配策略 + +### 物理页框分配方式 + +| 方式 | 说明 | +|------|------| +| **平均分配** | 将可用页框平均分配给所有进程 | +| **按比例分配** | 按进程大小占总需求的比例分配 | +| **考虑优先权** | 高优先级进程分配更多页框 | + +**按比例分配公式**: + +$$a_i = \frac{s_i}{\sum s_j} \times m$$ + +其中 $s_i$ 是进程 $i$ 的页面数,$m$ 是可用页框总数。 + +### 置换策略 + +| 策略 | 说明 | 特点 | +|------|------|------| +| **固定分配局部置换** | 每个进程固定页框数,只能在自己的页面中置换 | 公平,但可能浪费 | +| **可变分配全局置换** | 从全局空闲页框池中分配,可换出任何进程的页面 | 灵活,但可能影响其他进程 | +| **可变分配局部置换** | 进程页框数可调,但只能在自己的页面中置换 | 折中方案 | + +--- + +## 六、抖动与工作集 + +### 抖动 (Thrashing) + +当进程分配到的页框数**不足以**容纳其工作集时,会频繁发生缺页,CPU 大量时间花在页面置换上,利用率急剧下降——这种现象称为**抖动**。 + +```mermaid +flowchart TD + A["进程增多"] --> B["每个进程分到的页框减少"] + B --> C["缺页率上升"] + C --> D["CPU利用率下降"] + D --> E["调度器增加进程数
(以为CPU空闲)"] + E --> B + + style C fill:#ffcdd2,stroke:#c62828 + style D fill:#ffcdd2,stroke:#c62828 +``` + +CPU 利用率随进程数的变化: + +``` +CPU利用率 + ↑ +100%│ ╱‾‾‾‾╲ + │ ╱ ╲ + │ ╱ ╲ ← 抖动区域 + │ ╱ ╲ + │ ╱ ╲ + │╱ ╲ + └──────────────────────────→ 进程数 + ↑ + 最佳点 +``` + +### 工作集 (Working Set) + +**工作集**的定义:在最近 $\Delta$ 时间内,进程实际访问的页面集合。 + +$$W(t, \Delta) = \text{在时刻 } t-\Delta \text{ 到 } t \text{ 之间访问的页面集合}$$ + +**示例**: + +页面访问序列: ... 2, 6, 1, 5, 7, 7, 7, 2, ... + +设 $\Delta = 5$(最近 5 次访问),当前访问页面 2: +- 最近 5 次访问: {5, 7, 7, 7, 2} +- 工作集 = {2, 5, 7},大小 = 3 + +**工作集策略**: 为每个进程分配**不少于其工作集大小**的页框数,就可以避免抖动。 + +### 工作集模型的应用 + +```mermaid +flowchart TD + A["监控每个进程的工作集"] --> B{"总工作集 ≤ 物理页框数?"} + B -->|"是"| C["正常运行,不会抖动"] + B -->|"否"| D["可能发生抖动"] + D --> E["挂起某些进程
释放页框"] + E --> B + + style D fill:#ffcdd2,stroke:#c62828 + style C fill:#c8e6c9,stroke:#2e7d32 +``` + +--- + +## 七、请求分段 + +### 基本思想 + +**请求分段**是虚拟存储器在[[15_段式存储管理|分段]]基础上的实现: +- 段不需要全部装入内存 +- 访问到不在内存的段时,触发**缺段中断**,从外存调入 + +### 与请求分页的对比 + +| 对比项 | 请求分页 | 请求分段 | +|--------|---------|---------| +| 调入/调出单位 | 页面(固定大小) | 段(可变大小) | +| 碎片 | 内碎片 | 外碎片 | +| 共享 | 以页为粒度 | 以段为粒度 | +| 中断类型 | 缺页中断 | 缺段中断 | +| 实现复杂度 | 较简单 | 较复杂 | + +--- + +## 八、完整计算示例 + +### 综合例题 + +> 某系统采用请求分页存储管理,页面大小为 4KB,物理内存有 4 个页框。某进程的页面访问序列为:**0, 1, 4, 2, 0, 2, 6, 5, 1, 2, 3, 2, 1, 2, 6, 5, 2, 1, 3, 6**。分别用 FIFO 和 LRU 算法计算缺页次数。 + +**FIFO 算法**: + +| 访问 | 页框0 | 页框1 | 页框2 | 页框3 | 缺页? | +|------|-------|-------|-------|-------|-------| +| 0 | 0 | - | - | - | 缺页 | +| 1 | 0 | 1 | - | - | 缺页 | +| 4 | 0 | 1 | 4 | - | 缺页 | +| 2 | 0 | 1 | 4 | 2 | 缺页 | +| 0 | 0 | 1 | 4 | 2 | 命中 | +| 2 | 0 | 1 | 4 | 2 | 命中 | +| 6 | 6 | 1 | 4 | 2 | 缺页(淘汰0) | +| 5 | 6 | 5 | 4 | 2 | 缺页(淘汰1) | +| 1 | 6 | 5 | 1 | 2 | 缺页(淘汰4) | +| 2 | 6 | 5 | 1 | 2 | 命中 | +| 3 | 6 | 5 | 1 | 3 | 缺页(淘汰2) | +| 2 | 2 | 5 | 1 | 3 | 缺页(淘汰6) | +| 1 | 2 | 5 | 1 | 3 | 命中 | +| 2 | 2 | 5 | 1 | 3 | 命中 | +| 6 | 2 | 6 | 1 | 3 | 缺页(淘汰5) | +| 5 | 2 | 6 | 5 | 3 | 缺页(淘汰1) | +| 2 | 2 | 6 | 5 | 3 | 命中 | +| 1 | 2 | 6 | 5 | 1 | 缺页(淘汰3) | +| 3 | 3 | 6 | 5 | 1 | 缺页(淘汰2) | +| 6 | 3 | 6 | 5 | 1 | 命中 | + +**FIFO 缺页次数: 14** + +**LRU 算法**: + +| 访问 | 页框0 | 页框1 | 页框2 | 页框3 | 缺页? | +|------|-------|-------|-------|-------|-------| +| 0 | 0 | - | - | - | 缺页 | +| 1 | 0 | 1 | - | - | 缺页 | +| 4 | 0 | 1 | 4 | - | 缺页 | +| 2 | 0 | 1 | 4 | 2 | 缺页 | +| 0 | 0 | 1 | 4 | 2 | 命中 | +| 2 | 0 | 1 | 4 | 2 | 命中 | +| 6 | 0 | 1 | 6 | 2 | 缺页(淘汰4) | +| 5 | 0 | 5 | 6 | 2 | 缺页(淘汰1) | +| 1 | 1 | 5 | 6 | 2 | 缺页(淘汰0) | +| 2 | 1 | 5 | 6 | 2 | 命中 | +| 3 | 1 | 5 | 3 | 2 | 缺页(淘汰6) | +| 2 | 1 | 5 | 3 | 2 | 命中 | +| 1 | 1 | 5 | 3 | 2 | 命中 | +| 2 | 1 | 5 | 3 | 2 | 命中 | +| 6 | 1 | 5 | 3 | 6 | 缺页(淘汰2) | +| 5 | 1 | 5 | 3 | 6 | 命中 | +| 2 | 2 | 5 | 3 | 6 | 缺页(淘汰1) | +| 1 | 2 | 1 | 3 | 6 | 缺页(淘汰5) | +| 3 | 2 | 1 | 3 | 6 | 命中 | +| 6 | 2 | 1 | 3 | 6 | 命中 | + +**LRU 缺页次数: 12** + +--- + +## 九、小结 + +```mermaid +mindmap + root((虚拟存储器)) + 基本思想 + 虚拟空间>物理内存 + 部分装入 + 按需调入调出 + 局部性原理 + 时间局部性 + 空间局部性 + 行优先vs列优先 + 请求分页 + 页表扩展状态位 + 缺页中断处理 + 页面置换 + 置换算法 + OPT理论最优 + FIFO简单但有Belady异常 + LRU效果好但开销大 + Clock近似LRU + LFU最少使用 + 内存分配 + 平均/按比例/优先权 + 固定/可变分配 + 局部/全局置换 + 抖动与工作集 + 频繁缺页=抖动 + 工作集=近期访问页面集 + 帧数≥工作集大小 +``` + +--- + +## 思考题 + +1. **概念理解**: 为什么说虚拟存储器的基础是局部性原理?如果没有局部性,虚拟存储器还能工作吗? + +2. **算法对比**: 对于页面引用串 1,2,3,4,1,2,5,1,2,3,4,5(3个物理块),分别计算 OPT、FIFO、LRU 的缺页次数。是否存在Belady异常? + +3. **工作集**: 某进程页面访问序列为 2,6,1,5,7,7,7,2,5,1,3,1,5,7,2,5,设 $\Delta=5$,求各时刻的工作集大小。 + +4. **抖动分析**: 某系统物理内存可容纳 10 个工作集页面。目前有 5 个进程,工作集大小分别为 {3,2,2,1,3}。此时再增加一个工作集大小为 3 的进程,系统是否会发生抖动? + +5. **综合题**: 在请求分页系统中,页面大小 4KB,页表项 4 字节,虚拟地址 32 位,物理地址 32 位。 + - 一级页表有多大? + - 如果采用二级页表,页目录和页表各多大? + - 如果 TLB 命中率 95%,TLB 访问 10ns,内存访问 100ns,有效访问时间是多少? + +--- + +## 关联笔记 + +- [[14_分页存储管理]] — 分页基础、页表结构、地址变换 +- [[15_段式存储管理]] — 分段思想、段页式 +- [[11_处理机调度]] — 进程调度与抖动的关系 + +--- + +**上一讲**: [[15_段式存储管理]] +**目录**: [[00_课程导航]] diff --git a/操作系统/17_IO系统/17_IO系统.md b/操作系统/17_IO系统/17_IO系统.md index 16f3951..695cae2 100644 --- a/操作系统/17_IO系统/17_IO系统.md +++ b/操作系统/17_IO系统/17_IO系统.md @@ -1,937 +1,937 @@ -# 17 I/O系统 - -> **课程**:操作系统 -> **关联章节**:[[01_系统运行机制]] | [[04_文件IO编程]] | [[05_磁盘空间管理]] - ---- - -## 一、I/O系统概述 - -### 1.1 I/O系统的基本概念 - -I/O系统(输入/输出系统)是操作系统中负责管理**外部设备**与**主机(CPU和内存)**之间数据传输的子系统。它是计算机系统中最复杂的组成部分之一。 - -> **核心任务**:屏蔽各种I/O设备的硬件差异,为上层提供**统一、简洁**的I/O接口,使用户程序能够方便地使用各种外部设备。 - -I/O系统需要解决的关键问题: -- 设备的多样性与统一管理 -- CPU与I/O设备之间的速度差异 -- 设备的分配与回收 -- I/O操作的高效执行 - -### 1.2 I/O设备分类 - -#### 按数据传输单位分类 - -| 类型 | 特征 | 典型设备 | -|------|------|----------| -| **字符设备** | 以字符为单位传输,速率低,不可寻址 | 键盘、鼠标、串口 | -| **块设备** | 以数据块为单位传输,速率高,可寻址 | 磁盘、磁带、光盘 | - -#### 按传输速率分类 - -| 类型 | 速率范围 | 典型设备 | -|------|----------|----------| -| **低速设备** | 几字节/s ~ 几百B/s | 鼠标、键盘、调制解调器 | -| **中速设备** | 几KB/s ~ 几MB/s | 打印机、扫描仪 | -| **高速设备** | 几MB/s ~ 几GB/s | 磁盘、磁带、网络接口 | - -#### 按使用特性分类 - -| 类型 | 说明 | 典型设备 | -|------|------|----------| -| **存储设备** | 用于永久保存信息 | 磁盘、磁带、光盘 | -| **输入设备** | 用于向计算机输入信息 | 键盘、鼠标、扫描仪 | -| **输出设备** | 用于将计算机信息输出 | 显示器、打印机 | -| **交互设备** | 既能输入又能输出 | 触摸屏、网络接口 | - -### 1.3 I/O设备的组成 - -I/O设备一般由**机械部分**和**电子部分**组成: - -``` -I/O设备 = 设备(Device)+ 设备控制器(Device Controller) -``` - -- **设备(Device)**:执行实际的I/O操作(如磁盘的旋转和读写) -- **设备控制器(Device Controller)**:电子部件,负责控制设备的物理操作,是CPU与设备之间的接口 - -> **设备控制器的功能**: -> 1. 接收和识别CPU发来的命令 -> 2. 实现CPU与控制器、控制器与设备之间的数据交换 -> 3. 记录设备的状态供CPU查询 -> 4. 识别设备地址(端口地址) - ---- - -## 二、I/O控制方式 - -I/O控制方式的发展目标:**减少CPU对I/O过程的干预**,提高CPU与I/O设备的**并行操作**程度。 - -### 2.1 程序直接控制方式(Programmed I/O) - -#### 基本思想 - -CPU通过不断**轮询(polling)**设备状态寄存器,直接控制I/O操作的全过程。 - -#### 工作流程 - -```mermaid -sequenceDiagram - participant CPU - participant 控制器 - participant 设备 - - CPU->>控制器: 1. 发送I/O命令 - CPU->>控制器: 2. 读取状态寄存器 - loop 轮询等待 - CPU->>控制器: 检查忙/完成位 - 控制器-->>CPU: 返回状态(忙) - end - 控制器-->>CPU: 返回状态(完成) - CPU->>控制器: 3. 读取数据寄存器 - 控制器-->>CPU: 数据 -``` - -#### 优缺点 - -| 优点 | 缺点 | -|------|------| -| 实现简单 | CPU利用率极低(忙等待) | -| 硬件要求低 | CPU与设备串行工作 | -| 易于理解 | 不适用于多设备系统 | - -> [!note] 关键特征 -> CPU在整个I/O过程中**一直参与**,以**轮询方式**检查设备状态,CPU与设备完全**串行**工作。 - -### 2.2 中断驱动方式(Interrupt-Driven I/O) - -#### 基本思想 - -CPU发出I/O命令后,**不需要持续轮询**,可以去执行其他进程。当设备完成操作后,通过**中断**通知CPU。 - -#### 工作流程 - -```mermaid -sequenceDiagram - participant CPU - participant 控制器 - participant 设备 - - CPU->>控制器: 1. 发送I/O命令 - Note over CPU: CPU转去执行其他进程 - 控制器->>设备: 启动设备操作 - 设备->>控制器: 操作完成 - 控制器->>CPU: 2. 发出中断请求 - Note over CPU: CPU响应中断 - CPU->>控制器: 3. 读取数据 - 控制器-->>CPU: 数据 -``` - -#### 优缺点 - -| 优点 | 缺点 | -|------|------| -| CPU利用率提高 | 每次传输以字/字节为单位 | -| CPU与设备可并行工作 | 频繁中断开销大 | -| 支持多设备 | 数据仍需经过CPU中转 | - -> [!note] 与程序直接控制方式的对比 -> 中断驱动方式中,CPU在**启动I/O后**可执行其他任务,设备完成时通过**中断**通知CPU,实现了CPU与设备的**部分并行**。但每次数据传输都需要CPU干预。 - -### 2.3 DMA方式(Direct Memory Access) - -#### 基本思想 - -引入**DMA控制器**,在设备与内存之间开辟**直接数据通路**,数据传输不需要经过CPU中转。CPU只需在传输**开始**和**结束**时介入。 - -#### DMA控制器的组成 - -``` -DMA控制器寄存器: -┌─────────────────────────────────┐ -│ MAR(内存地址寄存器) │ → 数据在内存中的目标地址 -│ DC (数据计数器) │ → 剩余要传输的字节数 -│ DR (数据寄存器) │ → 暂存传输的数据 -│ CR (命令/状态寄存器) │ → 存放CPU发来的命令和状态 -└─────────────────────────────────┘ -``` - -#### 工作流程 - -```mermaid -sequenceDiagram - participant CPU - participant DMA - participant 内存 - participant 设备 - - CPU->>DMA: 1. 设置MAR、DC、CR(发送命令) - Note over CPU: CPU转去执行其他进程 - DMA->>设备: 启动设备 - loop 块传输(硬件自动) - 设备->>DMA: 读入一个字 - DMA->>内存: 写入内存(MAR) - Note over DMA: MAR++, DC-- - end - DMA->>CPU: 2. 传输完成,发出中断 - CPU->>DMA: 3. 处理完成中断 -``` - -#### DMA与中断驱动的对比 - -| 比较项 | 中断驱动 | DMA | -|--------|----------|-----| -| 数据传输单位 | 字/字节 | **数据块** | -| 数据是否经过CPU | **是** | **否**(直接内存访问) | -| 中断频率 | 每个字/字节一次 | **每块一次** | -| CPU干预程度 | 每次传输都干预 | 仅开始和结束干预 | - -> [!important] DMA的核心优势 -> 数据在**设备和内存之间**直接传输,不经过CPU,以**块**为单位传输,大大减少了CPU的干预次数。 - -### 2.4 通道方式(Channel I/O) - -#### 基本思想 - -通道是一种**专用处理器**(I/O处理器),有自己的**指令系统**(通道指令)和**程序**(通道程序)。CPU只需发出一条I/O指令,通道就能**自主完成**整个数据传输过程。 - -#### 通道的类型 - -| 类型 | 特征 | 传输单位 | -|------|------|----------| -| **字节多路通道** | 以字节为单位交叉传输多个设备 | 字节 | -| **数组选择通道** | 一次只服务一个设备,传输速率高 | 数据块 | -| **数组多路通道** | 上述两种的结合,兼顾效率和利用率 | 数据块 | - -#### 工作流程 - -```mermaid -sequenceDiagram - participant CPU - participant 通道 - participant 控制器 - participant 设备 - - CPU->>通道: 1. 发送通道程序地址 - Note over CPU: CPU完全转去执行其他进程 - 通道->>通道: 取通道指令执行 - 通道->>控制器: 控制设备操作 - loop 通道自主执行 - 通道->>内存: 数据传输(无需CPU干预) - end - 通道->>CPU: 2. 传输完成,发出中断 -``` - -#### 通道与DMA的对比 - -| 比较项 | DMA | 通道 | -|--------|-----|------| -| 控制能力 | 受CPU控制 | **自主执行**通道程序 | -| 并行度 | 一个DMA对应一类设备 | 一个通道可控制**多个**设备 | -| CPU干预 | 需要CPU设置传输参数 | 仅需CPU启动通道 | -| 灵活性 | 较低 | **高**(可编程) | - -> [!tip] I/O控制方式演进总结 -> | 方式 | CPU干预频率 | 数据单位 | 并行程度 | -> |------|-------------|----------|----------| -> | 程序直接控制 | 每个字 | 字 | 无并行 | -> | 中断驱动 | 每个字 | 字 | 部分并行 | -> | DMA | 每个块 | 块 | 较高并行 | -> | 通道 | 仅启动/结束 | 一组块 | 高度并行 | - ---- - -## 三、I/O软件层次结构 - -### 3.1 层次结构概览 - -I/O软件采用**分层设计**思想,每一层利用下层提供的服务,为上层提供更高级的服务。 - -```mermaid -graph TB - subgraph 用户空间 - A["用户层I/O软件
(库函数: printf, scanf)"] - end - subgraph 内核空间 - B["设备独立性软件
(统一接口、缓冲、分配)"] - C["设备驱动程序
(控制具体设备)"] - D["中断处理程序
(响应设备中断)"] - end - subgraph 硬件 - E["设备控制器 / 设备"] - end - - A -->|"系统调用"| B - B -->|"调用驱动"| C - C -->|"启动I/O"| E - E -->|"中断"| D - D -->|"唤醒驱动"| C - C -->|"唤醒上层"| B - B -->|"返回结果"| A - - style A fill:#e1f5fe - style B fill:#fff3e0 - style C fill:#fce4ec - style D fill:#f3e5f5 - style E fill:#e8f5e9 -``` - -### 3.2 各层功能详解 - -#### 第一层:中断处理程序 - -**位置**:最底层,直接与硬件交互 -**功能**: -- 响应设备中断请求 -- 保存被中断进程的现场 -- 分析中断原因,转入相应的中断处理 -- 唤醒等待I/O完成的进程 -- 恢复现场,返回被中断的进程 - -```c -// 中断处理程序伪代码 -void disk_interrupt_handler() { - save_context(); // 保存现场 - wake_up(device_waiter); // 唤醒等待该设备的进程 - schedule(); // 可能触发进程调度 - restore_context(); // 恢复现场 -} -``` - -#### 第二层:设备驱动程序 - -**位置**:紧邻中断处理程序之上 -**功能**: -- 将上层的抽象I/O请求转换为对**具体设备控制器**的操作 -- 设置设备控制器的寄存器 -- 启动设备进行I/O操作 -- 处理设备中断,检查操作结果 - -> [!note] 设备驱动程序的特点 -> - 每类设备对应一个**设备驱动程序** -> - 是操作系统中**了解设备控制器细节**的唯一模块 -> - 通常由**设备制造商**提供 - -#### 第三层:设备独立性软件 - -**位置**:设备驱动程序之上 -**功能**: -- 提供**统一的I/O接口**,屏蔽设备差异 -- 实现**缓冲管理**(缓冲区的分配与回收) -- 实现**设备分配与回收** -- 实现**逻辑设备名到物理设备名**的映射 -- 提供**差错处理**机制 -- 提供**设备保护**(防止非法访问) - -> **设备独立性**:应用程序使用**逻辑设备名**请求I/O,由系统将其映射到**物理设备**。这样,更换物理设备不影响程序运行。 - -``` -应用程序: read(fd, buf, size) - ↓ 逻辑设备名 -设备独立性软件: 查逻辑设备表 → 物理设备 - ↓ -设备驱动程序: 操作具体设备控制器 -``` - -#### 第四层:用户层I/O软件 - -**位置**:最上层,用户空间 -**功能**: -- 提供**库函数**接口(如C语言的 `printf`、`scanf`、`read`、`write`) -- 对用户数据进行**格式化**处理 -- 通过**系统调用**进入内核 - -### 3.3 一次完整的I/O操作流程 - -``` -用户调用 printf("Hello") - → 用户层:格式化数据,调用 write() 系统调用 - → 设备独立性软件:查设备表,分配缓冲区,确定物理设备 - → 设备驱动程序:向设备控制器发送命令 - → 设备控制器:启动设备进行输出 - → 设备完成操作,发出中断 - → 中断处理程序:唤醒等待进程 - → 设备驱动程序:检查操作结果 - → 设备独立性软件:释放缓冲区,返回结果 - → 用户层:系统调用返回 -用户程序继续执行 -``` - ---- - -## 四、缓冲管理 - -### 4.1 引入缓冲的原因 - -1. **缓和CPU与I/O设备速度不匹配**的矛盾 -2. **减少对CPU的中断频率**,放宽对中断响应时间的限制 -3. **提高CPU与I/O设备之间的并行性** - -### 4.2 单缓冲(Single Buffer) - -#### 基本思想 - -系统为设备分配**一个**缓冲区。 - -#### 工作方式 - -处理一块数据的时间 = **max(C, T) + M** -- C:CPU处理一块数据的时间 -- T:设备将一块数据传送到缓冲区的时间 -- M:缓冲区数据传送到用户区的时间 - -``` -设备 → [缓冲区] → 用户区 → CPU处理 -``` - -> **分析**:当C > T时,CPU处理慢,设备需等待;当T > C时,设备传输慢,CPU需等待。两者不能完全并行。 - -### 4.3 双缓冲(Double Buffer) - -#### 基本思想 - -系统为设备分配**两个**缓冲区,实现**并行操作**。 - -#### 工作方式 - -``` -设备 → [缓冲区1] → 用户区 缓冲区2 ← 设备 -设备 → [缓冲区2] → 用户区 缓冲区1 ← 设备 -``` - -处理一块数据的时间 = **max(C, T)** - -> [!tip] 双缓冲的优势 -> 当一个缓冲区的数据传送到用户区时,另一个缓冲区可同时接收设备的新数据,实现了**设备与CPU的并行**。 - -### 4.4 循环缓冲(Circular Buffer) - -#### 基本思想 - -系统分配**多个**大小相等的缓冲区,组成**循环队列**。 - -``` - ┌───┐ ┌───┐ ┌───┐ ┌───┐ -输入 → │ B1 │ → │ B2 │ → │ B3 │ → │ B4 │ → 输出 - └───┘ └───┘ └───┘ └───┘ - ↑ ↓ - └────────────────────────┘ - 循环使用 -``` - -- **输入指针(in)**:指向下一个可放入数据的缓冲区 -- **输出指针(out)**:指向下一个可取出数据的缓冲区 - -> **适用场景**:I/O速度与处理速度差异较大的系统。 - -### 4.5 缓冲池(Buffer Pool) - -#### 基本思想 - -系统从公用内存中分配**一组**缓冲区,组成缓冲池,供**多个进程共享**使用。 - -#### 缓冲区队列 - -缓冲池中的缓冲区按用途分为三个队列: - -| 队列 | 用途 | -|------|------| -| **空缓冲队列(emq)** | 尚未使用的空缓冲区 | -| **输入队列(inq)** | 装满输入数据的缓冲区 | -| **输出队列(outq)** | 装满输出数据的缓冲区 | - -#### 四种工作缓冲区 - -| 缓冲区类型 | 功能 | -|------------|------| -| **收容输入(hin)** | 从空缓冲队列取缓冲区,接收输入数据,挂入输入队列 | -| **提取输入(sin)** | 从输入队列取缓冲区,供CPU提取数据,释放回空缓冲队列 | -| **收容输出(hout)** | 从空缓冲队列取缓冲区,接收CPU输出数据,挂入输出队列 | -| **提取输出(sout)** | 从输出队列取缓冲区,供设备提取数据,释放回空缓冲队列 | - -```mermaid -graph LR - subgraph 缓冲池 - 空缓冲队列["空缓冲队列 (emq)"] - 输入队列["输入队列 (inq)"] - 输出队列["输出队列 (outq)"] - end - - 输入设备 -->|"收容输入(hin)"| 空缓冲队列 - 空缓冲队列 -->|"收容输入(hin)"| 输入队列 - 输入队列 -->|"提取输入(sin)"| CPU - CPU -->|"提取输入(sin)"| 空缓冲队列 - - CPU -->|"收容输出(hout)"| 空缓冲队列 - 空缓冲队列 -->|"收容输出(hout)"| 输出队列 - 输出队列 -->|"提取输出(sout)"| 输出设备 - 输出设备 -->|"提取输出(sout)"| 空缓冲队列 - - style 空缓冲队列 fill:#e8f5e9 - style 输入队列 fill:#e1f5fe - style 输出队列 fill:#fff3e0 -``` - ---- - -## 五、设备分配与回收 - -### 5.1 设备分配中的数据结构 - -#### 设备控制表(DCT) - -每个设备一张,记录设备的状态和属性: - -``` -┌─────────────────────────────┐ -│ 设备控制表 (DCT) │ -├─────────────────────────────┤ -│ 设备标识符 (设备ID) │ -│ 设备类型 │ -│ 设备状态:忙/空闲/故障 │ -│ 指向控制器表的指针 (COCT) │ -│ 重复执行次数/定时器 │ -│ 设备队列的队首指针 │ -└─────────────────────────────┘ -``` - -#### 控制器控制表(COCT) - -每个设备控制器一张: - -``` -┌─────────────────────────────┐ -│ 控制器控制表 (COCT) │ -├─────────────────────────────┤ -│ 控制器标识符 │ -│ 控制器状态 │ -│ 指向通道表的指针 (CHCT) │ -│ 控制器队列的队首指针 │ -└─────────────────────────────┘ -``` - -#### 通道控制表(CHCT) - -每个通道一张: - -``` -┌─────────────────────────────┐ -│ 通道控制表 (CHCT) │ -├─────────────────────────────┤ -│ 通道标识符 │ -│ 通道状态 │ -│ 通道连接的控制器列表 │ -│ 通道队列的队首指针 │ -└─────────────────────────────┘ -``` - -#### 系统设备表(SDT) - -整个系统一张,记录所有设备的信息: - -``` -┌─────────────────────────────┐ -│ 系统设备表 (SDT) │ -├─────────────────────────────┤ -│ 设备类 │ -│ 设备标识符 │ -│ DCT指针 │ -│ 驱动程序入口地址 │ -└─────────────────────────────┘ -``` - -### 5.2 设备分配原则 - -| 考虑因素 | 说明 | -|----------|------| -| **固有属性** | 独占设备、共享设备、虚拟设备 | -| **安全性** | 避免死锁(参考 [[12_死锁]]) | -| **效率** | 充分利用设备,提高系统吞吐量 | - -### 5.3 独占设备、共享设备与SPOOLing - -#### 独占设备 - -同一时刻**只能被一个进程**使用(如打印机)。 - -#### 共享设备 - -可被**多个进程交替**使用(如磁盘)。 - -#### SPOOLing技术(假脱机技术) - -将**独占设备**改造为**共享设备**的技术,是虚拟设备的典型实现。 - -``` -输入设备 ──→ 输入井 ──→ 内存 ──→ 输出井 ──→ 输出设备 - (输入进程) (磁盘) (用户进程) (磁盘) (输出进程) -``` - -> [!important] SPOOLing系统的核心思想 -> 在磁盘上开辟**输入井**和**输出井**,用**输入进程**模拟脱机输入,用**输出进程**模拟脱机输出。用户进程不直接操作独占设备,而是与磁盘缓冲区交互,从而实现**独占设备的共享**。 - -**经典应用**:共享打印机——多个进程的打印请求先存入输出井,由输出进程依次输出到打印机。 - -### 5.4 设备分配流程 - -```mermaid -flowchart TD - A[进程请求设备] --> B{分配设备} - B -->|独占设备| C{设备空闲?} - C -->|是| D[分配设备, 修改DCT] - C -->|否| E[进程阻塞, 排入设备等待队列] - B -->|共享设备| F[直接分配] - D --> G{分配控制器} - F --> G - G --> H{分配通道} - H --> I[分配成功, 启动I/O] - I --> J[I/O完成] - J --> K[回收通道] - K --> L[回收控制器] - L --> M[回收设备] -``` - ---- - -## 六、磁盘调度算法 - -### 6.1 磁盘结构与性能参数 - -#### 磁盘的物理结构 - -``` - 柱面 (Cylinder) - ↓ - ┌───────────────────────┐ - │ 磁头0 磁头1 磁头2 │ ← 磁头 (Head) - │ ───── ───── ───── │ - │ 磁道0 磁道0 磁道0 │ ← 磁道 (Track) - │ 磁道1 磁道1 磁道1 │ - │ ... ... ... │ - │ 磁道n 磁道n 磁道n │ - └───────────────────────┘ - 盘面 盘面 盘面 -``` - -#### 磁盘访问时间组成 - -``` -总访问时间 T = 寻道时间 Ts + 旋转延迟 Tr + 传输时间 Tt -``` - -| 参数 | 含义 | 典型值 | -|------|------|--------| -| **寻道时间 Ts** | 磁头移动到目标磁道的时间 | 几ms ~ 几十ms(**最主要**) | -| **旋转延迟 Tr** | 等待目标扇区旋转到磁头下的时间 | 转速7200rpm → 平均4.17ms | -| **传输时间 Tt** | 数据读写时间 | 较短 | - -> [!important] 优化重点 -> **寻道时间**在总访问时间中占比最大,因此磁盘调度算法主要优化**寻道时间**。 - -### 6.2 常见磁盘调度算法 - -#### 示例磁盘请求序列 - -假设当前磁头位置在**磁道53**,磁道范围0~199,请求队列: - -``` -请求队列: 98, 183, 37, 122, 14, 124, 65, 67 -方向: 磁道号增加方向 (向磁道号大的方向移动) -``` - ---- - -#### FCFS(先来先服务) - -**原则**:按请求到达的先后顺序依次服务。 - -``` -磁头移动顺序: 53 → 98 → 183 → 37 → 122 → 14 → 124 → 65 → 67 - -移动距离: -|53-98| + |98-183| + |183-37| + |37-122| + |122-14| + |14-124| + |124-65| + |65-67| -= 45 + 85 + 146 + 85 + 108 + 110 + 59 + 2 -= 640 磁道 - -平均寻道长度 = 640 / 8 = 80 磁道 -``` - -> [!note] FCFS特点 -> - **优点**:公平、简单、不会产生饥饿 -> - **缺点**:寻道距离大,性能差(磁头来回大幅移动) -> - **适用**:请求量少或对公平性要求高的场景 - ---- - -#### SSTF(最短寻道时间优先) - -**原则**:选择距离当前磁头位置**最近**的请求优先服务。 - -``` -当前位置: 53 -请求队列: 98, 183, 37, 122, 14, 124, 65, 67 - -Step 1: 当前53, 最近是65(距离12)或37(距离16), 选65 -Step 2: 当前65, 最近是67(距离2), 选67 -Step 3: 当前67, 最近是37(距离30)或98(距离31), 选37 -Step 4: 当前37, 最近是14(距离23), 选14 -Step 5: 当前14, 最近是98(距离84), 选98 -Step 6: 当前98, 最近是122(距离24), 选122 -Step 7: 当前122, 最近是124(距离2), 选124 -Step 8: 当前124, 最近是183(距离59), 选183 - -移动顺序: 53 → 65 → 67 → 37 → 14 → 98 → 122 → 124 → 183 - -移动距离: -|53-65| + |65-67| + |67-37| + |37-14| + |14-98| + |98-122| + |122-124| + |124-183| -= 12 + 2 + 30 + 23 + 84 + 24 + 2 + 59 -= 236 磁道 - -平均寻道长度 = 236 / 8 = 29.5 磁道 -``` - -> [!warning] SSTF的"饥饿"问题 -> - **优点**:平均寻道时间短 -> - **缺点**:可能导致某些请求**长期得不到服务**(饥饿),尤其是距离较远的磁道请求 -> - **原因**:距离远的请求总被距离近的新请求"插队" - ---- - -#### SCAN(电梯算法/扫描算法) - -**原则**:磁头沿**一个方向**移动,依次服务沿途的请求,到达磁道尽头后**反向**移动。 - -``` -当前位置: 53, 方向: 磁道号增加 - -磁头移动: 53 → 65 → 67 → 98 → 122 → 124 → 183 → (到达尽头) → 37 → 14 - -移动距离: -(53→65): 12 -(65→67): 2 -(67→98): 31 -(98→122): 24 -(122→124): 2 -(124→183): 59 -(183→37): 146 ← 到达尽头后反向 -(37→14): 23 - -总距离 = 12 + 2 + 31 + 24 + 2 + 59 + 146 + 23 = 299 磁道 - -平均寻道长度 = 299 / 8 = 37.4 磁道 -``` - -> [!note] SCAN特点 -> - **优点**:寻道性能好,不会产生饥饿 -> - **缺点**:两侧磁道的访问频率不均匀(中间磁道被更频繁访问) -> - **类比**:像电梯一样,先上后下(或先下后上) - ---- - -#### C-SCAN(循环扫描算法) - -**原则**:磁头**单向移动**,到达尽头后**快速返回**起始端(返回途中不服务),继续单向扫描。 - -``` -当前位置: 53, 方向: 磁道号增加 - -磁头移动: 53 → 65 → 67 → 98 → 122 → 124 → 183 → (到达尽头) -→ 快速返回0 → 14 → 37 - -移动距离: -(53→65): 12 -(65→67): 2 -(67→98): 31 -(98→122): 24 -(122→124): 2 -(124→183): 59 -(183→0): 183 ← 快速返回 -(0→14): 14 -(14→37): 23 - -总距离 = 12 + 2 + 31 + 24 + 2 + 59 + 183 + 14 + 23 = 350 磁道 - -平均寻道长度 = 350 / 8 = 43.75 磁道 -``` - -> [!tip] C-SCAN vs SCAN -> - **C-SCAN** 解决了 SCAN 中**磁道访问频率不均匀**的问题 -> - 返回过程不服务请求,使各磁道被访问的概率更**均匀** -> - 总移动距离可能比SCAN略大 - ---- - -#### LOOK与C-LOOK - -**LOOK**:SCAN的改进,磁头移动到**最后一个请求**就反向(不必到磁道尽头)。 -**C-LOOK**:C-SCAN的改进,同理不必到磁道尽头。 - -``` -当前位置: 53, 方向: 磁道号增加 - -LOOK: 53 → 65 → 67 → 98 → 122 → 124 → 183 → 37 → 14 -(不必到达磁道199,到183就反向) - -移动距离 = 12 + 2 + 31 + 24 + 2 + 59 + 146 + 23 = 299 -注意: SCAN和LOOK在此例中结果相同,因为183已是最大请求 - -C-LOOK: 53 → 65 → 67 → 98 → 122 → 124 → 183 → 14 → 37 -(不必返回磁道0,直接跳到最小请求14) - -移动距离 = 12 + 2 + 31 + 24 + 2 + 59 + 169 + 23 = 322 -``` - -### 6.3 磁盘调度算法对比 - -| 算法 | 寻道距离 | 公平性 | 饥饿问题 | 特点 | -|------|----------|--------|----------|------| -| **FCFS** | 640 | 公平 | 无 | 简单,性能差 | -| **SSTF** | 236 | 不公平 | **有** | 性能好,可能饥饿 | -| **SCAN** | 299 | 较公平 | 无 | 性能好,两侧不均匀 | -| **C-SCAN** | 350 | 公平 | 无 | 各磁道均匀访问 | -| **LOOK** | 299 | 较公平 | 无 | SCAN的优化版本 | -| **C-LOOK** | 322 | 公平 | 无 | C-SCAN的优化版本 | - -```mermaid -graph LR - subgraph 磁盘调度算法对比 - A["FCFS
距离: 640"] -->|"改进"| B["SSTF
距离: 236"] - B -->|"解决饥饿"| C["SCAN
距离: 299"] - C -->|"优化尽头"| D["LOOK
距离: 299"] - C -->|"解决不均匀"| E["C-SCAN
距离: 350"] - E -->|"优化尽头"| F["C-LOOK
距离: 322"] - end - - style A fill:#ffebee - style B fill:#fff3e0 - style C fill:#e8f5e9 - style D fill:#e1f5fe - style E fill:#f3e5f5 - style F fill:#fce4ec -``` - ---- - -## 七、磁盘高速缓存 - -### 7.1 基本概念 - -**磁盘高速缓存**是在**内存**中为磁盘盘块设置的缓冲区,保存了某些盘块的副本。 - -> **工作原理**:当有访问磁盘的请求时,先检查高速缓存中是否有所需盘块的数据。如果有(命中),直接从缓存读取;如果未命中,才启动磁盘读取,并将数据存入缓存。 - -``` -请求访问盘块X - → 查找高速缓存 - → 命中(Hit): 直接从缓存读取(速度提高几个数量级) - → 未命中(Miss): 启动磁盘读取 → 数据送入缓存 → 返回给进程 -``` - -### 7.2 数据交付方式 - -| 方式 | 说明 | 特点 | -|------|------|------| -| **数据交付** | 将缓存中的数据**复制**到进程的内存工作区 | 数据量大,有复制开销 | -| **指针交付** | 将指向缓存的**指针**交给进程 | 无复制,更高效 | - -### 7.3 置换策略 - -| 算法 | 说明 | -|------|------| -| **LRU**(最近最久未使用) | 替换最长时间未被访问的盘块 | -| **NRU**(最近未使用) | 替换最近一个周期内未被访问的盘块 | -| **LFU**(最少使用) | 替换被访问次数最少的盘块 | - -设计置换算法还需考虑: -- **访问频率**:经常被访问的盘块应保留在缓存中 -- **可预见性**:某些盘块可能很快又被访问(如顺序读取时的预读) -- **数据一致性**:确保缓存中的数据与磁盘中的数据一致 - -### 7.4 数据一致性保障 - -#### 写回策略 - -- **立即写回**:修改数据后立即写入磁盘(安全但性能低) -- **延迟写回**:修改数据先保留在缓存中,稍后写回磁盘(性能高但有风险) - -#### 周期性写回 - -UNIX系统使用 `sync` 系统调用,周期性地将所有已修改的缓存数据强制写回磁盘,防止数据丢失。 - -``` -UNIX update进程: 周期性调用 sync() - → 将所有"脏"缓冲区的数据写回磁盘 - → 保障数据一致性 -``` - ---- - -## 八、综合示例 - -### 8.1 磁盘调度算法计算练习 - -> [!example] 练习题 -> 某磁盘有200个磁道(0~199),当前磁头位于**磁道100**,向磁道号**减小**方向移动。请求队列为:`55, 58, 39, 18, 90, 160, 150, 38, 184`。请分别用 FCFS、SSTF、SCAN、C-SCAN 算法计算总寻道距离。 - -**FCFS**: -``` -100 → 55 → 58 → 39 → 18 → 90 → 160 → 150 → 38 → 184 -= 45+3+19+21+72+70+10+112+146 = 498 -``` - -**SSTF**: -``` -100 → 90(10) → 58(32) → 55(3) → 39(16) → 38(1) → 18(20) -→ 150(132) → 160(10) → 184(24) -= 10+32+3+16+1+20+132+10+24 = 248 -``` - -**SCAN**(向减小方向): -``` -100 → 90 → 58 → 55 → 39 → 38 → 18 → (到达0) → 150 → 160 → 184 -= 10+32+3+16+1+20+18+150+10+24 = 284 -``` - -**C-SCAN**(向减小方向,单向): -``` -100 → 90 → 58 → 55 → 39 → 38 → 18 → (到达0) → 快速到199 -→ 184 → 160 → 150 -= 10+32+3+16+1+20+18+199+15+24+10 = 348 -``` - ---- - -## 九、I/O系统与其他子系统的关系 - -```mermaid -graph TB - A["用户程序"] -->|"系统调用"| B["I/O系统"] - B --> C["文件系统
(参考 [[04_文件IO编程]])"] - B --> D["存储管理
(参考 [[12_存储管理]])"] - B --> E["进程管理
(参考 [[01_系统运行机制]])"] - C --> F["磁盘管理
(参考 [[05_磁盘空间管理]])"] - D -->|"缓冲区分配"| B - E -->|"中断处理、进程调度"| B - F -->|"磁盘调度"| B - - style A fill:#e1f5fe - style B fill:#fff3e0 - style C fill:#e8f5e9 - style D fill:#f3e5f5 - style E fill:#fce4ec - style F fill:#ffebee -``` - -> [!summary] 本章要点回顾 -> 1. **I/O控制方式**:程序直接控制 → 中断驱动 → DMA → 通道,CPU干预逐步减少 -> 2. **I/O软件层次**:中断处理程序 → 设备驱动 → 设备独立性软件 → 用户层软件 -> 3. **缓冲管理**:单缓冲、双缓冲、循环缓冲、缓冲池,解决速度匹配问题 -> 4. **设备分配**:考虑独占/共享/虚拟设备,SPOOLing将独占改造为共享 -> 5. **磁盘调度**:FCFS、SSTF(最短寻道)、SCAN(电梯)、C-SCAN(循环扫描) -> 6. **磁盘高速缓存**:内存中缓存磁盘数据,配合LRU等置换算法 - ---- - -> **参考教材**:汤小丹《计算机操作系统》第4版 第5章 -> **下一篇**:[[05_磁盘空间管理]] +# 17 I/O系统 + +> **课程**:操作系统 +> **关联章节**:[[01_系统运行机制]] | [[04_文件IO编程]] | [[05_磁盘空间管理]] + +--- + +## 一、I/O系统概述 + +### 1.1 I/O系统的基本概念 + +I/O系统(输入/输出系统)是操作系统中负责管理**外部设备**与**主机(CPU和内存)**之间数据传输的子系统。它是计算机系统中最复杂的组成部分之一。 + +> **核心任务**:屏蔽各种I/O设备的硬件差异,为上层提供**统一、简洁**的I/O接口,使用户程序能够方便地使用各种外部设备。 + +I/O系统需要解决的关键问题: +- 设备的多样性与统一管理 +- CPU与I/O设备之间的速度差异 +- 设备的分配与回收 +- I/O操作的高效执行 + +### 1.2 I/O设备分类 + +#### 按数据传输单位分类 + +| 类型 | 特征 | 典型设备 | +|------|------|----------| +| **字符设备** | 以字符为单位传输,速率低,不可寻址 | 键盘、鼠标、串口 | +| **块设备** | 以数据块为单位传输,速率高,可寻址 | 磁盘、磁带、光盘 | + +#### 按传输速率分类 + +| 类型 | 速率范围 | 典型设备 | +|------|----------|----------| +| **低速设备** | 几字节/s ~ 几百B/s | 鼠标、键盘、调制解调器 | +| **中速设备** | 几KB/s ~ 几MB/s | 打印机、扫描仪 | +| **高速设备** | 几MB/s ~ 几GB/s | 磁盘、磁带、网络接口 | + +#### 按使用特性分类 + +| 类型 | 说明 | 典型设备 | +|------|------|----------| +| **存储设备** | 用于永久保存信息 | 磁盘、磁带、光盘 | +| **输入设备** | 用于向计算机输入信息 | 键盘、鼠标、扫描仪 | +| **输出设备** | 用于将计算机信息输出 | 显示器、打印机 | +| **交互设备** | 既能输入又能输出 | 触摸屏、网络接口 | + +### 1.3 I/O设备的组成 + +I/O设备一般由**机械部分**和**电子部分**组成: + +``` +I/O设备 = 设备(Device)+ 设备控制器(Device Controller) +``` + +- **设备(Device)**:执行实际的I/O操作(如磁盘的旋转和读写) +- **设备控制器(Device Controller)**:电子部件,负责控制设备的物理操作,是CPU与设备之间的接口 + +> **设备控制器的功能**: +> 1. 接收和识别CPU发来的命令 +> 2. 实现CPU与控制器、控制器与设备之间的数据交换 +> 3. 记录设备的状态供CPU查询 +> 4. 识别设备地址(端口地址) + +--- + +## 二、I/O控制方式 + +I/O控制方式的发展目标:**减少CPU对I/O过程的干预**,提高CPU与I/O设备的**并行操作**程度。 + +### 2.1 程序直接控制方式(Programmed I/O) + +#### 基本思想 + +CPU通过不断**轮询(polling)**设备状态寄存器,直接控制I/O操作的全过程。 + +#### 工作流程 + +```mermaid +sequenceDiagram + participant CPU + participant 控制器 + participant 设备 + + CPU->>控制器: 1. 发送I/O命令 + CPU->>控制器: 2. 读取状态寄存器 + loop 轮询等待 + CPU->>控制器: 检查忙/完成位 + 控制器-->>CPU: 返回状态(忙) + end + 控制器-->>CPU: 返回状态(完成) + CPU->>控制器: 3. 读取数据寄存器 + 控制器-->>CPU: 数据 +``` + +#### 优缺点 + +| 优点 | 缺点 | +|------|------| +| 实现简单 | CPU利用率极低(忙等待) | +| 硬件要求低 | CPU与设备串行工作 | +| 易于理解 | 不适用于多设备系统 | + +> [!note] 关键特征 +> CPU在整个I/O过程中**一直参与**,以**轮询方式**检查设备状态,CPU与设备完全**串行**工作。 + +### 2.2 中断驱动方式(Interrupt-Driven I/O) + +#### 基本思想 + +CPU发出I/O命令后,**不需要持续轮询**,可以去执行其他进程。当设备完成操作后,通过**中断**通知CPU。 + +#### 工作流程 + +```mermaid +sequenceDiagram + participant CPU + participant 控制器 + participant 设备 + + CPU->>控制器: 1. 发送I/O命令 + Note over CPU: CPU转去执行其他进程 + 控制器->>设备: 启动设备操作 + 设备->>控制器: 操作完成 + 控制器->>CPU: 2. 发出中断请求 + Note over CPU: CPU响应中断 + CPU->>控制器: 3. 读取数据 + 控制器-->>CPU: 数据 +``` + +#### 优缺点 + +| 优点 | 缺点 | +|------|------| +| CPU利用率提高 | 每次传输以字/字节为单位 | +| CPU与设备可并行工作 | 频繁中断开销大 | +| 支持多设备 | 数据仍需经过CPU中转 | + +> [!note] 与程序直接控制方式的对比 +> 中断驱动方式中,CPU在**启动I/O后**可执行其他任务,设备完成时通过**中断**通知CPU,实现了CPU与设备的**部分并行**。但每次数据传输都需要CPU干预。 + +### 2.3 DMA方式(Direct Memory Access) + +#### 基本思想 + +引入**DMA控制器**,在设备与内存之间开辟**直接数据通路**,数据传输不需要经过CPU中转。CPU只需在传输**开始**和**结束**时介入。 + +#### DMA控制器的组成 + +``` +DMA控制器寄存器: +┌─────────────────────────────────┐ +│ MAR(内存地址寄存器) │ → 数据在内存中的目标地址 +│ DC (数据计数器) │ → 剩余要传输的字节数 +│ DR (数据寄存器) │ → 暂存传输的数据 +│ CR (命令/状态寄存器) │ → 存放CPU发来的命令和状态 +└─────────────────────────────────┘ +``` + +#### 工作流程 + +```mermaid +sequenceDiagram + participant CPU + participant DMA + participant 内存 + participant 设备 + + CPU->>DMA: 1. 设置MAR、DC、CR(发送命令) + Note over CPU: CPU转去执行其他进程 + DMA->>设备: 启动设备 + loop 块传输(硬件自动) + 设备->>DMA: 读入一个字 + DMA->>内存: 写入内存(MAR) + Note over DMA: MAR++, DC-- + end + DMA->>CPU: 2. 传输完成,发出中断 + CPU->>DMA: 3. 处理完成中断 +``` + +#### DMA与中断驱动的对比 + +| 比较项 | 中断驱动 | DMA | +|--------|----------|-----| +| 数据传输单位 | 字/字节 | **数据块** | +| 数据是否经过CPU | **是** | **否**(直接内存访问) | +| 中断频率 | 每个字/字节一次 | **每块一次** | +| CPU干预程度 | 每次传输都干预 | 仅开始和结束干预 | + +> [!important] DMA的核心优势 +> 数据在**设备和内存之间**直接传输,不经过CPU,以**块**为单位传输,大大减少了CPU的干预次数。 + +### 2.4 通道方式(Channel I/O) + +#### 基本思想 + +通道是一种**专用处理器**(I/O处理器),有自己的**指令系统**(通道指令)和**程序**(通道程序)。CPU只需发出一条I/O指令,通道就能**自主完成**整个数据传输过程。 + +#### 通道的类型 + +| 类型 | 特征 | 传输单位 | +|------|------|----------| +| **字节多路通道** | 以字节为单位交叉传输多个设备 | 字节 | +| **数组选择通道** | 一次只服务一个设备,传输速率高 | 数据块 | +| **数组多路通道** | 上述两种的结合,兼顾效率和利用率 | 数据块 | + +#### 工作流程 + +```mermaid +sequenceDiagram + participant CPU + participant 通道 + participant 控制器 + participant 设备 + + CPU->>通道: 1. 发送通道程序地址 + Note over CPU: CPU完全转去执行其他进程 + 通道->>通道: 取通道指令执行 + 通道->>控制器: 控制设备操作 + loop 通道自主执行 + 通道->>内存: 数据传输(无需CPU干预) + end + 通道->>CPU: 2. 传输完成,发出中断 +``` + +#### 通道与DMA的对比 + +| 比较项 | DMA | 通道 | +|--------|-----|------| +| 控制能力 | 受CPU控制 | **自主执行**通道程序 | +| 并行度 | 一个DMA对应一类设备 | 一个通道可控制**多个**设备 | +| CPU干预 | 需要CPU设置传输参数 | 仅需CPU启动通道 | +| 灵活性 | 较低 | **高**(可编程) | + +> [!tip] I/O控制方式演进总结 +> | 方式 | CPU干预频率 | 数据单位 | 并行程度 | +> |------|-------------|----------|----------| +> | 程序直接控制 | 每个字 | 字 | 无并行 | +> | 中断驱动 | 每个字 | 字 | 部分并行 | +> | DMA | 每个块 | 块 | 较高并行 | +> | 通道 | 仅启动/结束 | 一组块 | 高度并行 | + +--- + +## 三、I/O软件层次结构 + +### 3.1 层次结构概览 + +I/O软件采用**分层设计**思想,每一层利用下层提供的服务,为上层提供更高级的服务。 + +```mermaid +graph TB + subgraph 用户空间 + A["用户层I/O软件
(库函数: printf, scanf)"] + end + subgraph 内核空间 + B["设备独立性软件
(统一接口、缓冲、分配)"] + C["设备驱动程序
(控制具体设备)"] + D["中断处理程序
(响应设备中断)"] + end + subgraph 硬件 + E["设备控制器 / 设备"] + end + + A -->|"系统调用"| B + B -->|"调用驱动"| C + C -->|"启动I/O"| E + E -->|"中断"| D + D -->|"唤醒驱动"| C + C -->|"唤醒上层"| B + B -->|"返回结果"| A + + style A fill:#e1f5fe + style B fill:#fff3e0 + style C fill:#fce4ec + style D fill:#f3e5f5 + style E fill:#e8f5e9 +``` + +### 3.2 各层功能详解 + +#### 第一层:中断处理程序 + +**位置**:最底层,直接与硬件交互 +**功能**: +- 响应设备中断请求 +- 保存被中断进程的现场 +- 分析中断原因,转入相应的中断处理 +- 唤醒等待I/O完成的进程 +- 恢复现场,返回被中断的进程 + +```c +// 中断处理程序伪代码 +void disk_interrupt_handler() { + save_context(); // 保存现场 + wake_up(device_waiter); // 唤醒等待该设备的进程 + schedule(); // 可能触发进程调度 + restore_context(); // 恢复现场 +} +``` + +#### 第二层:设备驱动程序 + +**位置**:紧邻中断处理程序之上 +**功能**: +- 将上层的抽象I/O请求转换为对**具体设备控制器**的操作 +- 设置设备控制器的寄存器 +- 启动设备进行I/O操作 +- 处理设备中断,检查操作结果 + +> [!note] 设备驱动程序的特点 +> - 每类设备对应一个**设备驱动程序** +> - 是操作系统中**了解设备控制器细节**的唯一模块 +> - 通常由**设备制造商**提供 + +#### 第三层:设备独立性软件 + +**位置**:设备驱动程序之上 +**功能**: +- 提供**统一的I/O接口**,屏蔽设备差异 +- 实现**缓冲管理**(缓冲区的分配与回收) +- 实现**设备分配与回收** +- 实现**逻辑设备名到物理设备名**的映射 +- 提供**差错处理**机制 +- 提供**设备保护**(防止非法访问) + +> **设备独立性**:应用程序使用**逻辑设备名**请求I/O,由系统将其映射到**物理设备**。这样,更换物理设备不影响程序运行。 + +``` +应用程序: read(fd, buf, size) + ↓ 逻辑设备名 +设备独立性软件: 查逻辑设备表 → 物理设备 + ↓ +设备驱动程序: 操作具体设备控制器 +``` + +#### 第四层:用户层I/O软件 + +**位置**:最上层,用户空间 +**功能**: +- 提供**库函数**接口(如C语言的 `printf`、`scanf`、`read`、`write`) +- 对用户数据进行**格式化**处理 +- 通过**系统调用**进入内核 + +### 3.3 一次完整的I/O操作流程 + +``` +用户调用 printf("Hello") + → 用户层:格式化数据,调用 write() 系统调用 + → 设备独立性软件:查设备表,分配缓冲区,确定物理设备 + → 设备驱动程序:向设备控制器发送命令 + → 设备控制器:启动设备进行输出 + → 设备完成操作,发出中断 + → 中断处理程序:唤醒等待进程 + → 设备驱动程序:检查操作结果 + → 设备独立性软件:释放缓冲区,返回结果 + → 用户层:系统调用返回 +用户程序继续执行 +``` + +--- + +## 四、缓冲管理 + +### 4.1 引入缓冲的原因 + +1. **缓和CPU与I/O设备速度不匹配**的矛盾 +2. **减少对CPU的中断频率**,放宽对中断响应时间的限制 +3. **提高CPU与I/O设备之间的并行性** + +### 4.2 单缓冲(Single Buffer) + +#### 基本思想 + +系统为设备分配**一个**缓冲区。 + +#### 工作方式 + +处理一块数据的时间 = **max(C, T) + M** +- C:CPU处理一块数据的时间 +- T:设备将一块数据传送到缓冲区的时间 +- M:缓冲区数据传送到用户区的时间 + +``` +设备 → [缓冲区] → 用户区 → CPU处理 +``` + +> **分析**:当C > T时,CPU处理慢,设备需等待;当T > C时,设备传输慢,CPU需等待。两者不能完全并行。 + +### 4.3 双缓冲(Double Buffer) + +#### 基本思想 + +系统为设备分配**两个**缓冲区,实现**并行操作**。 + +#### 工作方式 + +``` +设备 → [缓冲区1] → 用户区 缓冲区2 ← 设备 +设备 → [缓冲区2] → 用户区 缓冲区1 ← 设备 +``` + +处理一块数据的时间 = **max(C, T)** + +> [!tip] 双缓冲的优势 +> 当一个缓冲区的数据传送到用户区时,另一个缓冲区可同时接收设备的新数据,实现了**设备与CPU的并行**。 + +### 4.4 循环缓冲(Circular Buffer) + +#### 基本思想 + +系统分配**多个**大小相等的缓冲区,组成**循环队列**。 + +``` + ┌───┐ ┌───┐ ┌───┐ ┌───┐ +输入 → │ B1 │ → │ B2 │ → │ B3 │ → │ B4 │ → 输出 + └───┘ └───┘ └───┘ └───┘ + ↑ ↓ + └────────────────────────┘ + 循环使用 +``` + +- **输入指针(in)**:指向下一个可放入数据的缓冲区 +- **输出指针(out)**:指向下一个可取出数据的缓冲区 + +> **适用场景**:I/O速度与处理速度差异较大的系统。 + +### 4.5 缓冲池(Buffer Pool) + +#### 基本思想 + +系统从公用内存中分配**一组**缓冲区,组成缓冲池,供**多个进程共享**使用。 + +#### 缓冲区队列 + +缓冲池中的缓冲区按用途分为三个队列: + +| 队列 | 用途 | +|------|------| +| **空缓冲队列(emq)** | 尚未使用的空缓冲区 | +| **输入队列(inq)** | 装满输入数据的缓冲区 | +| **输出队列(outq)** | 装满输出数据的缓冲区 | + +#### 四种工作缓冲区 + +| 缓冲区类型 | 功能 | +|------------|------| +| **收容输入(hin)** | 从空缓冲队列取缓冲区,接收输入数据,挂入输入队列 | +| **提取输入(sin)** | 从输入队列取缓冲区,供CPU提取数据,释放回空缓冲队列 | +| **收容输出(hout)** | 从空缓冲队列取缓冲区,接收CPU输出数据,挂入输出队列 | +| **提取输出(sout)** | 从输出队列取缓冲区,供设备提取数据,释放回空缓冲队列 | + +```mermaid +graph LR + subgraph 缓冲池 + 空缓冲队列["空缓冲队列 (emq)"] + 输入队列["输入队列 (inq)"] + 输出队列["输出队列 (outq)"] + end + + 输入设备 -->|"收容输入(hin)"| 空缓冲队列 + 空缓冲队列 -->|"收容输入(hin)"| 输入队列 + 输入队列 -->|"提取输入(sin)"| CPU + CPU -->|"提取输入(sin)"| 空缓冲队列 + + CPU -->|"收容输出(hout)"| 空缓冲队列 + 空缓冲队列 -->|"收容输出(hout)"| 输出队列 + 输出队列 -->|"提取输出(sout)"| 输出设备 + 输出设备 -->|"提取输出(sout)"| 空缓冲队列 + + style 空缓冲队列 fill:#e8f5e9 + style 输入队列 fill:#e1f5fe + style 输出队列 fill:#fff3e0 +``` + +--- + +## 五、设备分配与回收 + +### 5.1 设备分配中的数据结构 + +#### 设备控制表(DCT) + +每个设备一张,记录设备的状态和属性: + +``` +┌─────────────────────────────┐ +│ 设备控制表 (DCT) │ +├─────────────────────────────┤ +│ 设备标识符 (设备ID) │ +│ 设备类型 │ +│ 设备状态:忙/空闲/故障 │ +│ 指向控制器表的指针 (COCT) │ +│ 重复执行次数/定时器 │ +│ 设备队列的队首指针 │ +└─────────────────────────────┘ +``` + +#### 控制器控制表(COCT) + +每个设备控制器一张: + +``` +┌─────────────────────────────┐ +│ 控制器控制表 (COCT) │ +├─────────────────────────────┤ +│ 控制器标识符 │ +│ 控制器状态 │ +│ 指向通道表的指针 (CHCT) │ +│ 控制器队列的队首指针 │ +└─────────────────────────────┘ +``` + +#### 通道控制表(CHCT) + +每个通道一张: + +``` +┌─────────────────────────────┐ +│ 通道控制表 (CHCT) │ +├─────────────────────────────┤ +│ 通道标识符 │ +│ 通道状态 │ +│ 通道连接的控制器列表 │ +│ 通道队列的队首指针 │ +└─────────────────────────────┘ +``` + +#### 系统设备表(SDT) + +整个系统一张,记录所有设备的信息: + +``` +┌─────────────────────────────┐ +│ 系统设备表 (SDT) │ +├─────────────────────────────┤ +│ 设备类 │ +│ 设备标识符 │ +│ DCT指针 │ +│ 驱动程序入口地址 │ +└─────────────────────────────┘ +``` + +### 5.2 设备分配原则 + +| 考虑因素 | 说明 | +|----------|------| +| **固有属性** | 独占设备、共享设备、虚拟设备 | +| **安全性** | 避免死锁(参考 [[12_死锁]]) | +| **效率** | 充分利用设备,提高系统吞吐量 | + +### 5.3 独占设备、共享设备与SPOOLing + +#### 独占设备 + +同一时刻**只能被一个进程**使用(如打印机)。 + +#### 共享设备 + +可被**多个进程交替**使用(如磁盘)。 + +#### SPOOLing技术(假脱机技术) + +将**独占设备**改造为**共享设备**的技术,是虚拟设备的典型实现。 + +``` +输入设备 ──→ 输入井 ──→ 内存 ──→ 输出井 ──→ 输出设备 + (输入进程) (磁盘) (用户进程) (磁盘) (输出进程) +``` + +> [!important] SPOOLing系统的核心思想 +> 在磁盘上开辟**输入井**和**输出井**,用**输入进程**模拟脱机输入,用**输出进程**模拟脱机输出。用户进程不直接操作独占设备,而是与磁盘缓冲区交互,从而实现**独占设备的共享**。 + +**经典应用**:共享打印机——多个进程的打印请求先存入输出井,由输出进程依次输出到打印机。 + +### 5.4 设备分配流程 + +```mermaid +flowchart TD + A[进程请求设备] --> B{分配设备} + B -->|独占设备| C{设备空闲?} + C -->|是| D[分配设备, 修改DCT] + C -->|否| E[进程阻塞, 排入设备等待队列] + B -->|共享设备| F[直接分配] + D --> G{分配控制器} + F --> G + G --> H{分配通道} + H --> I[分配成功, 启动I/O] + I --> J[I/O完成] + J --> K[回收通道] + K --> L[回收控制器] + L --> M[回收设备] +``` + +--- + +## 六、磁盘调度算法 + +### 6.1 磁盘结构与性能参数 + +#### 磁盘的物理结构 + +``` + 柱面 (Cylinder) + ↓ + ┌───────────────────────┐ + │ 磁头0 磁头1 磁头2 │ ← 磁头 (Head) + │ ───── ───── ───── │ + │ 磁道0 磁道0 磁道0 │ ← 磁道 (Track) + │ 磁道1 磁道1 磁道1 │ + │ ... ... ... │ + │ 磁道n 磁道n 磁道n │ + └───────────────────────┘ + 盘面 盘面 盘面 +``` + +#### 磁盘访问时间组成 + +``` +总访问时间 T = 寻道时间 Ts + 旋转延迟 Tr + 传输时间 Tt +``` + +| 参数 | 含义 | 典型值 | +|------|------|--------| +| **寻道时间 Ts** | 磁头移动到目标磁道的时间 | 几ms ~ 几十ms(**最主要**) | +| **旋转延迟 Tr** | 等待目标扇区旋转到磁头下的时间 | 转速7200rpm → 平均4.17ms | +| **传输时间 Tt** | 数据读写时间 | 较短 | + +> [!important] 优化重点 +> **寻道时间**在总访问时间中占比最大,因此磁盘调度算法主要优化**寻道时间**。 + +### 6.2 常见磁盘调度算法 + +#### 示例磁盘请求序列 + +假设当前磁头位置在**磁道53**,磁道范围0~199,请求队列: + +``` +请求队列: 98, 183, 37, 122, 14, 124, 65, 67 +方向: 磁道号增加方向 (向磁道号大的方向移动) +``` + +--- + +#### FCFS(先来先服务) + +**原则**:按请求到达的先后顺序依次服务。 + +``` +磁头移动顺序: 53 → 98 → 183 → 37 → 122 → 14 → 124 → 65 → 67 + +移动距离: +|53-98| + |98-183| + |183-37| + |37-122| + |122-14| + |14-124| + |124-65| + |65-67| += 45 + 85 + 146 + 85 + 108 + 110 + 59 + 2 += 640 磁道 + +平均寻道长度 = 640 / 8 = 80 磁道 +``` + +> [!note] FCFS特点 +> - **优点**:公平、简单、不会产生饥饿 +> - **缺点**:寻道距离大,性能差(磁头来回大幅移动) +> - **适用**:请求量少或对公平性要求高的场景 + +--- + +#### SSTF(最短寻道时间优先) + +**原则**:选择距离当前磁头位置**最近**的请求优先服务。 + +``` +当前位置: 53 +请求队列: 98, 183, 37, 122, 14, 124, 65, 67 + +Step 1: 当前53, 最近是65(距离12)或37(距离16), 选65 +Step 2: 当前65, 最近是67(距离2), 选67 +Step 3: 当前67, 最近是37(距离30)或98(距离31), 选37 +Step 4: 当前37, 最近是14(距离23), 选14 +Step 5: 当前14, 最近是98(距离84), 选98 +Step 6: 当前98, 最近是122(距离24), 选122 +Step 7: 当前122, 最近是124(距离2), 选124 +Step 8: 当前124, 最近是183(距离59), 选183 + +移动顺序: 53 → 65 → 67 → 37 → 14 → 98 → 122 → 124 → 183 + +移动距离: +|53-65| + |65-67| + |67-37| + |37-14| + |14-98| + |98-122| + |122-124| + |124-183| += 12 + 2 + 30 + 23 + 84 + 24 + 2 + 59 += 236 磁道 + +平均寻道长度 = 236 / 8 = 29.5 磁道 +``` + +> [!warning] SSTF的"饥饿"问题 +> - **优点**:平均寻道时间短 +> - **缺点**:可能导致某些请求**长期得不到服务**(饥饿),尤其是距离较远的磁道请求 +> - **原因**:距离远的请求总被距离近的新请求"插队" + +--- + +#### SCAN(电梯算法/扫描算法) + +**原则**:磁头沿**一个方向**移动,依次服务沿途的请求,到达磁道尽头后**反向**移动。 + +``` +当前位置: 53, 方向: 磁道号增加 + +磁头移动: 53 → 65 → 67 → 98 → 122 → 124 → 183 → (到达尽头) → 37 → 14 + +移动距离: +(53→65): 12 +(65→67): 2 +(67→98): 31 +(98→122): 24 +(122→124): 2 +(124→183): 59 +(183→37): 146 ← 到达尽头后反向 +(37→14): 23 + +总距离 = 12 + 2 + 31 + 24 + 2 + 59 + 146 + 23 = 299 磁道 + +平均寻道长度 = 299 / 8 = 37.4 磁道 +``` + +> [!note] SCAN特点 +> - **优点**:寻道性能好,不会产生饥饿 +> - **缺点**:两侧磁道的访问频率不均匀(中间磁道被更频繁访问) +> - **类比**:像电梯一样,先上后下(或先下后上) + +--- + +#### C-SCAN(循环扫描算法) + +**原则**:磁头**单向移动**,到达尽头后**快速返回**起始端(返回途中不服务),继续单向扫描。 + +``` +当前位置: 53, 方向: 磁道号增加 + +磁头移动: 53 → 65 → 67 → 98 → 122 → 124 → 183 → (到达尽头) +→ 快速返回0 → 14 → 37 + +移动距离: +(53→65): 12 +(65→67): 2 +(67→98): 31 +(98→122): 24 +(122→124): 2 +(124→183): 59 +(183→0): 183 ← 快速返回 +(0→14): 14 +(14→37): 23 + +总距离 = 12 + 2 + 31 + 24 + 2 + 59 + 183 + 14 + 23 = 350 磁道 + +平均寻道长度 = 350 / 8 = 43.75 磁道 +``` + +> [!tip] C-SCAN vs SCAN +> - **C-SCAN** 解决了 SCAN 中**磁道访问频率不均匀**的问题 +> - 返回过程不服务请求,使各磁道被访问的概率更**均匀** +> - 总移动距离可能比SCAN略大 + +--- + +#### LOOK与C-LOOK + +**LOOK**:SCAN的改进,磁头移动到**最后一个请求**就反向(不必到磁道尽头)。 +**C-LOOK**:C-SCAN的改进,同理不必到磁道尽头。 + +``` +当前位置: 53, 方向: 磁道号增加 + +LOOK: 53 → 65 → 67 → 98 → 122 → 124 → 183 → 37 → 14 +(不必到达磁道199,到183就反向) + +移动距离 = 12 + 2 + 31 + 24 + 2 + 59 + 146 + 23 = 299 +注意: SCAN和LOOK在此例中结果相同,因为183已是最大请求 + +C-LOOK: 53 → 65 → 67 → 98 → 122 → 124 → 183 → 14 → 37 +(不必返回磁道0,直接跳到最小请求14) + +移动距离 = 12 + 2 + 31 + 24 + 2 + 59 + 169 + 23 = 322 +``` + +### 6.3 磁盘调度算法对比 + +| 算法 | 寻道距离 | 公平性 | 饥饿问题 | 特点 | +|------|----------|--------|----------|------| +| **FCFS** | 640 | 公平 | 无 | 简单,性能差 | +| **SSTF** | 236 | 不公平 | **有** | 性能好,可能饥饿 | +| **SCAN** | 299 | 较公平 | 无 | 性能好,两侧不均匀 | +| **C-SCAN** | 350 | 公平 | 无 | 各磁道均匀访问 | +| **LOOK** | 299 | 较公平 | 无 | SCAN的优化版本 | +| **C-LOOK** | 322 | 公平 | 无 | C-SCAN的优化版本 | + +```mermaid +graph LR + subgraph 磁盘调度算法对比 + A["FCFS
距离: 640"] -->|"改进"| B["SSTF
距离: 236"] + B -->|"解决饥饿"| C["SCAN
距离: 299"] + C -->|"优化尽头"| D["LOOK
距离: 299"] + C -->|"解决不均匀"| E["C-SCAN
距离: 350"] + E -->|"优化尽头"| F["C-LOOK
距离: 322"] + end + + style A fill:#ffebee + style B fill:#fff3e0 + style C fill:#e8f5e9 + style D fill:#e1f5fe + style E fill:#f3e5f5 + style F fill:#fce4ec +``` + +--- + +## 七、磁盘高速缓存 + +### 7.1 基本概念 + +**磁盘高速缓存**是在**内存**中为磁盘盘块设置的缓冲区,保存了某些盘块的副本。 + +> **工作原理**:当有访问磁盘的请求时,先检查高速缓存中是否有所需盘块的数据。如果有(命中),直接从缓存读取;如果未命中,才启动磁盘读取,并将数据存入缓存。 + +``` +请求访问盘块X + → 查找高速缓存 + → 命中(Hit): 直接从缓存读取(速度提高几个数量级) + → 未命中(Miss): 启动磁盘读取 → 数据送入缓存 → 返回给进程 +``` + +### 7.2 数据交付方式 + +| 方式 | 说明 | 特点 | +|------|------|------| +| **数据交付** | 将缓存中的数据**复制**到进程的内存工作区 | 数据量大,有复制开销 | +| **指针交付** | 将指向缓存的**指针**交给进程 | 无复制,更高效 | + +### 7.3 置换策略 + +| 算法 | 说明 | +|------|------| +| **LRU**(最近最久未使用) | 替换最长时间未被访问的盘块 | +| **NRU**(最近未使用) | 替换最近一个周期内未被访问的盘块 | +| **LFU**(最少使用) | 替换被访问次数最少的盘块 | + +设计置换算法还需考虑: +- **访问频率**:经常被访问的盘块应保留在缓存中 +- **可预见性**:某些盘块可能很快又被访问(如顺序读取时的预读) +- **数据一致性**:确保缓存中的数据与磁盘中的数据一致 + +### 7.4 数据一致性保障 + +#### 写回策略 + +- **立即写回**:修改数据后立即写入磁盘(安全但性能低) +- **延迟写回**:修改数据先保留在缓存中,稍后写回磁盘(性能高但有风险) + +#### 周期性写回 + +UNIX系统使用 `sync` 系统调用,周期性地将所有已修改的缓存数据强制写回磁盘,防止数据丢失。 + +``` +UNIX update进程: 周期性调用 sync() + → 将所有"脏"缓冲区的数据写回磁盘 + → 保障数据一致性 +``` + +--- + +## 八、综合示例 + +### 8.1 磁盘调度算法计算练习 + +> [!example] 练习题 +> 某磁盘有200个磁道(0~199),当前磁头位于**磁道100**,向磁道号**减小**方向移动。请求队列为:`55, 58, 39, 18, 90, 160, 150, 38, 184`。请分别用 FCFS、SSTF、SCAN、C-SCAN 算法计算总寻道距离。 + +**FCFS**: +``` +100 → 55 → 58 → 39 → 18 → 90 → 160 → 150 → 38 → 184 += 45+3+19+21+72+70+10+112+146 = 498 +``` + +**SSTF**: +``` +100 → 90(10) → 58(32) → 55(3) → 39(16) → 38(1) → 18(20) +→ 150(132) → 160(10) → 184(24) += 10+32+3+16+1+20+132+10+24 = 248 +``` + +**SCAN**(向减小方向): +``` +100 → 90 → 58 → 55 → 39 → 38 → 18 → (到达0) → 150 → 160 → 184 += 10+32+3+16+1+20+18+150+10+24 = 284 +``` + +**C-SCAN**(向减小方向,单向): +``` +100 → 90 → 58 → 55 → 39 → 38 → 18 → (到达0) → 快速到199 +→ 184 → 160 → 150 += 10+32+3+16+1+20+18+199+15+24+10 = 348 +``` + +--- + +## 九、I/O系统与其他子系统的关系 + +```mermaid +graph TB + A["用户程序"] -->|"系统调用"| B["I/O系统"] + B --> C["文件系统
(参考 [[04_文件IO编程]])"] + B --> D["存储管理
(参考 [[12_存储管理]])"] + B --> E["进程管理
(参考 [[01_系统运行机制]])"] + C --> F["磁盘管理
(参考 [[05_磁盘空间管理]])"] + D -->|"缓冲区分配"| B + E -->|"中断处理、进程调度"| B + F -->|"磁盘调度"| B + + style A fill:#e1f5fe + style B fill:#fff3e0 + style C fill:#e8f5e9 + style D fill:#f3e5f5 + style E fill:#fce4ec + style F fill:#ffebee +``` + +> [!summary] 本章要点回顾 +> 1. **I/O控制方式**:程序直接控制 → 中断驱动 → DMA → 通道,CPU干预逐步减少 +> 2. **I/O软件层次**:中断处理程序 → 设备驱动 → 设备独立性软件 → 用户层软件 +> 3. **缓冲管理**:单缓冲、双缓冲、循环缓冲、缓冲池,解决速度匹配问题 +> 4. **设备分配**:考虑独占/共享/虚拟设备,SPOOLing将独占改造为共享 +> 5. **磁盘调度**:FCFS、SSTF(最短寻道)、SCAN(电梯)、C-SCAN(循环扫描) +> 6. **磁盘高速缓存**:内存中缓存磁盘数据,配合LRU等置换算法 + +--- + +> **参考教材**:汤小丹《计算机操作系统》第4版 第5章 +> **下一篇**:[[05_磁盘空间管理]] diff --git a/操作系统/18_程序代码优化/18_程序代码优化.md b/操作系统/18_程序代码优化/18_程序代码优化.md index d7a5c81..1bc09f3 100644 --- a/操作系统/18_程序代码优化/18_程序代码优化.md +++ b/操作系统/18_程序代码优化/18_程序代码优化.md @@ -1,731 +1,731 @@ -# 18. 程序代码优化 - -> **课程**: 操作系统 - 程序代码优化 -> **核心内容**: 机器无关优化、代码移动、消除不必要的内存引用、优化障碍(指针别名、函数副作用)、性能度量 - ---- - -## 前置知识 -- [[03_C语言编程基础]] -- C语言编译链接过程、指针与内存模型 -- [[13_存储管理基础]] -- 存储器层次结构、局部性原理 - ---- - -## 一、代码优化的概念与意义 - -### 1.1 为什么需要代码优化 - -> [!important] 核心观点 -> 常数因子也很重要!根据代码编写方式的不同,程序性能可能相差 **10倍** 以上。必须在多个层次上进行优化:算法、数据表示、过程调用和循环。 - -代码优化的目标: -- 理解程序如何被编译和执行 -- 学习如何度量程序性能并识别瓶颈 -- 在不破坏代码模块化和通用性的前提下提升性能 - -### 1.2 优化的层次 - -```mermaid -graph TB - A["算法优化
选择更高效的算法"] --> B["数据结构优化
选择合适的内存布局"] - B --> C["编译器优化
利用编译器选项"] - C --> D["源代码级优化
代码移动、消除冗余"] - D --> E["指令级优化
利用底层硬件特性"] - - style A fill:#e8f5e9 - style B fill:#e1f5fe - style C fill:#fff3e0 - style D fill:#fce4ec - style E fill:#f3e5f5 -``` - -### 1.3 编译器优化级别 - -| 优化级别 | 说明 | 特点 | -|---------|------|------| -| `-O0` | 不优化 | 编译最快,调试最方便,性能最差 | -| `-O1` | 基本优化 | 消除冗余代码、简单内联,平衡编译速度和性能 | -| `-O2` | 推荐优化 | 启用大多数优化,包括循环优化、指令调度等 | -| `-O3` | 激进优化 | 包含 `-O2` 所有优化,加上循环展开、SIMD向量化等 | - -```bash -# 不同优化级别的编译 -gcc -O0 prog.c -o prog_O0 # 无优化(调试用) -gcc -O1 prog.c -o prog_O1 # 基本优化 -gcc -O2 prog.c -o prog_O2 # 推荐优化 -gcc -O3 prog.c -o prog_O3 # 激进优化 - -# 比较不同优化级别的汇编输出 -gcc -O0 -S prog.c -o prog_O0.s -gcc -O2 -S prog.c -o prog_O2.s -diff prog_O0.s prog_O2.s -``` - -> [!tip] 编译器的局限性 -> 编译器通常**不会**改善渐近效率(大O复杂度),这取决于程序员选择最优算法。大O节省通常比常数因子更重要,但常数因子也确实重要。编译器在面对"优化障碍"时往往难以进行优化。 - ---- - -## 二、性能度量:CPE(每元素周期数) - -### 2.1 CPE 概念 - -CPE(Cycles Per Element)是度量向量或列表操作程序性能的便捷方式: - -$$T = CPE \times n + Overhead$$ - -其中 $n$ 是向量长度,$T$ 是总执行时间(时钟周期数)。 - -### 2.2 时间尺度 - -| 指标 | 说明 | -|------|------| -| 绝对时间 | 通常使用纳秒($10^{-9}$ 秒) | -| 时钟周期 | 100 MHz → 10ns/周期;2 GHz → 0.5ns/周期 | - -### 2.3 CPE 度量示例 - -```c -// psum1: 朴素前缀和 -void psum1(float a[], float p[], long int n) { - long int i; - p[0] = a[0]; - for (i = 0; i < n; i++) - p[i] = p[i-1] + a[i]; -} - -// psum2: 循环展开的前缀和 -void psum2(float a[], float p[], long int n) { - long int i; - p[0] = a[0]; - for (i = 1; i < n-1; i += 2) { - float mid_val = p[i-1] + a[i]; - p[i] = mid_val; - p[i+1] = mid_val + a[i+1]; - } - if (i < n) - p[i] = p[i-1] + a[i]; -} -``` - -> [!note] 性能对比 -> `psum2` 通过循环展开减少了关键路径上的操作次数,从而降低了 CPE。 - ---- - -## 三、优化实例:向量求和的逐步优化 - -### 3.1 向量 ADT 定义 - -```c -typedef int data_t; - -typedef struct { - int len; - data_t *data; -} vec_rec, *vec_ptr; -``` - -相关操作: -- `new_vec(len)` -- 创建指定长度的向量 -- `vec_length(v)` -- 返回向量长度 -- `get_vec_start(v)` -- 返回向量数据起始指针 -- `get_vec_element(v, index, &dest)` -- 获取指定下标的元素(带边界检查) - -### 3.2 combine1:抽象版本(基线) - -```c -void combine1(vec_ptr v, data_t *dest) { - long int i; - *dest = IDENT; - for (i = 0; i < vec_length(v); i++) { - data_t val; - get_vec_element(v, i, &val); - *dest = *dest OP val; - } -} -``` - -> [!warning] 问题分析 -> 每次循环迭代都调用 `vec_length(v)`,即使其返回值始终不变。这属于**循环不变量**问题。 - -### 3.3 combine2:代码移动(Code Motion) - -```c -void combine2(vec_ptr v, data_t *dest) { - long int i; - long int length = vec_length(v); // 移出循环 - *dest = IDENT; - for (i = 0; i < length; i++) { - data_t val; - get_vec_element(v, i, &val); - *dest = *dest OP val; - } -} -``` - -> [!tip] 代码移动优化 -> 将 `vec_length()` 调用从循环体内移到循环之前。循环不变量外提(Loop-Invariant Code Motion)是最基本的优化之一。 - -### 3.4 combine3:消除过程调用(Reduction in Strength) - -```c -void combine3(vec_ptr v, data_t *dest) { - long int i; - long int length = vec_length(v); - data_t *data = get_vec_start(v); // 获取数据指针 - *dest = IDENT; - for (i = 0; i < length; i++) { - *dest = *dest OP data[i]; // 直接数组访问 - } -} -``` - -**优化要点**:避免每次迭代都调用 `get_vec_element()` 函数来获取元素,而是在循环前获取数据指针,循环内直接进行指针引用。 - -### 3.5 combine4:消除不必要的内存引用 - -```c -void combine4(vec_ptr v, data_t *dest) { - long int i; - long int length = vec_length(v); - data_t *data = get_vec_start(v); - data_t acc = IDENT; // 使用局部变量累积 - for (i = 0; i < length; i++) - acc = acc OP data[i]; - *dest = acc; // 最后一次性写回 -} -``` - -**汇编对比**: - -``` -combine3 的循环体(每次迭代需要3条内存指令): - movss (%rbp), %xmm0 # 从 dest 读取累加值 - mulss (%rax,%rdx,4), %xmm0 # 乘以 data[i] - movss %xmm0, (%rbp) # 写回 dest - addq $1, %rdx # i++ - cmpq %rdx, %r12 # 比较 i:limit - jg .L498 # 循环跳转 - -combine4 的循环体(只有1条内存指令): - mulss (%rax,%rdx,4), %xmm0 # acc *= data[i] - addq $1, %rdx # i++ - cmpq %rdx, %rbp # 比较 limit:i - jg .L488 # 循环跳转 -``` - -> [!important] 优化效果 -> - `combine3`:每次迭代需要 **1次读 + 1次写** 内存 -> - `combine4`:每次迭代只需要 **0次** 额外内存访问(`acc` 在寄存器中) -> - 局部变量 `acc` 告诉编译器:不需要在每轮循环都检查内存别名 - -### 3.6 优化效果汇总 - -| 函数 | 优化方法 | 整数+ CPE | 整数* CPE | 浮点+ CPE | -|------|---------|----------|----------|----------| -| combine1 | 抽象接口 | 12.00 | 12.00 | 12.00 | -| combine2 | 代码移动 | -- | -- | -- | -| combine3 | 消除过程调用 | -- | -- | -- | -| combine4 | 使用临时变量累积 | **2.00** | **3.00** | **3.00** | - ---- - -## 四、代码移动(Code Motion)详解 - -### 4.1 循环不变量外提 - -将循环中每次迭代结果相同的计算移到循环之前: - -```c -// 优化前:strlen 在每次循环都被调用 -void lower1(char *s) { - int i; - for (i = 0; i < strlen(s); i++) // O(n^2) 复杂度! - if (s[i] >= 'A' && s[i] <= 'Z') - s[i] -= ('A' - 'a'); -} - -// 优化后:strlen 只调用一次 -void lower2(char *s) { - int i; - int len = strlen(s); // O(n) 复杂度 - for (i = 0; i < len; i++) - if (s[i] >= 'A' && s[i] <= 'Z') - s[i] -= ('A' - 'a'); -} -``` - -> [!warning] 为什么编译器不能自动做这个优化? -> `strlen` 是一个函数调用,编译器**不知道**它是否有副作用,也不知道字符串长度在循环中是否会改变。编译器必须保守处理。 - -### 4.2 strlen 的内部实现 - -```c -size_t strlen(const char *s) { - int length = 0; - while (*s != '\0') { - s++; - length++; - } - return length; -} -``` - -`strlen` 本身是 $O(n)$ 的。如果在循环中每次迭代都调用它,整体复杂度就变成了 $O(n^2)$。 - ---- - -## 五、优化障碍(Optimization Blockers) - -编译器在进行优化时受到两大根本性约束: -1. 编译器在**任何可能的条件下**都不能改变程序的行为 -2. 当程序员知道的信息比编译器多时,需要程序员主动干预 - -### 5.1 指针别名(Memory Aliasing) - -> [!danger] 别名问题 -> 当两个不同的内存引用指向同一个存储位置时,就产生了别名。C语言允许地址算术运算和直接访问存储结构,因此别名问题非常容易出现。 - -```c -// twiddle1 和 twiddle2 看似等价,但实际上不一定! -void twiddle1(int *xp, int *yp) { - *xp += *yp; - *xp += *yp; -} - -void twiddle2(int *xp, int *yp) { - *xp += 2 * *yp; -} -``` - -**当 `xp == yp` 时(别名情况)**: -- `twiddle1`:`*xp = *xp + *xp = 2*xp`,然后 `*xp = 2*xp + 2*xp = 4*xp` -- `twiddle2`:`*xp = *xp + 2*(*xp) = 3*xp` -- 结果不同!编译器不能将 `twiddle1` 优化为 `twiddle2` - -**别名实例演示**: - -```c -// v = [2, 3, 5] -combine3(v, get_vec_start(v) + 2); // dest 指向 v->data[2] -combine4(v, get_vec_start(v) + 2); // dest 指向 v->data[2] -``` - -| 函数 | 初始 | i=0 | i=1 | i=2 | 最终 | -|------|------|-----|-----|-----|------| -| combine3 | [2,3,5] | [2,3,1] | [2,3,2] | [2,3,6] | [2,3,36] | -| combine4 | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,30] | - -> [!tip] 避免别名问题的方法 -> 养成使用**局部变量**的习惯。在循环中用局部变量累积结果,循环结束后再写回目标地址。这是告诉编译器"不需要检查别名"的方式。 - -### 5.2 函数调用的副作用 - -```c -int f(int); - -// func1:调用 f 4次 -int func1(int x) { - return f(x) + f(x) + f(x) + f(x); -} - -// func2:调用 f 1次 -int func2(int x) { - return 4 * f(x); -} -``` - -看起来 `func1` 可以优化为 `func2`,但当 `f` 有副作用时就不行了: - -```c -int counter = 0; -int f(int x) { - return counter++; // 每次调用返回不同的值! -} -``` - -> [!warning] 编译器的保守策略 -> 编译器通常假设函数调用可能有副作用,因此不会轻易将多次函数调用合并。程序员可以: -> - 使用 `inline` 关键字建议编译器内联 -> - 使用 `const`、`pure` 等属性标记无副作用的函数 -> - 将函数调用结果缓存到局部变量中 - ---- - -## 六、循环优化技术 - -### 6.1 循环展开(Loop Unrolling) - -减少循环控制开销,增加指令级并行性: - -```c -// 优化前 -for (int i = 0; i < n; i++) { - sum += a[i]; -} - -// 2x 循环展开 -int i; -for (i = 0; i < n - 1; i += 2) { - sum += a[i] + a[i+1]; -} -for (; i < n; i++) { // 处理剩余元素 - sum += a[i]; -} -``` - -### 6.2 循环合并(Loop Fusion) - -将多个独立循环合并为一个,改善数据局部性: - -```c -// 优化前:两次遍历 -for (int i = 0; i < n; i++) - a[i] = b[i] + c[i]; -for (int i = 0; i < n; i++) - d[i] = a[i] * 2; - -// 优化后:一次遍历 -for (int i = 0; i < n; i++) { - a[i] = b[i] + c[i]; - d[i] = a[i] * 2; -} -``` - -### 6.3 循环不变量外提(Loop-Invariant Code Motion) - -将循环中不变的计算移到循环外: - -```c -// 优化前 -for (int i = 0; i < n; i++) { - a[i] = b[i] * (x * y + z); // x*y+z 在循环中不变 -} - -// 优化后 -int tmp = x * y + z; -for (int i = 0; i < n; i++) { - a[i] = b[i] * tmp; -} -``` - -### 6.4 循环优化流程图 - -```mermaid -flowchart TD - A["循环代码"] --> B{"循环体中有
不变量?"} - B -->|是| C["循环不变量外提"] - B -->|否| D{"循环迭代
次数少?"} - C --> D - D -->|是| E["循环展开"] - D -->|否| F{"多个循环
数据相关?"} - E --> F - F -->|是| G["循环合并"] - F -->|否| H["保持原样"] - G --> I["优化后的循环"] - H --> I -``` - ---- - -## 七、内存访问优化与缓存友好性 - -### 7.1 空间局部性与时间局部性 - -程序的局部性原理(参见 [[13_存储管理基础]])是内存优化的基础: - -- **空间局部性**:访问了某个地址,附近地址很可能也会被访问 -- **时间局部性**:刚访问过的数据很可能再次被访问 - -### 7.2 按行优先 vs 按列遍历 - -```c -#define M 2048 -#define N 2048 - -// 方式P1:按行遍历(空间局部性好) -int sumarrayrows(int a[M][N]) { - int i, j, sum = 0; - for (i = 0; i < M; i++) - for (j = 0; j < N; j++) - sum += a[i][j]; // 顺序访问内存 - return sum; -} - -// 方式P2:按列遍历(空间局部性差) -int sumarraycols(int a[M][N]) { - int i, j, sum = 0; - for (j = 0; j < N; j++) - for (i = 0; i < M; i++) - sum += a[i][j]; // 每次跳过 N 个元素 - return sum; -} -``` - -> [!important] 实测性能差距 -> 在 2GHz Intel Pentium 4 上: -> - **P1(按行遍历)**:59,393,288 时钟周期 -> - **P2(按列遍历)**:1,277,877,876 时钟周期 -> - P1 比 P2 **快 21.5 倍**! - -### 7.3 局部性分析 - -| 数据 | 按行遍历(P1) | 按列遍历(P2) | -|------|-------------|-------------| -| 数组 `a` | 空间局部性好(顺序访问) | 空间局部性差(每次跳2048个单元) | -| 变量 `sum,i,j` | 时间局部性好(循环中反复访问) | 时间局部性好 | -| 循环指令 | 空间局部性好 + 时间局部性好 | 空间局部性好 + 时间局部性好 | - -### 7.4 缓存友好的代码编写原则 - -```mermaid -graph LR - A["缓存友好原则"] --> B["按行优先遍历多维数组"] - A --> C["使用连续内存布局"] - A --> D["减少跨步访问"] - A --> E["数据结构对齐缓存行"] - - style A fill:#e8f5e9 -``` - ---- - -## 八、函数调用优化 - -### 8.1 内联函数(Inline) - -将函数体直接展开到调用处,消除函数调用开销: - -```c -// 优化前:函数调用有开销 -int square(int x) { - return x * x; -} -int result = square(5); - -// 使用 inline 关键字 -static inline int square_inline(int x) { - return x * x; -} -int result = square_inline(5); // 编译器可能直接展开为 5*5 -``` - -> [!note] 编译器的内联决策 -> 即使不使用 `inline` 关键字,`-O2` 及以上优化级别下,编译器也会自动内联小型函数。但大型函数的内联会增加代码体积,可能导致指令缓存不友好。 - -### 8.2 消除过程调用 - -如 combine3 的优化所示,将函数调用替换为直接内存访问: - -```c -// 优化前:每次迭代调用函数 -for (i = 0; i < length; i++) { - data_t val; - get_vec_element(v, i, &val); // 函数调用开销 - *dest = *dest OP val; -} - -// 优化后:直接指针访问 -data_t *data = get_vec_start(v); -for (i = 0; i < length; i++) { - *dest = *dest OP data[i]; // 直接数组访问 -} -``` - -### 8.3 尾调用优化 - -当函数最后一步是调用另一个函数时,编译器可以复用当前栈帧: - -```c -// 优化前:递归可能导致栈溢出 -int factorial(int n) { - if (n <= 1) return 1; - return n * factorial(n - 1); // 不是尾调用(乘法在递归之后) -} - -// 尾递归版本 -int factorial_tail(int n, int acc) { - if (n <= 1) return acc; - return factorial_tail(n - 1, n * acc); // 尾调用 -} -``` - ---- - -## 九、SIMD 与底层优化 - -### 9.1 SIMD 概念 - -SIMD(Single Instruction, Multiple Data)允许一条指令同时处理多个数据元素: - -```c -// 标量版本:一次处理一个元素 -for (int i = 0; i < n; i++) { - c[i] = a[i] + b[i]; -} - -// SSE 向量化(伪代码):一次处理4个float -// 编译器在 -O3 下可能自动生成 SIMD 指令 -``` - -### 9.2 编译器自动向量化 - -```bash -# 查看编译器是否进行了向量化 -gcc -O3 -ftree-vectorize -fopt-info-vec prog.c - -# 明确启用 SIMD 优化 -gcc -O3 -mavx2 prog.c # 使用 AVX2 指令集 -gcc -O3 -msse4.2 prog.c # 使用 SSE4.2 指令集 -``` - -### 9.3 数据对齐 - -```c -// 确保数据对齐以获得最佳 SIMD 性能 -float *a = aligned_alloc(32, n * sizeof(float)); // 32字节对齐(AVX) -``` - ---- - -## 十、优化编译器的局限性 - -### 10.1 编译器的基本约束 - -> [!important] 根本约束 -> 编译器在**任何可能的条件下**都不能改变程序的行为。这意味着即使某些行为只在极端情况下发生,编译器也必须保守地保留这些行为。 - -### 10.2 编译器难以优化的情况 - -| 情况 | 原因 | 程序员对策 | -|------|------|-----------| -| 指针别名 | 编译器不知道两个指针是否指向同一位置 | 使用局部变量累积结果 | -| 函数副作用 | 编译器不知道函数是否有副作用 | 使用 `inline`、`const`、`pure` 属性 | -| 数据范围 | 编译器不知道变量的实际取值范围 | 使用更精确的数据类型 | -| 循环边界 | 编译器不确定循环次数 | 使用常量或 `restrict` 关键字 | - -### 10.3 `restrict` 关键字 - -C99 引入的 `restrict` 告诉编译器指针没有别名: - -```c -// 使用 restrict 告诉编译器 src 和 dest 不重叠 -void copy(int *restrict dest, const int *restrict src, int n) { - for (int i = 0; i < n; i++) { - dest[i] = src[i]; - } -} -``` - ---- - -## 十一、性能测量与 Profiling 工具 - -### 11.1 常用性能分析工具 - -| 工具 | 用途 | 命令示例 | -|------|------|---------| -| `time` | 测量程序总执行时间 | `time ./prog` | -| `perf` | Linux 性能计数器分析 | `perf stat ./prog` | -| `gprof` | GNU 函数级 profiling | `gcc -pg prog.c && ./a.out && gprof` | -| `valgrind/callgrind` | 缓存和分支预测分析 | `valgrind --tool=callgrind ./prog` | -| `cachegrind` | 缓存命中率分析 | `valgrind --tool=cachegrind ./prog` | - -### 11.2 使用 perf 进行分析 - -```bash -# 基本性能统计 -perf stat ./prog - -# 详细缓存分析 -perf stat -e cache-references,cache-misses ./prog - -# 函数级热点分析 -perf record ./prog -perf report -``` - -### 11.3 使用 gprof 进行分析 - -```bash -# 编译时加入 profiling 支持 -gcc -pg -O2 prog.c -o prog - -# 运行程序(生成 gmon.out) -./prog - -# 查看分析结果 -gprof prog gmon.out > analysis.txt -``` - -### 11.4 优化工作流程 - -```mermaid -flowchart TD - A["编写正确的代码"] --> B["选择合适的算法"] - B --> C["使用 profiling 找到瓶颈"] - C --> D{"瓶颈在哪里?"} - D -->|循环| E["循环优化:展开、外提、合并"] - D -->|内存| F["内存优化:局部性、缓存友好"] - D -->|函数调用| G["内联、消除过程调用"] - D -->|I/O| H["缓冲、批量处理"] - E --> I["测量优化效果"] - F --> I - G --> I - H --> I - I --> J{"性能满足要求?"} - J -->|否| C - J -->|是| K["完成"] -``` - ---- - -## 十二、优化总结 - -### 12.1 机器无关优化的核心策略 - -| 优化策略 | 说明 | 对应函数 | -|---------|------|---------| -| **代码移动** | 将循环不变量移出循环 | combine1 → combine2 | -| **消除过程调用** | 用直接内存访问替代函数调用 | combine2 → combine3 | -| **消除不必要内存引用** | 使用局部变量/寄存器累积 | combine3 → combine4 | -| **循环展开** | 减少循环控制开销 | psum1 → psum2 | - -### 12.2 关键原则 - -> [!abstract] 代码优化的核心原则 -> 1. **先正确,后优化**:保证代码正确性是前提 -> 2. **度量驱动**:使用 profiling 工具找到真正的瓶颈 -> 3. **算法优先**:$O(n \log n)$ 的算法比 $O(n^2)$ 的优化更重要 -> 4. **利用局部性**:按行访问数组,使用连续内存布局 -> 5. **减少函数调用**:在热循环中避免不必要的函数调用 -> 6. **使用局部变量**:告诉编译器数据没有别名问题 -> 7. **信任编译器**:`-O2` 通常足够,除非有明确的性能需求 - ---- - -## 本章术语 - -| 术语 | 英文 | 说明 | -|------|------|------| -| 代码移动 | Code Motion | 将循环不变量计算移到循环外 | -| CPE | Cycles Per Element | 每元素处理所需的时钟周期数 | -| 别名 | Aliasing | 两个不同指针指向同一内存位置 | -| 循环展开 | Loop Unrolling | 复制循环体以减少循环控制开销 | -| 内联 | Inline | 将函数体展开到调用处 | -| SIMD | Single Instruction Multiple Data | 单指令多数据并行 | -| 向量化 | Vectorization | 利用 SIMD 指令并行处理数据 | -| 代码选择 | Code Selection | 编译器选择合适的机器指令 | -| 寄存器分配 | Register Allocation | 将变量分配到 CPU 寄存器 | -| `restrict` | Restrict Qualifier | 告诉编译器指针没有别名 | - ---- - -## 复习与思考 - -1. 为什么 `combine1` 到 `combine4` 的优化能显著降低 CPE?每一步消除了什么开销? -2. 当 `xp` 和 `yp` 指向同一地址时,`twiddle1` 和 `twiddle2` 的结果为什么不同? -3. 为什么按列遍历二维数组比按行遍历慢很多?这与 [[13_存储管理基础]] 中的局部性原理有什么关系? -4. 编译器为什么不能自动将 `func1` 优化为 `func2`?程序员应该如何帮助编译器? -5. 在什么情况下应该使用 `-O3` 而不是 `-O2`?有什么潜在的代价? +# 18. 程序代码优化 + +> **课程**: 操作系统 - 程序代码优化 +> **核心内容**: 机器无关优化、代码移动、消除不必要的内存引用、优化障碍(指针别名、函数副作用)、性能度量 + +--- + +## 前置知识 +- [[03_C语言编程基础]] -- C语言编译链接过程、指针与内存模型 +- [[13_存储管理基础]] -- 存储器层次结构、局部性原理 + +--- + +## 一、代码优化的概念与意义 + +### 1.1 为什么需要代码优化 + +> [!important] 核心观点 +> 常数因子也很重要!根据代码编写方式的不同,程序性能可能相差 **10倍** 以上。必须在多个层次上进行优化:算法、数据表示、过程调用和循环。 + +代码优化的目标: +- 理解程序如何被编译和执行 +- 学习如何度量程序性能并识别瓶颈 +- 在不破坏代码模块化和通用性的前提下提升性能 + +### 1.2 优化的层次 + +```mermaid +graph TB + A["算法优化
选择更高效的算法"] --> B["数据结构优化
选择合适的内存布局"] + B --> C["编译器优化
利用编译器选项"] + C --> D["源代码级优化
代码移动、消除冗余"] + D --> E["指令级优化
利用底层硬件特性"] + + style A fill:#e8f5e9 + style B fill:#e1f5fe + style C fill:#fff3e0 + style D fill:#fce4ec + style E fill:#f3e5f5 +``` + +### 1.3 编译器优化级别 + +| 优化级别 | 说明 | 特点 | +|---------|------|------| +| `-O0` | 不优化 | 编译最快,调试最方便,性能最差 | +| `-O1` | 基本优化 | 消除冗余代码、简单内联,平衡编译速度和性能 | +| `-O2` | 推荐优化 | 启用大多数优化,包括循环优化、指令调度等 | +| `-O3` | 激进优化 | 包含 `-O2` 所有优化,加上循环展开、SIMD向量化等 | + +```bash +# 不同优化级别的编译 +gcc -O0 prog.c -o prog_O0 # 无优化(调试用) +gcc -O1 prog.c -o prog_O1 # 基本优化 +gcc -O2 prog.c -o prog_O2 # 推荐优化 +gcc -O3 prog.c -o prog_O3 # 激进优化 + +# 比较不同优化级别的汇编输出 +gcc -O0 -S prog.c -o prog_O0.s +gcc -O2 -S prog.c -o prog_O2.s +diff prog_O0.s prog_O2.s +``` + +> [!tip] 编译器的局限性 +> 编译器通常**不会**改善渐近效率(大O复杂度),这取决于程序员选择最优算法。大O节省通常比常数因子更重要,但常数因子也确实重要。编译器在面对"优化障碍"时往往难以进行优化。 + +--- + +## 二、性能度量:CPE(每元素周期数) + +### 2.1 CPE 概念 + +CPE(Cycles Per Element)是度量向量或列表操作程序性能的便捷方式: + +$$T = CPE \times n + Overhead$$ + +其中 $n$ 是向量长度,$T$ 是总执行时间(时钟周期数)。 + +### 2.2 时间尺度 + +| 指标 | 说明 | +|------|------| +| 绝对时间 | 通常使用纳秒($10^{-9}$ 秒) | +| 时钟周期 | 100 MHz → 10ns/周期;2 GHz → 0.5ns/周期 | + +### 2.3 CPE 度量示例 + +```c +// psum1: 朴素前缀和 +void psum1(float a[], float p[], long int n) { + long int i; + p[0] = a[0]; + for (i = 0; i < n; i++) + p[i] = p[i-1] + a[i]; +} + +// psum2: 循环展开的前缀和 +void psum2(float a[], float p[], long int n) { + long int i; + p[0] = a[0]; + for (i = 1; i < n-1; i += 2) { + float mid_val = p[i-1] + a[i]; + p[i] = mid_val; + p[i+1] = mid_val + a[i+1]; + } + if (i < n) + p[i] = p[i-1] + a[i]; +} +``` + +> [!note] 性能对比 +> `psum2` 通过循环展开减少了关键路径上的操作次数,从而降低了 CPE。 + +--- + +## 三、优化实例:向量求和的逐步优化 + +### 3.1 向量 ADT 定义 + +```c +typedef int data_t; + +typedef struct { + int len; + data_t *data; +} vec_rec, *vec_ptr; +``` + +相关操作: +- `new_vec(len)` -- 创建指定长度的向量 +- `vec_length(v)` -- 返回向量长度 +- `get_vec_start(v)` -- 返回向量数据起始指针 +- `get_vec_element(v, index, &dest)` -- 获取指定下标的元素(带边界检查) + +### 3.2 combine1:抽象版本(基线) + +```c +void combine1(vec_ptr v, data_t *dest) { + long int i; + *dest = IDENT; + for (i = 0; i < vec_length(v); i++) { + data_t val; + get_vec_element(v, i, &val); + *dest = *dest OP val; + } +} +``` + +> [!warning] 问题分析 +> 每次循环迭代都调用 `vec_length(v)`,即使其返回值始终不变。这属于**循环不变量**问题。 + +### 3.3 combine2:代码移动(Code Motion) + +```c +void combine2(vec_ptr v, data_t *dest) { + long int i; + long int length = vec_length(v); // 移出循环 + *dest = IDENT; + for (i = 0; i < length; i++) { + data_t val; + get_vec_element(v, i, &val); + *dest = *dest OP val; + } +} +``` + +> [!tip] 代码移动优化 +> 将 `vec_length()` 调用从循环体内移到循环之前。循环不变量外提(Loop-Invariant Code Motion)是最基本的优化之一。 + +### 3.4 combine3:消除过程调用(Reduction in Strength) + +```c +void combine3(vec_ptr v, data_t *dest) { + long int i; + long int length = vec_length(v); + data_t *data = get_vec_start(v); // 获取数据指针 + *dest = IDENT; + for (i = 0; i < length; i++) { + *dest = *dest OP data[i]; // 直接数组访问 + } +} +``` + +**优化要点**:避免每次迭代都调用 `get_vec_element()` 函数来获取元素,而是在循环前获取数据指针,循环内直接进行指针引用。 + +### 3.5 combine4:消除不必要的内存引用 + +```c +void combine4(vec_ptr v, data_t *dest) { + long int i; + long int length = vec_length(v); + data_t *data = get_vec_start(v); + data_t acc = IDENT; // 使用局部变量累积 + for (i = 0; i < length; i++) + acc = acc OP data[i]; + *dest = acc; // 最后一次性写回 +} +``` + +**汇编对比**: + +``` +combine3 的循环体(每次迭代需要3条内存指令): + movss (%rbp), %xmm0 # 从 dest 读取累加值 + mulss (%rax,%rdx,4), %xmm0 # 乘以 data[i] + movss %xmm0, (%rbp) # 写回 dest + addq $1, %rdx # i++ + cmpq %rdx, %r12 # 比较 i:limit + jg .L498 # 循环跳转 + +combine4 的循环体(只有1条内存指令): + mulss (%rax,%rdx,4), %xmm0 # acc *= data[i] + addq $1, %rdx # i++ + cmpq %rdx, %rbp # 比较 limit:i + jg .L488 # 循环跳转 +``` + +> [!important] 优化效果 +> - `combine3`:每次迭代需要 **1次读 + 1次写** 内存 +> - `combine4`:每次迭代只需要 **0次** 额外内存访问(`acc` 在寄存器中) +> - 局部变量 `acc` 告诉编译器:不需要在每轮循环都检查内存别名 + +### 3.6 优化效果汇总 + +| 函数 | 优化方法 | 整数+ CPE | 整数* CPE | 浮点+ CPE | +|------|---------|----------|----------|----------| +| combine1 | 抽象接口 | 12.00 | 12.00 | 12.00 | +| combine2 | 代码移动 | -- | -- | -- | +| combine3 | 消除过程调用 | -- | -- | -- | +| combine4 | 使用临时变量累积 | **2.00** | **3.00** | **3.00** | + +--- + +## 四、代码移动(Code Motion)详解 + +### 4.1 循环不变量外提 + +将循环中每次迭代结果相同的计算移到循环之前: + +```c +// 优化前:strlen 在每次循环都被调用 +void lower1(char *s) { + int i; + for (i = 0; i < strlen(s); i++) // O(n^2) 复杂度! + if (s[i] >= 'A' && s[i] <= 'Z') + s[i] -= ('A' - 'a'); +} + +// 优化后:strlen 只调用一次 +void lower2(char *s) { + int i; + int len = strlen(s); // O(n) 复杂度 + for (i = 0; i < len; i++) + if (s[i] >= 'A' && s[i] <= 'Z') + s[i] -= ('A' - 'a'); +} +``` + +> [!warning] 为什么编译器不能自动做这个优化? +> `strlen` 是一个函数调用,编译器**不知道**它是否有副作用,也不知道字符串长度在循环中是否会改变。编译器必须保守处理。 + +### 4.2 strlen 的内部实现 + +```c +size_t strlen(const char *s) { + int length = 0; + while (*s != '\0') { + s++; + length++; + } + return length; +} +``` + +`strlen` 本身是 $O(n)$ 的。如果在循环中每次迭代都调用它,整体复杂度就变成了 $O(n^2)$。 + +--- + +## 五、优化障碍(Optimization Blockers) + +编译器在进行优化时受到两大根本性约束: +1. 编译器在**任何可能的条件下**都不能改变程序的行为 +2. 当程序员知道的信息比编译器多时,需要程序员主动干预 + +### 5.1 指针别名(Memory Aliasing) + +> [!danger] 别名问题 +> 当两个不同的内存引用指向同一个存储位置时,就产生了别名。C语言允许地址算术运算和直接访问存储结构,因此别名问题非常容易出现。 + +```c +// twiddle1 和 twiddle2 看似等价,但实际上不一定! +void twiddle1(int *xp, int *yp) { + *xp += *yp; + *xp += *yp; +} + +void twiddle2(int *xp, int *yp) { + *xp += 2 * *yp; +} +``` + +**当 `xp == yp` 时(别名情况)**: +- `twiddle1`:`*xp = *xp + *xp = 2*xp`,然后 `*xp = 2*xp + 2*xp = 4*xp` +- `twiddle2`:`*xp = *xp + 2*(*xp) = 3*xp` +- 结果不同!编译器不能将 `twiddle1` 优化为 `twiddle2` + +**别名实例演示**: + +```c +// v = [2, 3, 5] +combine3(v, get_vec_start(v) + 2); // dest 指向 v->data[2] +combine4(v, get_vec_start(v) + 2); // dest 指向 v->data[2] +``` + +| 函数 | 初始 | i=0 | i=1 | i=2 | 最终 | +|------|------|-----|-----|-----|------| +| combine3 | [2,3,5] | [2,3,1] | [2,3,2] | [2,3,6] | [2,3,36] | +| combine4 | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,5] | [2,3,30] | + +> [!tip] 避免别名问题的方法 +> 养成使用**局部变量**的习惯。在循环中用局部变量累积结果,循环结束后再写回目标地址。这是告诉编译器"不需要检查别名"的方式。 + +### 5.2 函数调用的副作用 + +```c +int f(int); + +// func1:调用 f 4次 +int func1(int x) { + return f(x) + f(x) + f(x) + f(x); +} + +// func2:调用 f 1次 +int func2(int x) { + return 4 * f(x); +} +``` + +看起来 `func1` 可以优化为 `func2`,但当 `f` 有副作用时就不行了: + +```c +int counter = 0; +int f(int x) { + return counter++; // 每次调用返回不同的值! +} +``` + +> [!warning] 编译器的保守策略 +> 编译器通常假设函数调用可能有副作用,因此不会轻易将多次函数调用合并。程序员可以: +> - 使用 `inline` 关键字建议编译器内联 +> - 使用 `const`、`pure` 等属性标记无副作用的函数 +> - 将函数调用结果缓存到局部变量中 + +--- + +## 六、循环优化技术 + +### 6.1 循环展开(Loop Unrolling) + +减少循环控制开销,增加指令级并行性: + +```c +// 优化前 +for (int i = 0; i < n; i++) { + sum += a[i]; +} + +// 2x 循环展开 +int i; +for (i = 0; i < n - 1; i += 2) { + sum += a[i] + a[i+1]; +} +for (; i < n; i++) { // 处理剩余元素 + sum += a[i]; +} +``` + +### 6.2 循环合并(Loop Fusion) + +将多个独立循环合并为一个,改善数据局部性: + +```c +// 优化前:两次遍历 +for (int i = 0; i < n; i++) + a[i] = b[i] + c[i]; +for (int i = 0; i < n; i++) + d[i] = a[i] * 2; + +// 优化后:一次遍历 +for (int i = 0; i < n; i++) { + a[i] = b[i] + c[i]; + d[i] = a[i] * 2; +} +``` + +### 6.3 循环不变量外提(Loop-Invariant Code Motion) + +将循环中不变的计算移到循环外: + +```c +// 优化前 +for (int i = 0; i < n; i++) { + a[i] = b[i] * (x * y + z); // x*y+z 在循环中不变 +} + +// 优化后 +int tmp = x * y + z; +for (int i = 0; i < n; i++) { + a[i] = b[i] * tmp; +} +``` + +### 6.4 循环优化流程图 + +```mermaid +flowchart TD + A["循环代码"] --> B{"循环体中有
不变量?"} + B -->|是| C["循环不变量外提"] + B -->|否| D{"循环迭代
次数少?"} + C --> D + D -->|是| E["循环展开"] + D -->|否| F{"多个循环
数据相关?"} + E --> F + F -->|是| G["循环合并"] + F -->|否| H["保持原样"] + G --> I["优化后的循环"] + H --> I +``` + +--- + +## 七、内存访问优化与缓存友好性 + +### 7.1 空间局部性与时间局部性 + +程序的局部性原理(参见 [[13_存储管理基础]])是内存优化的基础: + +- **空间局部性**:访问了某个地址,附近地址很可能也会被访问 +- **时间局部性**:刚访问过的数据很可能再次被访问 + +### 7.2 按行优先 vs 按列遍历 + +```c +#define M 2048 +#define N 2048 + +// 方式P1:按行遍历(空间局部性好) +int sumarrayrows(int a[M][N]) { + int i, j, sum = 0; + for (i = 0; i < M; i++) + for (j = 0; j < N; j++) + sum += a[i][j]; // 顺序访问内存 + return sum; +} + +// 方式P2:按列遍历(空间局部性差) +int sumarraycols(int a[M][N]) { + int i, j, sum = 0; + for (j = 0; j < N; j++) + for (i = 0; i < M; i++) + sum += a[i][j]; // 每次跳过 N 个元素 + return sum; +} +``` + +> [!important] 实测性能差距 +> 在 2GHz Intel Pentium 4 上: +> - **P1(按行遍历)**:59,393,288 时钟周期 +> - **P2(按列遍历)**:1,277,877,876 时钟周期 +> - P1 比 P2 **快 21.5 倍**! + +### 7.3 局部性分析 + +| 数据 | 按行遍历(P1) | 按列遍历(P2) | +|------|-------------|-------------| +| 数组 `a` | 空间局部性好(顺序访问) | 空间局部性差(每次跳2048个单元) | +| 变量 `sum,i,j` | 时间局部性好(循环中反复访问) | 时间局部性好 | +| 循环指令 | 空间局部性好 + 时间局部性好 | 空间局部性好 + 时间局部性好 | + +### 7.4 缓存友好的代码编写原则 + +```mermaid +graph LR + A["缓存友好原则"] --> B["按行优先遍历多维数组"] + A --> C["使用连续内存布局"] + A --> D["减少跨步访问"] + A --> E["数据结构对齐缓存行"] + + style A fill:#e8f5e9 +``` + +--- + +## 八、函数调用优化 + +### 8.1 内联函数(Inline) + +将函数体直接展开到调用处,消除函数调用开销: + +```c +// 优化前:函数调用有开销 +int square(int x) { + return x * x; +} +int result = square(5); + +// 使用 inline 关键字 +static inline int square_inline(int x) { + return x * x; +} +int result = square_inline(5); // 编译器可能直接展开为 5*5 +``` + +> [!note] 编译器的内联决策 +> 即使不使用 `inline` 关键字,`-O2` 及以上优化级别下,编译器也会自动内联小型函数。但大型函数的内联会增加代码体积,可能导致指令缓存不友好。 + +### 8.2 消除过程调用 + +如 combine3 的优化所示,将函数调用替换为直接内存访问: + +```c +// 优化前:每次迭代调用函数 +for (i = 0; i < length; i++) { + data_t val; + get_vec_element(v, i, &val); // 函数调用开销 + *dest = *dest OP val; +} + +// 优化后:直接指针访问 +data_t *data = get_vec_start(v); +for (i = 0; i < length; i++) { + *dest = *dest OP data[i]; // 直接数组访问 +} +``` + +### 8.3 尾调用优化 + +当函数最后一步是调用另一个函数时,编译器可以复用当前栈帧: + +```c +// 优化前:递归可能导致栈溢出 +int factorial(int n) { + if (n <= 1) return 1; + return n * factorial(n - 1); // 不是尾调用(乘法在递归之后) +} + +// 尾递归版本 +int factorial_tail(int n, int acc) { + if (n <= 1) return acc; + return factorial_tail(n - 1, n * acc); // 尾调用 +} +``` + +--- + +## 九、SIMD 与底层优化 + +### 9.1 SIMD 概念 + +SIMD(Single Instruction, Multiple Data)允许一条指令同时处理多个数据元素: + +```c +// 标量版本:一次处理一个元素 +for (int i = 0; i < n; i++) { + c[i] = a[i] + b[i]; +} + +// SSE 向量化(伪代码):一次处理4个float +// 编译器在 -O3 下可能自动生成 SIMD 指令 +``` + +### 9.2 编译器自动向量化 + +```bash +# 查看编译器是否进行了向量化 +gcc -O3 -ftree-vectorize -fopt-info-vec prog.c + +# 明确启用 SIMD 优化 +gcc -O3 -mavx2 prog.c # 使用 AVX2 指令集 +gcc -O3 -msse4.2 prog.c # 使用 SSE4.2 指令集 +``` + +### 9.3 数据对齐 + +```c +// 确保数据对齐以获得最佳 SIMD 性能 +float *a = aligned_alloc(32, n * sizeof(float)); // 32字节对齐(AVX) +``` + +--- + +## 十、优化编译器的局限性 + +### 10.1 编译器的基本约束 + +> [!important] 根本约束 +> 编译器在**任何可能的条件下**都不能改变程序的行为。这意味着即使某些行为只在极端情况下发生,编译器也必须保守地保留这些行为。 + +### 10.2 编译器难以优化的情况 + +| 情况 | 原因 | 程序员对策 | +|------|------|-----------| +| 指针别名 | 编译器不知道两个指针是否指向同一位置 | 使用局部变量累积结果 | +| 函数副作用 | 编译器不知道函数是否有副作用 | 使用 `inline`、`const`、`pure` 属性 | +| 数据范围 | 编译器不知道变量的实际取值范围 | 使用更精确的数据类型 | +| 循环边界 | 编译器不确定循环次数 | 使用常量或 `restrict` 关键字 | + +### 10.3 `restrict` 关键字 + +C99 引入的 `restrict` 告诉编译器指针没有别名: + +```c +// 使用 restrict 告诉编译器 src 和 dest 不重叠 +void copy(int *restrict dest, const int *restrict src, int n) { + for (int i = 0; i < n; i++) { + dest[i] = src[i]; + } +} +``` + +--- + +## 十一、性能测量与 Profiling 工具 + +### 11.1 常用性能分析工具 + +| 工具 | 用途 | 命令示例 | +|------|------|---------| +| `time` | 测量程序总执行时间 | `time ./prog` | +| `perf` | Linux 性能计数器分析 | `perf stat ./prog` | +| `gprof` | GNU 函数级 profiling | `gcc -pg prog.c && ./a.out && gprof` | +| `valgrind/callgrind` | 缓存和分支预测分析 | `valgrind --tool=callgrind ./prog` | +| `cachegrind` | 缓存命中率分析 | `valgrind --tool=cachegrind ./prog` | + +### 11.2 使用 perf 进行分析 + +```bash +# 基本性能统计 +perf stat ./prog + +# 详细缓存分析 +perf stat -e cache-references,cache-misses ./prog + +# 函数级热点分析 +perf record ./prog +perf report +``` + +### 11.3 使用 gprof 进行分析 + +```bash +# 编译时加入 profiling 支持 +gcc -pg -O2 prog.c -o prog + +# 运行程序(生成 gmon.out) +./prog + +# 查看分析结果 +gprof prog gmon.out > analysis.txt +``` + +### 11.4 优化工作流程 + +```mermaid +flowchart TD + A["编写正确的代码"] --> B["选择合适的算法"] + B --> C["使用 profiling 找到瓶颈"] + C --> D{"瓶颈在哪里?"} + D -->|循环| E["循环优化:展开、外提、合并"] + D -->|内存| F["内存优化:局部性、缓存友好"] + D -->|函数调用| G["内联、消除过程调用"] + D -->|I/O| H["缓冲、批量处理"] + E --> I["测量优化效果"] + F --> I + G --> I + H --> I + I --> J{"性能满足要求?"} + J -->|否| C + J -->|是| K["完成"] +``` + +--- + +## 十二、优化总结 + +### 12.1 机器无关优化的核心策略 + +| 优化策略 | 说明 | 对应函数 | +|---------|------|---------| +| **代码移动** | 将循环不变量移出循环 | combine1 → combine2 | +| **消除过程调用** | 用直接内存访问替代函数调用 | combine2 → combine3 | +| **消除不必要内存引用** | 使用局部变量/寄存器累积 | combine3 → combine4 | +| **循环展开** | 减少循环控制开销 | psum1 → psum2 | + +### 12.2 关键原则 + +> [!abstract] 代码优化的核心原则 +> 1. **先正确,后优化**:保证代码正确性是前提 +> 2. **度量驱动**:使用 profiling 工具找到真正的瓶颈 +> 3. **算法优先**:$O(n \log n)$ 的算法比 $O(n^2)$ 的优化更重要 +> 4. **利用局部性**:按行访问数组,使用连续内存布局 +> 5. **减少函数调用**:在热循环中避免不必要的函数调用 +> 6. **使用局部变量**:告诉编译器数据没有别名问题 +> 7. **信任编译器**:`-O2` 通常足够,除非有明确的性能需求 + +--- + +## 本章术语 + +| 术语 | 英文 | 说明 | +|------|------|------| +| 代码移动 | Code Motion | 将循环不变量计算移到循环外 | +| CPE | Cycles Per Element | 每元素处理所需的时钟周期数 | +| 别名 | Aliasing | 两个不同指针指向同一内存位置 | +| 循环展开 | Loop Unrolling | 复制循环体以减少循环控制开销 | +| 内联 | Inline | 将函数体展开到调用处 | +| SIMD | Single Instruction Multiple Data | 单指令多数据并行 | +| 向量化 | Vectorization | 利用 SIMD 指令并行处理数据 | +| 代码选择 | Code Selection | 编译器选择合适的机器指令 | +| 寄存器分配 | Register Allocation | 将变量分配到 CPU 寄存器 | +| `restrict` | Restrict Qualifier | 告诉编译器指针没有别名 | + +--- + +## 复习与思考 + +1. 为什么 `combine1` 到 `combine4` 的优化能显著降低 CPE?每一步消除了什么开销? +2. 当 `xp` 和 `yp` 指向同一地址时,`twiddle1` 和 `twiddle2` 的结果为什么不同? +3. 为什么按列遍历二维数组比按行遍历慢很多?这与 [[13_存储管理基础]] 中的局部性原理有什么关系? +4. 编译器为什么不能自动将 `func1` 优化为 `func2`?程序员应该如何帮助编译器? +5. 在什么情况下应该使用 `-O3` 而不是 `-O2`?有什么潜在的代价? diff --git a/操作系统/2026年操作系统期末预测卷.md b/操作系统/2026年操作系统期末预测卷.md new file mode 100644 index 0000000..3a98671 --- /dev/null +++ b/操作系统/2026年操作系统期末预测卷.md @@ -0,0 +1,257 @@ +# 2026年操作系统期末预测卷 + +## 考试信息 +- **满分**:100分 +- **时间**:120分钟 +- **题型**:7道大题(与历年试卷格式一致) + +--- + +## 题目一:进程控制与进程树分析(15分) + +### 题目 + +阅读以下程序代码,回答问题: + +```c +#include +#include +#include + +int main() { + pid_t pid; + int status; + + printf("A: pid=%d\n", getpid()); + + pid = fork(); + if (pid == 0) { + printf("B: pid=%d, ppid=%d\n", getpid(), getppid()); + pid = fork(); + if (pid == 0) { + printf("C: pid=%d, ppid=%d\n", getpid(), getppid()); + } else { + wait(&status); + printf("D: pid=%d\n", getpid()); + } + } else { + printf("E: pid=%d\n", getpid()); + pid = fork(); + if (pid == 0) { + printf("F: pid=%d, ppid=%d\n", getpid(), getppid()); + } else { + wait(&status); + wait(&status); + printf("G: pid=%d\n", getpid()); + } + } + + return 0; +} +``` + +**问题**: +1. (6分)画出完整的进程树,标注每个进程执行的输出语句(用字母A-G表示) +2. (4分)该程序共有多少个进程(包括初始进程)?请列出所有进程的创建顺序 +3. (3分)输出语句D和G的执行顺序是否一定?为什么? +4. (2分)如果将所有`wait(&status)`调用删除,进程输出结果会发生什么变化? + +--- + +## 题目二:磁盘调度算法计算(15分) + +### 题目 + +某磁盘有200个磁道(编号0-199),磁盘请求队列中的磁道号依次为: + +**98, 183, 37, 122, 14, 124, 65, 67** + +当前磁头位于磁道**53**,向磁道号**增大**的方向移动。 + +**问题**: +1. (8分)分别使用以下磁盘调度算法,计算磁头移动的总磁道数: + - FCFS(先来先服务) + - SSTF(最短寻道时间优先) + - SCAN(电梯算法) + - C-SCAN(循环扫描算法) + +2. (4分)在上述四种算法中,哪种算法的磁头移动总磁道数最少?哪种最多?简要说明原因。 + +3. (3分)如果磁头初始方向改为向磁道号减小的方向移动,SCAN算法的结果会如何变化?请计算新的磁头移动总磁道数。 + +--- + +## 题目三:虚拟内存与页面置换(15分) + +### 题目 + +某系统采用请求分页存储管理,页面大小为4KB,分配给某进程的物理页框数为**4**。该进程的页面访问序列为: + +**1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5** + +**问题**: +1. (9分)分别使用以下页面置换算法,计算缺页次数和缺页率: + - OPT(最优算法) + - FIFO(先进先出算法) + - LRU(最近最久未使用算法) + +2. (3分)如果将分配的物理页框数增加到5,使用FIFO算法,缺页次数是否会减少?请验证并解释是否出现了Belady异常。 + +3. (3分)假设页面大小改为8KB(其他条件不变),该进程的虚拟地址空间大小为32KB,请问: + - 进程共有多少个虚拟页? + - 页表需要多少个页表项? + - 虚拟地址`0x3A5F`对应的页号和页内偏移分别是多少? + +--- + +## 题目四:文件系统与inode计算(15分) + +### 题目 + +某Unix文件系统采用Ext2结构,文件系统的块大小为**4KB**,磁盘块号占**4字节**,inode中包含**15个地址项**(12个直接索引、1个一次间接、1个二次间接、1个三次间接)。 + +**问题**: +1. (6分)计算: + - 每个磁盘块可以存放多少个磁盘块号? + - 一个inode支持的最大文件大小是多少?(请列出计算过程) + +2. (4分)假设某文件大小为260KB,请问: + - 该文件占用多少个数据块? + - inode中的哪些地址项会被使用? + - 是否需要使用间接索引块?如果需要,使用几个? + +3. (5分)假设要读取该文件偏移量为100KB处开始的4KB数据,请描述完整的地址转换过程: + - 如何确定使用inode中的哪个地址项? + - 如何计算数据在磁盘上的具体位置? + - 需要访问几次磁盘? + +--- + +## 题目五:CPU调度算法计算(15分) + +### 题目 + +某单处理器系统中有以下进程,按到达时间排序: + +| 进程 | 到达时间 | 运行时间 | +|------|----------|----------| +| P1 | 0 | 8 | +| P2 | 1 | 4 | +| P3 | 2 | 9 | +| P4 | 3 | 5 | +| P5 | 4 | 2 | + +**问题**: +1. (9分)分别使用以下调度算法,计算各进程的完成时间、周转时间和带权周转时间,并求平均周转时间和平均带权周转时间: + - FCFS(先来先服务) + - SJF(非抢占最短作业优先) + - HRRF(最高响应比优先) + +2. (3分)在上述三种算法中: + - 哪种算法的平均周转时间最短? + - 哪种算法可能产生"饥饿"现象?为什么? + +3. (3分)如果采用时间片轮转调度(RR),时间片大小为3,请画出甘特图并计算P1和P2的周转时间。 + +--- + +## 题目六:I/O控制方式与磁盘访问(15分) + +### 题目 + +1. (6分)比较以下四种I/O控制方式的特点,填写下表: + +| 特性 | 程序直接控制 | 中断驱动I/O | DMA | 通道 | +|------|-------------|-------------|-----|------| +| CPU介入频率 | | | | | +| 数据传输单位 | | | | | +| CPU与I/O并行性 | | | | | +| 硬件复杂度 | | | | | + +2. (4分)某磁盘转速为7200 RPM,平均寻道时间为8ms,每个磁道500个扇区,每个扇区512字节。 + - 计算平均旋转延迟 + - 计算读取一个扇区的平均访问时间 + - 如果要连续读取100个扇区(在同一磁道上),总访问时间是多少? + +3. (5分)某系统使用SPOOLing技术管理打印机: + - 画出SPOOLing系统的组成结构图 + - 解释SPOOLing如何将独占设备改造为共享设备 + - 说明SPOOLing与缓冲技术的区别 + +--- + +## 题目七:段页式存储管理与地址转换(10分) + +### 题目 + +某系统采用段页式存储管理,地址结构如下: +- 段号:8位 +- 页号:8位 +- 页内偏移:16位 + +系统参数: +- 页大小:64KB +- 段表基址寄存器指向的段表如下: + +| 段号 | 段长(页数) | 页表始址 | +|------|-------------|----------| +| 0 | 4 | 1000 | +| 1 | 6 | 2000 | +| 2 | 2 | 3000 | + +页表(部分)如下: + +| 页号 | 物理块号 | +|------|----------| +| 0 | 50 | +| 1 | 51 | +| 2 | 52 | +| 3 | 53 | + +**问题**: +1. (4分)逻辑地址`0x00018000`对应的段号、页号、页内偏移分别是多少?该地址对应的物理地址是多少?(写出计算过程) + +2. (3分)逻辑地址`0x01028000`是否合法?为什么?如果合法,计算其物理地址。 + +3. (3分)与纯分页和纯分段相比,段页式存储管理有哪些优点和缺点? + +--- + +## 参考答案要点 + +### 题目一参考答案 +1. 进程树:A→(B, E),B→(C, D),E→(F, G) +2. 共7个进程,创建顺序:A→B→E→C→F→D→G +3. D和G的执行顺序不一定,取决于哪个子进程先完成 +4. 删除wait后,输出顺序不确定,可能出现孤儿进程 + +### 题目二参考答案 +1. FCFS: 640道,SSTF: 236道,SCAN: 299道,C-SCAN: 356道 +2. SSTF最少(贪心选择最近),FCFS最多(无优化) +3. 反向SCAN:53→37→14→0→65→67→98→122→124→183,总移动:359道 + +### 题目三参考答案 +1. OPT: 8次(66.7%),FIFO: 10次(83.3%),LRU: 10次(83.3%) +2. 页框数=5时FIFO缺页仍为10次,未出现Belady异常 +3. 虚拟页数=4,页表项=4,地址0x3A5F:页号=3,偏移=0x2A5F + +### 题目四参考答案 +1. 每块1024个块号,最大文件约16GB+ +2. 65个数据块,使用直接索引12个+一次间接索引1个,不需要二次间接 +3. 偏移100KB=第25块,使用直接索引项2,需2次磁盘访问 + +### 题目五参考答案 +1. FCFS平均周转7.8,SJF平均周转6.2,HRRF平均周转7.0 +2. SJF最短,SJF可能饥饿(长作业持续等待) +3. RR甘特图:0-3(P1),3-6(P2),6-8(P5),8-11(P1),11-14(P3),14-17(P4),17-20(P1),20-23(P3),23-25(P3) + P1周转=23,P2周转=6 + +### 题目六参考答案 +1. 表格填写(见知识点总结) +2. 旋转延迟4.17ms,扇区访问12.21ms,连续100扇区约12.5ms +3. SPOOLing由输入/输出井和预输入/缓输出程序组成 + +### 题目七参考答案 +1. 段号=0,页号=1,偏移=0x8000,物理地址=51×64KB+0x8000 +2. 合法,段号1页号2,物理地址=52×64KB+0x8000 +3. 优点:分段便于共享/保护+分页解决外部碎片;缺点:地址转换复杂,需3次访存 diff --git a/操作系统/复习与教学笔记(加强版).md b/操作系统/复习与教学笔记(加强版).md new file mode 100644 index 0000000..86bd44d --- /dev/null +++ b/操作系统/复习与教学笔记(加强版).md @@ -0,0 +1,688 @@ +# 📖 操作系统复习与教学笔记 + +> **目标**:不仅能应付考试,更能真正理解操作系统的工作原理 +> **定位**:这份笔记不是简单的知识点罗列,而是教你"怎么学、怎么记、怎么做题" + +--- + +## 目录 + +1. [复习策略与时间规划](#一复习策略与时间规划) +2. [四大必考计算题 · 手把手教学](#二四大必考计算题--手把手教学) +3. [核心概念 · 快速记忆法](#三核心概念--快速记忆法) +4. [易混淆概念辨析](#四易混淆概念辨析) +5. [代码题考点精讲](#五代码题考点精讲) +6. [问答题高频考点](#六问答题高频考点) +7. [考前速查表](#七考前速查表) + +--- + +## 一、复习策略与时间规划 + +### 1.1 按优先级分三层 + +| 层级 | 内容 | 建议投入时间 | 策略 | +|------|------|-------------|------| +| 🔴 **保命层** | 磁盘调度、页面置换、fork进程树、inode计算 | 60% | 必须能**独立默写**解题步骤 | +| 🟡 **进阶层** | CPU调度、I/O控制方式、段页式地址转换、死锁 | 30% | 理解原理 + 做2-3道题 | +| 🟢 **基础层** | 概念题、代码优化、工作集模型 | 10% | 多看几遍,有印象即可 | + +### 1.2 复习三步法 + +``` +第一步:看本笔记 → 建立知识框架和记忆锚点 +第二步:做预测卷 → 实战演练,暴露薄弱点 +第三步:复盘错题 → 针对性地回顾对应章节 +``` + +### 1.3 考试时间分配建议(120分钟) + +| 题型 | 建议用时 | 策略 | +|------|---------|------| +| 计算题(磁盘/页面置换/CPU调度/inode) | 50-60min | 先做!这是拿分大头 | +| 代码分析题(fork进程树) | 15-20min | 画图要清晰规范 | +| 简答/概念题 | 25-30min | 分点作答,关键词给分 | +| 地址转换题 | 10-15min | 按步骤写,不要跳步 | +| 检查 | 5-10min | 检查计算题的数字 | + +--- + +## 二、四大必考计算题 · 手把手教学 + +### 2.1 磁盘调度算法计算(★★★★★ 每年必考) + +#### 本质理解 + +磁盘调度就是在解决"磁头怎么走最省时间"的问题。可以把磁头想象成电梯,磁道就是楼层号。 + +#### 各算法的"人设" + +| 算法 | 一句话人设 | 行为特征 | +|------|-----------|----------| +| **FCFS** | "先来后到"的耿直 boy | 来一个处理一个,不做任何优化 | +| **SSTF** | "贪心"的近视眼 | 每次都选最近的,但可能让远的永远等不到 | +| **SCAN** | "电梯"本梯 | 一直走到头再回头 | +| **C-SCAN** | 单向电梯 | 只往上走,到顶后直接回到底部重来 | +| **LOOK** | 智能电梯 | 走到最后一个请求就回头,不到边界 | +| **C-LOOK** | 智能单向电梯 | 只往上到最高请求,然后直接回最低请求 | + +#### 📐 解题模板(以最难的 C-SCAN 为例) + +``` +题目:磁道0-199,磁头在53,向增大方向移动 +请求序列:98, 183, 37, 122, 14, 124, 65, 67 + +Step 1: 将请求排序 +排序:14, 37, 65, 67, 98, 122, 124, 183 + +Step 2: 确定方向并依次服务 +C-SCAN规则:向增大方向走 → 到199(边界)→ 回到0 → 继续向增大方向 + +路径:53 → 65 → 67 → 98 → 122 → 124 → 183 → 199(到边界)→ 0 → 14 → 37 +移动距离:12 + 2 + 31 + 24 + 2 + 59 + 16 + 199 + 14 + 23 + +Step 3: 求和 += 12+2+31+24+2+59+16+199+14+23 = 382道 +``` + +#### ⚠️ 常见错误 + +| 错误 | 正确做法 | +|------|---------| +| SCAN/C-SCAN 忘记走到边界(199或0) | **必须**走到边界,除非题目说用 LOOK | +| SSTF 选择了相等距离的两个磁道 | 通常选编号小的(题目可能会有说明) | +| 方向搞反 | 题目说"向增大方向"就从53往上走 | +| C-SCAN 往回时逐个服务 | C-SCAN 回头时**不服务**,到底后才重新开始 | + +#### 💡 速算技巧 + +SSTF 的路径一定是最短的(贪心嘛),FCFS 通常是最长的。检查时如果 SSTF 比别的算法还长,肯定算错了。 + +--- + +### 2.2 页面置换算法(★★★★★ 每年必考) + +#### 本质理解 + +内存满了,要换掉哪个页面?OPT 是"预言家",LRU 是"记仇本",FIFO 是"排队机"。 + +#### 各算法速记 + +| 算法 | 口诀 | 关键特征 | +|------|------|---------| +| **OPT** | "看未来,谁最晚用就换谁" | 理论最优,无法实现(但考试能算) | +| **FIFO** | "先来先走,排队淘汰" | 可能 Belady 异常(页框变多反而缺页更多) | +| **LRU** | "看过去,谁最久没用就换谁" | 无 Belady 异常,硬件开销大 | +| **Clock** | "转圈查,引用位=0就换" | LRU 的简化版 | + +#### 📐 解题模板(以 LRU,页框数=3 为例) + +``` +访问序列:7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1 + +画表格法: +------------------------------------------------------------- +访问 页框1 页框2 页框3 是否缺页 说明 +------------------------------------------------------------- +7 7 - - ✓缺页 空,直接放入 +0 7 0 - ✓缺页 空,直接放入 +1 7 0 1 ✓缺页 空,直接放入 +2 7 0 2 ✓缺页 换掉最久未用的1(1离上次最远) +0 7 0 2 ✗命中 0已存在,更新它的使用时间 +3 3 0 2 ✓缺页 换掉最久未用的7(7离上次最远) +0 3 0 2 ✗命中 0已存在 +4 3 0 4 ✓缺页 换掉最久未用的2(2离上次最远) +...以此类推 +``` + +#### ⚠️ OPT 和 LRU 的区别(最容易混淆) + +``` +序列:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5(页框数=4) + +OPT 在访问5时: + 当前页框:1,2,3,4 + 看未来:1还要用(第5位),2还要用(第6位),3还要用(第10位),4还要用(第11位) + 最晚使用的是4(第11位才用),所以换4 + +LRU 在访问5时: + 当前页框:1,2,3,4 + 看过去:1最近在位置4用过,2在位置5用过,3在位置2用过,4在位置3用过 + 最久未用的是3(位置2之后就没用过),所以换3 +``` + +> **关键口诀**:OPT 看**未来**,LRU 看**过去**。OPT 是"预言",LRU 是"历史"。 + +#### 💡 Belady 异常 + +**只在 FIFO 中出现**。增加页框数反而缺页更多。LRU 和 OPT 都不会有这个问题。 + +--- + +### 2.3 fork 进程树分析(★★★★★ 每年必考) + +#### 本质理解 + +`fork()` 就像细胞分裂 — 调用一次,变成两个进程,从同一行代码继续执行。 + +#### 黄金法则 + +``` +法则1:fork() 一次,返回两次 + - 父进程返回值 = 子进程的 PID + - 子进程返回值 = 0 + +法则2:子进程从 fork() 的下一行开始执行(不是从 main 开头) + +法则3:fork() 嵌套时,每个进程都要独立分析它的执行路径 + +法则4:父进程总数 = 2^n(n为fork调用次数,无嵌套时) +``` + +#### 📐 解题模板 + +``` +程序: +fork() // fork1 +if (pid == 0) { + fork() // fork2 +} +fork() // fork3 + +Step 1: 画出进程树 + P(初始) + ╱ ╲ ← fork1 + P(子1) P(父) + ╱ ╲ ╲ ← fork2(只有子1执行) + P(孙1) P(子1续) P(父) + | | | ← fork3(所有进程都执行) + P(曾孙1) P(孙2) P(子2) + +Step 2: 统计进程总数 +2³ = 8个进程(3次fork,最外层没有if限制) + +Step 3: 分析每个进程的输出 + 按执行路径逐条跟踪... +``` + +#### ⚠️ 常见陷阱 + +| 陷阱 | 说明 | +|------|------| +| `if (pid == 0)` 内的 fork | 只有子进程执行,父进程跳过 | +| `wait(NULL)` | 父进程等待**任意**子进程结束 | +| 嵌套的 `wait` | 每个 `wait` 只能回收一个子进程 | +| 没有 wait | 子进程变成僵尸进程(Zombie) | + +#### 💡 wait 的个数 + +父进程有几个子进程,就应该有几个 `wait()`,否则会产生僵尸进程。 + +--- + +### 2.4 Ext2 inode 最大文件计算(★★★★★ 每年必考) + +#### 本质理解 + +inode 就像文件系统的"目录索引卡",有15个格子放地址。前12个直接指向数据块,后3个是间接指向(指向一个"指针块",指针块再指向数据块)。 + +#### 计算公式速记 + +``` +块大小 = 4KB = 4096 字节 +指针大小 = 4 字节 +每块指针数 = 4096 / 4 = 1024 个 + +最大文件: + 直接:12 × 4KB = 48KB + 一次间接:1024 × 4KB = 4MB + 二次间接:1024² × 4KB = 4GB + 三次间接:1024³ × 4KB = 4TB + ────────────────────────────────── + 总计:48KB + 4MB + 4GB + 4TB ≈ 4.004 TB +``` + +> **注意**:如果块大小是 1KB、指针 4B,则每块 256 个指针,结果约 16GB+(你的知识点总结里那个)。 + +#### 📐 解题模板:给定文件大小,问需要哪些地址项 + +``` +题目:块大小=4KB,指针=4B,文件=260KB + +Step 1: 计算文件占多少数据块 +260KB / 4KB = 65 块 + +Step 2: 检查直接索引能装多少 +直接索引 = 12 块 → 只能装 48KB,不够 + +Step 3: 一次间接能装多少 +一次间接 = 1024 块 → 1024 × 4KB = 4MB +12 + 1024 = 1036 块,65 ≤ 1036,所以只需要一次间接 + +结论:使用直接索引12个 + 一次间接1个(其中用到 65-12=53 个指针) +``` + +#### ⚠️ 易错点 + +- 问"需要几次磁盘访问":直接索引=1次,一次间接=2次(先读指针块,再读数据块),二次间接=3次... +- 在计算最大文件时,**不要忘了加直接索引的12块** + +--- + +## 三、核心概念 · 快速记忆法 + +### 3.1 操作系统四大特征 + +``` +口诀:共、享、虚、异 → "恭喜虚拟" +- 共(并发):宏观并行,微观串行 +- 享(共享):资源共享 +- 虚(虚拟):物理实体变为逻辑多个 +- 异(异步):进程走走停停 +``` + +### 3.2 CPU 双模式 + +``` +用户态 →→→ 中断/异常/系统调用 →→→ 内核态 +内核态 →→→ PSW切换 →→→ 用户态 + +记忆:用户态进内核态叫"陷入"(trap),内核态回用户态叫"恢复" +``` + +### 3.3 中断分类速记 + +``` +中断(Interrupt) +├── 硬中断(外部,异步) +│ ├── I/O中断(键盘敲一下) +│ └── 时钟中断(OS拿回控制权的关键!) +│ +└── 软中断/异常(内部,同步) + ├── 故障(Fault):缺页异常(可恢复) + ├── 陷阱(Trap):系统调用 int 0x80 + └── 终止(Abort):除零错误(不可恢复) +``` + +> **必考提问**:OS 如何获得 CPU 控制权?→ **时钟中断!** 时间片到了,时钟中断触发,OS 重新调度。 + +### 3.4 ELF 内存布局 + +``` +从低地址到高地址: +┌─────────────────┐ +│ .text 代码段 │ ← 只读,存放指令 +│ .rodata 只读数据 │ ← 字符串常量等 +│ .data 初始化数据 │ ← 已初始化全局变量 +│ .bss 未初始化数据 │ ← 不占磁盘!只占内存 +├─────────────────┤ +│ heap(堆)↑ │ ← malloc 分配 +├─────────────────┤ +│ ↓ │ +│ stack(栈) │ ← 局部变量、函数调用 +└─────────────────┘ +``` + +**记忆 trick**:堆向上长(像树往上),栈向下长(像水往下流)。 + +### 3.5 死锁四个必要条件 + +``` +口诀:互、请、不、循 → "互请不循"(互相请求,不能循环) +1. 互斥(Mutual exclusion) +2. 请求和保持(Hold and wait) +3. 不可抢占(No preemption) +4. 循环等待(Circular wait) + +破坏任意一个 → 死锁预防 +``` + +### 3.6 页面置换算法特征速记 + +``` +OPT → 看未来,最晚用的 → 不可能实现(除非能预知未来) +LRU → 看过去,最久没用 → 硬件开销大 +FIFO → 看时间,最早进入 → 会有 Belady 异常 +Clock→ 看引用位,转圈查 → 近似 LRU +LFU → 看次数,最少使用 → 可能被"热数据"刷掉 +``` + +--- + +## 四、易混淆概念辨析 + +### 4.1 并发 vs 并行 + +| | 并发 | 并行 | +|--|------|------| +| 含义 | 同时**处理**多个任务 | 同时**执行**多个任务 | +| 核心 | 宏观上同时,微观上交替 | 真正的同一时刻都在运行 | +| 硬件 | 单核 CPU | 多核 CPU | +| 类比 | 一个人交替做几件事 | 几个人同时做各自的事 | + +### 4.2 进程 vs 线程 + +| | 进程 | 线程 | +|--|------|------| +| 资源拥有 | 独立地址空间 | 共享进程资源 | +| 切换开销 | 大(需切换页表等) | 小(只需切换栈) | +| 通信方式 | IPC(管道、共享内存等) | 直接读写共享数据 | +| 独立性 | 一个崩溃不影响其他 | 一个线程崩溃→整个进程崩溃 | + +### 4.3 死锁 vs 饥饿 + +| | 死锁 | 饥饿 | +|--|------|------| +| 状态 | 多个进程**互相等待**,都无法前进 | 一个进程**一直等不到**资源 | +| 是否可以解除 | 必须外部干预(终止进程/抢占资源) | 其他进程释放资源后可自动解除 | +| 典型场景 | 银行家算法中不安全状态 | SJF 中长作业一直得不到 CPU | + +### 4.4 静态链接 vs 动态链接 + +| | 静态链接 | 动态链接 | +|--|---------|---------| +| 时机 | 编译时 | 运行时 | +| 文件大小 | 大(包含所有库代码) | 小(运行时加载) | +| 更新库 | 需重新编译 | 直接替换库文件 | +| 依赖 | 无外部依赖 | 需要 .so/.dll 文件存在 | + +### 4.5 全局置换 vs 局部置换 + +| | 全局置换 | 局部置换 | +|--|---------|---------| +| 范围 | 从所有进程中选页面换出 | 只从当前进程的页面中选 | +| 公平性 | 进程可能互相影响 | 每个进程独立管理 | +| 抖动控制 | 难控制 | 容易控制 | + +### 4.6 固定分区 vs 动态分区 vs 分页 + +| | 固定分区 | 动态分区 | 分页 | +|--|---------|---------|------| +| 内部碎片 | ✓ | ✗ | ✓(最后一页) | +| 外部碎片 | ✗ | ✓ | ✗ | +| 管理复杂度 | 低 | 中 | 高 | + +--- + +## 五、代码题考点精讲 + +### 5.1 fork 代码分析 + +#### 经典模式 1:单次 fork + +```c +pid_t pid = fork(); +if (pid == 0) { + // 子进程代码 +} else { + // 父进程代码,pid 是子进程的 PID +} +``` + +**输出特点**: +- 子进程和父进程的代码都会执行 +- 子进程从 fork() 返回处开始执行(不是从 main 开始) +- "A\n" 只有一个进程输出,"B\n" 和 "C\n" 各输出一次(因为 fork 后两个进程都继续) + +#### 经典模式 2:嵌套 fork + +```c +pid_t pid1 = fork(); // fork1 +if (pid1 == 0) { + pid_t pid2 = fork(); // fork2 — 只在子进程中执行 + if (pid2 == 0) { + // 孙进程 + } +} +``` + +**进程树分析**: +``` + P(初始) + / \ ← fork1 + P(子1) P(父) + / \ ← fork2(仅在子1中执行) + P(孙) P(父) +``` + +#### 经典模式 3:fork + wait + +```c +for (int i = 0; i < 3; i++) { + pid_t pid = fork(); + if (pid == 0) { + // 子进程 + exit(0); + } +} + +// 父进程需要回收所有子进程 +while (wait(NULL) > 0); +``` + +**重点**:循环中的 fork,子进程 exit 后,父进程用 `wait` 回收。总计 `2³ = 8` 个进程。 + +### 5.2 生产者消费者(多线程同步) + +```c +sem_t mutex, empty, full; + +void *producer(void *arg) { + // 生产数据... + sem_wait(&empty); // 等空位 + sem_wait(&mutex); // 加锁 + // 放入缓冲区 + sem_post(&mutex); // 解锁 + sem_post(&full); // 通知消费者 +} + +void *consumer(void *arg) { + sem_wait(&full); // 等数据 + sem_wait(&mutex); // 加锁 + // 取出数据 + sem_post(&mutex); // 解锁 + sem_post(&empty); // 通知生产者 +} +``` + +**⚠️ 注意顺序**:P(empty) 必须在 P(mutex) 之前!否则可能死锁。 +——如果先锁 mutex 再等 empty,而缓冲区满了,生产者拿不到 empty,消费者进不来临界区拿数据,死锁。 + +### 5.3 Shell 基本框架 + +```c +while (1) { + printf("$ "); + fgets(cmdline, MAXLINE, stdin); + + pid_t pid = fork(); + if (pid == 0) { + // 子进程执行命令 + execvp(argv[0], argv); + exit(0); + } + + // 父进程等待 + wait(NULL); +} +``` + +### 5.4 守护进程创建(5步法) + +```c +// 第1步:fork + 父进程退出 +pid_t pid = fork(); +if (pid > 0) exit(0); + +// 第2步:创建新会话 +setsid(); + +// 第3步:再 fork 一次,防止重新获得控制终端 +pid = fork(); +if (pid > 0) exit(0); + +// 第4步:改变工作目录 +chdir("/"); + +// 第5步:重设文件权限掩码 +umask(0); +``` + +**问**:为什么要 fork 两次? +**答**:第一次 fork 后 setsid() 创建新会话,但会话首进程可能重新获得控制终端。第二次 fork 的子进程不是会话首进程,**永远无法**获得控制终端。 + +--- + +## 六、问答题高频考点 + +### 6.1 什么是操作系统?它的四大特征是什么? + +**答题模板**: +1. 操作系统是管理计算机硬件和软件资源的系统软件 +2. 四大特征:并发、共享、虚拟、异步(各用一句话解释) + +### 6.2 中断在操作系统中的作用? + +**核心要点**: +- 中断是实现**并发执行**的基础 +- **时钟中断**是操作系统**获得 CPU 控制权**的关键机制 +- 中断使 CPU 能够**响应外部事件**(I/O 完成、硬件故障等) +- 系统调用通过**软中断(trap)**实现用户态→内核态切换 + +### 6.3 比较几种 I/O 控制方式 + +| 方式 | CPU介入 | 传输单位 | 并行性 | 硬件 | +|------|---------|---------|--------|------| +| 程序直接控制 | 全程忙等 | 字 | 无 | 最简单 | +| 中断驱动 | 每个字中断一次 | 字 | 有 | 较简单 | +| DMA | 每块中断一次 | 块 | 高 | 较复杂 | +| 通道 | 程序级 | 块/组 | 极高 | 最复杂 | + +**记忆线索**:从上到下,CPU 越来越省事,硬件越来越复杂,传输效率越来越高。 + +### 6.4 什么是抖动(Thrashing)?如何解决? + +**定义**:分配的页框数太少,导致频繁缺页,CPU 大部分时间花在页面置换上,利用率急剧下降。 + +**原因**:工作集 > 分配的页框数 + +**解决方案**: +1. 增加分配页框数(工作集模型) +2. 采用局部置换(不让进程互相影响) +3. 降低多道程序度 + +### 6.5 SPOOLing 如何将独占设备改造为共享设备? + +**原理**: +``` + 输入井 +用户进程1 ╲ ╱ 预输入程序 +用户进程2 ╲── 磁盘存储 ─╱ (把磁盘当"大缓冲区") +用户进程3 ╱ (输入/输出井) ╲ 缓输出程序 + 输出井 +``` + +**核心思想**:用磁盘(共享设备)做缓冲,把独占设备(打印机)的逻辑使用变成对磁盘文件的读写。用户觉得打印机是自己的,实际上是 SPOOLing 系统在排队管理。 + +### 6.6 段式 vs 分页 vs 段页式 + +| | 分段 | 分页 | 段页式 | +|--|------|------|--------| +| 出发点 | 用户视角,逻辑模块 | 系统视角,物理管理 | 兼具两者优点 | +| 大小 | 可变(由逻辑决定) | 固定(4KB等) | 段可变,段内页固定 | +| 碎片 | 外部碎片 | 内部碎片(最后一页) | 无外部碎片 | +| 共享/保护 | 方便 | 不方便 | 方便 | +| 地址结构 | 段号+偏移 | 页号+偏移 | 段号+页号+偏移 | +| 访存次数 | 2次 | 2次(有TLB可减) | 3次 | + +### 6.7 优先级反转 + +**场景**:高优先级进程(H)等待低优先级进程(L)持有的资源,而中等优先级进程(M)抢占了 L 的 CPU,导致 H 被 M 间接阻塞。 + +**解决方案**:优先级继承 — L 持有的资源被 H 需要时,L 临时继承 H 的优先级,运行完释放资源后再降回去。 + +--- + +## 七、考前速查表 + +### 7.1 公式速查 + +| 公式 | 适用场景 | +|------|---------| +| 周转时间 = 完成时间 - 到达时间 | CPU 调度 | +| 带权周转时间 = 周转时间 / 运行时间 | CPU 调度 | +| 响应比 = 1 + 等待时间 / 运行时间 | HRRF 调度 | +| EAT = λ + t + (1-a)·t | TLB 有效访问时间 | +| 每块指针数 = 块大小 / 指针大小 | inode 计算 | +| 加速比 S = T串行 / T并行 | 并行计算 | +| Amdahl: S = 1 / ((1-f) + f/P) | 并行计算加速比上限 | +| 磁盘访问时间 = 寻道 + 旋转延迟 + 传输 | I/O 性能 | +| 平均旋转延迟 = 1/2 × 转一圈时间 | 磁盘性能 | + +### 7.2 数值速查 + +| 场景 | 典型值 | +|------|--------| +| 页大小 | 4KB | +| 块大小(Ext2) | 1KB 或 4KB | +| 指针大小(地址项) | 4B | +| 时间片(RR) | 几十 ms 级别 | +| 7200RPM 旋转延迟 | 4.17ms(半圈) | + +### 7.3 算法时间复杂度速查 + +| 算法/机制 | 数据结构 | 复杂度 | +|-----------|---------|--------| +| FCFS 调度 | 队列 | O(1) | +| SJF 调度 | 最小堆 | O(log n) | +| CFS(Linux) | 红黑树 | O(log n) | +| 页表查找(无 TLB) | 数组 | O(1) | +| 多级页表 | 树 | O(级数) | +| 倒排页表 | 哈希表 | O(1)平均 | + +### 7.4 Linux 常用命令速查 + +```bash +chmod 755 file # rwxr-xr-x (7=4+2+1, 5=4+1) +chmod u+x file # 给所有者加执行权限 +ps -ef # 查看所有进程 +top # 动态查看进程 +kill -9 PID # 强制终止进程(SIGKILL) +grep pattern file # 搜索文件内容 +strace ./prog # 跟踪系统调用 +gdb ./prog # 调试程序 +``` + +--- + +## 附录:考前最后一天做什么 + +### 8小时冲刺计划 + +``` +第1小时:复习四大计算题模板(本笔记 2.1-2.4) +第2小时:做预测卷的计算题部分 +第3小时:复习进程树分析和代码题(第五部分) +第4小时:做预测卷的代码分析题 +第5小时:复习概念辨析和问答题(第三、四、六部分) +第6小时:做预测卷的问答题 +第7小时:错题回顾 + 速查表记忆 +第8小时:放松!早点休息 +``` + +### 考场检查清单 + +- ✅ 计算题先做(分值高、确定性大) +- ✅ 画进程树时标注字母/输出 +- ✅ 缺页次数计算时,初始空页框**算**缺页 +- ✅ SCAN/C-SCAN 注意方向 +- ✅ CPU 调度画甘特图辅助 +- ✅ 地址转换要写出每一步的数值 +- ✅ 简答题分点作答,关键词突出 + +--- + +> **最后的话**:操作系统不是死记硬背的科目,理解"为什么"比记住"是什么"重要得多。 +> 遇到问题时,想想"如果是 Linux,它会怎么做?"——这是最好的学习方式。 +> +> 祝考试顺利!🎯 diff --git a/操作系统/期末知识点总结与考点分析.md b/操作系统/期末知识点总结与考点分析.md new file mode 100644 index 0000000..8b74dbb --- /dev/null +++ b/操作系统/期末知识点总结与考点分析.md @@ -0,0 +1,626 @@ +# 操作系统期末知识点总结与考点分析 + +## 一、历年试卷考点频率统计 + +| 考点 | 20-21A | 21-22A | 2023A | 2025A | 频率 | +|------|--------|--------|-------|-------|------| +| 磁盘调度算法 | ✓ | ✓ | ✓ | ✓ | ★★★★★ | +| 虚拟内存页面置换 | ✓ | ✓ | ✓ | ✓ | ★★★★★ | +| 进程控制(fork) | ✓ | ✓ | ✓ | ✓ | ★★★★★ | +| CPU调度算法 | ✓ | - | ✓ | ✓ | ★★★★ | +| 文件系统(Ext2/inode) | - | ✓ | ✓ | ✓ | ★★★★ | +| I/O控制方式 | ✓ | - | - | ✓ | ★★★ | +| 段页式地址转换 | ✓ | ✓ | - | - | ★★★ | +| 银行家算法 | ✓ | - | - | - | ★★ | +| 工作集模型 | ✓ | ✓ | ✓ | - | ★★★ | +| 代码优化 | - | ✓ | ✓ | - | ★★ | +| 系统运行机制 | - | - | ✓ | - | ★★ | +| 多级反馈队列 | - | - | ✓ | - | ★★ | + +--- + +## 二、各章核心知识点总结 + +### 第1章:操作系统概述 + +**考点:OS基本概念、发展历史、结构** + +- **四大特征**:并发、共享、虚拟、异步 +- **OS结构类型**: + - 单体结构(Linux):所有服务在内核态运行,性能好但耦合度高 + - 分层结构:按层次组织,便于调试但层间通信开销大 + - 微内核结构:内核只保留最基本功能,其余在用户态运行,可靠性高但性能差 + - 虚拟机结构:在硬件上运行多个OS实例 + +--- + +### 第2章:系统运行机制 + +**考点:中断、MMU、CPU双模式、系统调用** + +- **中断机制**: + - 硬中断:外部设备产生,异步 + - 软中断(异常):CPU内部产生,同步(除零错误、缺页异常、系统调用) + - **时钟中断**:OS获得CPU控制权的关键机制 + +- **MMU地址转换**: + - 逻辑地址 → 物理地址的硬件支持 + - U/S位:用户态/内核态标识,实现内存保护 + +- **CPU双模式**: + - 用户态(目态)→ 内核态(管态):通过中断/异常/系统调用 + - 内核态 → 用户态:通过PSW(程序状态字)切换 + +- **系统调用流程**: + ``` + 用户程序 → 系统调用号放入EAX → int $0x80 → 内核态 + → 查系统调用表 → 执行服务程序 → 返回用户态 + ``` + +--- + +### 第3章:Linux基础 + +**考点:目录结构、文件权限、/proc文件系统** + +- **目录结构**:/bin, /etc, /home, /proc, /dev, /tmp +- **文件权限**:chmod 数字法(r=4, w=2, x=1) +- **/proc文件系统**:虚拟文件系统,提供进程和内核信息 + +--- + +### 第4章:C语言开发基础 + +**考点:编译流程、ELF内存布局、调试** + +- **编译流程**:预处理 → 编译 → 汇编 → 链接 +- **ELF内存布局**(从低到高): + ``` + .text(代码段) + .rodata(只读数据) + .data(已初始化全局变量) + .bss(未初始化全局变量,不占磁盘空间) + heap(堆,向上增长 ↗) + ↓ + stack(栈,向下增长 ↘) + ``` +- **关键区别**:.bss不占磁盘空间但占内存空间(运行时分配) + +--- + +### 第5-6章:文件I/O + +**考点:UNIX IO、文件描述符共享、mmap、重定向** + +- **UNIX IO函数**:open/read/write/close/lseek +- **文件描述符表共享**: + ``` + 进程fd表 → 文件表(引用计数) → v-node表(共享) + fork后父子进程共享文件表,引用计数+1 + ``` +- **mmap内存映射**:将文件映射到进程地址空间,实现高效I/O +- **dup2重定向**:实现I/O重定向的标准方法 +- **stdio vs UNIX IO缓冲**: + - stdio有用户缓冲区(全缓冲/行缓冲/无缓冲) + - UNIX IO无用户缓冲区 + +--- + +### 第7章:磁盘空间管理 ★★★★★必考 + +**考点:分配方式、Ext2文件系统、inode混合索引** + +- **三种分配方式**: + - 连续分配:支持顺序/随机访问,有外部碎片 + - 链接分配:无外部碎片,只支持顺序访问,FAT是改进 + - 索引分配:支持直接/间接访问,inode是典型实现 + +- **FAT文件系统**:FAT12/16/32,链接分配的改进版本 + +- **NTFS**:基于MFT(主文件表),B+树结构 + +- **Ext2文件系统**(重点): + ``` + 超级块 → 块组描述符 → 块位图 → inode位图 → inode表 → 数据块 + ``` + - **inode结构**:15个地址项 + - 0-11:直接索引(12个块) + - 12:一次间接索引(256个块,假设块大小1KB,指针4字节) + - 13:二次间接索引(256×256个块) + - 14:三次间接索引(256³个块) + - **最大文件计算**: + ``` + 块大小=1KB,指针=4B,则每块256个指针 + 直接:12块 + 一次间接:256块 + 二次间接:256×256=65536块 + 三次间接:256³=16777216块 + 总计:12+256+65536+16777216 ≈ 16GB+ + ``` + +- **HDFS**:分布式文件系统,NameNode+DataNode架构 + +- **空闲空间管理**:位图法、成组链接法 + +- **RAID**: + - RAID0:条带化,无冗余 + - RAID1:镜像,100%冗余 + - RAID3:位交叉+专用校验盘 + - RAID5:块交叉+分布式校验 + +--- + +### 第8章:进程控制 ★★★★★必考 + +**考点:fork/exec/wait/exit、僵尸进程、shell实现** + +- **fork()**: + - 调用一次,返回两次(父进程返回子进程PID,子进程返回0) + - **COW(写时复制)**:父子进程共享物理页,写时才复制 + - fork后文件描述符共享(引用计数+1) + +- **exec系列函数**:替换进程映像,不创建新进程 + +- **wait/waitpid**: + - 阻塞等待子进程状态变化 + - 回收子进程资源,防止僵尸进程 + +- **exit/_exit**: + - exit:执行清理(刷新缓冲区、调用atexit处理函数) + - _exit:直接退出,不执行清理 + +- **僵尸进程**: + - 子进程exit但父进程未wait + - 解决方法:父进程调用wait/waitpid,或SIGCHLD信号处理 + +- **shell实现**: + ```c + // 基本框架 + while (1) { + 读取命令行 + 解析命令 + if (内置命令) 直接执行 + else { + pid = fork() + if (pid == 0) execvp(...) // 子进程执行 + else wait(NULL) // 父进程等待 + } + } + ``` + +- **守护进程(daemon)创建5步**: + 1. fork()创建子进程,父进程exit + 2. setsid()创建新会话 + 3. fork()再次fork,父进程exit(防止重新获得控制终端) + 4. chdir("/")改变工作目录 + 5. umask(0)重设文件权限掩码 + +--- + +### 第9章:多线程 ★★★★ + +**考点:pthread API、竞态条件、互斥锁、信号量、生产者消费者** + +- **pthread API**: + ```c + pthread_create() // 创建线程 + pthread_join() // 等待线程结束 + pthread_exit() // 退出线程 + pthread_detach() // 分离线程 + ``` + +- **竞态条件(Race Condition)**: + - 多线程并发访问共享数据,结果取决于执行顺序 + - 解决:互斥锁、信号量 + +- **互斥锁(Mutex)**: + ```c + pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; + pthread_mutex_lock(&mutex); // 加锁 + // 临界区 + pthread_mutex_unlock(&mutex); // 解锁 + ``` + +- **信号量(Semaphore)**: + - P操作(wait):S--,若S<0则阻塞 + - V操作(signal):S++,若有等待者则唤醒 + - **实现同步和互斥** + +- **生产者消费者模型**: + ``` + 信号量: + mutex = 1 // 互斥访问缓冲区 + empty = N // 空闲缓冲区数量 + full = 0 // 满缓冲区数量 + + 生产者: 消费者: + P(empty) P(full) + P(mutex) P(mutex) + 放入产品 取出产品 + V(mutex) V(mutex) + V(full) V(empty) + ``` + +- **并行计算**: + - 加速比 S = T_serial / T_parallel + - 效率 E = S / P(P为处理器数量) + - Amdahl定律:S = 1 / ((1-f) + f/P) + +--- + +### 第10章:进程间通信IPC + +**考点:管道、消息队列、共享内存、信号** + +- **管道**: + - 匿名管道:只能用于父子进程,单向通信 + - 命名管道FIFO:可用于无亲缘关系进程 + +- **消息队列**:内核中的链表,按消息类型读取 + +- **共享内存**:最快的IPC方式,需要同步机制保护 + +- **信号**:异步通知机制,SIGINT/SIGTERM/SIGCHLD等 + +--- + +### 第11章:网络编程 + +**考点:Socket API、TCP客户端服务器模型** + +- **Socket API**: + ```c + // 服务器 + socket() → bind() → listen() → accept() → read/write → close() + // 客户端 + socket() → connect() → read/write → close() + ``` + +- **字节序转换**:htonl/htons/ntohl/ntohs + +--- + +### 第12章:并发服务器 + +**考点:多进程/多线程模型、I/O多路复用、线程池** + +- **多进程模型**:fork per request,简单但开销大 +- **多线程模型**:pthread_create per request,开销较小 +- **I/O多路复用(select)**: + ```c + fd_set read_set; + FD_ZERO(&read_set); + FD_SET(fd, &read_set); + select(maxfd+1, &read_set, NULL, NULL, NULL); + ``` +- **线程池**:预先创建线程,减少创建/销毁开销 + +--- + +### 第13章:CPU调度 ★★★★ + +**考点:调度算法计算、优先级反转、CFS** + +- **三级调度**:高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度) + +- **调度算法**: + - **FCFS**(先来先服务):简单,对长作业有利 + - **SJF/SRTF**(最短作业优先/最短剩余时间优先):平均等待时间最短,但可能饥饿 + - **HRRF**(最高响应比优先):响应比 = 1 + 等待时间/运行时间 + - **RR**(时间片轮转):时间片太大退化为FCFS,太小上下文切换开销大 + - **MFQ**(多级反馈队列):综合了多种算法优点 + - **优先级调度**:可能导致低优先级饥饿 + +- **优先级反转**:高优先级进程等待低优先级进程持有的资源 + - 解决方案:优先级继承协议 + +- **EDF**(最早截止时间优先):实时调度 +- **LLF**(最低松弛度优先):松弛度 = 截止时间 - 剩余执行时间 + +- **CFS**(完全公平调度):Linux默认 + - vruntime(虚拟运行时间) + - 红黑树组织运行队列 + +- **调度公式**: + ``` + 周转时间 = 完成时间 - 到达时间 + 带权周转时间 = 周转时间 / 运行时间 + 平均周转时间 = Σ周转时间 / n + ``` + +--- + +### 第14章:死锁 ★★★ + +**考点:必要条件、资源分配图、银行家算法** + +- **四个必要条件**: + 1. 互斥条件 + 2. 请求和保持条件 + 3. 不可抢占条件 + 4. 循环等待条件 + +- **资源分配图**:检测死锁的图形化方法 + +- **银行家算法**: + ``` + Available: 系统可用资源向量 + Max: 进程最大需求矩阵 + Allocation: 已分配矩阵 + Need = Max - Allocation: 还需要矩阵 + + 安全性检查: + 1. Work = Available, Finish = false + 2. 找一个 Need ≤ Work 且 Finish=false 的进程 + 3. Work += Allocation, Finish = true + 4. 重复直到所有 Finish=true(安全)或找不到(不安全) + ``` + +- **死锁处理**: + - 预防:破坏四个必要条件之一 + - 避免:银行家算法 + - 检测:资源分配图 + - 恢夺:终止进程或资源抢占 + +--- + +### 第15章:内存管理 ★★★★ + +**考点:地址转换、页表、TLB、多级页表、段页式** + +- **逻辑地址 vs 物理地址** + +- **分页管理**: + - 虚拟页号(VPN) + 页内偏移(VPO) + - 物理帧号(PPN) + 帧内偏移(PPO) + - 页表项:有效位、脏位、引用位、R/W位、U/S位 + +- **缺页处理**: + ``` + 访问页表 → 有效位=0 → 缺页异常 + → 选择牺牲页(若修改过则写回磁盘) + → 从磁盘调入新页 + → 更新页表 → 重新执行指令 + ``` + +- **TLB(快表)**: + ``` + 有效访问时间 EAT = λ + t + (1-a)·t + λ: TLB访问时间 + t: 内存访问时间 + a: TLB命中率 + ``` + +- **多级页表**:减少页表占用的连续内存 + - 二级页表:VPN分为两部分,第一级索引页目录,第二级索引页表 + +- **倒排页表**:按物理帧组织,减少内存占用但查找慢 + +- **段页式**:三维地址(段号 + 页号 + 页内偏移) + +--- + +### 第16章:虚拟内存 ★★★★★必考 + +**考点:页面置换算法、工作集、抖动** + +- **请求调页**:访问时才调入页面 + +- **局部性原理**: + - 时间局部性:最近访问的数据可能再次访问 + - 空间局部性:相邻地址可能被访问 + +- **页面置换算法**(重点中的重点): + - **OPT**(最优算法):替换将来最长时间不使用的页面,理论最优但不可实现 + - **FIFO**(先进先出):可能产生**Belady异常**(增加页框数反而增加缺页次数) + - **LRU**(最近最久未使用):替换最长时间未使用的页面,无Belady异常 + - **Clock算法**:LRU的近似,检查引用位 + - **LFU**(最不经常使用):替换访问次数最少的页面 + +- **抖动(Thrashing)**: + - 分配的页框数太少,频繁缺页 + - 工作集 > 分配的页框数 + +- **工作集模型**: + ``` + 工作集 W(t, Δ) = 在时刻t前Δ时间窗口内访问的页面集合 + Δ: 工作集窗口大小 + ``` + - 若 W(t,Δ) > 分配页框数 → 抖动 + +- **内存分配策略**: + - 固定分配 vs 可变分配 + - 全局置换 vs 局部置换 + +--- + +### 第17章:I/O系统 ★★★ + +**考点:I/O控制方式、缓冲、磁盘调度** + +- **四种I/O控制方式**: + 1. **程序直接控制**:CPU忙等,效率最低 + 2. **中断驱动**:CPU不必忙等,但每次传输一个字 + 3. **DMA**:直接内存访问,以块为单位传输 + 4. **通道**:独立的I/O处理器,执行通道程序 + +- **I/O软件层次**: + ``` + 用户层I/O软件 + ↓ + 设备独立性软件 + ↓ + 设备驱动程序 + ↓ + 中断处理程序 + ↓ + 硬件 + ``` + +- **缓冲**: + - 单缓冲、双缓冲、循环缓冲、缓冲池 + - **SPOOLing**:假脱机技术,将独占设备改为共享设备 + +- **磁盘调度算法**(必考): + - **FCFS**:简单但移动距离长 + - **SSTF**(最短寻道时间优先):可能饥饿 + - **SCAN**(电梯算法):双向扫描到边界 + - **C-SCAN**:单向扫描,返回时快速移动 + - **LOOK/C-LOOK**:改进版,不到达边界 + +- **磁盘访问时间**: + ``` + 访问时间 = 寻道时间 + 旋转延迟 + 传输时间 + ``` + +--- + +### 第18章:代码优化 + +**考点:CPE、代码移动、消除别名、循环展开** + +- **CPE**(每元素周期数):衡量循环性能 + +- **优化技术**: + 1. **代码移动**:将循环不变量移到循环外 + 2. **消除过程调用**:用内联代码替代函数调用 + 3. **减少内存引用**:使用局部变量累积,最后写回 + 4. **消除指针别名**:使用restrict关键字或引入局部变量 + 5. **循环展开**:减少循环控制开销 + 6. **SIMD**:单指令多数据流 + +- **指针别名问题**: + ```c + void twiddle1(int *xp, int *yp) { + *xp += *yp; + *xp += *yp; + } + // 若xp==yp,结果是4倍;否则是2倍 + // 引入局部变量可消除别名影响 + ``` + +--- + +## 三、高频计算题型总结 + +### 1. 磁盘调度计算(每年必考) + +**题型**:给定磁道请求序列和磁头初始位置,计算各算法的磁头移动总道数 + +**解题模板**: +``` +请求序列:98,183,37,122,14,124,65,67 +初始位置:53 + +FCFS: 53→98→183→37→122→14→124→65→67 +SSTF: 53→65→67→37→14→98→122→124→183 +SCAN: 53→37→14→0→65→67→98→122→124→183 +C-SCAN: 53→65→67→98→122→124→183→0→14→37 +``` + +--- + +### 2. 虚拟内存页面置换(每年必考) + +**题型**:给定页面访问序列和页框数,计算各算法的缺页次数 + +**解题模板**: +``` +访问序列:7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1 +页框数:3 + +OPT: 替换将来最长时间不用的 +FIFO: 替换最早进入的 +LRU: 替换最长时间未使用的 +``` + +--- + +### 3. fork进程树分析(每年必考) + +**题型**:分析fork()调用序列,画出进程树 + +**关键规则**: +- fork()创建子进程,子进程从fork处继续执行 +- 进程总数 = 2^n(n为fork调用次数,假设无嵌套) +- 嵌套fork需仔细跟踪每个进程的执行路径 + +--- + +### 4. CPU调度计算 + +**题型**:给定进程到达时间和运行时间,计算各算法的周转时间 + +**公式**: +``` +周转时间 = 完成时间 - 到达时间 +带权周转时间 = 周转时间 / 运行时间 +``` + +--- + +### 5. Ext2 inode最大文件计算 + +**题型**:给定块大小和指针大小,计算单个inode支持的最大文件 + +**公式**: +``` +每块指针数 = 块大小 / 指针大小 +直接索引:12块 +一次间接:块指针数 +二次间接:块指针数² +三次间接:块指针数³ +``` + +--- + +### 6. TLB有效访问时间计算 + +**题型**:给定TLB访问时间、内存访问时间、TLB命中率,计算EAT + +**公式**: +``` +EAT = λ + t + (1-a)·t +``` + +--- + +### 7. 银行家算法 + +**题型**:给定资源分配状态,判断是否安全 + +**步骤**: +1. 计算Need矩阵 = Max - Allocation +2. 执行安全性检查算法 +3. 判断是否存在安全序列 + +--- + +## 四、2026年考试预测 + +### 必考题型(概率>90%): +1. 磁盘调度算法计算(SSTF/SCAN/C-SCAN) +2. 虚拟内存页面置换(OPT/FIFO/LRU) +3. fork进程树分析 +4. 文件系统inode计算或Ext2结构分析 + +### 高概率题型(概率60-90%): +5. CPU调度算法(HRRF或SJF) +6. I/O控制方式比较 +7. 段页式地址转换 + +### 可能题型(概率30-60%): +8. 银行家算法安全性检查 +9. 代码优化(别名问题、循环展开) +10. 工作集模型与抖动分析 +11. 多线程同步(生产者消费者) + +--- + +## 五、复习建议 + +1. **重点掌握计算题**:磁盘调度、页面置换、CPU调度、inode计算必考 +2. **理解fork机制**:画进程树是每年必考题型 +3. **掌握基本概念**:I/O控制方式、死锁条件、虚拟内存原理 +4. **练习画图**:进程树、资源分配图、地址转换过程 +5. **熟悉公式**:EAT、CPI、周转时间、响应比等 diff --git a/操作系统/试卷/20-21操作系统a卷.pdf b/操作系统/试卷/20-21操作系统a卷.pdf new file mode 100644 index 0000000..fffec8e Binary files /dev/null and b/操作系统/试卷/20-21操作系统a卷.pdf differ diff --git a/操作系统/试卷/2023A卷参考答案.pdf b/操作系统/试卷/2023A卷参考答案.pdf new file mode 100644 index 0000000..aee7cb3 Binary files /dev/null and b/操作系统/试卷/2023A卷参考答案.pdf differ diff --git a/操作系统/试卷/2023B卷参考答案.docx b/操作系统/试卷/2023B卷参考答案.docx new file mode 100644 index 0000000..46cb490 Binary files /dev/null and b/操作系统/试卷/2023B卷参考答案.docx differ diff --git a/操作系统/试卷/2023年A卷(统稿).pdf b/操作系统/试卷/2023年A卷(统稿).pdf new file mode 100644 index 0000000..79e09f2 Binary files /dev/null and b/操作系统/试卷/2023年A卷(统稿).pdf differ diff --git a/操作系统/试卷/2023年B卷(统稿).doc b/操作系统/试卷/2023年B卷(统稿).doc new file mode 100644 index 0000000..81d6b7f Binary files /dev/null and b/操作系统/试卷/2023年B卷(统稿).doc differ diff --git a/操作系统/试卷/2025年A卷试卷.pdf b/操作系统/试卷/2025年A卷试卷.pdf new file mode 100644 index 0000000..b727987 Binary files /dev/null and b/操作系统/试卷/2025年A卷试卷.pdf differ diff --git a/操作系统/试卷/2025年B卷试卷.pdf b/操作系统/试卷/2025年B卷试卷.pdf new file mode 100644 index 0000000..641e0a6 Binary files /dev/null and b/操作系统/试卷/2025年B卷试卷.pdf differ diff --git a/操作系统/试卷/2025操作系统A卷参考答案.pdf b/操作系统/试卷/2025操作系统A卷参考答案.pdf new file mode 100644 index 0000000..749c6f1 Binary files /dev/null and b/操作系统/试卷/2025操作系统A卷参考答案.pdf differ diff --git a/操作系统/试卷/2025操作系统B卷参考答案及评分标准.docx b/操作系统/试卷/2025操作系统B卷参考答案及评分标准.docx new file mode 100644 index 0000000..17cca86 Binary files /dev/null and b/操作系统/试卷/2025操作系统B卷参考答案及评分标准.docx differ diff --git a/操作系统/试卷/21-22网安学院操作系统A卷.pdf b/操作系统/试卷/21-22网安学院操作系统A卷.pdf new file mode 100644 index 0000000..1b7ba13 Binary files /dev/null and b/操作系统/试卷/21-22网安学院操作系统A卷.pdf differ diff --git a/操作系统/试卷/4cec9c0a5e1b0615426df487f9a6716d.docx b/操作系统/试卷/4cec9c0a5e1b0615426df487f9a6716d.docx new file mode 100644 index 0000000..fbdb92c Binary files /dev/null and b/操作系统/试卷/4cec9c0a5e1b0615426df487f9a6716d.docx differ diff --git a/操作系统/试卷/d6fdbce11aa9feef6f246e73ed09a00e.docx b/操作系统/试卷/d6fdbce11aa9feef6f246e73ed09a00e.docx new file mode 100644 index 0000000..f822f19 Binary files /dev/null and b/操作系统/试卷/d6fdbce11aa9feef6f246e73ed09a00e.docx differ diff --git a/操作系统/试卷/f9c2aa5324dd828dfa7840fc2458f98d.doc b/操作系统/试卷/f9c2aa5324dd828dfa7840fc2458f98d.doc new file mode 100644 index 0000000..7e59a53 Binary files /dev/null and b/操作系统/试卷/f9c2aa5324dd828dfa7840fc2458f98d.doc differ diff --git a/操作系统/试卷/操作系统期中考试参考答案2025-2026-2.docx b/操作系统/试卷/操作系统期中考试参考答案2025-2026-2.docx new file mode 100644 index 0000000..6099b82 Binary files /dev/null and b/操作系统/试卷/操作系统期中考试参考答案2025-2026-2.docx differ diff --git a/操作系统/试卷/操作系统期中考试试卷.pdf b/操作系统/试卷/操作系统期中考试试卷.pdf new file mode 100644 index 0000000..001df02 Binary files /dev/null and b/操作系统/试卷/操作系统期中考试试卷.pdf differ diff --git a/软件需求分析/各章笔记/第08章-包图.md b/软件需求分析/各章笔记/第08章-包图.md index 9dbf9e8..0c923ac 100644 --- a/软件需求分析/各章笔记/第08章-包图.md +++ b/软件需求分析/各章笔记/第08章-包图.md @@ -1,290 +1,290 @@ -# 第8章 包图 - -> **考试重要度**:★★ -> **核心内容**:包的概念与可见性、包之间的依赖与泛化关系、四大设计原则 - ---- - -## 📢 从文件夹理解包图 - -💬 你电脑上肯定有文件夹(目录),用来把相关文件放在一起。 - -在UML中,**包**就是"文件夹"——把关系密切的类、接口、组件等放在一起。 - -``` -┌─────────────────┐ -│ 包名 │ -│ ┌───────────┐ │ -│ │ + 公开类 │ │ ← 被import后外部可用 -│ │ # 受保护 │ │ ← 只有子包可用 -│ │ - 私有类 │ │ ← 只有包内部可用 -│ └───────────┘ │ -└─────────────────┘ -``` - ---- - -## 💡 一、包的可见性 - -跟类的可见性一样,包中的元素也有三种可见性: - -| 可见性 | 符号 | 含义 | -|--------|------|------| -| **公有** | `+` | 任何导入此包的包都可以用 | -| **受保护** | `#` | 只有子包可以用 | -| **私有** | `-` | 只有包内部可以用 | - ---- - -## 💡 二、包之间的关系 - -### 1. 依赖关系 - -> 包A中的某个类依赖于包B中的某个类 → 包A依赖于包B。 - -⚠️ **注意**:包之间的依赖关系**没有传递性**!A依赖B,B依赖C,不等于A依赖C。 - -### 2. 泛化关系 - -> 子包继承父包中可见性为public和protected的元素。 - -### 📌 包图各元素的画法速查 - -> 这一节把包图里所有的画图元素(包本身、依赖、泛化)用字符画列出来。 - -#### ① 包的两种画法 - -**标准文件夹画法**(最常用): - -``` - ┌──────────────┐ - │ UI层 │ ← 上方有"小标签"(像文件夹的舌头) - └──────────────┘ -``` - -字符画表示(小标签在左上角): - -``` - ┌────────────┐ - │┌─┐ │ - ││ │ │ - │└─┘─────────┤ - │ UI层 │ - │ │ - │ ┌─────────┐ │ - │ │ 登录页 │ │ ← 包内可以嵌套子包或类 - │ └─────────┘ │ - └─────────────┘ -``` - -**简化画法**(只用矩形): - -``` - ┌──────────────┐ - │ UI层 │ - │ ┌────────┐ │ - │ │ 登录页 │ │ - │ └────────┘ │ - │ ┌────────┐ │ - │ │ 主页面 │ │ - │ └────────┘ │ - └──────────────┘ -``` - -#### ② 包的嵌套 - -``` - ┌──────────────────────┐ - │ 电子商务系统 │ - │ ┌──────────┐ │ - │ │ 用户模块 │ │ - │ │ ┌──────┐ │ │ - │ │ │登录类│ │ │ - │ │ └──────┘ │ │ - │ │ ┌──────┐ │ │ - │ │ │注册类│ │ │ - │ │ └──────┘ │ │ - │ └──────────┘ │ - │ ┌──────────┐ │ - │ │ 订单模块 │ │ - │ └──────────┘ │ - └──────────────────────┘ -``` - -#### ③ 包的可见性符号 - -``` - ┌────────────────┐ - │ ┌─┐ │ - │ │+│ 公开类 │ ← + 公有:任何外部包都可用 - │ └─┘───────────┤ - │ ┌─┐ │ - │ │#│ 受保护类 │ ← # 受保护:只有子包可用 - │ └─┘───────────┤ - │ ┌─┐ │ - │ │-│ 私有类 │ ← - 私有:只有包内部可用 - │ └─┘───────────┤ - │ │ - └────────────────┘ -``` - -#### ④ 包之间的依赖关系 —— 虚线 + 开放箭头 - -> 一个包中的类**引用**另一个包中的类 → 包之间形成依赖。 - -``` - ┌──────────────┐ ┌──────────────┐ - │ UI层 │ │ 业务层 │ - │ │ │ │ - │ ┌────────┐ │ │ ┌────────┐ │ - │ │登录窗口 │ │ │ │登录服务 │ │ - │ └────────┘ │ │ └────────┘ │ - │ │ │ │ - └──────────────┘ └──────────────┘ - │ ▲ - └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┘ - 虚线 + 开放箭头 - (依赖方向:UI层 依赖 业务层) -``` - -⚠️ **依赖方向**:箭头从"使用者"指向"被使用者"(类似类图里的依赖)。 - -#### ⑤ 包之间的泛化关系 —— 实线 + 空心三角 - -> 子包继承父包的内容。子包继承父包中**public 和 protected** 的元素。 - -``` - ┌──────────────┐ ┌──────────────┐ - │ 业务层 │ │ 通用层 │ - │ (子包) │ ─ ─ ─ ─ ─ ─ ─ ▷ │ (父包) │ - │ │ 空心三角 │ │ - │ ┌────────┐ │ │ ┌────────┐ │ - │ │订单处理 │ │ │ │日志工具 │ │ - │ └────────┘ │ │ └────────┘ │ - │ ┌────────┐ │ │ ┌────────┐ │ - │ │支付处理 │ │ │ │通用工具 │ │ - │ └────────┘ │ │ └────────┘ │ - └──────────────┘ └──────────────┘ -``` - -⚠️ **泛化方向**:空心三角**指向父包**(与类图泛化一致)。 - -#### ⑥ 完整包图示例 - -``` - ┌────────────────┐ - │ 表现层 │ - │ ┌────────┐ │ - │ │登录页 │ │ - │ └────────┘ │ - │ ┌────────┐ │ - │ │主窗体 │ │ - │ └────────┘ │ - └───────┬────────┘ - │ 依赖 - ╌ ╌ ╌ ╌ ╌ ╌ ╌ - ▼ - ┌────────────────┐ ▷ ┌────────────────┐ - │ 业务层 │ ─ ─ ─ ─ ─ ─ ─ ─ ─│ 通用层 │ - │ │ 泛化 │ │ - │ ┌────────┐ │ │ ┌────────┐ │ - │ │登录管理 │ │ │ │日志工具 │ │ - │ └────────┘ │ │ └────────┘ │ - │ ┌────────┐ │ │ ┌────────┐ │ - │ │订单管理 │ │ │ │数据校验 │ │ - │ └────────┘ │ │ └────────┘ │ - └───────┬────────┘ └────────────────┘ - │ 依赖 - ╌ ╌ ╌ ╌ ╌ ╌ ╌ - ▼ - ┌────────────────┐ - │ 数据层 │ - │ ┌────────┐ │ - │ │用户DAO │ │ - │ └────────┘ │ - │ ┌────────┐ │ - │ │订单DAO │ │ - │ └────────┘ │ - └────────────────┘ -``` - -🔑 **一句话记忆口诀**:包用文件夹画,标签是包名;包间依赖是虚线开放箭头(指向被依赖),包间泛化是实线空心三角(指向父包);包可以嵌套,元素分+/-可见性。 - ---- - -## 💡 三、包的四大设计原则 - -这些原则告诉你**怎么把类分到不同的包里**: - -### 1. 重用等价原则(REP) - -> 把可以一起复用的类放在一个包中。包 = 可重用的单元。 - -💬 就像一个工具包——扳手和螺丝刀放在一起,因为它们总是一起用。 - -### 2. 共同闭包原则(CCP) - -> 把需要同时修改的类放在一个包中。 - -💬 修改了类A就必须修改类B?那它们应该在一个包里——改一个包就够了。 - -### 3. 共同重用原则(CRP) - -> 不会一起用的类不要放在同一个包里。 - -💬 别把厨房用品和修车工具放一起——要用厨房用品的人不需要修车工具。 - -### 4. 非循环依赖原则(ADP) - -> 包之间的依赖关系**不能形成环**。 - -💬 A→B→C→A 这样的循环依赖会严重妨碍复用。破解方法:提取共同接口或拆分包。 - -### 🔑 核心目标:高内聚、低耦合 - -| 原则 | 作用 | -|------|------| -| **高内聚** | 包内的类关系紧密(REP + CCP) | -| **低耦合** | 包之间的依赖尽量少(CRP + ADP) | - ---- - -## ✍️ 边学边练 - -**题目**:判断以下做法是否合理。 - -1. 把订单处理类和用户登录类放在同一个包 -2. A包依赖B包,B包依赖C包,C包依赖A包 -3. 把所有工具类放在一个util包中,让其他包依赖它 -4. 把用户界面类、数据库访问类、业务逻辑类放在同一个包 - -**答案:** -1. ❌ 不合理 — 违反CRP(不会一起使用的类不要放一起)。订单处理的人不需要登录功能。 -2. ❌ 不合理 — 违反ADP(循环依赖)。 -3. ✅ 合理 — 符合REP(工具类作为可重用单元)。 -4. ❌ 不合理 — 违反CCP和CRP。不同层次的类(界面/数据/逻辑)修改原因不同,应该分开。 - -⚠️ **实用建议**:通常按层次分包(UI层、业务层、数据层),而不是按功能分包。 - ---- - -## 📝 章末自测 - -**1. 填空题** -- 包的四大设计原则缩写是:( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ ) -- ADP原则要求包之间的依赖不能形成( ___ )( ___ ) -- 包中元素的三种可见性是:公有(( ___ )( ___ ))、受保护(( ___ )( ___ ))、私有(( ___ )( ___ )) - -**2. 简答题** -- 简述REP和CRP之间的矛盾是什么? -- 包图和组件图的关系是什么? - -**答案:** -**填空题**:REP、CCP、CRP、ADP;循环;+、#、- - -**简答题**: -- REP说要把可一起复用的类放在一个包,这可能导致一个包很大(包含所有可能用到的类);CRP说不会一起用的类要分开,这可能导致很多小包。两者需要权衡。 -- 包存放组件,组件包含类。包相当于文件夹,组件相当于文件。部署时:类→组件→包→结点。 - ---- -> 🔗 上一篇:[第7章 组件图与部署图](第07章-组件图与部署图.md) | 下一篇:[第9章 数据建模](第09章-数据建模.md) +# 第8章 包图 + +> **考试重要度**:★★ +> **核心内容**:包的概念与可见性、包之间的依赖与泛化关系、四大设计原则 + +--- + +## 📢 从文件夹理解包图 + +💬 你电脑上肯定有文件夹(目录),用来把相关文件放在一起。 + +在UML中,**包**就是"文件夹"——把关系密切的类、接口、组件等放在一起。 + +``` +┌─────────────────┐ +│ 包名 │ +│ ┌───────────┐ │ +│ │ + 公开类 │ │ ← 被import后外部可用 +│ │ # 受保护 │ │ ← 只有子包可用 +│ │ - 私有类 │ │ ← 只有包内部可用 +│ └───────────┘ │ +└─────────────────┘ +``` + +--- + +## 💡 一、包的可见性 + +跟类的可见性一样,包中的元素也有三种可见性: + +| 可见性 | 符号 | 含义 | +|--------|------|------| +| **公有** | `+` | 任何导入此包的包都可以用 | +| **受保护** | `#` | 只有子包可以用 | +| **私有** | `-` | 只有包内部可以用 | + +--- + +## 💡 二、包之间的关系 + +### 1. 依赖关系 + +> 包A中的某个类依赖于包B中的某个类 → 包A依赖于包B。 + +⚠️ **注意**:包之间的依赖关系**没有传递性**!A依赖B,B依赖C,不等于A依赖C。 + +### 2. 泛化关系 + +> 子包继承父包中可见性为public和protected的元素。 + +### 📌 包图各元素的画法速查 + +> 这一节把包图里所有的画图元素(包本身、依赖、泛化)用字符画列出来。 + +#### ① 包的两种画法 + +**标准文件夹画法**(最常用): + +``` + ┌──────────────┐ + │ UI层 │ ← 上方有"小标签"(像文件夹的舌头) + └──────────────┘ +``` + +字符画表示(小标签在左上角): + +``` + ┌────────────┐ + │┌─┐ │ + ││ │ │ + │└─┘─────────┤ + │ UI层 │ + │ │ + │ ┌─────────┐ │ + │ │ 登录页 │ │ ← 包内可以嵌套子包或类 + │ └─────────┘ │ + └─────────────┘ +``` + +**简化画法**(只用矩形): + +``` + ┌──────────────┐ + │ UI层 │ + │ ┌────────┐ │ + │ │ 登录页 │ │ + │ └────────┘ │ + │ ┌────────┐ │ + │ │ 主页面 │ │ + │ └────────┘ │ + └──────────────┘ +``` + +#### ② 包的嵌套 + +``` + ┌──────────────────────┐ + │ 电子商务系统 │ + │ ┌──────────┐ │ + │ │ 用户模块 │ │ + │ │ ┌──────┐ │ │ + │ │ │登录类│ │ │ + │ │ └──────┘ │ │ + │ │ ┌──────┐ │ │ + │ │ │注册类│ │ │ + │ │ └──────┘ │ │ + │ └──────────┘ │ + │ ┌──────────┐ │ + │ │ 订单模块 │ │ + │ └──────────┘ │ + └──────────────────────┘ +``` + +#### ③ 包的可见性符号 + +``` + ┌────────────────┐ + │ ┌─┐ │ + │ │+│ 公开类 │ ← + 公有:任何外部包都可用 + │ └─┘───────────┤ + │ ┌─┐ │ + │ │#│ 受保护类 │ ← # 受保护:只有子包可用 + │ └─┘───────────┤ + │ ┌─┐ │ + │ │-│ 私有类 │ ← - 私有:只有包内部可用 + │ └─┘───────────┤ + │ │ + └────────────────┘ +``` + +#### ④ 包之间的依赖关系 —— 虚线 + 开放箭头 + +> 一个包中的类**引用**另一个包中的类 → 包之间形成依赖。 + +``` + ┌──────────────┐ ┌──────────────┐ + │ UI层 │ │ 业务层 │ + │ │ │ │ + │ ┌────────┐ │ │ ┌────────┐ │ + │ │登录窗口 │ │ │ │登录服务 │ │ + │ └────────┘ │ │ └────────┘ │ + │ │ │ │ + └──────────────┘ └──────────────┘ + │ ▲ + └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┘ + 虚线 + 开放箭头 + (依赖方向:UI层 依赖 业务层) +``` + +⚠️ **依赖方向**:箭头从"使用者"指向"被使用者"(类似类图里的依赖)。 + +#### ⑤ 包之间的泛化关系 —— 实线 + 空心三角 + +> 子包继承父包的内容。子包继承父包中**public 和 protected** 的元素。 + +``` + ┌──────────────┐ ┌──────────────┐ + │ 业务层 │ │ 通用层 │ + │ (子包) │ ─ ─ ─ ─ ─ ─ ─ ▷ │ (父包) │ + │ │ 空心三角 │ │ + │ ┌────────┐ │ │ ┌────────┐ │ + │ │订单处理 │ │ │ │日志工具 │ │ + │ └────────┘ │ │ └────────┘ │ + │ ┌────────┐ │ │ ┌────────┐ │ + │ │支付处理 │ │ │ │通用工具 │ │ + │ └────────┘ │ │ └────────┘ │ + └──────────────┘ └──────────────┘ +``` + +⚠️ **泛化方向**:空心三角**指向父包**(与类图泛化一致)。 + +#### ⑥ 完整包图示例 + +``` + ┌────────────────┐ + │ 表现层 │ + │ ┌────────┐ │ + │ │登录页 │ │ + │ └────────┘ │ + │ ┌────────┐ │ + │ │主窗体 │ │ + │ └────────┘ │ + └───────┬────────┘ + │ 依赖 + ╌ ╌ ╌ ╌ ╌ ╌ ╌ + ▼ + ┌────────────────┐ ▷ ┌────────────────┐ + │ 业务层 │ ─ ─ ─ ─ ─ ─ ─ ─ ─│ 通用层 │ + │ │ 泛化 │ │ + │ ┌────────┐ │ │ ┌────────┐ │ + │ │登录管理 │ │ │ │日志工具 │ │ + │ └────────┘ │ │ └────────┘ │ + │ ┌────────┐ │ │ ┌────────┐ │ + │ │订单管理 │ │ │ │数据校验 │ │ + │ └────────┘ │ │ └────────┘ │ + └───────┬────────┘ └────────────────┘ + │ 依赖 + ╌ ╌ ╌ ╌ ╌ ╌ ╌ + ▼ + ┌────────────────┐ + │ 数据层 │ + │ ┌────────┐ │ + │ │用户DAO │ │ + │ └────────┘ │ + │ ┌────────┐ │ + │ │订单DAO │ │ + │ └────────┘ │ + └────────────────┘ +``` + +🔑 **一句话记忆口诀**:包用文件夹画,标签是包名;包间依赖是虚线开放箭头(指向被依赖),包间泛化是实线空心三角(指向父包);包可以嵌套,元素分+/-可见性。 + +--- + +## 💡 三、包的四大设计原则 + +这些原则告诉你**怎么把类分到不同的包里**: + +### 1. 重用等价原则(REP) + +> 把可以一起复用的类放在一个包中。包 = 可重用的单元。 + +💬 就像一个工具包——扳手和螺丝刀放在一起,因为它们总是一起用。 + +### 2. 共同闭包原则(CCP) + +> 把需要同时修改的类放在一个包中。 + +💬 修改了类A就必须修改类B?那它们应该在一个包里——改一个包就够了。 + +### 3. 共同重用原则(CRP) + +> 不会一起用的类不要放在同一个包里。 + +💬 别把厨房用品和修车工具放一起——要用厨房用品的人不需要修车工具。 + +### 4. 非循环依赖原则(ADP) + +> 包之间的依赖关系**不能形成环**。 + +💬 A→B→C→A 这样的循环依赖会严重妨碍复用。破解方法:提取共同接口或拆分包。 + +### 🔑 核心目标:高内聚、低耦合 + +| 原则 | 作用 | +|------|------| +| **高内聚** | 包内的类关系紧密(REP + CCP) | +| **低耦合** | 包之间的依赖尽量少(CRP + ADP) | + +--- + +## ✍️ 边学边练 + +**题目**:判断以下做法是否合理。 + +1. 把订单处理类和用户登录类放在同一个包 +2. A包依赖B包,B包依赖C包,C包依赖A包 +3. 把所有工具类放在一个util包中,让其他包依赖它 +4. 把用户界面类、数据库访问类、业务逻辑类放在同一个包 + +**答案:** +1. ❌ 不合理 — 违反CRP(不会一起使用的类不要放一起)。订单处理的人不需要登录功能。 +2. ❌ 不合理 — 违反ADP(循环依赖)。 +3. ✅ 合理 — 符合REP(工具类作为可重用单元)。 +4. ❌ 不合理 — 违反CCP和CRP。不同层次的类(界面/数据/逻辑)修改原因不同,应该分开。 + +⚠️ **实用建议**:通常按层次分包(UI层、业务层、数据层),而不是按功能分包。 + +--- + +## 📝 章末自测 + +**1. 填空题** +- 包的四大设计原则缩写是:( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ ) +- ADP原则要求包之间的依赖不能形成( ___ )( ___ ) +- 包中元素的三种可见性是:公有(( ___ )( ___ ))、受保护(( ___ )( ___ ))、私有(( ___ )( ___ )) + +**2. 简答题** +- 简述REP和CRP之间的矛盾是什么? +- 包图和组件图的关系是什么? + +**答案:** +**填空题**:REP、CCP、CRP、ADP;循环;+、#、- + +**简答题**: +- REP说要把可一起复用的类放在一个包,这可能导致一个包很大(包含所有可能用到的类);CRP说不会一起用的类要分开,这可能导致很多小包。两者需要权衡。 +- 包存放组件,组件包含类。包相当于文件夹,组件相当于文件。部署时:类→组件→包→结点。 + +--- +> 🔗 上一篇:[第7章 组件图与部署图](第07章-组件图与部署图.md) | 下一篇:[第9章 数据建模](第09章-数据建模.md) diff --git a/软件需求分析/各章笔记/第09章-数据建模.md b/软件需求分析/各章笔记/第09章-数据建模.md index 3026ff6..96fe3ad 100644 --- a/软件需求分析/各章笔记/第09章-数据建模.md +++ b/软件需求分析/各章笔记/第09章-数据建模.md @@ -1,141 +1,141 @@ -# 第9章 数据建模 - -> **考试重要度**:★★★★ -> **核心内容**:对象模型→数据模型的转换规则、多重性映射、泛化关系映射、三大范式 - ---- - -## 📢 从类图到数据库表 - -💬 前面用类图设计好了系统结构,但数据最终要存到数据库里。**数据建模**就是回答:怎么把"类"变成"数据库表"? - ---- - -## 💡 一、数据库设计的三个阶段 - -| 阶段 | 任务 | 产物 | -|------|------|------| -| **概念设计** | 把用户需求统一到一个逻辑结构中 | E-R图(或UML类图) | -| **逻辑设计** | 转换为具体DBMS支持的数据模型 | 关系模式(表结构) | -| **物理设计** | 选择存储方式、索引策略等 | 在特定数据库上实现 | - -💬 UML类图可以替代E-R图——类图不仅能描述数据,还能描述行为(触发器和存储过程)。 - -### 关键概念速查 - -| 概念 | 说明 | -|------|------| -| **主键** | 唯一标识一条记录的字段 | -| **外键** | 引用另一个表主键的字段 | -| **模式** | 所有表及表间关系的集合 | -| **索引** | 提高查询速度的数据结构 | -| **触发器** | 满足条件时自动执行的操作 | -| **存储过程** | 预编译的SQL语句集合 | - ---- - -## 💡 二、对象模型 → 数据模型转换规则 - -### 基本转换规则 - -``` -类 → 表 -属性 → 字段(列) -操作 → 触发器/存储过程 -类间关系 → 表间关系(或单独的表) -``` - -### 多重性在数据模型中的映射(重点!) - -| 多重性 | 映射方法 | -|--------|----------| -| **1 对 0..\*** | 在多的一端加外键(指向一的一端的主键) | -| **0..1 对 1** | 在0..1一端加外键(指向1的一端的主键) | -| **0..\* 对 0..\*** | 新建一个关联表,两端主键作为外键 | - -> 例:客户(Customer) 1 —— 0..* 订单(Order) -> -> → Order表加外键 `customer_id` 指向 Customer表的主键。 - -> 例:学生(Student) * —— * 课程(Course) -> -> → 新建选课表(Enrollment),包含 `student_id` 和 `course_id` 两个外键。 - -### 泛化关系在数据模型中的映射(三种方案) - -| 方案 | 做法 | 优点 | 缺点 | -|------|------|------|------| -| **方案一** | 父类和每个子类各建一个表 | 结构清晰 | 表多,查询要JOIN | -| **方案二** | 不建父类表,父类属性放在每个子类表中 | 查询快 | 数据冗余 | -| **方案三** | 不建子类表,所有子类属性放在父类表中 | 表少 | 字段很多,有空值 | - -💬 **方案一像"分家"**——各过各的,但联系起来需要花时间。 -💬 **方案二像"合住但分灶"**——各自独立但有些东西重复了。 -💬 **方案三像"大家庭"**——都在一个屋檐下,但人多事杂。 - ---- - -## 💡 三、三大数据库范式 - -| 范式 | 要求 | 解决的问题 | -|------|------|------------| -| **1NF** | 每个字段值都是**不可再分**的原子值 | 消除重复列 | -| **2NF** | 满足1NF + 非主键字段**完全依赖**主键 | 消除部分依赖 | -| **3NF** | 满足2NF + 非主键字段**不传递依赖**主键 | 消除传递依赖 | - -💬 **1NF**:别在一个格子里塞多个值。 -💬 **2NF**:每个字段只跟主键有关,别跟主键的一部分有关。 -💬 **3NF**:非主键字段之间不要相互依赖。 - ---- - -## ✍️ 边学边练 - -**题目**:以下表是否符合3NF?如果不符合,说明原因并修正。 - -``` -订单表(Order) - 订单ID (主键) - 客户ID - 客户姓名 - 客户电话 - 商品ID - 商品名称 - 数量 - 下单日期 -``` - -**答案:** -不符合3NF。存在传递依赖: -- 客户姓名、客户电话 → 依赖客户ID(不是直接依赖订单ID) -- 商品名称 → 依赖商品ID(不是直接依赖订单ID) - -**修正方案**(拆分成三个表): -- 订单表:订单ID、客户ID、商品ID、数量、下单日期 -- 客户表:客户ID、客户姓名、客户电话 -- 商品表:商品ID、商品名称 - -这样每个非主键字段都直接依赖于所在表的主键,满足3NF。 - ---- - -## 📝 章末自测 - -**1. 填空题** -- 1对多关联的映射方法是:在( ___ )( ___ )的一端加外键指向( ___ )( ___ )的一端 -- 多对多关联的映射方法是:新建一个( ___ )( ___ ),两边的主键作为外键 -- 3NF要求非主键字段之间没有( ___ )( ___ )依赖 - -**2. 简答题** -- 简述泛化关系映射的三种方案及其适用场景。 -- 类图中的操作在数据模型中对应什么? - -**答案:** -**填空题**:多、一;关联表;传递 - -**简答题**: -- 方案一(每个类一个表):适合查询灵活、结构变化少的系统。方案二(父子属性合并到子表):适合子类差异大、按子类查询多的场景。方案三(所有属性合并到父表):适合子类差异小、字段不多的场景。 -- 类图中的操作对应数据库中的**触发器和存储过程**。触发器是满足条件自动执行的代码,存储过程是预编译的SQL程序。 - ---- -> 🔗 上一篇:[第8章 包图](第08章-包图.md) | 下一篇:[第11章 RUP统一过程](第11章-RUP统一过程.md) +# 第9章 数据建模 + +> **考试重要度**:★★★★ +> **核心内容**:对象模型→数据模型的转换规则、多重性映射、泛化关系映射、三大范式 + +--- + +## 📢 从类图到数据库表 + +💬 前面用类图设计好了系统结构,但数据最终要存到数据库里。**数据建模**就是回答:怎么把"类"变成"数据库表"? + +--- + +## 💡 一、数据库设计的三个阶段 + +| 阶段 | 任务 | 产物 | +|------|------|------| +| **概念设计** | 把用户需求统一到一个逻辑结构中 | E-R图(或UML类图) | +| **逻辑设计** | 转换为具体DBMS支持的数据模型 | 关系模式(表结构) | +| **物理设计** | 选择存储方式、索引策略等 | 在特定数据库上实现 | + +💬 UML类图可以替代E-R图——类图不仅能描述数据,还能描述行为(触发器和存储过程)。 + +### 关键概念速查 + +| 概念 | 说明 | +|------|------| +| **主键** | 唯一标识一条记录的字段 | +| **外键** | 引用另一个表主键的字段 | +| **模式** | 所有表及表间关系的集合 | +| **索引** | 提高查询速度的数据结构 | +| **触发器** | 满足条件时自动执行的操作 | +| **存储过程** | 预编译的SQL语句集合 | + +--- + +## 💡 二、对象模型 → 数据模型转换规则 + +### 基本转换规则 + +``` +类 → 表 +属性 → 字段(列) +操作 → 触发器/存储过程 +类间关系 → 表间关系(或单独的表) +``` + +### 多重性在数据模型中的映射(重点!) + +| 多重性 | 映射方法 | +|--------|----------| +| **1 对 0..\*** | 在多的一端加外键(指向一的一端的主键) | +| **0..1 对 1** | 在0..1一端加外键(指向1的一端的主键) | +| **0..\* 对 0..\*** | 新建一个关联表,两端主键作为外键 | + +> 例:客户(Customer) 1 —— 0..* 订单(Order) +> +> → Order表加外键 `customer_id` 指向 Customer表的主键。 + +> 例:学生(Student) * —— * 课程(Course) +> +> → 新建选课表(Enrollment),包含 `student_id` 和 `course_id` 两个外键。 + +### 泛化关系在数据模型中的映射(三种方案) + +| 方案 | 做法 | 优点 | 缺点 | +|------|------|------|------| +| **方案一** | 父类和每个子类各建一个表 | 结构清晰 | 表多,查询要JOIN | +| **方案二** | 不建父类表,父类属性放在每个子类表中 | 查询快 | 数据冗余 | +| **方案三** | 不建子类表,所有子类属性放在父类表中 | 表少 | 字段很多,有空值 | + +💬 **方案一像"分家"**——各过各的,但联系起来需要花时间。 +💬 **方案二像"合住但分灶"**——各自独立但有些东西重复了。 +💬 **方案三像"大家庭"**——都在一个屋檐下,但人多事杂。 + +--- + +## 💡 三、三大数据库范式 + +| 范式 | 要求 | 解决的问题 | +|------|------|------------| +| **1NF** | 每个字段值都是**不可再分**的原子值 | 消除重复列 | +| **2NF** | 满足1NF + 非主键字段**完全依赖**主键 | 消除部分依赖 | +| **3NF** | 满足2NF + 非主键字段**不传递依赖**主键 | 消除传递依赖 | + +💬 **1NF**:别在一个格子里塞多个值。 +💬 **2NF**:每个字段只跟主键有关,别跟主键的一部分有关。 +💬 **3NF**:非主键字段之间不要相互依赖。 + +--- + +## ✍️ 边学边练 + +**题目**:以下表是否符合3NF?如果不符合,说明原因并修正。 + +``` +订单表(Order) + 订单ID (主键) + 客户ID + 客户姓名 + 客户电话 + 商品ID + 商品名称 + 数量 + 下单日期 +``` + +**答案:** +不符合3NF。存在传递依赖: +- 客户姓名、客户电话 → 依赖客户ID(不是直接依赖订单ID) +- 商品名称 → 依赖商品ID(不是直接依赖订单ID) + +**修正方案**(拆分成三个表): +- 订单表:订单ID、客户ID、商品ID、数量、下单日期 +- 客户表:客户ID、客户姓名、客户电话 +- 商品表:商品ID、商品名称 + +这样每个非主键字段都直接依赖于所在表的主键,满足3NF。 + +--- + +## 📝 章末自测 + +**1. 填空题** +- 1对多关联的映射方法是:在( ___ )( ___ )的一端加外键指向( ___ )( ___ )的一端 +- 多对多关联的映射方法是:新建一个( ___ )( ___ ),两边的主键作为外键 +- 3NF要求非主键字段之间没有( ___ )( ___ )依赖 + +**2. 简答题** +- 简述泛化关系映射的三种方案及其适用场景。 +- 类图中的操作在数据模型中对应什么? + +**答案:** +**填空题**:多、一;关联表;传递 + +**简答题**: +- 方案一(每个类一个表):适合查询灵活、结构变化少的系统。方案二(父子属性合并到子表):适合子类差异大、按子类查询多的场景。方案三(所有属性合并到父表):适合子类差异小、字段不多的场景。 +- 类图中的操作对应数据库中的**触发器和存储过程**。触发器是满足条件自动执行的代码,存储过程是预编译的SQL程序。 + +--- +> 🔗 上一篇:[第8章 包图](第08章-包图.md) | 下一篇:[第11章 RUP统一过程](第11章-RUP统一过程.md) diff --git a/软件需求分析/各章笔记/第11章-RUP统一过程.md b/软件需求分析/各章笔记/第11章-RUP统一过程.md index adaa08b..ae84707 100644 --- a/软件需求分析/各章笔记/第11章-RUP统一过程.md +++ b/软件需求分析/各章笔记/第11章-RUP统一过程.md @@ -1,164 +1,164 @@ -# 第11章 Rational统一过程(RUP) - -> **考试重要度**:★★★ -> **核心内容**:RUP的三大特点、四个阶段、九个核心工作流、六大最佳实践 - ---- - -## 📢 软件开发不只是写代码 - -💬 开发软件就像盖一座大楼——你不能上来就搬砖,得先画图纸、打地基、搭框架、装修…… - -**RUP** 就是一套完整的"盖楼流程指南",告诉你:什么人、在什么时候、做什么事、怎么做。 - ---- - -## 💡 一、RUP是什么? - -> **RUP (Rational Unified Process)** = 一套基于UML的软件开发过程框架。 - -🔑 **核心公式**:RUP = **用例驱动** + **以体系结构为中心** + **迭代和增量开发** - -### 三大特点 - -| 特点 | 含义 | -|------|------| -| **用例驱动** | 从用例出发,贯穿需求→设计→实现→测试全过程 | -| **以体系结构为中心** | 先搭建系统骨架,再逐步填充细节 | -| **迭代和增量开发** | 不是一口气做完,而是一轮一轮地完善 | - ---- - -## 💡 二、RUP的二维结构 - -``` - ┌─────────────────────────────────────┐ - 纵轴 │ 工作流 │ - (做 │ 业务建模 需求 分析设计 实现 测试 部署 │ - 什么) │ 配置管理 项目管理 环境 │ - ├─────────────────────────────────────┤ - 横轴 │ 初始 → 细化 → 构造 → 移交 │ - (什么 │ ↑ ↑ ↑ ↑ │ - 时候) │ 里程碑 里程碑 里程碑 里程碑 │ - └─────────────────────────────────────┘ -``` - ---- - -## 💡 三、四个阶段(横轴) - -| 阶段 | 核心任务 | 里程碑 | -|------|----------|--------| -| **初始 (Inception)** | 定义项目范围,评估可行性 | 生命周期目标里程碑 | -| **细化 (Elaboration)** | 设计体系结构,制定计划 | 生命周期体系结构里程碑 | -| **构造 (Construction)** | 大规模开发,完成所有功能 | 初始操作能力里程碑 | -| **移交 (Transition)** | 交付用户,培训,部署 | 产品发布里程碑 | - -💬 **比喻**:初始=立项审批,细化=画施工图,构造=主体施工,移交=交房验收。 - ---- - -## 💡 四、九个核心工作流(纵轴) - -### 六个过程工作流 - -| 工作流 | 做什么 | 主要产物 | -|--------|--------|----------| -| **业务建模** | 理解业务现状 | 业务用例模型、业务对象模型 | -| **需求** | 定义系统功能 | 用例模型、SRS | -| **分析与设计** | 设计系统结构 | 分析模型、设计模型、数据模型 | -| **实现** | 编写代码 | 源代码、可执行系统 | -| **测试** | 验证系统质量 | 测试计划、测试报告 | -| **部署** | 交付使用 | 安装包、用户手册、培训资料 | - -### 三个支持工作流 - -| 工作流 | 做什么 | -|--------|--------| -| **配置与变更管理** | 控制制品版本和变更 | -| **项目管理** | 计划、分配、监控项目 | -| **环境** | 提供开发工具和过程支持 | - ---- - -## 💡 五、4W概念 - -RUP回答了软件开发的四个基本问题: - -| 维度 | 问题 | RUP概念 | -|------|------|---------| -| **Who** | 谁来做? | 角色(Role) | -| **How** | 怎么做? | 活动(Activity) | -| **What** | 产出什么? | 制品(Artifact) | -| **When** | 什么时候做? | 工作流(Workflow) | - ---- - -## 💡 六、六大最佳实践 - -🔑 **这是考试的常考点!** - -| 最佳实践 | 含义 | -|----------|------| -| **① 迭代式开发** | 不是一次做完,而是多轮迭代,每轮都有一个可运行版本 | -| **② 管理需求** | 用用例来组织和管理需求,控制变更 | -| **③ 使用基于构件的体系结构** | 把系统设计成可替换的构件 | -| **④ 可视化软件建模** | 用UML图来描述系统结构和行为 | -| **⑤ 验证软件质量** | 把质量评估嵌入整个过程,不是事后检查 | -| **⑥ 控制软件变更** | 通过配置管理跟踪和控制每个修改 | - ---- - -## 💡 七、迭代 vs 瀑布 - -| 维度 | 瀑布模型 | RUP迭代 | -|------|----------|---------| -| **节奏** | 需求→设计→编码→测试,一次性 | 多轮迭代,每轮都走完整流程 | -| **风险** | 到最后才发现问题 | 早期就能发现和解决风险 | -| **变更** | 不欢迎变更 | 每轮迭代可以调整方向 | -| **产出** | 最后才有可运行版本 | 每轮都有可运行版本 | - ---- - -## ✍️ 边学边练 - -**题目**:判断以下描述对应RUP的哪个阶段或工作流。 - -1. 项目经理制定本次迭代的计划 -2. 测试人员编写测试用例并执行 -3. 确定系统的技术架构是B/S还是C/S -4. 系统上线,培训用户使用 -5. 绘制用例图,确定系统功能范围 - -**答案:** -1. **项目管理**(支持工作流)| 可在任何阶段 -2. **测试**(过程工作流)| 主要在构造阶段 -3. **细化阶段** — 设计体系结构 -4. **移交阶段** — 交付给用户 -5. **初始阶段** 或 **需求工作流** — 定义范围和功能 - ---- - -## 📝 章末自测 - -**1. 填空题** -- RUP的三大特点是:( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ ) -- RUP的四个阶段依次是:( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ ) -- 九个核心工作流中,6个是( ___ )( ___ )工作流,3个是( ___ )( ___ )工作流 - -**2. 简答题** -- 简述RUP六大最佳实践。 -- 迭代和增量开发相比瀑布模型有什么优势? - -**答案:** -**填空题**: -- 用例驱动、以体系结构为中心、迭代和增量开发 -- 初始、细化、构造、移交 -- 过程、支持 - -**简答题**: -- ①迭代式开发 ②管理需求 ③使用基于构件的体系结构 ④可视化软件建模 ⑤验证软件质量 ⑥控制软件变更 -- 迭代开发可以早期发现风险、更容易容纳需求变更、每轮都有可运行的版本(增强信心)、可以根据反馈及时调整方向。 - ---- -> 🔗 上一篇:[第9章 数据建模](第09章-数据建模.md) +# 第11章 Rational统一过程(RUP) + +> **考试重要度**:★★★ +> **核心内容**:RUP的三大特点、四个阶段、九个核心工作流、六大最佳实践 + +--- + +## 📢 软件开发不只是写代码 + +💬 开发软件就像盖一座大楼——你不能上来就搬砖,得先画图纸、打地基、搭框架、装修…… + +**RUP** 就是一套完整的"盖楼流程指南",告诉你:什么人、在什么时候、做什么事、怎么做。 + +--- + +## 💡 一、RUP是什么? + +> **RUP (Rational Unified Process)** = 一套基于UML的软件开发过程框架。 + +🔑 **核心公式**:RUP = **用例驱动** + **以体系结构为中心** + **迭代和增量开发** + +### 三大特点 + +| 特点 | 含义 | +|------|------| +| **用例驱动** | 从用例出发,贯穿需求→设计→实现→测试全过程 | +| **以体系结构为中心** | 先搭建系统骨架,再逐步填充细节 | +| **迭代和增量开发** | 不是一口气做完,而是一轮一轮地完善 | + +--- + +## 💡 二、RUP的二维结构 + +``` + ┌─────────────────────────────────────┐ + 纵轴 │ 工作流 │ + (做 │ 业务建模 需求 分析设计 实现 测试 部署 │ + 什么) │ 配置管理 项目管理 环境 │ + ├─────────────────────────────────────┤ + 横轴 │ 初始 → 细化 → 构造 → 移交 │ + (什么 │ ↑ ↑ ↑ ↑ │ + 时候) │ 里程碑 里程碑 里程碑 里程碑 │ + └─────────────────────────────────────┘ +``` + +--- + +## 💡 三、四个阶段(横轴) + +| 阶段 | 核心任务 | 里程碑 | +|------|----------|--------| +| **初始 (Inception)** | 定义项目范围,评估可行性 | 生命周期目标里程碑 | +| **细化 (Elaboration)** | 设计体系结构,制定计划 | 生命周期体系结构里程碑 | +| **构造 (Construction)** | 大规模开发,完成所有功能 | 初始操作能力里程碑 | +| **移交 (Transition)** | 交付用户,培训,部署 | 产品发布里程碑 | + +💬 **比喻**:初始=立项审批,细化=画施工图,构造=主体施工,移交=交房验收。 + +--- + +## 💡 四、九个核心工作流(纵轴) + +### 六个过程工作流 + +| 工作流 | 做什么 | 主要产物 | +|--------|--------|----------| +| **业务建模** | 理解业务现状 | 业务用例模型、业务对象模型 | +| **需求** | 定义系统功能 | 用例模型、SRS | +| **分析与设计** | 设计系统结构 | 分析模型、设计模型、数据模型 | +| **实现** | 编写代码 | 源代码、可执行系统 | +| **测试** | 验证系统质量 | 测试计划、测试报告 | +| **部署** | 交付使用 | 安装包、用户手册、培训资料 | + +### 三个支持工作流 + +| 工作流 | 做什么 | +|--------|--------| +| **配置与变更管理** | 控制制品版本和变更 | +| **项目管理** | 计划、分配、监控项目 | +| **环境** | 提供开发工具和过程支持 | + +--- + +## 💡 五、4W概念 + +RUP回答了软件开发的四个基本问题: + +| 维度 | 问题 | RUP概念 | +|------|------|---------| +| **Who** | 谁来做? | 角色(Role) | +| **How** | 怎么做? | 活动(Activity) | +| **What** | 产出什么? | 制品(Artifact) | +| **When** | 什么时候做? | 工作流(Workflow) | + +--- + +## 💡 六、六大最佳实践 + +🔑 **这是考试的常考点!** + +| 最佳实践 | 含义 | +|----------|------| +| **① 迭代式开发** | 不是一次做完,而是多轮迭代,每轮都有一个可运行版本 | +| **② 管理需求** | 用用例来组织和管理需求,控制变更 | +| **③ 使用基于构件的体系结构** | 把系统设计成可替换的构件 | +| **④ 可视化软件建模** | 用UML图来描述系统结构和行为 | +| **⑤ 验证软件质量** | 把质量评估嵌入整个过程,不是事后检查 | +| **⑥ 控制软件变更** | 通过配置管理跟踪和控制每个修改 | + +--- + +## 💡 七、迭代 vs 瀑布 + +| 维度 | 瀑布模型 | RUP迭代 | +|------|----------|---------| +| **节奏** | 需求→设计→编码→测试,一次性 | 多轮迭代,每轮都走完整流程 | +| **风险** | 到最后才发现问题 | 早期就能发现和解决风险 | +| **变更** | 不欢迎变更 | 每轮迭代可以调整方向 | +| **产出** | 最后才有可运行版本 | 每轮都有可运行版本 | + +--- + +## ✍️ 边学边练 + +**题目**:判断以下描述对应RUP的哪个阶段或工作流。 + +1. 项目经理制定本次迭代的计划 +2. 测试人员编写测试用例并执行 +3. 确定系统的技术架构是B/S还是C/S +4. 系统上线,培训用户使用 +5. 绘制用例图,确定系统功能范围 + +**答案:** +1. **项目管理**(支持工作流)| 可在任何阶段 +2. **测试**(过程工作流)| 主要在构造阶段 +3. **细化阶段** — 设计体系结构 +4. **移交阶段** — 交付给用户 +5. **初始阶段** 或 **需求工作流** — 定义范围和功能 + +--- + +## 📝 章末自测 + +**1. 填空题** +- RUP的三大特点是:( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ ) +- RUP的四个阶段依次是:( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ )、( ___ )( ___ ) +- 九个核心工作流中,6个是( ___ )( ___ )工作流,3个是( ___ )( ___ )工作流 + +**2. 简答题** +- 简述RUP六大最佳实践。 +- 迭代和增量开发相比瀑布模型有什么优势? + +**答案:** +**填空题**: +- 用例驱动、以体系结构为中心、迭代和增量开发 +- 初始、细化、构造、移交 +- 过程、支持 + +**简答题**: +- ①迭代式开发 ②管理需求 ③使用基于构件的体系结构 ④可视化软件建模 ⑤验证软件质量 ⑥控制软件变更 +- 迭代开发可以早期发现风险、更容易容纳需求变更、每轮都有可运行的版本(增强信心)、可以根据反馈及时调整方向。 + +--- +> 🔗 上一篇:[第9章 数据建模](第09章-数据建模.md) diff --git a/软件需求分析/期中复习/期中复习指南.md b/软件需求分析/期中复习/期中复习指南.md index 147ff96..538c98e 100644 --- a/软件需求分析/期中复习/期中复习指南.md +++ b/软件需求分析/期中复习/期中复习指南.md @@ -1,189 +1,189 @@ -# 期中考试复习 - -> **考试**:2022-2023学年第1学期《软件需求分析与设计》期中考试 -> **满分**:100分 | 选择题30分 + 系统分析题70分 -> **系统场景**:在线酒店订房系统 - ---- - -## 📋 试卷结构一览 - -| 题型 | 题量 | 分值 | 考点 | -|------|------|------|------| -| 选择题 | 15题 | 30分(2分/题) | UML基本概念、关系类型、面向对象特性 | -| 系统分析题 | 7题 | 70分(10分/题) | 7种UML图的实际绘制 | - ---- - -## 🎯 七道大题逐题详解 - -### 第1题:用例图(10分) - -> 画出在线订房系统的用例图。 - -**答案要点**: - -**参与者(3个)**:客户、管理员、身份信息平台 - -**用例(10个)**: -- 客户相关:找回密码、注册、身份认证、登录、查询房间、下订单、在线支付 -- 管理员相关:登录、房间上线、修改房间信息、房间下线 - -**关键关系**: -- **包含 `<>`**:注册 → 身份认证;下订单 → 查询房间;在线支付 → 下订单 -- **关联**:客户↔找回密码、注册、登录、查询房间、下订单;管理员↔登录、房间上线、修改、下线 -- **身份认证 → 身份信息平台**(外部系统交互) - -⚠️ **易错**:包含关系箭头从基本用例指向包含用例! - ---- - -### 第2题:"找回密码"用例描述(10分) - -> 写出"找回密码"的完整用例描述。 - -**参考答案**: - -| 项目 | 内容 | -|------|------| -| **用例名** | 找回密码 | -| **参与者** | 客户 | -| **前置条件** | 客户已注册 | -| **基本流程** | ①输入手机号 → ②获取短信验证码 → ③输入验证码 → ④输入新密码(两次) → ⑤两次一致则密码修改成功 | -| **备选流程** | 若两次新密码不一致,提示重新输入 | -| **后置条件** | 密码修改成功,用户可用新密码登录 | - ---- - -### 第3题:实体类类图(10分) - -> 画出系统的实体类类图,标注多重性。 - -**答案要点**: - -**实体类**:客户(Customer)、房间(Room)、订单(Order)、管理员(Admin) - -**关系及多重性**: -``` -客户 1 ────── 0..* 订单 (一个客户可以有多个订单) -房间 1 ────── 0..* 订单 (一个房间可以被多次预订) -管理员 0..2 ── 0..* 订单 (管理员处理订单) -``` - ---- - -### 第4题:"找回密码"的基于协作的类图(10分) - -> 使用边界类、控制类、实体类画出协作类图。 - -**答案要点**: - -| 版型 | 类名 | 职责 | -|------|------|------| -| <> | RetrievePasswordBoundary | 找回密码界面,与用户交互 | -| <> | RetrievePasswordControl | 协调找回密码的业务逻辑 | -| <> | SMSInterface | 短信接口(外部) | -| <> | UserInfo | 用户信息(持久化) | - -**依赖关系**: -``` -Boundary → Control → SMSInterface - → UserInfo -``` - ---- - -### 第5题:"找回密码"的顺序图(10分) - -> 画出"找回密码"的顺序图,标注消息编号。 - -**答案要点**: - -**对象**:用户、:RetrievePasswordBoundary、:RetrievePasswordControl、:UserInfo、:SMSInterface - -**消息序列(核心流程)**: -``` -1: 用户 → 边界类 : 找回密码() - 1.1: 用户 → 边界类 : 输入手机号 - 1.1.1: 边界 → 控制 : 获取短信(手机号) - 1.1.1.1: 控制 → SMS : 获取短信(手机号) -2: 用户 → 边界类 : 输入验证码 - 2.1: 边界 → 控制 : 验证(验证码) - 2.1.1: 控制 → UserInfo : 验证(验证码) -3: 用户 → 边界类 : 输入(新密码1, 新密码2) - 3.1: 边界 → 控制 : 验证一致性(p1, p2) - 3.1.1: [一致] 控制 → UserInfo : 写入新密码 -``` - -🔑 **编号规则**:`1` → `1.1` → `1.1.1` 表示嵌套调用,`1.1.1` 必须在 `1.1` 完成之前完成。 - ---- - -### 第6题:"订单"的状态图(10分) - -> 画出实体类"订单"的状态图。 - -**答案要点**: - -``` -初始 → 待支付 → 已支付 → 已入住 → 完成 - ↓ ↓ - 取消 失效 -``` - -| 转移 | 触发事件 | -|------|----------| -| 初始 → 待支付 | 下单 | -| 待支付 → 取消 | 未支付 | -| 待支付 → 已支付 | 成功支付 | -| 已支付 → 失效 | 超时 | -| 已支付 → 已入住 | 入住 | -| 已入住 → 完成 | 退房 | - ---- - -### 第7题:"找回密码"的带泳道活动图(10分) - -> 画出"找回密码"的带泳道活动图。 - -**答案要点**: - -**泳道(5条)**:用户 | 找回密码边界类 | 找回密码控制类 | 短信接口 | 用户信息 - -**活动流程**: -``` -用户:开始 → 找回密码 → 输入手机号 - ↓ -控制类 → 短信接口:获取短信验证码 → 返回验证码 - ↓ -用户:输入验证码 → 输入2次新密码 - ↓ -控制类:验证两次密码 - ├─ [不一致] → 结束 - └─ [一致] → 用户信息:写入新密码 → 结束 -``` - ---- - -## 📊 七道题的分值分布与重点 - -| 题号 | 考什么 | 难度 | 必会程度 | -|------|--------|------|----------| -| 1 | 用例图 | ⭐⭐ | ★★★★★ | -| 2 | 用例描述 | ⭐ | ★★★★★ | -| 3 | 类图(实体类) | ⭐⭐ | ★★★★★ | -| 4 | 协作类图(MVC) | ⭐⭐⭐ | ★★★★ | -| 5 | 顺序图(消息编号) | ⭐⭐⭐ | ★★★★ | -| 6 | 状态图 | ⭐⭐ | ★★★★★ | -| 7 | 活动图(带泳道) | ⭐⭐⭐ | ★★★★ | - ---- - -## 🔑 考前必记要点 - -1. **包含关系 vs 扩展关系**:包含=必然,扩展=条件。箭头方向不同! -2. **多重性**:`1` `0..1` `0..*` `1..*` 的含义和标注位置 -3. **边界类/控制类/实体类**:View/Controller/Model 对应关系 -4. **消息编号层级**:`1.1.1` 表示嵌套调用深度 -5. **状态图**:别忘了初始状态(实心圆)和终态(圆圈+实心圆) -6. **活动图泳道**:根据参与者来划分,每道一个责任区 +# 期中考试复习 + +> **考试**:2022-2023学年第1学期《软件需求分析与设计》期中考试 +> **满分**:100分 | 选择题30分 + 系统分析题70分 +> **系统场景**:在线酒店订房系统 + +--- + +## 📋 试卷结构一览 + +| 题型 | 题量 | 分值 | 考点 | +|------|------|------|------| +| 选择题 | 15题 | 30分(2分/题) | UML基本概念、关系类型、面向对象特性 | +| 系统分析题 | 7题 | 70分(10分/题) | 7种UML图的实际绘制 | + +--- + +## 🎯 七道大题逐题详解 + +### 第1题:用例图(10分) + +> 画出在线订房系统的用例图。 + +**答案要点**: + +**参与者(3个)**:客户、管理员、身份信息平台 + +**用例(10个)**: +- 客户相关:找回密码、注册、身份认证、登录、查询房间、下订单、在线支付 +- 管理员相关:登录、房间上线、修改房间信息、房间下线 + +**关键关系**: +- **包含 `<>`**:注册 → 身份认证;下订单 → 查询房间;在线支付 → 下订单 +- **关联**:客户↔找回密码、注册、登录、查询房间、下订单;管理员↔登录、房间上线、修改、下线 +- **身份认证 → 身份信息平台**(外部系统交互) + +⚠️ **易错**:包含关系箭头从基本用例指向包含用例! + +--- + +### 第2题:"找回密码"用例描述(10分) + +> 写出"找回密码"的完整用例描述。 + +**参考答案**: + +| 项目 | 内容 | +|------|------| +| **用例名** | 找回密码 | +| **参与者** | 客户 | +| **前置条件** | 客户已注册 | +| **基本流程** | ①输入手机号 → ②获取短信验证码 → ③输入验证码 → ④输入新密码(两次) → ⑤两次一致则密码修改成功 | +| **备选流程** | 若两次新密码不一致,提示重新输入 | +| **后置条件** | 密码修改成功,用户可用新密码登录 | + +--- + +### 第3题:实体类类图(10分) + +> 画出系统的实体类类图,标注多重性。 + +**答案要点**: + +**实体类**:客户(Customer)、房间(Room)、订单(Order)、管理员(Admin) + +**关系及多重性**: +``` +客户 1 ────── 0..* 订单 (一个客户可以有多个订单) +房间 1 ────── 0..* 订单 (一个房间可以被多次预订) +管理员 0..2 ── 0..* 订单 (管理员处理订单) +``` + +--- + +### 第4题:"找回密码"的基于协作的类图(10分) + +> 使用边界类、控制类、实体类画出协作类图。 + +**答案要点**: + +| 版型 | 类名 | 职责 | +|------|------|------| +| <> | RetrievePasswordBoundary | 找回密码界面,与用户交互 | +| <> | RetrievePasswordControl | 协调找回密码的业务逻辑 | +| <> | SMSInterface | 短信接口(外部) | +| <> | UserInfo | 用户信息(持久化) | + +**依赖关系**: +``` +Boundary → Control → SMSInterface + → UserInfo +``` + +--- + +### 第5题:"找回密码"的顺序图(10分) + +> 画出"找回密码"的顺序图,标注消息编号。 + +**答案要点**: + +**对象**:用户、:RetrievePasswordBoundary、:RetrievePasswordControl、:UserInfo、:SMSInterface + +**消息序列(核心流程)**: +``` +1: 用户 → 边界类 : 找回密码() + 1.1: 用户 → 边界类 : 输入手机号 + 1.1.1: 边界 → 控制 : 获取短信(手机号) + 1.1.1.1: 控制 → SMS : 获取短信(手机号) +2: 用户 → 边界类 : 输入验证码 + 2.1: 边界 → 控制 : 验证(验证码) + 2.1.1: 控制 → UserInfo : 验证(验证码) +3: 用户 → 边界类 : 输入(新密码1, 新密码2) + 3.1: 边界 → 控制 : 验证一致性(p1, p2) + 3.1.1: [一致] 控制 → UserInfo : 写入新密码 +``` + +🔑 **编号规则**:`1` → `1.1` → `1.1.1` 表示嵌套调用,`1.1.1` 必须在 `1.1` 完成之前完成。 + +--- + +### 第6题:"订单"的状态图(10分) + +> 画出实体类"订单"的状态图。 + +**答案要点**: + +``` +初始 → 待支付 → 已支付 → 已入住 → 完成 + ↓ ↓ + 取消 失效 +``` + +| 转移 | 触发事件 | +|------|----------| +| 初始 → 待支付 | 下单 | +| 待支付 → 取消 | 未支付 | +| 待支付 → 已支付 | 成功支付 | +| 已支付 → 失效 | 超时 | +| 已支付 → 已入住 | 入住 | +| 已入住 → 完成 | 退房 | + +--- + +### 第7题:"找回密码"的带泳道活动图(10分) + +> 画出"找回密码"的带泳道活动图。 + +**答案要点**: + +**泳道(5条)**:用户 | 找回密码边界类 | 找回密码控制类 | 短信接口 | 用户信息 + +**活动流程**: +``` +用户:开始 → 找回密码 → 输入手机号 + ↓ +控制类 → 短信接口:获取短信验证码 → 返回验证码 + ↓ +用户:输入验证码 → 输入2次新密码 + ↓ +控制类:验证两次密码 + ├─ [不一致] → 结束 + └─ [一致] → 用户信息:写入新密码 → 结束 +``` + +--- + +## 📊 七道题的分值分布与重点 + +| 题号 | 考什么 | 难度 | 必会程度 | +|------|--------|------|----------| +| 1 | 用例图 | ⭐⭐ | ★★★★★ | +| 2 | 用例描述 | ⭐ | ★★★★★ | +| 3 | 类图(实体类) | ⭐⭐ | ★★★★★ | +| 4 | 协作类图(MVC) | ⭐⭐⭐ | ★★★★ | +| 5 | 顺序图(消息编号) | ⭐⭐⭐ | ★★★★ | +| 6 | 状态图 | ⭐⭐ | ★★★★★ | +| 7 | 活动图(带泳道) | ⭐⭐⭐ | ★★★★ | + +--- + +## 🔑 考前必记要点 + +1. **包含关系 vs 扩展关系**:包含=必然,扩展=条件。箭头方向不同! +2. **多重性**:`1` `0..1` `0..*` `1..*` 的含义和标注位置 +3. **边界类/控制类/实体类**:View/Controller/Model 对应关系 +4. **消息编号层级**:`1.1.1` 表示嵌套调用深度 +5. **状态图**:别忘了初始状态(实心圆)和终态(圆圈+实心圆) +6. **活动图泳道**:根据参与者来划分,每道一个责任区 diff --git a/软件需求分析/速查手册/UML九图速查.md b/软件需求分析/速查手册/UML九图速查.md index a86cd98..fdbc2a2 100644 --- a/软件需求分析/速查手册/UML九图速查.md +++ b/软件需求分析/速查手册/UML九图速查.md @@ -1,101 +1,101 @@ -# UML九图速查手册 - -> **用途**:考前快速回顾9种UML图的核心特征 - ---- - -## 📊 九种UML图一句话定位 - -| 想表达什么? | 用什么图? | -|--------------|-----------| -| 系统能干什么? | **用例图** | -| 系统里有哪些类?它们什么关系? | **类图** | -| 某一刻对象的具体情况? | **对象图** | -| 对象之间怎么按时间顺序交互? | **顺序图** | -| 对象之间怎么在空间上协作? | **协作图** | -| 一个对象的状态怎么变化? | **状态图** | -| 一个业务流程步骤怎么走? | **活动图** | -| 代码文件之间怎么依赖? | **组件图** | -| 软件部署在哪些硬件上? | **部署图** | - ---- - -## 🔗 九图分类 - -``` -UML图 -├── 功能模型:用例图 -├── 静态模型(结构):类图、对象图、包图 -├── 动态模型(行为):顺序图、协作图、状态图、活动图 -└── 物理模型(实现):组件图、部署图 -``` - ---- - -## 📋 九图完整对比表 - -| 图类型 | 分类 | 描述几个对象? | 强调 | 核心元素 | -|--------|------|:---:|------|----------| -| **用例图** | 功能 | 多 | 系统功能全景 | 参与者、用例、系统边界 | -| **类图** | 静态结构 | 多 | 类的定义和关系 | 类、属性、操作、关系 | -| **对象图** | 静态结构 | 多 | 某一时刻的快照 | 对象、属性值、链 | -| **包图** | 静态结构 | 多 | 类的分组组织 | 包、依赖、泛化 | -| **顺序图** | 动态交互 | 多 | 消息的时间顺序 | 对象、生命线、控制焦点、消息 | -| **协作图** | 动态交互 | 多 | 对象的空间位置 | 对象、链、消息(带序号) | -| **状态图** | 动态行为 | **1个** | 状态如何变迁 | 状态、转移、事件 | -| **活动图** | 动态行为 | 多 | 流程步骤 | 活动、分支、泳道、分叉 | -| **组件图** | 物理实现 | 多 | 代码文件组织 | 组件、接口、依赖 | -| **部署图** | 物理实现 | 多 | 硬件拓扑 | 结点、组件、连接 | - ---- - -## 🎨 各图关键语法速查 - -### 用例图 -- 参与者 = 火柴人 🧑 -- 用例 = 椭圆 ○ -- 系统边界 = 方框 □ -- 关系:包含 `<>` (必然)、扩展 `<>` (条件) - -### 类图 -- 类 = 三格矩形(类名|属性|操作) -- 可见性:`+`公有 `-`私有 `#`受保护 -- 六大关系:关联(实线)、聚合(空心菱形)、组合(实心菱形)、依赖(虚线)、泛化(空心三角)、实现(虚线空心三角) - -### 顺序图 -- 对象 + 生命线(虚线) + 控制焦点(矩形条) + 消息(箭头) -- 调用消息 = 实心箭头,异步消息 = 开放箭头 -- 嵌套:1 → 1.1 → 1.1.1 - -### 协作图 -- 对象 + 链(实线) + 消息(带序号) -- 必须标注消息序号!没有时间轴 - -### 状态图 -- 状态 = 圆角矩形,转移 = 箭头 -- 事件/ [条件] / 动作 -- 初始状态 ● → 终态 ⊙ - -### 活动图 -- 活动 = 圆角矩形 -- 分支 = 菱形 ◇ -- 分叉/汇合 = 粗横线 -- 泳道 = 纵向分区 - -### 组件图 -- 组件 = 带<>的矩形 -- 关系主要是依赖(虚线箭头) - -### 部署图 -- 结点 = 3D立方体 -- 通信路径 = 实线 - ---- - -## 📐 MVC版型速查 - -| 版型 | 图标 | 职责 | 例子 | -|------|------|------|------| -| <> 边界类 | 带竖线的圆 | 与外界交互 | 登录页面、购物车页面 | -| <> 控制类 | 带箭头的圆 | 协调逻辑 | 订单处理、支付验证 | -| <> 实体类 | 带横线的圆 | 持久化数据 | 用户、订单、商品 | +# UML九图速查手册 + +> **用途**:考前快速回顾9种UML图的核心特征 + +--- + +## 📊 九种UML图一句话定位 + +| 想表达什么? | 用什么图? | +|--------------|-----------| +| 系统能干什么? | **用例图** | +| 系统里有哪些类?它们什么关系? | **类图** | +| 某一刻对象的具体情况? | **对象图** | +| 对象之间怎么按时间顺序交互? | **顺序图** | +| 对象之间怎么在空间上协作? | **协作图** | +| 一个对象的状态怎么变化? | **状态图** | +| 一个业务流程步骤怎么走? | **活动图** | +| 代码文件之间怎么依赖? | **组件图** | +| 软件部署在哪些硬件上? | **部署图** | + +--- + +## 🔗 九图分类 + +``` +UML图 +├── 功能模型:用例图 +├── 静态模型(结构):类图、对象图、包图 +├── 动态模型(行为):顺序图、协作图、状态图、活动图 +└── 物理模型(实现):组件图、部署图 +``` + +--- + +## 📋 九图完整对比表 + +| 图类型 | 分类 | 描述几个对象? | 强调 | 核心元素 | +|--------|------|:---:|------|----------| +| **用例图** | 功能 | 多 | 系统功能全景 | 参与者、用例、系统边界 | +| **类图** | 静态结构 | 多 | 类的定义和关系 | 类、属性、操作、关系 | +| **对象图** | 静态结构 | 多 | 某一时刻的快照 | 对象、属性值、链 | +| **包图** | 静态结构 | 多 | 类的分组组织 | 包、依赖、泛化 | +| **顺序图** | 动态交互 | 多 | 消息的时间顺序 | 对象、生命线、控制焦点、消息 | +| **协作图** | 动态交互 | 多 | 对象的空间位置 | 对象、链、消息(带序号) | +| **状态图** | 动态行为 | **1个** | 状态如何变迁 | 状态、转移、事件 | +| **活动图** | 动态行为 | 多 | 流程步骤 | 活动、分支、泳道、分叉 | +| **组件图** | 物理实现 | 多 | 代码文件组织 | 组件、接口、依赖 | +| **部署图** | 物理实现 | 多 | 硬件拓扑 | 结点、组件、连接 | + +--- + +## 🎨 各图关键语法速查 + +### 用例图 +- 参与者 = 火柴人 🧑 +- 用例 = 椭圆 ○ +- 系统边界 = 方框 □ +- 关系:包含 `<>` (必然)、扩展 `<>` (条件) + +### 类图 +- 类 = 三格矩形(类名|属性|操作) +- 可见性:`+`公有 `-`私有 `#`受保护 +- 六大关系:关联(实线)、聚合(空心菱形)、组合(实心菱形)、依赖(虚线)、泛化(空心三角)、实现(虚线空心三角) + +### 顺序图 +- 对象 + 生命线(虚线) + 控制焦点(矩形条) + 消息(箭头) +- 调用消息 = 实心箭头,异步消息 = 开放箭头 +- 嵌套:1 → 1.1 → 1.1.1 + +### 协作图 +- 对象 + 链(实线) + 消息(带序号) +- 必须标注消息序号!没有时间轴 + +### 状态图 +- 状态 = 圆角矩形,转移 = 箭头 +- 事件/ [条件] / 动作 +- 初始状态 ● → 终态 ⊙ + +### 活动图 +- 活动 = 圆角矩形 +- 分支 = 菱形 ◇ +- 分叉/汇合 = 粗横线 +- 泳道 = 纵向分区 + +### 组件图 +- 组件 = 带<>的矩形 +- 关系主要是依赖(虚线箭头) + +### 部署图 +- 结点 = 3D立方体 +- 通信路径 = 实线 + +--- + +## 📐 MVC版型速查 + +| 版型 | 图标 | 职责 | 例子 | +|------|------|------|------| +| <> 边界类 | 带竖线的圆 | 与外界交互 | 登录页面、购物车页面 | +| <> 控制类 | 带箭头的圆 | 协调逻辑 | 订单处理、支付验证 | +| <> 实体类 | 带横线的圆 | 持久化数据 | 用户、订单、商品 | diff --git a/软件需求分析/速查手册/设计原则与术语速查.md b/软件需求分析/速查手册/设计原则与术语速查.md index 662ef3f..89db90b 100644 --- a/软件需求分析/速查手册/设计原则与术语速查.md +++ b/软件需求分析/速查手册/设计原则与术语速查.md @@ -1,110 +1,110 @@ -# 设计原则与术语速查 - -> **用途**:考前快速回顾关键设计原则和术语 - ---- - -## 🔷 SOLID五大原则 - -| 原则 | 全称 | 含义 | -|------|------|------| -| **S** | 单一职责原则 | 一个类只负责一件事 | -| **O** | 开闭原则 | 对扩展开放,对修改关闭 | -| **L** | 里氏替换原则 | 子类可以替换父类而不出问题 | -| **I** | 接口隔离原则 | 接口要小而精,不要大而全 | -| **D** | 依赖倒置原则 | 依赖抽象(接口),不依赖具体实现 | - ---- - -## 🔷 包设计四大原则 - -| 缩写 | 原则 | 含义 | -|------|------|------| -| **REP** | 重用等价原则 | 可一起复用的类放一个包 | -| **CCP** | 共同闭包原则 | 需要同时修改的类放一个包 | -| **CRP** | 共同重用原则 | 不会一起用的类分开放 | -| **ADP** | 非循环依赖原则 | 包之间的依赖不能形成环 | - ---- - -## 🔷 其他重要原则 - -| 原则 | 含义 | -|------|------| -| **高内聚低耦合** | 包内关系紧密(内聚),包间关系稀疏(耦合) | -| **迪米特法则** | 最少知识原则——只跟"朋友"说话 | -| **组合优于继承** | 能用组合实现的效果,别用继承(更灵活) | - ---- - -## 🔷 数据库三大范式 - -| 范式 | 要求 | -|------|------| -| **1NF** | 字段值不可再分(原子性) | -| **2NF** | 非主键字段完全依赖主键(消除部分依赖) | -| **3NF** | 非主键字段不传递依赖主键(消除传递依赖) | - ---- - -## 🔷 对象模型→数据模型映射速查 - -| 对象模型 | 数据模型 | -|----------|----------| -| 类 | 表 | -| 属性 | 字段(列) | -| 操作 | 触发器 + 存储过程 | -| 1对0..*关联 | 多的一端加外键 | -| *对*关联 | 新建关联表 | -| 泛化关系 | 三种方案(父子各建表/只建子表/只建父表) | - ---- - -## 🔷 需求工程术语速查 - -| 术语 | 定义 | -|------|------| -| **软件需求** | 用户对系统在功能、行为、性能、约束方面的期望 | -| **业务需求** | 组织的高层次目标 | -| **用户需求** | 用户使用系统完成的任务 | -| **功能需求** | 系统必须实现的功能 | -| **非功能需求** | 系统的质量属性和约束 | -| **SRS** | 软件需求规格说明书 | -| **需求基线** | 评审通过、双方承诺的需求版本 | -| **需求跟踪矩阵(RTM)** | 记录需求与设计、代码、测试对应关系的表 | -| **需求变更控制** | 变更申请→审批→修改→重新确认的流程 | - ---- - -## 🔷 面向对象核心概念 - -| 概念 | 含义 | -|------|------| -| **封装** | 隐藏内部实现,只暴露接口 | -| **继承** | 子类自动拥有父类的属性和方法 | -| **多态** | 同一操作在不同对象上有不同行为 | -| **抽象** | 提取共性,忽略细节 | - ---- - -## 🔷 关联关系多重性速记 - -| 符号 | 含义 | -|------|------| -| `1` | 恰好1个 | -| `0..1` | 0个或1个 | -| `*` 或 `0..*` | 0个或多个 | -| `1..*` | 至少1个 | -| `n` | 恰好n个 | - ---- - -## 🔷 常见设计模式速查 - -| 模式 | 类型 | 解决什么问题 | -|------|------|-------------| -| **策略模式** | 行为型 | 同一功能有多种算法实现,可动态切换 | -| **工厂方法** | 创建型 | 创建对象时不指定具体类 | -| **组合模式** | 结构型 | 统一处理整体和部分(如文件系统目录和文件) | -| **DAO模式** | 数据访问 | 分离数据访问逻辑和业务逻辑 | -| **MVC模式** | 架构型 | 分离界面(View)、逻辑(Controller)、数据(Model) | +# 设计原则与术语速查 + +> **用途**:考前快速回顾关键设计原则和术语 + +--- + +## 🔷 SOLID五大原则 + +| 原则 | 全称 | 含义 | +|------|------|------| +| **S** | 单一职责原则 | 一个类只负责一件事 | +| **O** | 开闭原则 | 对扩展开放,对修改关闭 | +| **L** | 里氏替换原则 | 子类可以替换父类而不出问题 | +| **I** | 接口隔离原则 | 接口要小而精,不要大而全 | +| **D** | 依赖倒置原则 | 依赖抽象(接口),不依赖具体实现 | + +--- + +## 🔷 包设计四大原则 + +| 缩写 | 原则 | 含义 | +|------|------|------| +| **REP** | 重用等价原则 | 可一起复用的类放一个包 | +| **CCP** | 共同闭包原则 | 需要同时修改的类放一个包 | +| **CRP** | 共同重用原则 | 不会一起用的类分开放 | +| **ADP** | 非循环依赖原则 | 包之间的依赖不能形成环 | + +--- + +## 🔷 其他重要原则 + +| 原则 | 含义 | +|------|------| +| **高内聚低耦合** | 包内关系紧密(内聚),包间关系稀疏(耦合) | +| **迪米特法则** | 最少知识原则——只跟"朋友"说话 | +| **组合优于继承** | 能用组合实现的效果,别用继承(更灵活) | + +--- + +## 🔷 数据库三大范式 + +| 范式 | 要求 | +|------|------| +| **1NF** | 字段值不可再分(原子性) | +| **2NF** | 非主键字段完全依赖主键(消除部分依赖) | +| **3NF** | 非主键字段不传递依赖主键(消除传递依赖) | + +--- + +## 🔷 对象模型→数据模型映射速查 + +| 对象模型 | 数据模型 | +|----------|----------| +| 类 | 表 | +| 属性 | 字段(列) | +| 操作 | 触发器 + 存储过程 | +| 1对0..*关联 | 多的一端加外键 | +| *对*关联 | 新建关联表 | +| 泛化关系 | 三种方案(父子各建表/只建子表/只建父表) | + +--- + +## 🔷 需求工程术语速查 + +| 术语 | 定义 | +|------|------| +| **软件需求** | 用户对系统在功能、行为、性能、约束方面的期望 | +| **业务需求** | 组织的高层次目标 | +| **用户需求** | 用户使用系统完成的任务 | +| **功能需求** | 系统必须实现的功能 | +| **非功能需求** | 系统的质量属性和约束 | +| **SRS** | 软件需求规格说明书 | +| **需求基线** | 评审通过、双方承诺的需求版本 | +| **需求跟踪矩阵(RTM)** | 记录需求与设计、代码、测试对应关系的表 | +| **需求变更控制** | 变更申请→审批→修改→重新确认的流程 | + +--- + +## 🔷 面向对象核心概念 + +| 概念 | 含义 | +|------|------| +| **封装** | 隐藏内部实现,只暴露接口 | +| **继承** | 子类自动拥有父类的属性和方法 | +| **多态** | 同一操作在不同对象上有不同行为 | +| **抽象** | 提取共性,忽略细节 | + +--- + +## 🔷 关联关系多重性速记 + +| 符号 | 含义 | +|------|------| +| `1` | 恰好1个 | +| `0..1` | 0个或1个 | +| `*` 或 `0..*` | 0个或多个 | +| `1..*` | 至少1个 | +| `n` | 恰好n个 | + +--- + +## 🔷 常见设计模式速查 + +| 模式 | 类型 | 解决什么问题 | +|------|------|-------------| +| **策略模式** | 行为型 | 同一功能有多种算法实现,可动态切换 | +| **工厂方法** | 创建型 | 创建对象时不指定具体类 | +| **组合模式** | 结构型 | 统一处理整体和部分(如文件系统目录和文件) | +| **DAO模式** | 数据访问 | 分离数据访问逻辑和业务逻辑 | +| **MVC模式** | 架构型 | 分离界面(View)、逻辑(Controller)、数据(Model) |