跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C

Linux 一切皆文件:深入理解文件与文件 IO

Linux 系统下文件抽象为资源交互单元。文章涵盖文件概念、属性及内容组成。对比 C 标准库文件接口与系统调用接口(open/close/read/write)。解析文件描述符分配规则及标准流(stdin/stdout/stderr)。讲解重定向机制与 dup2 函数。深入缓冲区原理,区分行缓冲、全缓冲及无缓冲模式,分析 fork 对缓冲区的影响。旨在帮助开发者深入理解 Linux 文件 IO 底层机制。

DebugKing发布于 2026/3/30更新于 2026/6/420 浏览
Linux 一切皆文件:深入理解文件与文件 IO

Linux 一切皆文件:深入理解文件与文件 IO

一、理解文件

1.1、文件的概念

文件存储在磁盘上。(狭义)

Linux 中一切皆文件,即把所有需要交互的资源全部抽象成为文件:普通文件,目录文件,设备文件,管道文件...。(广义)

1.2、文件的认知

文件 = 内容 + 属性。

内容:文件存储的数据,如文本中的文字,程序二进制代码。

属性:文件的信息,包括文件名、大小、创建时间、权限、所有者等。

对于 0KB 的文件,即没有任何内容,但由于属性数据,所以占磁盘空间。

从系统角度看:对文件的操作其实是进程对文件的操作。

二、回顾 C 文件

2.1、C 文件接口

FILE *fopen(const char *path, const char *mode); // 写文件
int fputc(int character, FILE* stream);
int fputs(const char *s, FILE *stream);
int fprintf(FILE *stream, const char *format, ...);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
// 读文件
int fgetc(FILE* stream);
char *fgets(char *str, int num, FILE *stream);
int fscanf(FILE* stream, const char* format, ...);
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
int fclose(FILE *stream);

2.2、实现 cat 指令

当我们执行 cat log.txt 指令,其实就是读 log.txt 文件。

#include <stdio.h>
#include <string.h>
// argv[0]:./cat
// argv[1]:文件名
int main(int argc, char* argv[]) {
    if(argc != 2) {
        printf("cat error\n");
        return 1;
    }
    FILE* fp = fopen(argv[1], "r"); // 打开文件
    if(fp == NULL) {
        perror("fopen");
        return 2;
    }
    char buff[1024];
    while(1) {
        int ch = fread(buff, 1, sizeof(buff), fp); // 将文件内容写入数组
        if(ch > 0) {
            buff[ch] = 0; // 修正边界
            printf("%s", buff);
        }
        if(feof(fp)) break;
    }
    fclose(fp);
    return 0;
}

2.3、stdin & stdout & stderr

C 语言程序在启动的时候,默认打开了 3 个流:

• **stdin-标准输入流:**在大多数的环境中从键盘输入,scanf 函数就是从标准输入流中读取数据。

• **stdout-标准输出流:**大多数的环境中输出至显示器界面,printf 函数就是将信息输出到标准输出流中。

• **stderr-标准错误流:**大多数环境中输出到显示器界面。

三、系统文件 IO

系统文件 IO 就是通过系统调用的方式实现文件的读和写,C 语言文件读写的接口就是通过底层封装 Linux 系统调用接口实现的。C 语言读写文件通过 w,r,a 等选项确定,而系统调用则是通过传递标志位的方式实现的。

3.1、传递标志位的方法

定义几个宏,每个宏代表一个标志位,而标志位就是只有一个二进制位为 1 的数,当我们将参数 flags 与标志位进行按位与&操作时,只有当 flags 的对应二进制与标志位同时为 1 时,才执行相应的操作。

将文件读写的各种方式都设置一个对应的标志位,通过这种方式我们就可以控制对文件的读和写。

