从文件描述符到文件偏移量:用 Linux 系统调用完成一次文件读写

学习 C 语言文件操作时,我们通常会先接触 fopen()fprintf()fread()fclose()。这些函数使用 FILE* 表示文件,并在标准库中帮我们处理了缓冲等细节。

这份示例换一个更靠近 Linux 底层文件 I/O 的视角,直接使用:

  • open() 打开或创建文件。
  • write() 写入原始字节。
  • lseek() 修改文件偏移量。
  • read() 读取原始字节。
  • close() 释放文件描述符。

整个过程可以概括为:

open 打开 file.txt
    ↓
write 写入 "Hello Linux\n"
    ↓
lseek 把文件偏移量移回开头
    ↓
read 重新读出文件内容
    ↓
write 把读到的字节输出到终端
    ↓
close 关闭文件

这个例子的代码不复杂,但它刚好串起了 Linux 文件 I/O 中几个很重要的概念:文件描述符、字节数、文件偏移量和 C 字符串结束符。

构建与运行

进入当前目录后执行:

make
make run

输出示例:

Successfully wrote the file.
Successfully repositioned the file offset.
Successfully read the file.
Hello Linux

程序还会在当前目录生成或重写 file.txt,其内容为:

Hello Linux

文件结构

linux-c/system-programming/001-basic-file-io
├── src
│   └── unistd.c   # 系统调用示例
├── README.md       # 本篇学习笔记
├── README-en.md    # 英文版学习笔记
├── Makefile        # 构建脚本
└── file.txt        # 程序运行后生成的测试文件

Makefile 会将 src/unistd.c 编译为 src/unistd.o,再在当前目录链接生成可执行文件 file_io

文件描述符:用一个整数表示打开的文件

标准 I/O 使用 FILE*,而 open() 返回的是一个 int

int fileDescriptor = open(
        fileName,
        O_RDWR | O_CREAT | O_TRUNC,
        0644
);

这个整数就是文件描述符,简称 fd

可以先把它理解成:当前进程用来找到某个已打开文件的编号。后面的 read()write()lseek()close() 都不再使用文件名,而是使用这个文件描述符。

Linux 进程启动时通常已经拥有三个标准文件描述符:

STDIN_FILENO   = 0   标准输入
STDOUT_FILENO  = 1   标准输出
STDERR_FILENO  = 2   标准错误

所以我们不仅可以向 fileDescriptor 写入文件,也可以直接向 STDOUT_FILENOSTDERR_FILENO 写入终端。

open:打开文件并设置初始状态

open() 的公开接口可以简化写成:

int open(const char *path, int flags, ...);

flags 中包含 O_CREAT 时,后面还需要提供创建权限参数。

本例使用了三个标志:

O_RDWR | O_CREAT | O_TRUNC

它们的作用分别是:

  • O_RDWR:以可读、可写方式打开,因为后面同时会使用 write()read()
  • O_CREAT:如果文件不存在,就创建它。
  • O_TRUNC:如果文件已存在,将它的长度截断为 0,也就是清空原内容。

第三个参数 0644 是创建文件时请求的权限:

0 6 4 4
  │ │ │
  │ │ └─ 其他用户:只读
  │ └── 同组用户:只读
  └─── 文件所有者:可读可写

实际生成的权限还会受当前进程 umask 的影响。在这个示例里,先记住 0644 表示“所有者可读写,其他人只读”即可。

open() 成功时返回一个非负整数,失败时返回 -1

if (fileDescriptor == -1) {
        /* 输出错误信息并结束程序 */
}

write:向文件写入原始字节

write() 的接口是:

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

三个参数分别表示:

  • fd:要写入的文件描述符。
  • buf:待写入数据所在的内存地址。
  • count:最多写入多少个字节。

本例要写入的文本是:

static const char text[] = "Hello Linux\n";

它在内存中实际还有一个字符串结束符:

H e l l o   L i n u x \n \0
└─────── 12 字节 ──────┘  └─ C 字符串结束符

write() 不理解 C 字符串,也不会自动寻找 \0。它只会严格写出 count 指定的字节数。因此这里使用:

ssize_t writeBytes = write(
        fileDescriptor,
        text,
        sizeof(text) - 1
);

sizeof(text) 包含末尾的 \0,减去 1 后才是需要写入文本文件的字节数。

write() 成功时返回实际写入的字节数,失败时返回 -1。这里的单位是“字节”,而不是“字符”。

写入之后,文件偏移量去了哪里

文件描述符不只是让内核知道“操作哪个文件”,对应的打开文件状态还会记录当前偏移量。

刚打开并清空文件时,偏移量从 0 开始:

Hello Linux\n
^
偏移量 0

写入 12 个字节后,偏移量会向后移动 12 个字节,此时它已经在文件末尾:

Hello Linux\n
             ^
             偏移量 12,也是 EOF

如果这时直接调用 read(),就会从偏移量 12 开始读。因为那里已经是文件末尾,所以 read() 会返回 0,表示读到 EOF。

这就是本例必须在 write()read() 之间加入 lseek() 的原因。

lseek:把文件偏移量移回开头

lseek() 的接口是:

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

本例的调用是:

off_t lseekResult = lseek(fileDescriptor, 0, SEEK_SET);

可以读作:

以文件开头 SEEK_SET 为基准,
再向后偏移 0 个字节。

所以结果就是把当前文件偏移量设置为 0。

whence 常用的三个取值是:

偏移基准
SEEK_SET 文件开头
SEEK_CUR 当前偏移量
SEEK_END 文件末尾

