Files
obsidian/操作系统/04_文件IO编程/04_文件IO编程.md

620 lines
18 KiB
Markdown
Raw Normal View History

2026-06-13 23:46:22 +08:00
# 第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 <fcntl.h>
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 |
|------|---------------|---------|
| 头文件 | `<stdio.h>` | `<unistd.h>`, `<fcntl.h>` |
| 缓冲 | 用户空间缓冲(默认行缓冲/全缓冲) | 无用户缓冲 |
| 性能 | 频繁小IO时快减少系统调用 | 大块IO时相当 |
| 可移植性 | 高ANSI C标准 | 低POSIXLinux/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字节缓冲区效率更高
### 示例3lseek随机访问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...
```
### 示例4mmap内存映射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` 表示私有映射(写时复制),修改不会影响原文件
- 访问映射区域时触发缺页中断,内核按需加载文件数据
### 示例5dup2实现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); // 保存原始stdoutfd=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/`