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

18 KiB
Raw Permalink Blame History

第04讲文件IO编程

🎯 本节目标掌握UNIX文件IO系统调用的使用方法理解文件描述符、文件共享、mmap内存映射和I/O重定向机制

📋 前置知识


🤔 为什么需要这个?

程序要读写文件、处理数据必须通过操作系统的I/O接口。UNIX提供了一套简洁而强大的系统调用open/read/write/close/lseek几乎所有的Linux程序都建立在这套接口之上。Shell的重定向机制、数据库的存储引擎、Web服务器的文件传输底层都依赖这些原语。

生活比喻

  • 文件描述符就像取餐号牌你去餐厅点餐open服务员给你一个号牌fd之后你用号牌取餐read/write吃完归还号牌close
  • lseek就像唱片针头移动:可以跳到唱片的任意位置开始播放

📖 核心概念

1. UNIX IO函数

Linux将所有I/O设备都视为文件,统一通过文件描述符进行操作。内核为每个进程维护一个文件描述符表从0开始编号。

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() - 打开/创建文件

#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() - 关闭文件

int close(int fd);
// 关闭文件描述符,释放内核资源

read() / write() - 读写文件

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开始。

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. 文件共享

当多个进程打开同一个文件时,内核通过三层数据结构实现共享:

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 修改文件的当前偏移量,实现随机访问:

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获取文件大小

off_t size = lseek(fd, 0, SEEK_END);

利用lseek创建空洞文件

lseek(fd, 1024*1024, SEEK_SET);  // 跳到1MB位置
write(fd, "X", 1);               // 写1字节
// 文件大小为 1MB+1中间全是空洞'\0'

4. mmap内存映射

mmap 将文件直接映射到进程的虚拟地址空间之后可以像访问内存一样读写文件无需read/write系统调用。

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
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() - 复制文件描述符

int dup(int oldfd);
// 返回:新的文件描述符(取当前最小可用值)

dup2() - 原子复制并重定向

int dup2(int oldfd, int newfd);
// 先关闭newfd再将oldfd复制到newfd
// 返回newfd失败返回-1
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

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 - 结构体二进制读写

在操作系统实验中,经常需要将结构体数据以二进制格式写入文件并读回:

// 写入结构体数组
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的基本用法

// 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);
}

编译运行

gcc -o fcopy1 fcopy1.c -L. -lwrapper
echo "Hello, UNIX IO!" > file.in
./fcopy1
cat file.out    # 输出: Hello, UNIX IO!

关键点

  • OpenReadWriteClose 是wrapper.h提供的带错误检查的包装函数
  • O_CREAT 标志需要提供第三个参数(文件权限)
  • 逐字节拷贝效率很低,实际应用应使用缓冲区

示例2块缓冲文件拷贝fcopy2.c

改用1024字节的缓冲区大幅提升性能

// 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

演示文件随机读写:

// 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

将整个文件映射到内存,通过指针直接读取:

// 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);
}

编译运行

gcc -o mmap1 mmap1.c -L. -lwrapper
./mmap1

关键点

  • fstat() 获取文件大小,确定映射长度
  • PROT_READ 表示只读映射,写入会触发段错误
  • MAP_PRIVATE 表示私有映射(写时复制),修改不会影响原文件
  • 访问映射区域时触发缺页中断,内核按需加载文件数据

示例5dup2实现I/O重定向dup2.c

演示如何将stdout重定向到文件再恢复

// 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.cfread1.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);
}
// 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

// 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开始分配。


🔗 知识关联


📝 思考题

  1. 概念理解题文件描述符、文件表、v-node表三者的关系是什么同一个文件被同一个进程打开两次和被两个不同进程各打开一次有什么区别
  2. 代码分析题:以下代码的输出是什么?为什么?
    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/