# 第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/`