// 标志位
#define FIRST_FLAGS (1<<0)
#define SECOND_FLAGS (1<<1)
#define THIRD_FLAGS (1<<2)
#define FORTH_FLAGS (1<<3)
void Print(int flags) {
    if(flags & FIRST_FLAGS) printf("FIRST_FLAGS : %d\n", FIRST_FLAGS);
    if(flags & SECOND_FLAGS) printf("SECOND_FLAGS : %d\n", SECOND_FLAGS);
    if(flags & THIRD_FLAGS) printf("THIRD_FLAGS : %d\n", THIRD_FLAGS);
    if(flags & FORTH_FLAGS) printf("FORTH_FLAGS : %d\n", FORTH_FLAGS);
}
int main() {
    Print(FIRST_FLAGS);
    Print(FIRST_FLAGS | SECOND_FLAGS);
    Print(FIRST_FLAGS | SECOND_FLAGS | THIRD_FLAGS);
    Print(FIRST_FLAGS | SECOND_FLAGS | THIRD_FLAGS | FORTH_FLAGS);
    return 0;
}

常用的标志位:

创建: O_CREAT,如对应 c 文件在以 "w" 方式打开,当文件不存在就会新建。

写: O_WRONLY,对应 "w"。

读: O_RDONLY,对应 "r"。

追加: O_APPEND,对应 "a"。

清空: O_TRUNC,当以 "w" 方式写文件时,就会将文件的内容先清空。

3.2、系统调用接口

1、open——打开文件

参数:

(1)**pathname:**文件名(路径可带也可不带);

(2)**flags:**标志位;

• 当我们读文件即为:O_RDONLY; • 写文件:O_CREAT | O_WRONLY | O_TRUNC; • 追加写文件:O_CREAT | O_WRONLY | O_APPEND。

(3)**mode:**权限位。

如果不传该参数,且文件不存在,创建的文件默认初始权限为:-r-xr-x--x

所以如果文件不存在,我们就需要设置权限位,而我们前面已经介绍过,修改文件权限可以直接用八进制数。通过 0666 就可以正常设置普通文件的权限。

返回值:文件描述符。

文章配图

2、close——关闭文件

参数即为要关闭文件的文件描述符,open 函数的返回值。

文章配图

3、write——写文件

write() 函数会从指针 buf 指向的缓冲区中,向文件描述符 fd 所引用的文件写入最多 count 个字节的数据。而且系统其实并不关心写入数据的类型。

文章配图

4、read——读文件

read() 函数尝试从文件描述符 fd 所指向的文件中,读取最多 count 个字节的数据,并将其存入以 buf 为起始地址的缓冲区中。

3.3、文件描述符

open 函数返回值即为创建或打开文件的文件描述符,但我们注意到这个数字是 3 而不是其他。

这是为什么?

当启动一个 Shell(比如 Bash)时,Shell 进程本身会默认打开这三个标准流,即标准输入流,标准输出流和标准错误流。而后续在这个 Shell 中启动的子进程,也会继承这三个已打开的流。

这三个流其实也是文件,对应文件描述符为 0,1 和 2。

标准输入(stdin, fd=0) → 默认绑定到键盘 标准输出(stdout, fd=1) → 默认绑定到显示器 标准错误(stderr, fd=2) → 默认绑定到显示器

后面再打开文件,自然就对应 3,4... 了。

而我们在 c 语言中打开文件时,是用 FILE* 指针指向我们打开的文件,但是在底层一个整数即代表打开的文件。0,1,2,3... 是不是跟数组下标又有什么关系?这就涉及到操作系统对文件的管理。

当进程打开多个文件,怎么管理:先描述,再组织。

将文件的特性用一个结构体进行描述,用一个指针指向 file 结构体,然后将这个指针放在文件描述符表中,就可以进行管理了。

// 路径:include/linux/fs.h
struct file {
    union {
        struct llist_node fu_llist;
        struct rcu_head fu_rcuhead;
    } f_u;
    struct path f_path; // 文件的路径(包含 dentry 和 vfsmount)
    const struct file_operations *f_op; // 文件操作方法(read/write 等)
    spinlock_t f_lock; // 保护该结构体的自旋锁
    atomic_long_t f_count; // 引用计数(被多少进程打开)
    unsigned int f_flags; // 文件打开时的标志(O_RDONLY/O_WRONLY/O_APPEND 等)
    fmode_t f_mode; // 文件的访问模式(读/写/执行权限)
    loff_t f_pos; // 当前读写位置(文件指针)
    struct fown_struct f_owner; // 信号异步 IO 相关的所有者信息
    const struct cred *f_cred; // 文件的安全凭证
    struct file_ra_state f_ra; // 预读状态
    void *private_data; // 驱动/文件系统的私有数据
};

