# 第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标准） | 低（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/`
