Linux中基础IO

一、回顾C语言文件接口

1. 打开文件

cpp 复制代码
#include <stdio.h>

int main() {
    FILE *fp = fopen("myfile", "w");
    if (!fp) {
        printf("fopen error!\n");
        return 1;
    }
    fclose(fp);
    return 0;
}

问题myfile 会被创建在哪里?

在当前进程的"当前工作目录"下

那么系统如何知道进程的当前路径?

Linux 提供了 /proc 文件系统,可以查看运行中进程的信息:

cpp 复制代码
ps ajx | grep myProc
ls /proc/533463 -l

重点关注两个符号链接:

  • cwd → 当前进程的工作目录

  • exe → 当前进程的可执行文件路径

✅ 打开文件,本质是进程打开文件 ,因此即使不带路径,进程也能通过 cwd 知道文件应该放在哪里。

2. 写文件

cpp 复制代码
const char *msg = "hello bit!\n";
int count = 5;
while (count--) {
    fwrite(msg, strlen(msg), 1, fp);
}
  • fwrite 是按二进制块写入,适合结构体、固定长度数据。

  • 写入的是原始内存数据,不自动加 \0

3. 读文件

cpp 复制代码
char buf[1024];
while (1) {
    size_t s = fread(buf, 1, sizeof(buf), fp);
    if (s > 0) {
        buf[s] = 0;
        printf("%s", buf);
    }
    if (feof(fp)) break;
}
  • fread 返回实际读取的字节数。

  • feof 判断是否读到文件末尾。

4. 输出到显示器:三种标准方式

cpp 复制代码
fwrite(msg, strlen(msg), 1, stdout);  // 二进制写
printf("hello printf\n");              // 格式化输出
fprintf(stdout, "hello fprintf\n");   // 文件流方式
  • stdin / stdout / stderr 都是 FILE* 类型,默认已打开。

5. 打开方式总结

模式 含义
r 只读,位置在文件开头
r+ 读写,位置在文件开头
w 只写,文件长度截断或创建
w+ 读写,截断或创建
a 追加,位置在文件末尾
a+ 读写追加,读从开头,写从末尾

用w文件会先被清空然后再进行写入

二、系统文件I/O

1. 标志位的传递方式

系统调用中经常需要传递多个选项,比如 O_WRONLY | O_CREAT。这种写法依赖位掩码技术:

cpp 复制代码
#define ONE   0001  // 二进制 0001
#define TWO   0002  // 二进制 0010
#define THREE 0004  // 二进制 0100

void func(int flags) {
    if (flags & ONE)   printf("ONE ");
    if (flags & TWO)   printf("TWO ");
    if (flags & THREE) printf("THREE ");
}

func(ONE | TWO);        // 输出:ONE TWO

每个标志位独占一个 bit,通过 | 组合,通过 & 检测,高效且直观。

2. 接口介绍

2.1 open接口

函数原型

cpp 复制代码
#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);

参数详解

参数 类型 说明
pathname const char* 要打开或创建的文件路径(相对或绝对路径)
flags int 文件打开方式标志位(见下表)
mode mode_t 创建文件时的权限(仅当 flags 包含 O_CREAT 时使用)

flags 常用标志位

标志 含义 说明
访问模式(三选一)
O_RDONLY 只读打开 不能写
O_WRONLY 只写打开 不能读
O_RDWR 读写打开 可读可写
可选标志(可组合)
O_CREAT 文件不存在则创建 需提供 mode 参数
O_APPEND 追加模式 每次写入自动到文件末尾
O_TRUNC 截断 打开文件时清空内容
O_EXCL 排他创建 O_CREAT 一起用,文件已存在则失败
O_SYNC 同步写入 write 等待数据落盘才返回
O_NONBLOCK 非阻塞模式 用于设备文件或管道
O_CLOEXEC 执行时关闭 forkexec 时自动关闭

mode 权限详解

cpp 复制代码
// mode 格式:八进制数,三位分别表示:所有者、所属组、其他人
0644  // 二进制:110 100 100
      // 所有者:rw- (6)
      // 所属组:r-- (4)
      // 其他人:r-- (4)

返回值

返回值 含义
>= 0 成功,返回文件描述符(非负小整数)
-1 失败,设置 errno,可用 perror() 查看错误

完整示例

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>