文章配图

此时,我们也就懂了为什么 log.txt 文件的文件标识符为 3 了。

文件描述符分配规则:

创建文件后,操作系统遍历文件标识符表,找到的第一个空位置就用来存放指向该文件 file 结构体的指针。

验证:我们手动关闭标准输入流文件,然后创建 log.txt 文件,观察 log.txt 文件的文件标识符。

3.4、重定向

回顾以前的重定向操作:

• 输入重定向 <:将键盘读入改成从文件读入。

• 输出重定向:将向显示器输出改为向文件输出,> 覆盖写入和 >> 追加写入。

现在我们已经知道 fd_array[0] 指向标准输入流文件,fd_array[1] 指向标准输出流文件,fd_array[2] 指向标准错误流文件。所以,我们只需要让原来指向对应流的指针指向我们的文件即可,因此:

输入重定向:

文章配图

输出重定向:

文章配图

重定向函数——dup2

dup2 为系统接口,用来将 oldfd 重定向到 newfd。

文章配图

对于输入重定向:oldfd = fd,newfd = 0;即 dup2(fd, 0)

输出重定向:oldfd = fd,newfd = 1;即 dup2(fd, 1)

根据文件描述符分配规则,如果我们先关闭标准输出流,则新创建的文件的 fd = 1,此时我们再向标准输出流打印数据,实际上也会重定向到该文件。

int main() {
    // 关闭标准输出流
    close(1);
    int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    printf("xxxxxxxxxx\n");
    fprintf(stdout, "sssssssssss\n");
    // close(fd);
    return 0;
}

文章配图

***注意:***有一个细节,我们最后不关闭 fd 文件,这里涉及缓冲区刷新的问题,下面会讲到。

补充:标准错误重定向

直接看效果:标准输出流重定向 ./myfie > log.txt,其实真正的写法为:./myfile 1 > log.txt

只是把 1 省略不写。

int main() {
    printf("wwwwwwww\n");
    fprintf(stdout, "ssssssssss\n");
    const char *s = "hello Linux\n";
    fwrite(s, strlen(s), 1, stdout);
    return 0;
}

文章配图

标准错误流与标准输出流一样,都和显示器绑定。因此,重定向操作为:

./myfile 2>log.err

注意: 2>log.err 之间没有空格。

将标准输出流内容与标准错误流的内容全部重定向到一个文件:

❌ ./myfile 1>log.txt 2>log.txt:第二次重定向时即第二次打开文件,先清空之前内容再写入,无法将所有内容重定向到 log.txt 文件。

✔️ ./myfile 1>>log.txt 2>>log.txt:追加重定向。

💡还有一种写法:./myfile 1>log.txt 2>&1

3.5、理解一切皆文件

Linux 中键盘,显示器,磁盘,网卡等外设,也被抽象为文件,来方便操作系统管理(先描述,再组织)。但是对于不同的外设,读写方式不同,而在 file 结构体中还有一个东西:f_op 指针

其类型为 const struct file_operations,在 struct file_operations 结构体中的成员除了 struct module* owner 其余都是函数指针,这些函数指针可以指向不同外设的读写的函数。

file_operation 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都 对应着一个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从而 完成了 Linux 设备驱动程序的工作。

