Linux系统:基础I/O
文章目录
- 前言
- 一,理解文件?
- 二,C语言的文件操作
- 2.1 C语言打开文件的方式
- 三,系统文件I/O
- 3-1 ⼀种传递标志位的方法
- 3-2 写文件
- 3-3 读文件
- 3-4 接口介绍
- 3-5 open函数的返回值
- 3-6 文件描述符
- 3-6-1 文件描述符是什么?
- 3-6-2 1 & 2 &3
- 3-6-3 文件描述符的分配规则
- 3-6-4 重定向
- 四,一切皆文件
- 4-1 概念
- 4-2内核源代码理解
- 五,缓冲区
- 5-1 什么是缓冲区?
- 5-2 为什么需要缓冲区?
- 5-3 缓冲类型
- 5-4 FILE
- 5-4-1 库函数会自带缓冲区,而系统调用不会
- 5-4-2 fd
前言
学习Linux的基础I/O可以让我们理解文件本质,提高系统操作能力, 写出高质量的 C/C++ 系统程序,理解命令工具背后的机制,系统调优 & 性能优化,开发脚本、工具、守护进程的基础能力,为后续高级知识打基础,Linux 的基础 I/O 是“系统之眼”
,是你进入操作系统底层世界的钥匙。
一,理解文件?
理解 Linux 系统中的“文件”,是掌握 Linux 的核心概念之一,因为在 Linux 中,“一切皆文件”
。要全面理解 Linux 中的文件,可以从以下几个层面来思考:
- 普通文件: 一般的数据文件、文本文件 比如:/etc/passwd, a.txt
- 目录文件:其实是保存文件名 -> inode的映射 比如:/home/, /usr/bin/
- 设备文件:硬件设备的接口(字符设备、块设备) 比如:/dev/sda, /dev/null
- 套接字文件: 本地进程间通信(socket 比如: /tmp/mysocket
- 链接文件:软/硬链接 比如: /usr/bin/python -> python3.8
所以 Linux 把一切资源都抽象为文件,包括键盘、鼠标、网络接口、内存、终端……
二,C语言的文件操作
通过文件操作函数,程序可以实现数据的读写、持久化存储、日志记录、配置管理等功能,是连接内存与外部世界的重要桥梁。
2.1 C语言打开文件的方式
- 普通的打开文件的方式
#include <stdio.h>
int main()
{FILE* fp = fopen("myfile", "w");if (!fp) {printf("fopen error!\n");}while (1);fclose(fp);return 0;
}
- 在程序的当前路径下,那系统怎么知道程序的当前路径在哪⾥呢?
创建一个循环的程序
text.c
#include<stdio.h>
int main()
{while (1){}return 0;
}
执行这个程序
gcc -o text1 text1.c
./text1
- 可以使⽤
ls /proc/[进程id] -l
命令查看当前正在运⾏进程的信息:
[hyb@VM-8-12-centos io]$ ps ajx | grep text1
506729 533463 533463 506729 pts/249 533463 R+ 1002 7:45 ./text1
536281 536542 536541 536281 pts/250 536541 R+ 1002 0:00 grep –
color=auto myProc
[hyb@VM-8-12-centos io]$ ls /proc/533463 -l
total 0
…
-r–r–r-- 1 hyb hyb 0 Aug 26 17:01 cpuset
lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 cwd -> /home/hyb/io
-r-------- 1 hyb hyb 0 Aug 26 17:01 environ
lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 exe -> /home/hyb/io/text1
dr-x------ 2 hyb hyb 0 Aug 26 16:54 fd
…
-
cwd:指向当前进程运⾏⽬录的⼀个符号链接。
-
exe:指向启动当前进程的可执⾏⽂件(完整路径)的符号链接。
-
打开⽂件,本质是进程打开,所以,进程知道⾃⼰在哪⾥,即便⽂件不带路径,进程也知道。由此OS
就能知道要创建的⽂件放在哪⾥。
以上我们只对系统是怎么知道文件在哪里讲解,其它关于C语言文件的操作请查看这篇文章 文件操作
三,系统文件I/O
我们平时用的 fopen、ifstream
这些,其实只是语言层提供的流式方式
,真正打开文件的是操作系统底层的系统调用。在学习这些系统级的文件 I/O 接口之前,先得搞清楚怎么给函数传标志位
—— 这是系统调用里很常见的一种用法。
3-1 ⼀种传递标志位的方法
#include <stdio.h>
#define ONE 0001 //0000 0001
#define TWO 0002 //0000 0010
#define THREE 0004 //0000 0100
void func(int flags) {if (flags & ONE) printf("flags has ONE! ");if (flags & TWO) printf("flags has TWO! ");if (flags & THREE) printf("flags has THREE! ");printf("\n");
}
int main() {func(ONE);func(THREE);func(ONE | TWO);func(ONE | THREE | TWO);return 0;
}
除了上一节介绍的 C 语言接口(当然,C++ 及其他语言也提供相应接口),我们还可以直接使用操作系统提供的系统调用接口来进行文件访问。下面通过系统调用的代码示例,实现与上面代码功能完全一致的文件操作。
3-2 写文件
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{umask(0);int fd = open("myfile", O_WRONLY | O_CREAT, 0644);if (fd < 0) {perror("open");return 1;}int count = 5;const char* msg = "hello bit!\n";int len = strlen(msg);while (count--) {write(fd, msg, len);//fd: 后⾯讲, msg:缓冲区⾸地址, len: 本次读取,期望写⼊多少个字节的数据。 返回值:实际写了多少字节数据}close(fd);return 0;
}
3-3 读文件
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{int fd = open("myfile", O_RDONLY);if (fd < 0) {perror("open");return 1;}const char* msg = "hello bit!\n";char buf[1024];while (1) {ssize_t s = read(fd, buf, strlen(msg));//类⽐writeif (s > 0) {printf("%s", buf);}else {break;}}close(fd);return 0;
}
3-4 接口介绍
#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
:要打开或创建的目标文件路径flags
:文件打开方式,可以使用以下常量之一或多个进行按位“或”运算(|
)组合:O_RDONLY
:只读方式打开文件O_WRONLY
:只写方式打开文件O_RDWR
:读写方式打开文件O_CREAT
:如果文件不存在,则创建新文件。使用此标志时必须提供 mode 参数,用于设置新文件的权限O_APPEND
:以追加方式写入文件
mode
:类型为mode_t
,通常用于指定新文件的权限(例如0644
)
返回值:
- 成功:返回新打开文件的文件描述符(非负整数)
- 失败:返回
-1
,并设置errno
来指示错误原因
3-5 open函数的返回值
在了解“返回值”之前,先来认识两个重要的概念:系统调用
和 库函数
像 fopen、fclose、fread、fwrite 这些,都是 C 语言标准库(libc)
提供的函数,我们称之为库函数
。它们封装了更底层的操作,使用起来更方便
而 open、close、read、write、lseek 等,直接与操作系统交互,属于系统调用接口
,是操作系统提供给程序员的底层功能
系统调用接口与库函数的关系一目了然。因此,可以认为 f#
系列函数都是对系统调用的封装,旨在简化二次开发
3-6 文件描述符
文件描述符是 Linux 内核分配给进程的一个非负整数
,用来标识进程打开的文件、管道、套接字、设备等I/O资源。
3-6-1 文件描述符是什么?
文件描述符(fd)就是一个数字,用来代表程序打开的文件、网络连接、管道等东西
- 比如你打开一个文件,系统会给你一个
fd
(比如3
),之后你读写这个文件就用3
来操作 - 0、1、2 是默认的:
0
= 输入(键盘)1
= 输出(屏幕)2
= 错误信息(屏幕)
- 用完要关闭,不然会占用系统资源
类比
: 就像去图书馆借书,管理员给你一个 编号(fd)
,你凭这个编号借书、还书,不用管书具体放在哪。
3-6-2 1 & 2 &3
Linux系统中,每个进程启动时都会自动获得三个预分配的文件描述符:
0
号文件描述符(STDIN_FILENO):标准输入
,默认对应键盘1
号文件描述符(STDOUT_FILENO):标准输出
,默认指向终端显示器2
号文件描述符(STDERR_FILENO):标准错误输出
,同样默认显示在终端
所以输⼊输出还可以采⽤如下⽅式:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{char buf[1024];char arr[1024]="请输入文本:";write(1,arr,sizeof(arr));ssize_t s = read(0, buf, sizeof(buf));if (s > 0) {buf[s] = 0;write(1, buf, strlen(buf));write(2, buf, strlen(buf));}return 0;
}
程序结果
请输入文本:hello world
hello world
hello world
理解file
-
我们现在知道,
文件描述符
其实就是一个从0
开始的整数。每次我们用程序打开一个文件,操作系统都会在内存里给这个文件建一个“档案”
,用来记录这个文件的各种信息,这个“档案”就是file 结构体
-
因为是进程调用 open 打开的文件,操作系统需要把这个文件和进程关联起来。每个进程都有一个叫
files
的指针,它指向一个叫files_struct
的表,这张表里面最关键的部分就是一个数组file* fd_array
。这个数组里的每一项都是一个指向某个打开文件的指针
-
所以说,文件描述符其实就是这个
数组的下标
。你拿到一个文件描述符,就等于知道了这个数组的第几个位置,然后就能找到你打开的那个文件
3-6-3 文件描述符的分配规则
操作系统总是分配当前进程中最小且未被占用的文件描述符
当你调用 open()
、socket()
、pipe()
等系统调用打开文件或创建资源时,系统会按照以下规则分配文件描述符:
- 从小到大分配(最小可用优先)
int fd1 = open("a.txt", O_RDONLY); // 可能是 3
int fd2 = open("b.txt", O_RDONLY); // 可能是 4
close(fd1);
int fd3 = open("c.txt", O_RDONLY); // 重新用上了 3
输出结果为
3
4
3
- 受限于进程最大文件描述符数量
– 每个进程可打开的文件描述符数量是有限的
,通常默认是1024
(可以用 ulimit -n 查看)
– 超出这个数量会返回错误EMFILE
(Too many open files) - 基本文件描述符也可以替换
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{close(0);int fd = open("myfile", O_RDONLY);if (fd < 0) {perror("open");return 1;}printf("fd: %d\n", fd);close(fd);return 0;
}
输出结果为:fd: 0
当打开一个新文件时,系统会在files_struct
这张表里,从头开始找一个没用过的位置,找到最小的那个空位,把它的下标当作新的文件描述符用
3-6-4 重定向
重定向就是让程序的输入或输出“换个方向”,从默认的终端转向你指定的目标。
- 编程中的重定向(C语言为例)
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); // 将标准输出重定向到 log.txt
printf("This will go into log.txt\n");
运行程序,屏幕
不会打印任何信息,让我们查看一下log.txt
cat log.txt
This will go into log.txt
这时fd
的1
指向的文件就是log.txt
四,一切皆文件
在 Linux 内核中,通过统一的 struct file
和 file_operations
机制,把各种资源抽象为“文件”
,使得用户程序可以使用统一接口访问万物,真正实现了一切皆文件
4-1 概念
- Linux 把各种系统资源 如普通文件、目录、设备、套接字、管道、终端等 都统一抽象成文件,这样上层程序就可以使用统一的接口 open、read、write、close 等来访问它们
– 换句话说:
不管你是读一个文本文件
、读键盘输入
,还是跟网络通信
,在编程时都像是在操作文件
4-2内核源代码理解
- 每个打开的文件,都会有一个 file 结构体
struct file {const struct file_operations *f_op;...
};
file
就是内核中描述“已打开文件”
的对象- 不管你打开的是
/etc/passwd
还是/dev/null
,内核都会创建一个file
结构
- file_operations 决定了文件能做什么
struct file_operations {ssize_t (*read)(...);ssize_t (*write)(...);...
};
你也可以理解为:fd -> file -> file_operations,无论是读硬盘还是发网络包,最后其实都是调用“某个文件的函数”
五,缓冲区
缓冲区是内存中临时存放数据的区域,用于缓解数据传输或处理速度不一致的问题。它先接收数据,待积累一定量后再统一读写,减少频繁的I/O操作,提高系统效率。缓冲区常用于文件读写和网络通信,使数据传输更加流畅和快速。
5-1 什么是缓冲区?
- 缓冲区是一块临时存储数据的
内存区域
,用来提高数据读写效率。 - 在 Linux(或操作系统)中,
“缓冲区(Buffer)”
是在内存中预先分配的一块区域,用来临时存储数据
,它广泛应用于 文件读写、网络通信、输入输出、进程通信等场景
5-2 为什么需要缓冲区?
- 速度不匹配
CPU/内存
的速度远高于磁盘、网络
等设备;- 如果直接读取
磁盘文件
,CPU
会经常空等; 缓冲区
一次从设备
读取/写入一大块,之后再慢慢处理,效率高
- 减少系统调用次数
- 每次系统调用都有开销;
缓冲区
可聚合小数据,统一处理
5-3 缓冲类型
- 全缓冲
- 触发写入时机:
缓冲区
满时才写入/输出 - 常见场景:
普通文件
写入 - 例子:
fwrite()
写到文件,不调用fflush()
时不会立即写入磁盘
- 触发写入时机:
示例代码(写入文件):
#include <stdio.h>int main() {FILE* fp = fopen("output.txt", "w"); // 打开一个文件,默认全缓冲fprintf(fp, "Hello, world!"); // 写入,但还没真正写入磁盘// fclose(fp); // 如果不加这行,文件里可能什么都没有// fflush(fp); // 如果不加这行,文件里可能什么都没有return 0;
}
- 说明:
- 文件默认是
全缓冲
- 这段代码如果没有
fclose(fp)
或fflush(fp)
,"Hello, world!"
可能不会立刻写入磁盘
,因为数据还在内存缓冲区
里
- 文件默认是
- 行缓冲
- 触发写入时机:遇到
换行符
或缓冲区满
- 常见场景:
终端交互
- 例子:
printf("hello\n");
会立即输出;没有\n
时可能延迟输出
- 触发写入时机:遇到
示例代码(输出到终端):
#include <stdio.h>int main() {printf("This is line buffered"); // 不带 \n,可能不会立即输出// fflush(stdout); // 如果加这个,才会立即输出return 0;
}
- 说明:
stdout
(标准输出)连接终端时是行缓冲- 不写
\n
或不手动fflush(stdout)
,输出可能不显示 - 如果你改成:
printf("This is line buffered\n");
,就会立刻显示
- 行缓冲
- 触发写入时机:立刻写入或读取
- 常见场景:低级 I/O 或标准错误输出
- 例子:
write()
系统调用,stderr
默认无缓冲
示例代码(标准错误):
#include <stdio.h>int main() {fprintf(stderr, "This is unbuffered error output"); // 会立即输出return 0;
}
- 说明:
stderr
默认是无缓冲,输出立刻生效- 所以这句话会马上出现在终端上,无需
\n
或fflush()
stdout 是行缓冲,fwrite 写文件是全缓冲,stderr 是无缓冲!
5-4 FILE
FILE
是 C语言中表示文件流的结构体,提供了方便、高效的文件操作接口。它通过缓冲机制减少系统调用,提高读写效率,简化程序员对文件的访问,同时支持跨平台和多种文件操作,是管理文件读写的核心工具。
5-4-1 库函数会自带缓冲区,而系统调用不会
示例代码:
#include <sys/stat.h>
#include <stdio.h>
#include <string.h>
int main()
{const char* msg0 = "hello printf\n";const char* msg1 = "hello fwrite\n";const char* msg2 = "hello write\n";printf("%s", msg0);fwrite(msg1, strlen(msg0), 1, stdout);write(1, msg2, strlen(msg2));fork();return 0;
}
输出结果:
hello printf
hello fwrite
hello write
但如果对进程实现输出重定向呢? . / hello > file
, 我们发现结果变成了:
hello write
hello printf
hello fwrite
hello printf
hello fwrite
我们发现 printf 和 fwrite(库函数)输出了两次,而 write(系统调用)只输出了一次,这是为什么?这肯定与 fork 有关!
- 缓冲行为说明
- 一般来说,C标准库函数写入“终端”是行缓冲,写入“普通文件”是
全缓冲
printf
和fwrite
都是库函数,它们自带用户态缓冲区
。当标准输出没有重定向(即输出到终端)时,是行缓冲;但一旦被重定向到文件,就变成了全缓冲- 而
write
是系统调用,它不带缓冲区,每次调用都会直接将数据写入内核,再由内核写入目标设备或文件
- 一般来说,C标准库函数写入“终端”是行缓冲,写入“普通文件”是
- fork 造成输出两次的原因:
- 当程序执行到
fork()
时,父子进程会各自拷贝一份用户空间,包括缓冲区的内容(采用写时复制) - 假如
fork()
之前调用了printf
或fwrite
,数据其实还只是存在于缓冲区中(尚未真正写入文件) fork()
之后,父子进程都持有各自独立的那份缓冲区数据,所以在进程退出时,两个进程都会各自刷新自己的缓冲区,导致同样的内容被写入文件两次- 而
write
没有用户态缓冲,它的数据已经在调用时就写入系统内核缓冲了,不受fork()
影响,所以只输出一次
- 当程序执行到
- 总结:
printf
和fwrite
是库函数,有用户级缓冲区,由 C 标准库提供write
是系统调用,不提供用户态缓冲,直接操作内核- 我们这里讨论的缓冲主要是 用户态缓冲区,虽然内核层还有 IO 缓冲机制,但那属于内核范围,不在本讨论中
5-4-2 fd
-
因为IO相关函数与系统调⽤接⼝对应,并且库函数封装系统调⽤,所以本质上,访问⽂件都是通
过fd访问的。所以C库当中的FILE
结构体内部,必定封装了fd
-
代码验证:C库中的
FILE
包含fd
glibc
中的FILE
定义(在 <stdio.h> 内部的源码,简化后):
struct _IO_FILE {int _fileno; // 对应的文件描述符// 还有缓冲区、指针、状态等字段
};
_fileno
就是这个FILE
所对应的底层fd
,即int
类型的文件描述符
- 示例代码:验证
FILE*
对应哪个fd
#include <stdio.h>
#include <unistd.h>int main() {FILE* fp = fopen("demo.txt", "w");if (fp == NULL) {perror("fopen");return 1;}// 获取 FILE 对应的文件描述符int fd = fileno(fp);printf("FILE* 对应的 fd 是:%d\n", fd);fprintf(fp, "hello\n");fclose(fp);return 0;
}
输出示例:
FILE*
对应的fd
是:3
这说明 FILE*
结构确实通过内部的 fd
完成系统级的读写操作
- 总结:
FILE*
是标准库封装的高级文件结构,内部其实就是通过int fd
这个文件描述符来访问底层文件系统的,所有的 fread/fwrite 最终都会通过 fd 调用 read/write