int main() {
    // 读写打开,不存在则创建,存在则清空
    int fd = open("test.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        fprintf(stderr, "open failed: %s\n", strerror(errno));
        return 1;
    }
    printf("open success, fd = %d\n", fd);
    
    // ... 使用 fd 进行读写 ...
    
    close(fd);
    return 0;
}
2.2 close 接口

函数原型

cpp 复制代码
#include <unistd.h>

int close(int fd);

参数

参数 说明
fd 要关闭的文件描述符(由 open 返回)

返回值

返回值 含义
0 成功关闭
-1 失败(如 fd 无效),设置 errno
2.3 read 接口
cpp 复制代码
#include <unistd.h>

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

参数详解

参数 类型 说明
fd int 已打开的文件描述符
buf void* 用户缓冲区指针,用于存放读取的数据
count size_t 要读取的字节数(不能超过 buf 大小)

返回值详解

返回值 含义
> 0 成功读取的字节数(可能小于 count
0 读到文件末尾(EOF)
-1 失败,设置 errno
2.4 write接口

函数原型

cpp 复制代码
#include <unistd.h>

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

参数详解

参数 类型 说明
fd int 已打开的文件描述符(必须是写模式打开)
buf const void* 要写入的用户数据缓冲区
count size_t 要写入的字节数

返回值

返回值 含义
> 0 实际写入的字节数(可能小于 count
-1 失败,设置 errno

3. files_struct文件管理

核心结构 :每个进程的 task_struct 中包含 files_struct,它管理进程打开的所有文件

cpp 复制代码
进程 task_struct
    │
    └── files_struct
            │
            └── fd_array[] (文件描述符表)
                  │
    ┌─────────────┼─────────────┬─────────────┐
    │             │             │             │
  fd[0]         fd[1]         fd[2]         fd[3]
    │             │             │             │
    ▼             ▼             ▼             ▼
 ┌──────┐     ┌──────┐     ┌──────┐     ┌──────┐
 │struct│     │struct│     │struct│     │struct│
 │ file │     │ file │     │ file │     │ file │
 │ (终端│     │ (终端│     │ (终端│     │(文件 │
 │ 输入)│     │ 输出)│     │ 错误)│     │ out) │
 └──────┘     └──────┘     └──────┘     └──────┘
    │            │            │            │
    ▼            ▼            ▼            ▼
 键盘          屏幕          屏幕          磁盘文件

说明

  • 每个 fd 是数组下标(0,1,2,3...)

  • 数组元素指向内核中的 struct file 对象

  • struct file 再指向具体的物理设备或文件

磁盘文件会将数据放在文件缓冲区中,一个struct file对应一个文件缓冲区,对文件内容做任何操作,都必须先把文件加载到内核对应的文件缓冲区内。

4. 重定向原理

cpp 复制代码
【执行前 - 终端输出】
                    ┌─────────────────┐
进程 files_struct   │  内核文件表       │
                    │                  │
fd[0] ────────────→│ stdin (键盘)     │
fd[1] ────────────→│ stdout (终端)    │ ← ls 写入这里
fd[2] ────────────→│ stderr (终端)    │
fd[3] ────────────→│ out.txt (文件)   │
                    └─────────────────┘

【执行 dup2(3, 1) 后】
                    ┌─────────────────┐
进程 files_struct   │  内核文件表       │
                    │                  │
fd[0] ────────────→│ stdin (键盘)     │
fd[1] ────────────→│ out.txt (文件)   │ ← ls 重定向写到这里
fd[2] ────────────→│ stderr (终端)    │
fd[3] ────────────→│ out.txt (文件)   │
                    └─────────────────┘

【结果】ls 写入 fd[1] → 实际写入 out.txt 文件

重定向的本质是:改变文件描述符与文件之间的映射关系。

5. dup2操作

dup2 可以让我们显式地"复制"一个文件描述符:

cpp 复制代码
#include <unistd.h>
int dup2(int oldfd, int newfd);

它的作用:让 newfd 指向 oldfd 所指向的文件。如果 newfd 已经打开,会先关闭它。

输出重定向示例

cpp 复制代码
int fd = open("output.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
dup2(fd, 1);   // 让 fd=1 指向 output.txt
printf("这条消息会写入文件,而不是屏幕");
fflush(stdout);

输入重定向示例

cpp 复制代码
int fd = open("input.txt", O_RDONLY);
dup2(fd, 0);   // 让 fd=0 指向 input.txt
char buf[256];
fgets(buf, sizeof(buf), stdin);  // 实际从文件读取

追加重定向

只需在 open 时加上 O_APPEND 标志,再用 dup2 重定向 1

cpp 复制代码
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0644);
dup2(fd, 1);

6. 重定向操作

在 Linux 中,每个程序运行时默认打开三个特殊文件:

  • 标准输入 (stdin) → 文件描述符 0

  • 标准输出 (stdout) → 文件描述符 1coutprintf

  • 标准错误 (stderr) → 文件描述符 2cerrperror

通过重定向,我们可以把它们分别导向文件或合并。

1. 只重定向标准输出
bash 复制代码
./stream > log.txt
//也可以写成
./stream 1>log.txt
  • > 等价于 1>

  • 仅将 stdout 写入 log.txtstderr 仍显示在终端

2. 有标准输出和标准错误,只重定向标准输出
bash 复制代码
./stream
hello cout
hello printf
hello cerr
hello stderr

./stream > log.txt
hello cerr
hello stderr

./stream 正常运行时,cout/printf(标准输出)和 cerr/stderr(标准错误)都打印到终端。

./stream > log.txt 只把标准输出(1>)重定向到 log.txt 文件,没有重定向标准错误(2>,所以:

hello couthello printf 写入 log.txt(不在屏幕显示)

hello cerrhello stderr 仍然直接输出到终端(屏幕)

3. 分别重定向 stdout 和 stderr
bash 复制代码
./stream 1>log.normal 2>log.error
  • 1>:标准输出 → log.normal

  • 2>:标准错误 → log.error

4. 将 stdout 和 stderr 重定向到同一个文件(覆盖/追加)
bash 复制代码
# 方法一:分别重定向但使用同一个文件(追加模式)
./stream 1>log.normal 2>>log.normal

# 方法二:将 stderr 合并到 stdout(常用)
./stream 1>log.normal 2>&1
  • 2>> 表示追加到文件,避免覆盖

  • 2>&1 表示将 stderr 重定向到 stdout 当前指向的位置

三、理解"一切皆文件"

什么是"一切皆文件"?

在 Windows 中,只有常规文件才是"文件"。

但在 Linux 中:

  • 普通文件是文件(如文本、二进制)

  • 目录是文件

  • 硬件设备(如磁盘、显示器、键盘)也被抽象为文件

  • 进程信息 (如 /proc 下的内容)是文件

  • 管道、Socket 也是文件

你能用 read() 读取一个文件,也能用 read() 读取键盘输入、管道数据、甚至网络数据。

统一接口带来的巨大好处

开发者只需掌握一套 API(如 openreadwriteclose),就能操作 Linux 中绝大部分资源。

  • 几乎所有读取 操作:read()

  • 几乎所有写入 操作:write()

这一统一抽象极大降低了学习成本和开发复杂性。

内核中的关键数据结构:struct filestruct file_operations

Linux 内核为每个打开的文件维护一个 struct file,其中包含文件的状态、位置、标志等。

cpp 复制代码
struct file {
    struct inode *f_inode;
    const struct file_operations *f_op;
    unsigned int f_flags;
    fmode_t f_mode;
    loff_t f_pos;
    // ...
};

其中最关键的成员是 f_op ,它指向一个 struct file_operations,这个结构体里几乎全是函数指针:

cpp 复制代码
struct file_operations {
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    // ...
};

系统调用与驱动的桥梁

  • 每个设备、文件类型都可以自定义自己的 read / write 实现

  • 当你调用 read() 时,内核会通过 file->f_op->read 找到真正属于该设备的读取方法

  • 这就是 多态 在内核中的体现

尽管键盘、显示器、普通文件的读写方式完全不同,但开发者看到的接口是统一的。

一张图理解

cpp 复制代码
进程调用 read()
      ↓
内核中的 struct file
      ↓
f_op 指向 struct file_operations
      ↓
调用该结构中的 read() 函数指针
      ↓
真正执行:普通文件 / 键盘 / 管道 / Socket 的读取逻辑

四、缓冲区

什么是缓冲区?

缓冲区是内存中预留的一片区域,用于暂存输入或输出数据。根据用途,可分为输入缓冲区输出缓冲区。它像是一个"中转站",在 CPU 与慢速设备(如磁盘、打印机)之间协调数据流动。

为什么要引入缓冲区?

如果没有缓冲区,每次读写文件都需要直接调用系统调用(如 read / write),这会频繁触发 CPU 从用户态切换到内核态,导致上下文切换开销巨大,严重影响程序性能。

引入缓冲区后,可以:

  • 减少系统调用次数:一次读取大量数据到缓冲区,后续操作直接从缓冲区读取。

  • 提高 CPU 利用率:CPU 无需等待慢速 I/O 设备,可以继续处理其他任务。

  • 协调速度差异:缓冲区的读写速度远快于磁盘,显著提升整体效率。

💡 举个例子:打印文档时,数据先写入缓冲区,打印机慢慢处理,CPU 则可以继续执行其他任务。

缓冲区刷新触发条件:

  1. 强制刷新

  2. 刷新条件满足(满足这个条件有下面三种情况)

  3. 进程退出

缓冲区的三种类型(标准 I/O)

类型 刷新条件 典型场景
全缓冲 缓冲区满后才执行系统调用 磁盘文件操作
行缓冲 遇到换行符或缓冲区满(默认 1024 字节) 终端输入输出(如 stdin、stdout)
无缓冲 立即执行系统调用 标准错误流 stderr
  • 全缓冲的效率最高,普通文件一般用这种方式
  • 行缓冲一般是显示器用
  • 无缓冲就是立即刷新,写透模式WT

一个小实验:重定向带来的缓冲陷阱

cpp 复制代码
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("hello world: %d\n", fd);
close(fd);

现象log.txt 中没有任何内容。

原因 :文件描述符 1(stdout)被重定向到磁盘文件后,缓冲方式从行缓冲 变为全缓冲printf 的内容未填满缓冲区,因此未写入文件。

解决方法 :调用 fflush(stdout) 强制刷新。

stderr 是无缓冲的,因此重定向 2 号描述符后,perror 的内容会立即写入文件。

两级缓冲机制

在 Linux 系统中,数据从应用程序写入磁盘通常要经过两层缓冲区:

  1. 用户级缓冲区 (C 标准库提供,如 FILE 结构中的缓冲区)

  2. 内核级缓冲区(页缓存 Page Cache,由操作系统内核管理)

数据流向如下:

cpp 复制代码
应用程序
   ↓
用户级缓冲区(printf/fwrite 写入)
   ↓
内核缓冲区(页缓存)
   ↓
磁盘设备

为什么需要两层缓冲?

层级 提供者 作用 刷新方式
用户级缓冲区 C 标准库 减少系统调用次数 行缓冲/全缓冲/无缓冲,fflush 强制刷新
内核级缓冲区 OS 内核 减少磁盘 I/O 次数,提高磁盘读写性能 由内核管理(如 fsync 强制落盘)

💡 关键区别

  • fflush 只将数据从用户级缓冲区刷新到内核缓冲区,并不保证数据写入磁盘。

  • fsync / sync 才会强制将内核缓冲区的数据写入磁盘。

图示:数据写入全流程

cpp 复制代码
+-------------------+     +-------------------+
|   应用程序代码     |     |                   |
|  printf("hello")  |     |                   |
+--------+----------+     |    用户空间        |
         |                |                   |
         v                |                   |
+-------------------+     |                   |
| C 标准库 FILE 缓冲区 |     |                   |
|   (用户级缓冲)     |     |                   |
+--------+----------+     |                   |
         |                |                   |
         | fflush()        |                   |
         v                |                   |
+-------------------+     |                   |
|     系统调用        |     |                   |
|   write(fd, ...)   |     |                   |
+--------+----------+     +-------------------+
         |
         | 系统调用(用户态 → 内核态)
         v
+-------------------+     +-------------------+
|   内核页缓存       |     |    内核空间        |
|  (Page Cache)    |     |                   |
+--------+----------+     |                   |
         |                |                   |
         | fsync()         |                   |
         v                |                   |
+-------------------+     |                   |
|     磁盘设备        |     |                   |
|   (物理存储)       |     |                   |
+-------------------+     +-------------------+

数据交给计算机,交给硬件本质全是拷贝

计算机数据流动的本质:一切皆拷贝

五、FILE 结构与用户级缓冲区

FILE 是什么?

FILE 是 C 标准库中定义的一个结构体,用于管理文件流。它封装了:

  • 文件描述符 _fileno

  • 用户级缓冲区的指针(读/写指针、基址、边界等)

  • 缓冲区大小、标志位、锁等

FILE 的形成过程(简化)

  1. 调用 fopen

    cpp 复制代码
    FILE *fp = fopen("file.txt", "r");
  2. fopen 内部执行

    • 调用 open 系统调用,获得一个文件描述符 fd

    • 动态分配 FILE 结构体(通常在堆上)

    • fd 赋值给 FILE_fileno 字段

    • 根据打开模式(如行缓冲、全缓冲)初始化缓冲区(可选,默认可能分配 BUFSIZ 字节)

    • 返回 FILE* 指针

  3. 使用时 (如 fread / fwrite / printf):

    • 先操作 FILE 内部的用户缓冲区

    • 必要时调用 write / read 系统调用

  4. 调用 fclose

    • 刷新用户缓冲区(fflush

    • 调用 close 系统调用关闭文件描述符

    • 释放 FILE 结构体内存

图示:

cpp 复制代码
用户调用 fopen("log.txt", "w")
       │
       ▼
┌──────────────────────────────────────────────┐
│  glibc 中的 fopen 实现                         │
│  1. 调用 open() 系统调用                        │
│     └─→ 内核创建 struct file,分配 fd(如 3)   │
│  2. 分配 FILE 结构体(用户态堆内存)             │
│     └─→ 填充 _fileno = fd                      │
│     └─→ 分配缓冲区(如 1024 字节)              │
│     └─→ 设置缓冲类型(默认全缓冲或行缓冲)       │
│  3. 返回 FILE* 指针                             │
└──────────────────────────────────────────────┘

FILE 结构体关键成员

cpp 复制代码
// /usr/include/libio.h
struct _IO_FILE {
    int _flags;           // 文件状态标志
    char* _IO_read_ptr;   // 读缓冲区当前指针
    char* _IO_read_end;   // 读缓冲区结束位置
    char* _IO_read_base;  // 读缓冲区基址
    char* _IO_write_base; // 写缓冲区基址
    char* _IO_write_ptr;  // 写缓冲区当前指针
    char* _IO_write_end;  // 写缓冲区结束位置
    char* _IO_buf_base;   // 缓冲区基址
    char* _IO_buf_end;    // 缓冲区结束位置
    int _fileno;          // ← 封装的文件描述符(核心!)
    // ... 其他成员
};

一个有趣的 fork 现象

cpp 复制代码
printf("hello printf\n");
fwrite("hello fwrite\n", ...);
write(1, "hello write\n", ...);
fork();

正常运行输出一次,但重定向到文件后,printffwrite 输出两次,write 只输出一次。

原因

  • 重定向到文件后,库函数缓冲变为全缓冲。

  • fork 时,子进程复制了父进程的缓冲区(写时拷贝)。

  • 进程退出时,父子进程各自刷新缓冲区,导致重复输出。

  • write 无缓冲区,不受影响。

✅ 这说明:用户级缓冲区由 C 标准库提供,位于系统调用之上。

相关推荐
满怀冰雪1 小时前
第29篇-状态压缩DP-当状态很多时如何降维优化
java·算法·动态规划
令狐掌门1 小时前
2026华为OD面试题020:日志文件异常检测
算法·leetcode·华为od
七夜zippoe1 小时前
DolphinDB告警分析:告警统计与趋势
运维·服务器·统计·告警·趋势·dolphindb
炸薯条!1 小时前
从零开始学C++(4) --类和对象
开发语言·c++·算法
渡我白衣1 小时前
打印宏与socket模块设计
java·linux·开发语言·c++·ide·人工智能·eclipse
程序员老油条1 小时前
用 n8n 自建自动化:GitHub Push 后自动部署到服务器
服务器·自动化·github
haluhalu.2 小时前
prompts.chat:07-few-shot-prompting
人工智能·算法
AI创界者2 小时前
打造内网安全防线:使用 Kali Linux 进行企业级漏洞风险自查与防御指南
linux·运维·安全
Jerry2 小时前
LeetCode 150. 逆波兰表达式求值
算法