title: 文件 IO 基础与深入探究 tags: [
嵌入式Linux,
Linux应用编程,
文件IO,
文件描述符,
open,
read,
write,
lseek,
dup,
fcntl,
原子操作,
inode,
IMX6ULL,
] created: 2026-09-18 updated: 2026-09-18
💡 关联知识:[[01-Linux应用编程基础/01-应用编程概念与开发环境]]、[[01-Linux应用编程基础/03-标准IO库]];延伸阅读:[[Linux+C+C++技术体系梳理/2. Linux系统编程/2. 系统调用IO]]、[[Linux+C+C++技术体系梳理/2. Linux系统编程/3. 文件描述符操作]]
Linux 下"一切皆文件"。文件 I/O 既是应用编程最基础的部分,也是最容易"会用但不懂"的部分。本篇分两层:
open / read / write / close / lseek 五个系统调用,够你完成绝大多数简单文件读写;errno 怎么用、O_APPEND/O_TRUNC 的细节、多次打开同一文件、dup、文件共享、原子操作与竞争冒险、fcntl/ioctl、文件截断。通用 IO 模型通常包括:打开文件、读写文件、关闭文件,涉及 4 个函数 open()、read()、write()、close()。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
char buff[1024];
int fd1, fd2;
int ret;
/* 打开源文件 src_file(只读方式) */
fd1 = open("./src_file", O_RDONLY);
if (-1 == fd1)
return fd1;
/* 打开目标文件 dest_file(只写方式) */
fd2 = open("./dest_file", O_WRONLY);
if (-1 == fd2) {
ret = fd2;
goto out1;
}
/* 读取源文件 1KB 数据到 buff 中 */
ret = read(fd1, buff, sizeof(buff));
if (-1 == ret)
goto out2;
/* 将 buff 中的数据写入目标文件 */
ret = write(fd2, buff, sizeof(buff));
if (-1 == ret)
goto out2;
ret = 0;
out2:
/* 关闭目标文件 */
close(fd2);
out1:
/* 关闭源文件 */
close(fd1);
return ret;
}
流程:先 open 源文件和目标文件,成功后 read 源文件 1KB 数据,再 write 到目标文件,最后 close 两个文件。
flowchart LR
A["open 源文件<br/>O_RDONLY"] --> B["open 目标文件<br/>O_WRONLY"]
B --> C["read 源文件<br/>1024 字节"]
C --> D["write 目标文件<br/>1024 字节"]
D --> E["close 目标文件"]
E --> F["close 源文件"]
open 执行成功时返回一个非负整数,这个返回值就是文件描述符(fd)。Linux 内核用文件描述符索引所有已打开的文件;所有 IO 系统调用都通过文件描述符找到对应文件。
0~1023;查看上限:
ulimit -n
# 1024
程序里 open 得到的 fd 通常从 3 开始,因为 0、1、2 已被系统默认占用:
| 文件描述符 | 名称 | 宏 | 默认对应设备 |
|---|---|---|---|
| 0 | 标准输入 | STDIN_FILENO |
键盘 |
| 1 | 标准输出 | STDOUT_FILENO |
LCD 显示器 |
| 2 | 标准错误 | STDERR_FILENO |
LCD 显示器 |
这三个宏定义在 <unistd.h> 中:
#define STDIN_FILENO 0 /* Standard input. */
#define STDOUT_FILENO 1 /* Standard output. */
#define STDERR_FILENO 2 /* Standard error output. */
Linux 下一切皆文件,也包括各种硬件设备。每个硬件设备都对应系统下的某个设备文件,应用通过对设备文件读写来操控硬件,如 LCD、串口、音频、键盘等。标准输入可理解为"打开键盘设备文件得到的 fd",标准输出/标准错误可理解为"打开显示设备文件得到的 fd"。
操作文件前要先打开,得到 fd,读写后再关闭。open 既可以打开已存在的文件,也可以创建新文件。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
open 有两种原型,原因是 C 语言不支持重载,它是可变参函数:可传 2 个参数,也可传 3 个参数,第三个参数 mode 只在 flags 含 O_CREAT 或 O_TMPFILE 时有效。
用 man 查看帮助:
man 2 open # 2 表示系统调用;1 是命令,3 是标准 C 库函数
| 参数 | 类型 | 说明 |
|---|---|---|
pathname |
const char * |
文件路径,可含绝对/相对路径;若为符号链接会对其解引用 |
flags |
int |
打开标志,可用 ` |
mode |
mode_t |
新建文件的访问权限,仅当 flags 含 O_CREAT/O_TMPFILE 时有效 |
返回值:成功返回文件描述符(非负整数);失败返回 -1。
| 标志 | 用途 | 说明 |
|---|---|---|
O_RDONLY |
以只读方式打开 | 这三个是文件访问权限标志,必须且只能包含其中一种,决定 fd 能做什么操作 |
O_WRONLY |
以只写方式打开 | 同上 |
O_RDWR |
以可读可写方式打开 | 同上 |
O_CREAT |
文件不存在则创建 | 需传入第 3 个参数 mode 指定权限 |
O_DIRECTORY |
要求是目录 | pathname 不是目录则调用失败 |
O_EXCL |
与 O_CREAT 配合专门创建文件 |
若文件已存在则返回错误,可用于测试文件是否存在,使"测试+创建"成为原子操作 |
O_NOFOLLOW |
不解引用符号链接 | 若 pathname 是符号链接直接返回错误;不加则解引用 |
不同内核版本支持的 flags 存在差别,man 手册中会说明某标志从哪个版本开始支持。
多个标志通过位或组合:
open("./src_file", O_RDONLY); /* 单独使用一个标志 */
open("./src_file", O_RDONLY | O_NOFOLLOW); /* 多个标志组合 */
其它常用但本篇稍后展开的标志还有:O_APPEND、O_TRUNC、O_NONBLOCK、O_SYNC、O_DSYNC、O_ASYNC、O_NOATIME、O_TMPFILE 等。
mode 类型是 mode_t(u32 无符号整型)。权限位从低位往上看,每 3 个 bit 一组:
S U U U G G G O O O
rwx rwx rwx
| 字段 | 含义 |
|---|---|
O(3 bit) |
其他用户的权限 |
G(3 bit) |
同组用户(与文件所有者有相同组 ID)的权限 |
U(3 bit) |
文件所属用户的权限 |
S(3 bit) |
文件特殊权限,一般少用 |
每组 3 个 bit 按 rwx 顺序分配:最高位(权值 4)读、中间位(权值 2)写、最低位(权值 1)执行。
举例:
111111111(二进制)= 777(八进制)= 511(十进制):所有用户都有读/写/执行权限;111000000:仅文件所属者拥有读、写、执行权限;100100100:所属者、同组用户、其他用户都只有读权限。实际编程中直接使用 Linux 已定义好的权限宏:
| 宏定义 | 说明 |
|---|---|
S_IRUSR |
文件所属者读 |
S_IWUSR |
文件所属者写 |
S_IXUSR |
文件所属者执行 |
S_IRWXU |
文件所属者读、写、执行 |
S_IRGRP |
同组用户读 |
S_IWGRP |
同组用户写 |
S_IXGRP |
同组用户执行 |
S_IRWXG |
同组用户读、写、执行 |
S_IROTH |
其他用户读 |
S_IWOTH |
其他用户写 |
S_IXOTH |
其他用户执行 |
S_IRWXO |
其他用户读、写、执行 |
S_ISUID |
set-user-ID(特殊权限) |
S_ISGID |
set-group-ID(特殊权限) |
S_ISVTX |
sticky(特殊权限) |
这些宏可单独使用,也可位或组合:
S_IRUSR | S_IWUSR | S_IROTH
O_RDONLY / O_WRONLY / O_RDWR决定"以什么方式打开",与文件权限是两回事:只有用户对该文件具有相应权限时,才能用对应标志打开文件,否则打开失败。若程序对该文件只有只读权限,用O_RDWR或O_WRONLY打开会失败。
/* (1) 只读方式打开已存在文件 */
int fd = open("./app.c", O_RDONLY);
if (-1 == fd)
return fd;
/* (2) 可读可写方式打开已存在文件 */
int fd = open("./app.c", O_RDWR);
if (-1 == fd)
return fd;
/* (3) 可读可写打开;若是符号链接则不解引用,直接返回错误 */
int fd = open("/home/dengtao/hello", O_RDWR | O_NOFOLLOW);
if (-1 == fd)
return fd;
/* (4) 不存在则创建:所属者 rwx,同组和其他用户只读 */
int fd = open("/home/dengtao/hello", O_RDWR | O_CREAT,
S_IRWXU | S_IRGRP | S_IROTH);
if (-1 == fd)
return fd;
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
| 参数 | 说明 |
|---|---|
fd |
文件描述符 |
buf |
待写入数据所在的缓冲区 |
count |
要写入的字节数 |
返回值:成功返回实际写入的字节数(0 表示未写入任何字节);若返回值小于 count,这不是错误(例如磁盘空间已满可能发生这种情况);写入出错返回 -1。
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
| 参数 | 说明 |
|---|---|
fd |
文件描述符 |
buf |
存放读取数据的缓冲区 |
count |
希望读取的字节数 |
返回值:成功返回实际读取的字节数;实际值可能小于 count,也可能为 0(如已到文件末尾)。
例如文件在到达末尾前只剩 30 字节,而要求读 100 字节,则 read 返回 30;下一次再 read 返回 0。
#include <unistd.h>
int close(int fd);
返回值:成功返回 0,失败返回 -1。
close;每个打开的文件,系统都会记录它的读写位置偏移量(读写偏移量),read/write 从当前偏移量开始操作。文件第一个字节的位置偏移量为 0;打开文件时偏移量指向文件开始处;每次 read/write 后会自动移动,指向已读/已写数据后的下一字节。
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
| 参数 | 说明 |
|---|---|
fd |
文件描述符 |
offset |
偏移量,以字节为单位 |
whence |
偏移量参考基准,取以下宏之一 |
whence |
含义 |
|---|---|
SEEK_SET |
指向 offset 字节处(从文件头算) |
SEEK_CUR |
指向「当前位置 + offset」处,offset 可正可负 |
SEEK_END |
指向「文件末尾 + offset」处,offset 可正可负 |
返回值:成功返回从文件头部算起的当前位置偏移量(字节);出错返回 -1。
/* 移动到文件开头 */
off_t off = lseek(fd, 0, SEEK_SET);
/* 移动到文件末尾 */
off_t off = lseek(fd, 0, SEEK_END);
/* 移动到偏移文件头 100 字节处 */
off_t off = lseek(fd, 100, SEEK_SET);
/* 获取当前读写位置偏移量 */
off_t off = lseek(fd, 0, SEEK_CUR);
下面这份源码对应第二章的编程实战例 1:打开 src_file(只读),新建 dest_file(只写、权限 rwxr--r--),从 src_file 偏移 500 字节处读取 1KB,写入 dest_file 开头,最后显式关闭所有文件。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void)
{
char buffer[1024];
int fd1, fd2;
int ret;
/* 打开 src_file 文件 */
fd1 = open("./src_file", O_RDONLY);
if (-1 == fd1) {
printf("Error: open src_file failed!\n");
return -1;
}
/* 新建 dest_file 文件并打开 */
fd2 = open("./dest_file", O_WRONLY | O_CREAT | O_EXCL,
S_IRWXU | S_IRGRP | S_IROTH);
if (-1 == fd2) {
printf("Error: open dest_file failed!\n");
ret = -1;
goto err1;
}
/* 将 src_file 读写位置移动到偏移文件头 500 个字节处 */
ret = lseek(fd1, 500, SEEK_SET);
if (-1 == ret)
goto err2;
/* 读取 src_file 数据,大小 1KByte */
ret = read(fd1, buffer, sizeof(buffer));
if (-1 == ret) {
printf("Error: read src_file failed!\n");
goto err2;
}
/* 将 dest_file 读写位置移动到文件头 */
ret = lseek(fd2, 0, SEEK_SET);
if (-1 == ret)
goto err2;
/* 将 buffer 中的数据写入 dest_file,大小 1KByte */
ret = write(fd2, buffer, sizeof(buffer));
if (-1 == ret) {
printf("Error: write dest_file failed!\n");
goto err2;
}
printf("OK: test successful\n");
ret = 0;
err2:
close(fd2);
err1:
close(fd1);
return ret;
}
逐段解释:
| 代码 | 说明 |
|---|---|
open("./src_file", O_RDONLY) |
只读打开源文件;失败返回 -1 |
open("./dest_file", O_WRONLY \| O_CREAT \| O_EXCL, S_IRWXU\|S_IRGRP\|S_IROTH) |
只写方式创建新文件;O_EXCL 保证文件已存在时失败;权限 rwxr--r-- |
lseek(fd1, 500, SEEK_SET) |
把源文件读位置移到偏移 500 字节处 |
read(fd1, buffer, 1024) |
读 1KB 到 buffer |
lseek(fd2, 0, SEEK_SET) |
把目标文件写位置移到开头 |
write(fd2, buffer, 1024) |
写 1KB |
goto err2/err1 |
出错时逐级释放资源,避免 fd 泄漏 |
编译运行:
gcc -o testApp_1 testApp_1.c
# 准备一个 >= 1KB 的 src_file
ls -l src_file
./testApp_1
ls -l dest_file # 大小应为 1024,权限为 rwxr--r--
文件未被打开时存放在磁盘文件系统中,称为静态文件。磁盘的最小存储单位是扇区(Sector,512 字节);操作系统一次读取一个块(block),通常 4KB(连续 8 个扇区)。
磁盘分区、格式化时会分为两个区域:
inode table,其中是一个个 inode。每个文件对应唯一一个 inode,inode 本质是一个结构体,记录:文件字节大小、文件所有者、读/写/执行权限、时间戳、文件类型、文件数据存储的 block 位置等信息。注意:文件名不记录在 inode 中。
查看 inode 编号:
ls -i file
# 或
stat file
flowchart LR
F["文件名"] -->|"1. 找到 inode 编号"| N["inode 编号"]
N -->|"2. 在 inode table 中定位"| IT["inode table<br/>inode 结构体"]
IT -->|"3. 根据 inode 记录的 block 位置"| B["数据区中的 block"]
打开一个文件,系统内部大致分三步:
U 盘"快速格式化"很快,因为它只清除了 inode table,真正存储文件数据的区域没动,所以数据仍可能被找回。
调用 open 时,内核会申请一段内存(缓冲区),把静态文件数据从磁盘读取到内存中管理、缓存。内存中的这份叫动态文件(内核缓冲区)。之后对该文件的读写都是针对内存中的动态文件,而不是磁盘中的静态文件。
当动态文件被改写后,与磁盘静态文件不同步,内核会在之后把动态文件更新到磁盘。这解释了:
这样设计的原因:磁盘/硬盘/U 盘多为 Flash 块设备,块设备以"块"为单位读写,改一个字节也要把整块读出、修改、写回,非常不灵活;而内存可按字节随机操作,且读写速率远高于磁盘。因此先把静态文件缓存为动态文件,读写针对内存,效率更高。
内核为每个进程设置专门的数据结构管理进程状态、运行特征等,称为进程控制块(PCB)。
PCB 中有指针指向文件描述符表(File descriptors);表中的每个元素索引到对应的文件表(File table);文件表记录:文件状态标志、引用计数、当前文件读写偏移量、i-node 指针(指向该文件对应的 inode)等。
flowchart LR
subgraph 进程["进程 PCB"]
FDT["文件描述符表"]
FD0["fd 0"]
FD1["fd 1"]
FD2["fd 2"]
FD3["fd 3"]
FDT --- FD0
FDT --- FD1
FDT --- FD2
FDT --- FD3
end
subgraph 打开文件表["打开文件表(File table)"]
FT1["文件表项<br/>状态标志 / 引用计数<br/>偏移量 / i-node 指针"]
end
INODE["inode<br/>数据 block 位置 / 权限 / 大小"]
FD3 --> FT1
FT1 --> INODE
INODE --> BLK["磁盘数据块"]
前面示例出错后只能返回 -1,却不知道原因。Linux 为常见错误做了编号,函数出错时操作系统把错误编号赋给 errno:
errno 是 int 类型变量,每个进程维护自己的 errno;errno,可查 man 手册的返回值描述;包含 <errno.h> 即可使用。
#include <stdio.h>
#include <errno.h>
int main(void)
{
printf("%d\n", errno);
return 0;
}
把错误编号转换成可读字符串(C 库函数,man 3 strerror):
#include <string.h>
char *strerror(int errnum);
| 参数 | 说明 |
|---|---|
errnum |
错误编号 errno |
返回值:对应错误编号的字符串描述。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void)
{
int fd;
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
printf("Error: %s\n", strerror(errno));
return -1;
}
close(fd);
return 0;
}
文件不存在时打印:
Error: No such file or directory
更常用:内部自动读取 errno,直接把错误信息打印出来,还可在前面加自己的提示信息(man 3 perror):
#include <stdio.h>
void perror(const char *s);
| 参数 | 说明 |
|---|---|
s |
错误提示字符串之前附加的信息,不加则传空字符串 |
返回值:无(void)。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int fd;
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
perror("open error");
return -1;
}
close(fd);
return 0;
}
输出:
open error: No such file or directory
perror 会在附加信息后自动加入冒号和空格分隔。推荐优先使用 perror。
进程退出分正常退出和异常退出,这里只讨论正常退出。正常退出有三种方式:
_exit() 与 _Exit()(系统调用)_exit() 会清除进程使用的内存空间,销毁内核中的各种数据结构,关闭进程所有文件描述符,结束进程并把控制权交给操作系统。它会直接进入内核终止进程,不执行 stdio 缓冲刷新等清理。
#include <unistd.h>
void _exit(int status);
#include <stdlib.h>
void _Exit(int status);
_exit() 和 _Exit() 两者等价,用法作用一样,都是系统调用。status 为状态标志:0 表示正常结束,其它值表示执行过程中检测到有错误发生。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int fd;
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
perror("open error");
_exit(-1);
}
close(fd);
_exit(0);
}
exit()(标准 C 库函数)exit() 是标准 C 库函数,会执行一些清理工作,最后调用 _exit()。用法与 _exit()/_Exit() 一样。
#include <stdlib.h>
void exit(int status);
| 方式 | 类型 | 是否刷新 stdio 缓冲 | 头文件 |
|---|---|---|---|
main 中 return |
语言机制 | 是 | — |
exit(status) |
C 库函数 | 是 | <stdlib.h> |
_exit(status) |
系统调用 | 否 | <unistd.h> |
_Exit(status) |
系统调用 | 否 | <stdlib.h> |
推荐使用 exit()。return、exit()、_exit()/_Exit() 的区别在标准 I/O 缓冲场景下会显现(见 [[01-Linux应用编程基础/03-标准IO库]])。
lseek 不仅能改变位置偏移量,还允许偏移量超出文件长度。例如一个 4K 的文件,把偏移量移到 6000 字节处再 write,那么 4096~6000 字节之间没有写入数据,形成空洞,该文件称为空洞文件。
用途:对多线程共同操作文件非常有用。要构建一个大文件,可把文件分段,多个线程各写一段。实际场景:
创建虚拟机分配 100G 磁盘,实际只用了 3、4G。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(void)
{
int fd;
int ret;
char buffer[1024];
int i;
/* 打开文件:不存在则创建,权限 rw-r--r-- */
fd = open("./hole_file", O_WRONLY | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将文件读写位置移动到偏移文件头 4096 字节(4K)处 */
ret = lseek(fd, 4096, SEEK_SET);
if (-1 == ret) {
perror("lseek error");
goto err;
}
/* 初始化 buffer 为 0xFF */
memset(buffer, 0xFF, sizeof(buffer));
/* 循环写入 4 次,每次 1K */
for (i = 0; i < 4; i++) {
ret = write(fd, buffer, sizeof(buffer));
if (-1 == ret) {
perror("write error");
goto err;
}
}
ret = 0;
err:
close(fd);
exit(ret);
}
测试:
gcc -o hole hole.c && ./hole
ls -l hole_file # 逻辑大小 8K(4K 空洞 + 4K 数据)
du -h hole_file # 实际占用 4K
ls 看到的是逻辑大小(含空洞),du 看到的是实际占用存储块大小。
思考题:用
read读取文件空洞部分,读到的是什么?(可自己写代码验证。)
使用 O_TRUNC 打开文件时,会将文件原本的内容全部丢弃,文件大小变为 0。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd;
fd = open("./test_file", O_WRONLY | O_TRUNC);
if (-1 == fd) {
perror("open error");
exit(-1);
}
close(fd);
exit(0);
}
即使不读写,仅用 O_TRUNC 打开再关闭,文件也会被清空(比如 8760 字节 → 0)。
使用 O_APPEND 打开文件后,每次 write 都会自动把写位置偏移量移动到文件末尾,从文件末尾开始写入。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char buffer[16];
int fd;
int ret;
/* 打开文件,携带 O_APPEND */
fd = open("./test_file", O_RDWR | O_APPEND);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 初始化 buffer 中的数据 */
memset(buffer, 0x55, sizeof(buffer));
/* 写入 4 个字节数据 */
ret = write(fd, buffer, 4);
if (-1 == ret) {
perror("write error");
goto err;
}
/* 将 buffer 清 0 */
memset(buffer, 0x00, sizeof(buffer));
/* 将位置偏移量移动到距离文件末尾 4 个字节处 */
ret = lseek(fd, -4, SEEK_END);
if (-1 == ret) {
perror("lseek error");
goto err;
}
/* 读取最后 4 个字节 */
ret = read(fd, buffer, 4);
if (-1 == ret) {
perror("read error");
goto err;
}
printf("0x%x 0x%x 0x%x 0x%x\n", buffer[0], buffer[1],
buffer[2], buffer[3]);
ret = 0;
err:
close(fd);
exit(ret);
}
打印 0x55 0x55 0x55 0x55,说明 O_APPEND 生效:写入确实发生在文件末尾。
两个重要细节:
O_APPEND 不影响读:读文件时位置偏移量默认仍在文件头;O_APPEND 下用 lseek 改不了写位置:写入依然从文件末尾开始。思考题:
O_APPEND与O_TRUNC同时指定时会怎样?
同一个文件可以被多次打开:在一个进程中多次打开,或在多个进程中分别打开。
一个进程内多次 open 同一文件,会得到多个不同的 fd,关闭时需依次 close。
fd1 = open("./test_file", O_RDWR);
fd2 = open("./test_file", O_RDWR);
fd3 = open("./test_file", O_RDWR);
printf("%d %d %d\n", fd1, fd2, fd3); /* 例如 6 7 8 */
在 VSCode 提供的终端里运行,得到的可能是 6、7、8 而不是 3、4、5,因为 3、4、5 已被 VSCode 进程占用,而子进程会继承父进程的文件描述符。直接在 Ubuntu Terminal 里运行则得到 3、4、5。
多次打开同一文件,内存中不会存在多份动态文件。验证:先用 fd1 写入 4 字节,再用 fd2(未 lseek)从文件头读取,若能读到刚写的数据,说明共享同一份动态文件。
/* 通过 fd1 写入 4 个字节 0x11 0x22 0x33 0x44 */
write(fd1, buffer, 4);
/* fd2 从文件头读取 */
lseek(fd2, 0, SEEK_SET);
read(fd2, buffer, 4);
/* 读出的仍是 0x11 0x22 0x33 0x44,证明只有一份动态文件 */
多次打开会得到多个 fd,对应多个文件表,读写偏移量记录在文件表中,因此不同 fd 的偏移量相互独立;但文件表中的 i-node 指针指向同一个 inode。
flowchart LR
subgraph P["进程"]
A["fd1"]
B["fd2"]
end
A --> T1["文件表 1<br/>偏移量 off1<br/>i-node 指针"]
B --> T2["文件表 2<br/>偏移量 off2<br/>i-node 指针"]
T1 --> I["同一个 inode"]
T2 --> I
多个不同进程
open同一文件,内存中同样只维护一份动态文件,多个进程共享,但各有独立的读写偏移量。
动态文件何时关闭? 当文件的引用计数为 0 时,系统自动关闭它。文件表中记录引用计数,表示当前被多少个 fd 关联。
两个 fd 各自独立偏移量,所以默认是分别写(各从各的位置写,会互相覆盖)。测试:
/* 循环 4 次,交替写入 */
write(fd1, buffer1, 4); /* 0x11 0x22 0x33 0x44 */
write(fd2, buffer2, 4); /* 0xAA 0xBB 0xCC 0xDD */
因为两者偏移量独立、都从 0 开始,读取结果是 aabbccdd...(fd1 写的数据被 fd2 覆盖)。
想变成接续写,可用 O_APPEND:O_APPEND 使每次 write 都自动到文件末尾,于是变成 11223344aabbccdd...。
复制成功得到新的 fd,新旧 fd 都可操作文件,权限相同。复制得到的 fd 与旧 fd 指向同一个文件表,所以对文件的读写权限、状态标志、文件偏移量都相同——"复制"实则是复制文件表。使用完也要 close。
#include <unistd.h>
int dup(int oldfd);
| 参数 | 说明 |
|---|---|
oldfd |
需要被复制的文件描述符 |
返回值:成功返回新的文件描述符(由系统按 fd 分配原则分配);失败返回 -1 并设置 errno。
fd2 = dup(fd1); /* fd1=6 -> 得到 fd2=7 */
由于新旧 fd 指向同一个文件表,偏移量共享,因此不用 O_APPEND 也能实现接续写。
dup 不能自己指定 fd,dup2 修复了这个缺陷:
#include <unistd.h>
int dup2(int oldfd, int newfd);
| 参数 | 说明 |
|---|---|
oldfd |
需要被复制的文件描述符 |
newfd |
指定一个文件描述符(需是当前进程没有使用的) |
返回值:成功返回手动指定的 newfd;失败返回 -1 并设置 errno。
fd2 = dup2(fd1, 100); /* 得到 fd2 == 100 */
同一个 fd 可以多次调用
dup/dup2复制,得到多个不同的 fd。
完整 dup 测试源码(实现接续写):
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
unsigned char buffer1[4], buffer2[4];
int fd1, fd2;
int ret;
int i;
/* 创建新文件 test_file 并打开 */
fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 复制文件描述符 */
fd2 = dup(fd1);
if (-1 == fd2) {
perror("dup error");
ret = -1;
goto err1;
}
printf("fd1: %d\nfd2: %d\n", fd1, fd2);
buffer1[0] = 0x11; buffer1[1] = 0x22;
buffer1[2] = 0x33; buffer1[3] = 0x44;
buffer2[0] = 0xAA; buffer2[1] = 0xBB;
buffer2[2] = 0xCC; buffer2[3] = 0xDD;
/* 循环写入数据,共享偏移量,因此是接续写 */
for (i = 0; i < 4; i++) {
write(fd1, buffer1, sizeof(buffer1));
write(fd2, buffer2, sizeof(buffer2));
}
/* 回到文件头读取全部数据 */
lseek(fd1, 0, SEEK_SET);
for (i = 0; i < 8; i++) {
read(fd1, buffer1, sizeof(buffer1));
printf("%x%x%x%x", buffer1[0], buffer1[1],
buffer1[2], buffer1[3]);
}
printf("\n");
ret = 0;
err2:
close(fd2);
err1:
close(fd1);
exit(ret);
}
输出应为 11223344aabbccdd11223344aabbccdd,证明是接续写。
文件共享指同一个文件(对应同一个 inode)被多个独立读写体同时进行 IO 操作。独立读写体可理解为同一个文件的多个不同 fd(多次 open 得到,或 dup/dup2 得到)。核心问题:如何制造多个不同 fd 指向同一个文件。
三种常见实现方式:
flowchart TB
subgraph M1["方式一:同进程多次 open"]
A1["fd1"] --> TA1["文件表 1"]
A2["fd2"] --> TA2["文件表 2"]
TA1 --> IA["同一 inode"]
TA2 --> IA
end
subgraph M2["方式二:不同进程各自 open"]
B1["进程1 fd1"] --> TB1["文件表 1"]
B2["进程2 fd2"] --> TB2["文件表 2"]
TB1 --> IB["同一 inode"]
TB2 --> IB
end
subgraph M3["方式三:dup / dup2 复制"]
C1["fd1"] --> TC["同一个文件表<br/>共享偏移量"]
C2["fd2"] --> TC
TC --> IC["同一 inode"]
end
| 方式 | fd 是否各自独立 | 是否共享偏移量 | 典型用途 |
|---|---|---|---|
同进程多次 open |
是(多个文件表) | 否 | 需要独立读写位置 |
不同进程各自 open |
是(多个文件表) | 否 | 进程间共享文件 |
dup/dup2 复制 |
是(同一文件表) | 是 | 接续写、重定向 |
文件共享存在竞争冒险,且要关心不同读写体之间是分别写还是接续写。
假设进程 A 和 B 都对同一文件追加写(文件末尾写入),都调用了 open 但未使用 O_APPEND。每个进程有自己的 PCB、自己的文件表(独立偏移量),但共享同一个 inode。
lseek 把 A 的偏移量设为 1500(文件末尾);lseek,也把 B 的偏移量设为 1500;write 100 字节,B 的偏移量变为 1600;A write,从 A 的偏移量 1500 开始写——但 1500 处已不是文件末尾,覆盖了 B 刚写入的数据。
sequenceDiagram
participant A as 进程 A
participant B as 进程 B
participant F as 文件
A->>F: lseek 到 1500(末尾)
Note over A: 时间片耗尽
B->>F: lseek 到 1500(末尾)
B->>F: write 100 字节(到 1600)
Note over B: 时间片耗尽
A->>F: write(仍从 1500 开始)→ 覆盖 B 的数据
这种两个(或多个)操作共享资源、结果不可预期的情形,就是竞争状态(竞争冒险)。因为进程获得 CPU 使用权的先后由操作系统调配,不可预期。
问题出在"先定位到文件末尾、再写"分成了两个函数调用。解决办法是把这两步合并成一个原子操作:原子操作由多步组成,要么一步也不执行,一旦执行就必须执行完所有步骤,不可能只执行子集。
(1) O_APPEND 实现原子操作
O_APPEND 使每次 write 时把「移动写位置到文件末尾、写入数据」组成一个原子操作。无论怎么调换顺序,写入都从文件末尾开始,不会出现 A 覆盖 B 的情况。
(2) pread() 与 pwrite()
与 read/write 作用一样,区别是可用于实现原子操作,并传入位置偏移量 offset。调用 pread 相当于 lseek + read,且把两步组成原子操作。
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
| 参数 | 说明 |
|---|---|
fd、buf、count |
与 read/write 意义相同 |
offset |
当前读或写的位置偏移量 |
返回值:与 read/write 意义相同。
与「lseek + read/write」的区别:
pread 时,无法中断其定位和读操作(即原子操作);验证第 2 点:pread(fd, buffer, 100, 1024) 后,lseek(fd, 0, SEEK_CUR) 返回 0 而不是 1124。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
unsigned char buffer[100];
int fd;
int ret;
fd = open("./test_file", O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 从偏移文件头 1024 字节处读取 100 字节 */
ret = pread(fd, buffer, sizeof(buffer), 1024);
if (-1 == ret) {
perror("pread error");
goto err;
}
/* 获取当前位置偏移量,预期仍为 0 */
ret = lseek(fd, 0, SEEK_CUR);
if (-1 == ret) {
perror("lseek error");
goto err;
}
printf("Current Offset: %d\n", ret); /* 输出 0 */
ret = 0;
err:
close(fd);
exit(ret);
}
(3) 创建一个文件(O_EXCL)
进程 A、B 都要打开一个不存在的文件:A 先 open("./file", O_RDWR) 失败,时间片耗尽;B 同样失败,接着 B 用 O_CREAT 创建成功;切回 A,A 也用 O_CREAT 创建"成功"——同一个文件被创建两次,这是不允许的。
用 O_EXCL + O_CREAT:若文件已存在则返回错误,若不存在则创建,把「判断是否存在、创建文件」合并成一个原子操作,保证进程是文件的创建者。
fcntl() 可对一个已打开的 fd 执行一系列控制操作,类似"多功能文件描述符管理工具箱"。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
| 参数 | 说明 |
|---|---|
fd |
文件描述符 |
cmd |
操作命令,以 F_XXX 开头 |
... |
可变参,第三个参数根据 cmd 传入 |
cmd 大致分 5 类功能:
| 功能 | cmd |
|---|---|
| 复制文件描述符 | F_DUPFD、F_DUPFD_CLOEXEC |
| 获取/设置文件描述符标志 | F_GETFD、F_SETFD |
| 获取/设置文件状态标志 | F_GETFL、F_SETFL |
| 获取/设置异步 IO 所有权 | F_GETOWN、F_SETOWN |
| 获取/设置记录锁 | F_GETLK、F_SETLK |
返回值:失败返回 -1 并设置 errno;成功时返回值与 cmd 有关(如 F_DUPFD 返回新 fd、F_GETFD 返回描述符标志、F_GETFL 返回文件状态标志)。
(1) 复制文件描述符
cmd = F_DUPFD:根据 fd 复制出新 fd,第三个参数指出新 fd 是大于或等于该参数的可用 fd;若该参数等于已存在的 fd,则取大于它的可用 fd。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd1, fd2;
int ret;
fd1 = open("./test_file", O_RDONLY);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 复制 fd1,新 fd 需大于等于 0 */
fd2 = fcntl(fd1, F_DUPFD, 0);
if (-1 == fd2) {
perror("fcntl error");
ret = -1;
goto err;
}
printf("fd1: %d\nfd2: %d\n", fd1, fd2);
ret = 0;
close(fd2);
err:
close(fd1);
exit(ret);
}
(2) 获取/设置文件状态标志
F_GETFL 获取、F_SETFL 设置。设置的就是调用 open 时传入的 flags,但有限制:
O_RDONLY、O_WRONLY、O_RDWR)不能被设置;O_CREAT、O_EXCL、O_NOCTTY、O_TRUNC)不能被设置,会被忽略;Linux 下只有 O_APPEND、O_ASYNC、O_DIRECT、O_NOATIME、O_NONBLOCK 这些标志可以被修改。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd;
int ret;
int flag;
fd = open("./test_file", O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 获取文件状态标志 */
flag = fcntl(fd, F_GETFL);
if (-1 == flag) {
perror("fcntl F_GETFL error");
ret = -1;
goto err;
}
printf("flags: 0x%x\n", flag);
/* 在原标志基础上添加 O_APPEND */
ret = fcntl(fd, F_SETFL, flag | O_APPEND);
if (-1 == ret) {
perror("fcntl F_SETFL error");
goto err;
}
ret = 0;
err:
close(fd);
exit(ret);
}
ioctl() 可认为是一个文件 IO 操作的"杂物箱",处理的事情非常杂、不统一,一般用于操作特殊文件或硬件外设(例如用它获取 LCD 相关信息)。
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);
| 参数 | 说明 |
|---|---|
fd |
文件描述符 |
request |
与具体要操作的对象有关,没有统一值,表示向 fd 请求相应操作 |
... |
可变参,第三个参数根据 request 决定 |
返回值:成功返回 0,失败返回 -1。
使用 truncate() 或 ftruncate() 可将普通文件截断为指定字节长度。
#include <unistd.h>
#include <sys/types.h>
int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length);
两者功能一样,区别在于:ftruncate() 用文件描述符 fd 指定目标文件,truncate() 直接用文件路径 path。
行为:
length,多余数据丢失(被"砍"掉);length,文件被扩展,扩展部分读取得到空字节 "\0"。使用 ftruncate() 前必须先用 open() 打开文件得到 fd,且必须具有可写权限(O_WRONLY 或 O_RDWR)。
注意:调用这两个函数不会改变文件读写位置偏移量,截断后一般需要重新设置读写位置,以免之前指向的位置已不存在而出错。
返回值:成功返回 0,失败返回 -1 并设置 errno。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
int fd;
/* 打开 file1 文件 */
if (0 > (fd = open("./file1", O_RDWR))) {
perror("open error");
exit(-1);
}
/* 使用 ftruncate 将 file1 截断为 0 字节 */
if (0 > ftruncate(fd, 0)) {
perror("ftruncate error");
exit(-1);
}
/* 使用 truncate 将 file2 截断为 1024 字节 */
if (0 > truncate("./file2", 1024)) {
perror("truncate error");
exit(-1);
}
close(fd);
exit(0);
}
运行前 file1、file2 均为 592 字节;运行后 file1 变 0,file2 变 1024。
# 1. 建目录
mkdir -p ~/vscode_ws/2_chapter && cd ~/vscode_ws/2_chapter
# 2. 创建源文件(用 VSCode 或 vi)
# 粘贴本篇示例,如 testApp_1.c、hole.c、dup_test.c 等
# 3. 编译
gcc -Wall -o testApp_1 testApp_1.c
# 4. 准备测试文件
dd if=/dev/urandom of=src_file bs=1K count=2 # 生成 2KB 随机文件
# 5. 运行、验证
./testApp_1
ls -l dest_file
xxd dest_file | head # 查看内容
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
open 返回 -1 |
文件不存在/权限不足/路径错误 | perror("open error");ls -l 查权限;pwd 查路径 |
read 返回 0 |
已到文件末尾 | 结合 lseek(fd,0,SEEK_END) 看文件大小 |
read 返回值小于 count |
到达文件末尾,不是错误 | 用返回值而非 count 判断 |
write 少于 count |
磁盘空间满等,不是错误 | 检查磁盘空间 df -h |
| 读到的内容和预期不符 | 偏移量没设置对 | 用 lseek(..., SEEK_CUR) 打印当前偏移量 |
| 数据"少了"/互相覆盖 | 竞争冒险或分别写 | 加 O_APPEND,或用 dup/pread |
| 文件逻辑大小与占用不符 | 空洞文件 | 对比 ls -l 与 du |
| fd 泄漏 | 出错分支没 close |
用 lsof -p <pid> 观察,检查 goto 清理链 |
| 6、7、8 而不是 3、4、5 | 父进程(如 VSCode)占用了 3、4、5 | 直接在系统 Terminal 运行验证 |
常用工具:
man 2 open # 系统调用手册
man 3 strerror # 库函数手册
strace ./testApp # 跟踪程序发起的系统调用与参数、返回值
lsof -p $(pgrep testApp) # 查看进程打开的文件描述符
ls -i file # 查看 inode 编号
stat file # 查看文件元数据
du -h file # 查看实际占用
strace 尤其适合验证本篇知识点:
strace -e trace=open,openat,read,write,lseek,close ./testApp_1
⚠️ 来源说明:本节不属于《I.MX6U嵌入式Linux C应用编程指南》内容,为扩展知识。
| 维度 | I.MX6ULL | STM32(裸机/RTOS) | RK3568 |
|---|---|---|---|
| 文件抽象 | 一切皆文件,fd 为索引 | 无 fd 概念,直接操作存储/寄存器 | 同 I.MX6ULL |
| 打开方式 | open 返回 fd |
无;用 FatFs 的 f_open() 返回 FIL |
open 返回 fd |
| 读写接口 | read/write(系统调用) |
f_read/f_write(库函数,操作 FILE) |
read/write |
| 偏移定位 | lseek |
f_lseek |
lseek |
| 缓冲 | read/write 无用户缓冲;内核有 page cache | 通常无内核缓冲,由 FatFs 自管 | 同 I.MX6ULL |
| 并发与原子 | 多进程共享文件,存在竞争冒险;O_APPEND/pread 解决 |
一般单任务,竞争问题多由 RTOS 信号量/临界区解决 | 同 I.MX6ULL |
| dup/重定向 | dup/dup2/fcntl |
无此概念 | 同 I.MX6ULL |
| 权限模型 | 有 uid/gid/rwx,open 受权限约束 |
无文件权限概念 | 同 I.MX6ULL |
| 设备访问 | 通过 /dev/xxx + ioctl |
直接读写寄存器或 HAL 库 | 通过 /dev/xxx + ioctl |
| 32/64 位 | 32 位(off_t 注意大文件) |
通常 32 位,无文件系统或 FatFs | 64 位,注意 off_t/size_t 宽度 |
要点:
/dev 下的字符设备时,open/read/write/ioctl 的语义由驱动定义,理解本篇的 fd 与文件表模型是读懂驱动行为的基础。⚠️ 来源说明:本节不属于《I.MX6U嵌入式Linux C应用编程指南》内容,为扩展知识。
open 通常从 3 开始分配?答:文件描述符是内核返回给进程、用于索引已打开文件的非负整数。0、1、2 在进程启动时已被默认占用,分别对应标准输入、标准输出、标准错误(宏 STDIN_FILENO/STDOUT_FILENO/STDERR_FILENO)。fd 从最小可用值开始分配,所以 open 通常得到 3。默认上限 1024(ulimit -n),范围 0~1023;fd 关闭后会被释放并可重新分配。若超过进程最大文件数,内核会发信号并结束进程。
open 的 O_APPEND 和 O_TRUNC 分别有什么作用?为什么说 O_APPEND 实现了原子操作?答:O_TRUNC 在打开文件时将原有内容全部丢弃、文件大小变 0。O_APPEND 使每次 write 都自动把写位置偏移量移动到文件末尾,从文件末尾开始写。之所以说它是原子操作:追加写的本质是"先定位到文件末尾,再写",若分成 lseek + write 两步,在多进程环境下会出现 A 定位后被切走、B 写完、A 回来仍从旧位置写而覆盖 B 数据的竞争冒险。O_APPEND 把这两步合并成一个不可分割的原子操作,从而避免覆盖。补充:O_APPEND 不影响读;且使用 O_APPEND 后,lseek 无法改变写位置。
答:不会有多份动态文件。多次打开同一文件,内核在内存中只维护一份动态文件(数据缓存),多个 fd 共享;但每次 open 会得到不同的 fd 和不同的文件表,而读写偏移量记录在文件表中,所以各 fd 的偏移量相互独立,默认是"分别写"。而通过 dup/dup2 复制得到的 fd 指向同一个文件表,因此共享偏移量,可实现"接续写"。动态文件在引用计数为 0 时被系统关闭。
dup 和 dup2 有什么区别?如何用它们在不用 O_APPEND 的情况下实现接续写?答:dup(oldfd) 复制文件描述符,新 fd 由系统按 fd 分配原则分配,不能指定;dup2(oldfd, newfd) 可手动指定新 fd(须是当前进程未使用的),失败均返回 -1 并设置 errno。二者复制得到的 fd 与原 fd 指向同一个文件表,因此共享文件偏移量、状态标志和访问权限。所以只要 dup/dup2 出第二个 fd,两个 fd 交替 write 时就会接着对方的位置写,实现接续写,而无需 O_APPEND。
open/pread 中分别用什么机制避免?pread 和 lseek+read 有何区别?答:竞争冒险指多个进程/线程并发操作同一共享资源时,因获得 CPU 的先后顺序不可预期,导致结果不可预期。典型场景是两个进程分别"先 lseek 到文件末尾、再 write"做追加写,结果互相覆盖。解决思路是把多步操作合并为原子操作:open 层面用 O_APPEND(把"定位末尾+写"合并),创建文件用 O_EXCL|O_CREAT(把"判断存在+创建"合并);读写层面用 pread/pwrite(把"定位+读写"合并)。pread/pwrite 与 lseek+read/write 的区别有两点:一是定位和读写不可中断、是原子操作;二是不更新文件表中记录的当前位置偏移量(pread 后 lseek(SEEK_CUR) 仍返回 0)。
内容来源:《I.MX6U嵌入式Linux C应用编程指南》第二章 文件I/O基础、第三章 深入探究文件I/O