2UNIX环境高级编程(第3版)

文件I/O

底层I/O操作

文件描述符openreadwrite

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第2章:文件I/O(File I/O)

一、导读

1.1 本章学习目标

第2章深入讲解UNIX的底层I/O模型,这是整个UNIX I/O体系的基础。UNIX将一切设备都抽象为文件,通过统一的文件描述符接口进行操作。这种设计使得程序员可以用相同的方式操作磁盘文件、终端、管道、网络设备等各种资源。

本章的核心学习目标包括:

  • 掌握五个核心系统调用:open、read、write、lseek、close,它们是UNIX I/O的基石
  • 理解文件描述符的本质:文件描述符是进程级的索引,指向内核中的文件表条目
  • 掌握文件偏移量的概念:每次读写操作后偏移量如何变化?如何用lseek显式控制偏移量?
  • 理解I/O效率问题:缓冲区大小如何影响I/O性能?如何选择合适的缓冲区大小?
  • 掌握文件描述符复制:dup和dup2如何实现I/O重定向?
  • 处理部分读写:为什么read/write可能返回部分结果?如何编写健壮的I/O代码?

1.2 为什么本章如此重要

文件I/O是UNIX编程中最基本的操作之一。无论是读写配置文件、处理用户数据、还是进行网络通信,都离不开I/O操作。本章讲解的底层I/O模型是后续章节的基础:

  • 第3章(标准I/O库)建立在底层I/O之上,提供了更高级的缓冲接口
  • 第4章(文件和目录)的文件操作最终都要通过I/O系统调用完成
  • 第10章(信号)中的I/O多路复用依赖于对文件描述符的深入理解
  • 网络编程中的套接字I/O也使用read/write系统调用

更重要的是,底层I/O的性能特性直接影响程序的整体性能。理解系统调用的开销、缓冲区大小的选择、部分读写的处理,是编写高效、健壮I/O代码的前提。

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

open返回的文件描述符到底代表什么?内核是如何管理打开的文件的?

为什么read可能返回的字节数少于请求的字节数?这种情况应该如何处理?

lseek的SEEK_END为什么不能用于管道和套接字?

缓冲区大小选择4096字节(一个页大小)有什么特殊原因?

dup2(fd, 1)和close(1); dup(fd)的效果是否完全相同?为什么?

什么是原子操作?在什么场景下原子操作至关重要?


二、核心概念详解

2.1 文件描述符

文件描述符(file descriptor)是一个非负整数,用于标识进程打开的文件。在UNIX内核中,每个进程都维护一张文件描述符表,表中的每个条目指向内核中的一个文件表条目(file table entry)。

内核中的文件相关数据结构有三个层次:

进程的文件描述符表:每个进程独立拥有,描述符是表的下标

内核的文件表:记录文件偏移量、访问模式、引用计数等

内核的v-node表:记录文件的实际信息(inode),包括文件大小、数据块位置等

c
// 文件描述符的典型使用模式
#include <fcntl.h>
#include <unistd.h>

int fd = open("data.txt", O_RDONLY);  // 返回文件描述符
if (fd == -1) {
    perror("open");
    exit(1);
}

char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));  // 通过描述符读写
close(fd);  // 关闭描述符,释放资源

2.1.1 文件描述符的分配规则

open系统调用总是返回当前进程文件描述符表中最小可用的描述符。这个特性看似简单,却是I/O重定向的基础:

c
// I/O重定向的经典技巧
close(STDOUT_FILENO);  // 关闭fd 1
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// open返回1(最小可用描述符),因为0已打开,1刚被关闭
// 此后所有写到fd 1的数据都会写入output.txt
printf("This goes to file\n");  // printf内部使用write(1, ...)

2.1.2 默认的三个文件描述符

每个进程启动时,内核默认打开三个文件描述符:

描述符常量含义默认指向
0STDIN_FILENO标准输入终端键盘
1STDOUT_FILENO标准输出终端屏幕
2STDERR_FILENO标准错误终端屏幕

这三个描述符由shell在启动程序时设置。通过I/O重定向(>、<、2>等),shell可以改变它们的指向。

2.2 open系统调用

open系统调用用于打开或创建文件:

c
#include <fcntl.h>
#include <unistd.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

2.2.1 flags参数

flags参数指定文件的打开模式,必须包含以下三个访问模式之一:

  • O_RDONLY:只读打开
  • O_WRONLY:只写打开
  • O_RDWR:读写打开

