第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),包括文件大小、数据块位置等
// 文件描述符的典型使用模式
#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重定向的基础:
// 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 默认的三个文件描述符
每个进程启动时,内核默认打开三个文件描述符:
| 描述符 | 常量 | 含义 | 默认指向 |
|---|---|---|---|
| 0 | STDIN_FILENO | 标准输入 | 终端键盘 |
| 1 | STDOUT_FILENO | 标准输出 | 终端屏幕 |
| 2 | STDERR_FILENO | 标准错误 | 终端屏幕 |
这三个描述符由shell在启动程序时设置。通过I/O重定向(>、<、2>等),shell可以改变它们的指向。
2.2 open系统调用
open系统调用用于打开或创建文件:
#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,每次写操作等待数据物理写入
// 只读打开
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。
#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系统调用
#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
被信号中断
// 健壮的文件读取——处理部分读取
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系统调用
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);write将buf中的count字节写入文件描述符fd。返回值含义:
- >0:实际写入的字节数(可能小于
count) - =-1:出错
为什么write可能返回部分结果?
磁盘空间不足
超过进程的文件大小限制(RLIMIT_FSIZE)
被信号中断
管道或套接字的缓冲区满
// 健壮的写入——处理部分写入
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操作从文件偏移量处开始,操作完成后偏移量增加读写的字节数。
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);lseek显式设置文件偏移量,whence参数指定基准位置:
- SEEK_SET:从文件开头偏移
offset字节 - SEEK_CUR:从当前位置偏移
offset字节 - SEEK_END:从文件末尾偏移
offset字节
#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)。这在创建稀疏文件时非常有用。
# 查看文件的逻辑大小和实际占用空间
ls -l data.txt # 逻辑大小:1000001字节
du -h data.txt # 实际占用:可能只有几KB注意:lseek不能用于管道、FIFO、套接字等不支持随机访问的设备。对这些设备使用lseek会返回-1并设置errno为ESPIPE。
2.5 dup和dup2
dup和dup2用于复制文件描述符:
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);dup(oldfd)返回一个新的文件描述符,指向与oldfd相同的文件表条目。新描述符是当前最小可用的描述符。dup2(oldfd, newfd)将newfd指向oldfd的文件。如果newfd已经打开,先关闭它。
// 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 1dup2(fd, 1)和close(1); dup(fd)的效果看似相同,但有一个关键区别:dup2是原子操作,而close + dup不是。在多线程环境中,close + dup之间可能被其他线程的open打断,导致dup返回错误的描述符。
2.6 I/O效率
缓冲区大小对I/O性能有显著影响。以下程序测试不同缓冲区大小的性能:
#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文件):
| 缓冲区大小 | 时间(秒) | 系统调用次数 |
|---|---|---|
| 1 | 45.2 | 200,000,000 |
| 512 | 0.85 | 390,625 |
| 4096 | 0.15 | 48,828 |
| 65536 | 0.12 | 3,052 |
| 1048576 | 0.11 | 191 |
分析:
缓冲区从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 健壮的文件复制程序
#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 文件锁实现
#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实现安全的并发追加
#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的输出重定向功能(>操作符)。