struct file { 
    // ...
    const struct file_operations *f_op; // 文件操作方法(read/write 等)
    // ...
};
struct file_operations {
    struct module *owner; // 指向拥有该模块的指针;
    loff_t (*llseek) (struct file *, loff_t, int); // llseek 方法用作改变文件中的当前读/写位置,并且新位置作为 (正的) 返回值.
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); // 用来从设备中获取数据
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); // 发送数据给设备。如果 NULL, -EINVAL 返回给调用 write 系统调用的程序。如果非负,返回值代表成功写的字节数.
    ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t); // 初始化一个异步读 -- 可能在函数返回前不结束的读操作.
    ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t); // 初始化设备上的一个异步写.
    int (*readdir) (struct file *, void *, filldir_t); // 对于设备文件这个成员应当为 NULL; 它用来读取目录,并且仅对文件系统有用.
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *); // mmap 用来请求将设备内存映射到进程的地址空间。如果这个方法是 NULL, mmap 系统调用 返回 -ENODEV.
    int (*open) (struct inode *, struct file *); // 打开一个文件
    int (*flush) (struct file *, fl_owner_t id); // flush 操作在进程关闭它的设备文件描述符的拷贝时调用;
    int (*release) (struct inode *, struct file *); // 在文件结构被释放时引用这个操作。如同 open, release 可以为 NULL.
    int (*fsync) (struct file *, struct dentry *, int datasync); // 用户调用来刷新任何挂着的数据.
    int (*aio_fsync) (struct kiocb *, int datasync);
    int (*fasync) (int, struct file *, int);
    int (*lock) (struct file *, int, struct file_lock *); // lock 方法用来实现文件加锁; 加锁对常规文件是必不可少的特性,但是设备驱动几乎从不实现它.
    ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
    int (*check_flags)(int);
    int (*flock) (struct file *, int, struct file_lock *);
    ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
    ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
    int (*setlease)(struct file *, long, struct file_lock **);
};

文章配图

甚至管道,也是文件;将来我们要学习网络编程中的 socket(套接字)这样的东西,使用的接口跟文件接口也是一致的。

四、缓冲区

4.1、什么是缓冲区?

缓冲区(Buffer) 是内存中开辟的一块临时存储区域,核心作用是协调两个速度不匹配的设备 / 组件之间的数据传输,通过'批量读写'替代'逐字节读写'减少高频交互的开销,提升整体效率。

缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。

4.2、为什么要有缓冲区?

⚠️你往硬盘写数据(硬盘速度:MB/s 级),内存速度是 GB/s 级,两者速度差上万倍;如果逐字节写,内存要等硬盘每次写完,大部分时间都在闲置;

先把数据写到内存缓冲区,攒够一批再一次性写入硬盘,内存不用频繁等待,硬盘也能批量处理,整体效率大幅提升。

⚠️我们向显示器打印,如果每调用一次 fprintf 就对应调用一次系统调用 write,那么系统调用的频率就会大大增加。我们知道操作系统是非常忙的,而这样就会降低操作系统的效率。

所以在用户层,设置用户态缓冲区(C 标准库封装,针对 FILE*,C 标准库(<stdio.h>)为每个 **FILE***对象(如 stdin/stdout/stderr)维护的一块内存区域);可以看看 FILE 结构体:

// 在/usr/include/stdio.h
typedef struct _IO_FILE FILE;
// 在/usr/include/libio.h
struct _IO_FILE {
    int _flags; /* High-order word is _IO_MAGIC; rest is flags.*/
    #define _IO_file_flags _flags // 缓冲区相关
    /* The following pointers correspond to the C++ streambuf protocol. */
    /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
    char* _IO_read_ptr; /* Current read pointer */
    char* _IO_read_end; /* End of get area. */
    char* _IO_read_base; /* Start of putback+get area. */
    char* _IO_write_base; /* Start of put area. */
    char* _IO_write_ptr; /* Current put pointer. */
    char* _IO_write_end; /* End of put area. */
    char* _IO_buf_base; /* Start of reserve area. */
    char* _IO_buf_end; /* End of reserve area. */
    /* The following fields are used to support backing up and undo. */
    char *_IO_save_base; /* Pointer to start of non-current get area. */
    char *_IO_backup_base; /* Pointer to first valid character of backup area */
    char *_IO_save_end; /* Pointer to end of non-current get area. */
    struct _IO_marker *_markers;
    struct _IO_FILE *_chain;
    int _fileno; // 封装的文件描述符
    #if 0
    int _blksize;
    #else
    int _flags2;
    #endif
    _IO_off_t _old_offset; /* This used to be _offset but it's too small. */
    #define __HAVE_COLUMN /* temporary */
    /* 1+column number of pbase(); 0 is unknown. */
    unsigned short _cur_column;
    signed char _vtable_offset;
    char _shortbuf[1];
    // char* _save_gptr;
    // char* _save_egptr;
    _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

当调用库函数(fopen/fwrite/printf/fputs 等)时,先将内容存储到缓冲区,然后按照一定的规则进行批量化的刷新,将数据刷新到文件内核缓冲区,就会大大降低系统调用的次数,从而提高效率。

行缓冲(stdout 默认):缓冲区遇到换行符 、缓冲区满、调用 fflush()/fclose() 时,才会把数据批量传给内核;无缓冲(stderr 默认):数据不经过缓冲区,调用 fprintf(stderr) 时直接传给内核,这也是错误信息能实时输出的原因;全缓冲(普通文件默认):只有缓冲区满或调用 fflush()/fclose() 时,才会批量传给内核(缓冲区大小一般为 4KB/8KB)。

计算机数据流动的本质:拷贝!!!

深入了解缓冲区

我们来看这样一段代码:

int main() {
    // 库函数
    printf("hello printf\n");
    fprintf(stdout,"hello fprintf\n");
    const char *s = "hello fwrite\n";
    fwrite(s, strlen(s), 1, stdout);
    // 系统调用
    const char* ss = "hello write\n";
    write(1, ss, strlen(ss));
    fork();
    return 0;
}

文章配图

我们发现 printf 和 fwrite(库函数)都输出了 2 次,而 write 只输出了一次(系统调用)。为什么呢?肯定和 fork 有关!

• 一般 C 库函数写入文件时是全缓冲的,而写入显示器是行缓冲。

• printf fwrite 库函数 + 会自带缓冲区,当发生重定向到普通文 件时,数据的缓冲方式由行缓冲变成了全缓冲。

• 而我们放在缓冲区中的数据,就不会被立即刷新,甚至 fork 之后。

• 但是进程退出之后,会统一刷新,写入文件当中。

• 而 fork 的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。

• write 没有变化,说明没有所谓的缓冲。

目录