可以用OR运算符组合以下可选标志:

  • O_CREAT:文件不存在时创建,需要提供mode参数
  • O_TRUNC:如果文件存在且为普通文件,将其长度截断为0
  • O_APPEND:每次写操作前将文件偏移量设置到文件末尾
  • O_EXCL:与O_CREAT一起使用,如果文件已存在则出错(原子检查)
  • O_NONBLOCK:非阻塞模式
  • O_SYNC:同步I/O,每次写操作等待数据物理写入
c
// 只读打开
int fd = open("file.txt", O_RDONLY);

// 创建新文件,如果已存在则出错
int fd = open("newfile.txt", O_WRONLY | O_CREAT | O_EXCL, 0644);

// 追加模式打开
int fd = open("log.txt", O_WRONLY | O_APPEND);

// 截断已有文件
int fd = open("data.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);

2.2.2 mode参数

当使用O_CREAT标志时,必须提供mode参数指定新文件的访问权限。mode受进程的umask影响,实际权限为mode & ~umask。

c
#include <sys/stat.h>
#include <fcntl.h>

// 设置umask
umask(022);  // 权限掩码为022

// 创建文件,mode为0666,实际权限为0666 & ~022 = 0644
int fd = open("file.txt", O_CREAT | O_WRONLY, 0666);
// 实际权限:rw-r--r--

权限位的含义:

位含义数值
S_IRUSR所有者读0400
S_IWUSR所有者写0200
S_IXUSR所有者执行0100
S_IRGRP组读0040
S_IWGRP组写0020
S_IXGRP组执行0010
S_IROTH其他读0004
S_IWOTH其他写0002
S_IXOTH其他执行0001

2.3 read和write系统调用

2.3.1 read系统调用

c
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

read从文件描述符fd中读取最多count字节到buf中。返回值含义:

  • >0:实际读取的字节数(可能小于count)
  • =0:到达文件末尾
  • =-1:出错,errno指示错误原因

为什么read可能返回部分结果?

读取普通文件时,读到文件末尾之前的字节数不足count

从终端读取时,通常一次读取一行

从网络读取时,网络缓冲可能没有count字节的数据

读取管道时,管道中的数据不足count

被信号中断

c
// 健壮的文件读取——处理部分读取
ssize_t readn(int fd, void *buf, size_t n) {
    size_t left = n;
    char *p = buf;
    while (left > 0) {
        ssize_t nread = read(fd, p, left);
        if (nread == 0)  // EOF
            break;
        if (nread == -1) {
            if (errno == EINTR)  // 被信号中断,继续读
                continue;
            return -1;  // 真正的错误
        }
        left -= nread;
        p += nread;
    }
    return n - left;
}

2.3.2 write系统调用

c
#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

write将buf中的count字节写入文件描述符fd。返回值含义:

  • >0:实际写入的字节数(可能小于count)
  • =-1:出错

为什么write可能返回部分结果?

磁盘空间不足

超过进程的文件大小限制(RLIMIT_FSIZE)

被信号中断

管道或套接字的缓冲区满

c
// 健壮的写入——处理部分写入
ssize_t writen(int fd, const void *buf, size_t n) {
    size_t left = n;
    const char *p = buf;
    while (left > 0) {
        ssize_t nwritten = write(fd, p, left);
        if (nwritten <= 0) {
            if (nwritten < 0 && errno == EINTR)
                continue;  // 被信号中断,继续写
            return -1;  // 真正的错误
        }
        left -= nwritten;
        p += nwritten;
    }
    return n;
}

注意:对于普通文件,如果文件偏移量加上写入字节数不超过文件大小,write通常不会返回部分结果。部分写入主要发生在管道、FIFO、网络套接字等场景。

2.4 lseek与文件偏移量

每个打开的文件都有一个文件偏移量(file offset),表示当前读写的位置。read和write操作从文件偏移量处开始,操作完成后偏移量增加读写的字节数。

c
#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);

lseek显式设置文件偏移量,whence参数指定基准位置:

  • SEEK_SET:从文件开头偏移offset字节
  • SEEK_CUR:从当前位置偏移offset字节
  • SEEK_END:从文件末尾偏移offset字节
c
#include <fcntl.h>
#include <unistd.h>

int main() {
    int fd = open("data.txt", O_RDWR);
    
    // 获取当前偏移量
    off_t pos = lseek(fd, 0, SEEK_CUR);
    printf("Current offset: %ld\n", pos);
    
    // 移动到文件开头
    lseek(fd, 0, SEEK_SET);
    
    // 移动到文件末尾
    lseek(fd, 0, SEEK_END);
    
    // 获取文件大小
    off_t size = lseek(fd, 0, SEEK_END);
    printf("File size: %ld\n", size);
    
    // 在文件中"打洞"(创建空洞文件)
    lseek(fd, 1000000, SEEK_SET);
    write(fd, "X", 1);  // 文件变为1000001字节,中间是空洞
    
    close(fd);
    return 0;
}

空洞文件(holey file)是UNIX文件系统的一个重要特性。空洞不占用磁盘空间,但逻辑上文件包含了这些字节(值为0)。这在创建稀疏文件时非常有用。

bash
# 查看文件的逻辑大小和实际占用空间
ls -l data.txt      # 逻辑大小:1000001字节
du -h data.txt      # 实际占用:可能只有几KB

注意:lseek不能用于管道、FIFO、套接字等不支持随机访问的设备。对这些设备使用lseek会返回-1并设置errno为ESPIPE。

2.5 dup和dup2

dup和dup2用于复制文件描述符:

c
#include <unistd.h>

int dup(int oldfd);
int dup2(int oldfd, int newfd);
  • dup(oldfd)返回一个新的文件描述符,指向与oldfd相同的文件表条目。新描述符是当前最小可用的描述符。
  • dup2(oldfd, newfd)将newfd指向oldfd的文件。如果newfd已经打开,先关闭它。
c
// dup2的经典用法:I/O重定向
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);  // 将标准输出重定向到文件
close(fd);  // 关闭原始描述符,STDOUT_FILENO仍然有效