lseek() 成功时返回调整后的偏移量,失败时返回 -1

需要注意的是,不是所有文件描述符都能使用 lseek()。普通文件通常可以,而管道这种字节流没有可以随意跳转的位置。

read:从当前偏移量开始读取

read() 的接口是:

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

本例准备了一个 100 字节的缓冲区:

char buffer[100];

然后从文件中读取数据:

ssize_t readBytes = read(
        fileDescriptor,
        buffer,
        sizeof(buffer)
);

这里可以使用完整的 sizeof(buffer),因为后面不会把 buffer 交给 printf("%s")strlen() 等 C 字符串函数。

read() 不会自动在末尾添加 \0。它只负责把字节放进缓冲区,并通过返回值告诉我们实际放了多少字节:

  • 大于 0:实际读到的字节数。
  • 等于 0:已经到达 EOF。
  • 等于 -1:读取失败。

因此输出时要使用 readBytes 作为长度:

write(
        STDOUT_FILENO,
        buffer,
        (size_t)readBytes
);

这里的 write() 不需要 buffer\0 结尾,因为它已经从第三个参数得到了准确的字节数。

如果后面要将数据当成 C 字符串使用,写法才需要变成:

ssize_t readBytes = read(
        fileDescriptor,
        buffer,
        sizeof(buffer) - 1
);

if (readBytes >= 0) {
        buffer[readBytes] = '\0';
}

这里的 -1 不是 read() 的要求,而是为了给 C 字符串结束符留一个位置。

标准输出和标准错误也是文件描述符

示例中输出状态信息时没有使用 printf(),而是继续使用 write()

const char successText[] = "Successfully read the file.\n";

write(
        STDOUT_FILENO,
        successText,
        sizeof(successText) - 1
);

出错时则写入 STDERR_FILENO

const char errorText[] = "Failed to read the file.\n";

write(
        STDERR_FILENO,
        errorText,
        sizeof(errorText) - 1
);

这里再次体现了 Linux “一切皆文件”这种接口风格:普通文件、终端输出和错误输出都可以通过文件描述符交给 write()

close:释放文件描述符

完成读写后,调用 close()

close(fileDescriptor);

close() 的接口是:

int close(int fd);

关闭后,这个文件描述符就不能再继续用于 read()write()lseek()

close() 成功时返回 0,失败时返回 -1 并设置 errno。这份代码的目标是展示主要读写流程,所以没有继续展开 close() 错误处理。

把整个调用链串起来

把错误输出和接口说明暂时折叠起来,这份代码的主干就是:

int fileDescriptor = open(
        "file.txt",
        O_RDWR | O_CREAT | O_TRUNC,
        0644
);

write(fileDescriptor, text, sizeof(text) - 1);

lseek(fileDescriptor, 0, SEEK_SET);

ssize_t readBytes = read(
        fileDescriptor,
        buffer,
        sizeof(buffer)
);

write(STDOUT_FILENO, buffer, (size_t)readBytes);

close(fileDescriptor);

真正要理解的不只是这五个函数名,而是它们之间的状态变化:

文件不存在或保留旧内容
    ↓ open(O_CREAT | O_TRUNC)
文件已打开,长度为 0,偏移量为 0
    ↓ write(12 bytes)
文件长度为 12,偏移量为 12
    ↓ lseek(fd, 0, SEEK_SET)
文件长度为 12,偏移量回到 0
    ↓ read(...)
读到 12 字节,偏移量再次到达 12
    ↓ close(fd)
文件描述符被释放

这个示例解决了什么,还没展开什么

这份入门示例已经展示了:

  • 如何用 open() 得到文件描述符。
  • 如何用 write() 将内存中的字节写入文件。
  • 为什么写入后需要用 lseek() 重置偏移量。
  • 如何用 read() 获得原始字节和实际长度。
  • \0 与文本有效字节数之间的区别。
  • 如何用 STDOUT_FILENOSTDERR_FILENO 输出信息。
  • 如何用 close() 结束本次文件操作。

为了保持学习主线清晰,这份代码暂时没有展开:

  • 循环处理短读和短写。
  • 处理系统调用被信号中断的 EINTR
  • 通过 errno 输出更具体的错误原因。
  • 使用 fsync() 请求数据同步。
  • close() 的错误进行完整处理。

这些内容很重要,但不影响当前例子要表达的核心过程。先把“文件描述符 + 文件偏移量 + 原始字节读写”建立起来,后面再逐步加入更完整的错误处理。

如何观察程序真正发生了哪些调用

除了直接阅读代码,还可以用 strace 观察程序与 Linux 内核之间的调用:

strace -e trace=openat,read,write,lseek,close ./file_io

在一些平台和 C 库实现中,源代码里的 open() 可能会在 strace 中显示为 openat()。这是因为用户代码通常是通过 C 库包装进入内核,而具体底层调用可以由 C 库选择。

这一步很适合学习系统调用:代码告诉我们“想做什么”,strace 则帮我们看到程序运行时“实际请求了内核做什么”。

小结

这份代码展示了一条最基础、也最值得先建立起来的 Linux 文件 I/O 链路:

open -> write -> lseek -> read -> close
  • open() 创建这个与已打开文件之间的联系。
  • write() 写入字节,同时推进文件偏移量。
  • lseek() 显式修改文件偏移量。
  • read() 从当前偏移量读取字节,并再次推进偏移量。
  • close() 释放文件描述符。

用一句话概括就是:

Linux 底层文件 I/O 不依赖 \0 判断数据结尾,而是通过“文件描述符 + 明确的字节数 + 当前文件偏移量”来完成数据读写。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