  1. Linux 一切皆文件:深入理解文件与文件 IO
  2. 一、理解文件
  3. 1.1、文件的概念
  4. 1.2、文件的认知
  5. 二、回顾 C 文件
  6. 2.1、C 文件接口
  7. 2.2、实现 cat 指令
  8. 2.3、stdin & stdout & stderr
  9. 三、系统文件 IO
  10. 3.1、传递标志位的方法
  11. 3.2、系统调用接口
  12. 1、open——打开文件
  13. 2、close——关闭文件
  14. 3、write——写文件
  15. 4、read——读文件
  16. 3.3、文件描述符
  17. 文件描述符分配规则:
  18. 3.4、重定向
  19. 重定向函数——dup2
  20. 补充:标准错误重定向
  21. 3.5、理解一切皆文件
  22. 四、缓冲区
  23. 4.1、什么是缓冲区?
  24. 4.2、为什么要有缓冲区?
  25. 深入了解缓冲区
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • 网络安全行业发展前景与零基础转行指南
  • 基于 AI 辅助开发电商系统核心模块实战:商品、购物车与订单流程
  • 词岛 AI 学术写作工具核心功能与使用解析
  • OmniInsert:借助扩散变换器模型实现任意参考对象的无掩码视频插入
  • K-RagRec:基于知识图谱检索增强生成的 LLM 推荐系统
  • Vue Print Designer 前端可视化打印设计器详解
  • OpenClaw 接入 QVeris:让 AI 助手具备实时数据查询能力
  • Windows 系统安装 Neo4j 图数据库图文教程
  • 使用 Trae 插件 Builder 模式开发端午包粽子小游戏
  • C++ 哈希表核心机制:冲突处理与负载因子
  • GPT-OSS-20B 模型本地部署及 WebUI 交互指南
  • UniApp 打包鸿蒙应用流程与系统权限配置
  • C++ 类和对象(中)
  • Flutter 集成 google_generative_language_api 适配鸿蒙实现 AI
  • ChatGPT 如何利用结构化原则实现高效信息管理
  • 前端核心知识点梳理与面试复习
  • 基于视觉的增强现实特效技术解析
  • AI“代笔”的困境与破局:百考通AI如何理性应对论文查重与AIGC检测
  • 前端面试高频场景题汇总
  • C++ STL 源码解析:基于红黑树实现 map 和 set

相关免费在线工具

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

  • JSON 压缩

    通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online

  • JSON美化和格式化

    将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online