printf("This goes to output.txt\n");  // printf使用fd 1

dup2(fd, 1)和close(1); dup(fd)的效果看似相同,但有一个关键区别:dup2是原子操作,而close + dup不是。在多线程环境中,close + dup之间可能被其他线程的open打断,导致dup返回错误的描述符。

2.6 I/O效率

缓冲区大小对I/O性能有显著影响。以下程序测试不同缓冲区大小的性能:

c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <sys/time.h>

double get_time() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + tv.tv_usec / 1000000.0;
}

void copy_file(const char *src, const char *dst, size_t buf_size) {
    int sfd = open(src, O_RDONLY);
    int dfd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    char *buf = malloc(buf_size);
    ssize_t n;
    
    while ((n = read(sfd, buf, buf_size)) > 0) {
        // 处理部分写入
        ssize_t written = 0;
        while (written < n) {
            ssize_t w = write(dfd, buf + written, n - written);
            if (w <= 0) break;
            written += w;
        }
    }
    
    free(buf);
    close(sfd);
    close(dfd);
}

int main(int argc, char *argv[]) {
    if (argc != 4) {
        fprintf(stderr, "Usage: %s <buf_size> <src> <dst>\n", argv[0]);
        exit(1);
    }
    
    size_t buf_size = atoi(argv[1]);
    double start = get_time();
    copy_file(argv[2], argv[3], buf_size);
    double end = get_time();
    
    printf("Buffer: %zu bytes, Time: %.4f seconds\n", 
           buf_size, end - start);
    return 0;
}

典型测试结果(复制100MB文件):

缓冲区大小时间(秒)系统调用次数
145.2200,000,000
5120.85390,625
40960.1548,828
655360.123,052
10485760.11191

分析:

缓冲区从1字节增大到4096字节,性能提升约300倍

缓冲区从4096字节增大到65536字节,性能提升约20%

缓冲区继续增大,性能提升趋于平缓

4096字节(一个页大小)通常是最佳选择,因为它是内核页大小的整数倍

2.7 内核数据结构

理解内核如何管理打开的文件,有助于深入理解文件I/O的行为:

进程A的文件描述符表      内核的文件表          内核的v-node表
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ fd 0 → ptr ──┼────→│ offset=100   │────→│ inode info   │
│ fd 1 → ptr ──┼────→│ offset=0     │     │ file size    │
│ fd 2 → ptr ──┼────→│ access mode  │     │ data blocks  │
│ fd 3 → ptr ──┼──┐  │ ref count=1  │     └──────────────┘
└──────────────┘  │  └──────────────┘
                  │
进程B的文件描述符表  │  ┌──────────────┐     ┌──────────────┐
┌──────────────┐  └─→│ offset=500   │────→│ same inode   │
│ fd 0 → ptr ──┼────→│ access mode  │     └──────────────┘
│ fd 3 → ptr ──┼──┘  │ ref count=2  │
└──────────────┘     └──────────────┘

关键点:

两个进程打开同一个文件,各自有独立的文件表条目和偏移量

fork后父子进程共享文件表条目,偏移量是共享的

dup/dup2创建的新描述符指向同一个文件表条目

O_APPEND标志在每次write前原子地将偏移量设置到文件末尾


三、实战案例

3.1 健壮的文件复制程序

c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>

#define BUF_SIZE 4096

void error_exit(const char *msg) {
    perror(msg);
    exit(EXIT_FAILURE);
}

ssize_t readn(int fd, void *buf, size_t count) {
    size_t left = count;
    char *p = buf;
    while (left > 0) {
        ssize_t n = read(fd, p, left);
        if (n == 0) break;
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        left -= n;
        p += n;
    }
    return count - left;
}

ssize_t writen(int fd, const void *buf, size_t count) {
    size_t left = count;
    const char *p = buf;
    while (left > 0) {
        ssize_t n = write(fd, p, left);
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        left -= n;
        p += n;
    }
    return count;
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <source> <destination>\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    
    int src = open(argv[1], O_RDONLY);
    if (src == -1) error_exit("open source");
    
    struct stat st;
    fstat(src, &st);
    
    int dst = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, st.st_mode);
    if (dst == -1) {
        close(src);
        error_exit("open destination");
    }
    
    char buf[BUF_SIZE];
    ssize_t n;
    off_t total = 0;
    
    while ((n = read(src, buf, BUF_SIZE)) > 0) {
        if (writen(dst, buf, n) != n) {
            error_exit("write");
        }
        total += n;
    }
    
    if (n == -1) error_exit("read");
    
    printf("Copied %ld bytes from %s to %s\n", total, argv[1], argv[2]);
    
    close(src);
    close(dst);
    return 0;
}

3.2 文件锁实现

c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    int fd = open("lockfile.txt", O_WRONLY | O_CREAT, 0644);
    if (fd == -1) {
        perror("open");
        exit(1);
    }
    
    struct flock fl;
    fl.l_type = F_WRLCK;     // 写锁
    fl.l_whence = SEEK_SET;
    fl.l_start = 0;
    fl.l_len = 0;            // 锁定整个文件
    
    // 尝试获取锁(非阻塞)
    if (fcntl(fd, F_SETLK, &fl) == -1) {
        if (errno == EACCES || errno == EAGAIN) {
            printf("File is locked by another process\n");
            close(fd);
            exit(1);
        }
        perror("fcntl");
        exit(1);
    }
    
    printf("Lock acquired. Press Enter to release...\n");
    getchar();
    
    // 释放锁
    fl.l_type = F_UNLCK;
    fcntl(fd, F_SETLK, &fl);
    
    close(fd);
    return 0;
}

3.3 使用O_APPEND实现安全的并发追加

c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <logfile>\n", argv[0]);
        exit(1);
    }
    
    int fd = open(argv[1], O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (fd == -1) {
        perror("open");
        exit(1);
    }
    
    for (int i = 0; i < 10; i++) {
        time_t now = time(NULL);
        char buf[256];
        int len = snprintf(buf, sizeof(buf), 
                          "PID %d: log entry %d at %s",
                          getpid(), i, ctime(&now));
        
        // O_APPEND保证每次write原子地追加到文件末尾
        write(fd, buf, len);
        sleep(1);
    }
    
    close(fd);
    return 0;
}

四、本章小结

第2章深入讲解了UNIX的底层I/O模型。核心知识点包括:

文件描述符:非负整数,标识进程打开的文件。内核通过文件描述符表、文件表、v-node表三层结构管理打开的文件。

五个核心系统调用:open打开/创建文件,read读取数据,write写入数据,lseek设置偏移量,close关闭文件。

部分读写:read/write可能返回部分结果,健壮的I/O代码必须用循环处理。

文件偏移量:每次读写后自动增加,lseek可显式控制。O_APPEND在每次写前原子地设置到末尾。

I/O效率:缓冲区大小显著影响性能,4096字节(页大小)通常是最佳选择。

dup/dup2:复制文件描述符,是I/O重定向的基础。dup2是原子操作,多线程环境下更安全。


五、思考与练习

思考题

为什么O_APPEND的"先定位到末尾再写入"必须是原子操作?如果不是原子操作,并发追加会出现什么问题?

fork后父子进程共享文件表条目意味着什么?如果一个进程读取了一些数据,另一个进程的偏移量会如何变化?

为什么read从终端设备读取时通常一次返回一行?这与从普通文件读取有什么不同?

空洞文件的空洞部分是否占用磁盘空间?如何验证?

编程练习

编写一个程序,使用lseek创建一个1GB的空洞文件,验证它实际占用的磁盘空间。

实现一个readn函数,确保读取指定数量的字节(或到达EOF),处理EINTR错误。

编写一个程序,打开同一个文件两次(两个不同的open调用),验证两个文件描述符的偏移量是独立的。

使用dup2实现shell的输出重定向功能(>操作符)。


2/12文件I/
0%