Linux 基础 IO
- 一、什么是 " 文件 "
- [二、C 文件接口](#二、C 文件接口)
- [三、系统文件 I/O](#三、系统文件 I/O)
-
- 传递标志位
- 系统接口写文件
- 系统接口读文件
- 部分系统调用接口介绍
-
- [打开文件函数 open](#打开文件函数 open)
- [关闭文件函数 close](#关闭文件函数 close)
- [写入文件函数 write](#写入文件函数 write)
- [读取文件函数 read](#读取文件函数 read)
- [文件描述符 fd](#文件描述符 fd)
-
- [fd 0 & 1 & 2 与内核缓冲区](#fd 0 & 1 & 2 与内核缓冲区)
- 文件描述符的分配规则
- 重定向基本原理
- [使用 dup2 系统调用](#使用 dup2 系统调用)
- [四、" Linux 中一切皆文件 "](#四、" Linux 中一切皆文件 ")
- 五、缓冲区
以下代码环境为 Linux Ubuntu 22.04.5 gcc C语言。
一、什么是 " 文件 "
狭义理解:
-
文件在磁盘里
-
磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的
-
磁盘是外设(即是输出设备也是输入设备)
-
磁盘上的文件本质是对文件的所有权操作,都是对外设的输入和输出,简称 IO(Input Output)
广义理解:
对 Linux 来说,一切皆文件(键盘、显示器、网卡、磁盘 ...... 被 Linux 抽象化)
文件操作的归类认知:
-
文件是文件属性(元数据) 和文件内容的集合,即
文件 = 属性 + 内容
-
对于 0 KB 的空文件是占用磁盘空间的
-
所有的文件操作本质是 文件内容操作 和 文件属性操作
系统角度:
-
用户对文件的操作本质是进程对文件的操作
-
磁盘的管理者是操作系统
-
文件的读写 本质不是通过 C 语言 / C++ 的库函数来操作的(这些库函数只是为用户提供方便),而是通过文件相关的系统调用接口来实现的
二、C 文件接口
打开文件
c
#include <stdio.h>
int main()
{
FILE* fp = fopen("myFile.txt", "w");
if (fp == NULL)
{
perror("fopen:");
return 1;
}
while (1);
fclose(fp);
return 0;
}
其中,打开的 myFile 文件在程序的当前路径下,这是系统根据进程的符号 cwd 和 exe 来查看当前路径创建的:

-
cwd :指向当前进程运行目录的一个符号链接
-
exe :指向启动当前进程的可执行文件(完整路径)的符号链接
打开文件本质是用进程打开,而进程知道自己在哪里。当操作系统要创建文件时,根据进程提供的信息就能知道文件应该放在哪里。
写文件
c
#include <stdio.h>
int main()
{
FILE* fp = fopen("myFile.txt", "w");
if (fp == NULL)
{
perror("fopen:");
return 1;
}
const char* info = "Hello Linux!\n";
int count = 5;
while (count--)
{
fprintf(fp, "%s", info);
}
fclose(fp);
return 0;
}
在执行 test 命令后,我们可以检查 myFiles.txt 中具体内容:
读文件
c
#include <stdio.h>
int main()
{
FILE* fp = fopen("myFile.txt", "r");
if (fp == NULL)
{
perror("fopen:");
return 1;
}
char buf[6][64];
int row = 0;
while (1)
{
fgets(buf[row++], 64, fp);
if (feof(fp))
{
break;
}
}
fclose(fp);
for (int i = 0; i < 5; ++i)
{
printf("%s", buf[i]);
}
return 0;
}

我们可以注意到,这个读取文件信息打印到屏幕的程序和 cat 命令原理是相似的。
其它介绍
stdin、stdout 和 stderr :C 语言默认会打开三个输入输出流,分别是 stdin、stdout 和 stderr。而且这三个流都是 FILE* 类型的,fopen 返回值类型也是文件指针:
如果想了解更多关于 C 语言文件操作,请参考:(学习总结7)C语言文件操作
三、系统文件 I/O
打开文件的方式不仅仅是 fopen 等流式。这是语言层的方案,而系统才是打开文件最底层的方案。
不过,在介绍系统文件 IO 之前,先要了解下如何给函数传递标志位,该方法在系统文件 IO 接口中会使用到。
传递标志位
传递标志位,本质是利用其中的比特位进行记录,借用位图的思想:
c
#include <stdio.h>
#define PRINT_ONE (1 << 0)
#define PRINT_TWO (1 << 1)
#define PRINT_THREE (1 << 2)
void printInfo(int num)
{
if (num & PRINT_ONE)
{
printf("one\n");
}
if (num & PRINT_TWO)
{
printf("two\n");
}
if (num & PRINT_THREE)
{
printf("three\n");
}
}
int main()
{
printInfo(PRINT_ONE);
printf("---------------\n");
printInfo(PRINT_THREE);
printf("---------------\n");
printInfo(PRINT_ONE | PRINT_TWO | PRINT_THREE);
printf("---------------\n");
return 0;
}

操作文件,除了上述的 C 接口,我们还可以采用系统接口来进行文件访问, 接下来先以系统代码的形式,实现和上部分操作一样的代码。
系统接口写文件
c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int ret = open("myFile.txt", O_WRONLY | O_CREAT, 0666);
if (ret == -1)
{
perror("open:");
return 1;
}
const char* info = "Hello Linux!\n";
int count = 5;
int len = strlen(info);
while (count--)
{
write(ret, info, len);
}
close(ret);
return 0;
}

系统接口读文件
c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int ret = open("myFile.txt", O_RDONLY, 0666);
if (ret == -1)
{
perror("open:");
return 1;
}
char buf[6][64];
int row = 0;
while (1)
{
ssize_t last = read(ret, buf[row++], 17);
if (last == 0)
{
break;
}
}
close(ret);
for (int i = 0; i < 4; ++i)
{
printf("%s", buf[i]);
}
return 0;
}

部分系统调用接口介绍
在认识刚刚的系统调用函数之前,我们需要明确两个概念:系统调用函数 和 库函数。
之前的函数 fopen、fclose、fprintf、fgets 都是 C语言标准库当中的函数,我们称之为库函数(libc)。
而 open、close、read、write 都是系统提供的接口函数称之为系统调用接口。
之前文章曾提到过两者的关系和图片,图片放在此处:
所以可以认为, C语言当中相关函数,都是对系统调用的封装,方便 C 程序员二次开发。
这里要注意的是,系统并不关心文件的写入方式,文本写入和二进制写入是 C 语言提供的概念。
打开文件函数 open

open 返回值:成功返回文件描述符(非负整数),失败返回 -1 并设置 errno
open 参数 pathname :要打开或创建的文件路径(绝对或相对路径)
open 参数 mode :设置新文件的权限,当 flags 中存在 O_CREAT 时使用,受 umask 影响(实际权限为 mode & ~umask)
open 参数 flags :文件访问模式和选项
-
基本访问模式(至少要有一个):
O_RDONLY
:只读O_WRONLY
:只写O_RDWR
:读写
-
常用选项:
O_CREAT
:文件不存在时创建(需指定 mode)O_APPEND
:追加写入(避免多进程竞争)O_EXCL
:与 O_CREAT 联用,若文件存在则失败(用于原子性创建)O_TRUNC
:若文件存在,清空内容(长度截断为0)O_NONBLOCK
:非阻塞模式(用于设备文件或管道)O_SYNC
:同步写入(数据与元数据写入磁盘后才返回)O_CLOEXEC
:执行 exec 时关闭文件描述符(避免泄漏)O_DIRECTORY
:确保路径是目录,否则失败O_NOFOLLOW
:不解析符号链接
关闭文件函数 close

close 返回值:成功时返回 0,失败返回 -1,并设置 errno
close 参数 fd :需要关闭的文件描述符(由 open() 等系统调用返回的有效句柄)
写入文件函数 write

write 返回值:成功返回实际写入的字节数(可能小于 count),失败返回 -1,并设置 errno
write 参数 fd :文件描述符,由 open() 等系统调用返回的有效文件描述符句柄
write 参数 buf :指向准备写入数据的缓冲区(用户空间内存地址)
write 参数 count :要求写入的字节数
读取文件函数 read

read 返回值:成功时返回实际读取的字节数(可能小于 count),返回 0 表示已到达文件末尾(EOF),失败返回 -1,并设置 errno
read 参数 fd :文件描述符,由 open() 等系统调用返回的有效句柄,且必须以可读模式(O_RDONLY 或 O_RDWR)打开
read 参数 buf :读取文件数据时准备存放的空间地址
read 参数 count :请求读取的最大字节数(buf 大小需能够存储)
文件描述符 fd
通过 open 函数的理解,我们知道文件描述符其实是一个整数。
fd 0 & 1 & 2 与内核缓冲区
Linux 进程默认情况下会有 3 个已经打开的文件描述符,分别是标准输入 0(stdin)、标准输出 1(stdout)、标准错误 2(stderr)
0、1、2 对应的物理设备一般是:键盘、显示器、显示器,所以输入输出还可以采用如下方式:
c
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
char buf[1024];
ssize_t ret = 0;
while (ret = read(0, buf, sizeof(buf) - 1))
{
buf[ret] = '\0';
if (ret > 0)
{
printf("标准输出 stdout 内容:> "); // print 打印没有 \n 会存放到 C语言的缓冲区中
fflush(stdout); // 使用 fflush 函数强制将C语言缓冲区内容写入对应流中
write(1, buf, ret);
printf("标准错误 stderr 内容:> ");
fflush(stdout);
write(2, buf, ret);
}
}
return 0;
}

现在我们知道,文件描述符就是从 0 开始的整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是有了 file 结构体,表示一个已经打开的文件对象。
而进程执行 open 系统调用,就必须让进程和文件关联起来。Linux 中每个进程的 task_struct 都有一个指针 files ,指向一张表 files_struct,该表最重要的部分就是包含一个指针数组,每个元素都是一个指向打开文件的指针!
所以本质上,文件描述符就是该数组的下标。也就是说,只要拿着文件描述符就可以找到对应的文件。
另外,对文件内容做任何操作,都必须先将文件加载到内核对应的文件缓冲区 内:
则对于 read 系统调用函数,本质是从内核对应文件缓冲区拷贝到用户空间的拷贝函数!
但为什么要分类同样是打印到显示器的 stdout 和 stderr 呢?这是为了方便使用重定向将普通信息与错误信息分离!
我们可以通过内核源码查看大致结构(Linux 内核 2.6.32):
文件描述符的分配规则
我们这里具体看看打开的 myFile.txt 文件 fd 为多少:
c
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int ret = open("myFile.txt", O_WRONLY | O_CREAT, 0666);
if (ret == -1)
{
perror("open:");
return -1;
}
printf("myFile.txt 对应 fd 为: %d\n", ret);
close(ret);
return 0;
}

输出发现是 fd 为 3,接下来关闭 fd 为 0 的文件再打开:
c
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
close(0); // 关闭 fd 为 0 的文件
int ret = open("myFile.txt", O_WRONLY | O_CREAT, 0666);
if (ret == -1)
{
perror("open:");
return -1;
}
printf("myFile.txt 对应 fd 为: %d\n", ret);
close(ret);
return 0;
}

发现结果 fd 为 0,可见文件描述符的分配规则是在 files_struct 数组当中找到当前没有被使用的最小的一个下标,作为新的文件描述符。
重定向基本原理
如果我们关闭 fd 为 1 的文件,也就是关闭 stdout 文件,再打开 myFile.txt 文件并用 printf 函数打印,原本输出到显示屏的内容会输出到 myFile.txt 文件中:
c
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
close(1);
int ret = open("myFile.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (ret == -1)
{
perror("open:");
return -1;
}
const char* info = "this is test.\n";
int count = 3;
while (count--)
{
printf("%s", info);
}
fflush(stdout); // 将 C语言缓冲区内容写到文件缓冲区中
close(ret);
return 0;
}

此时我们发现,原来的 Hello Linux! 字符串不见了,转而是当前程序输出的 this is test. 字符串。
事实上,本来应该输出到显示器上的内容,却输出到了规定的文件当中,这种现象就叫做输出重定向。常见的重定向有:>、>>、< 等等。
那重定向的原理是什么呢?根据上面的测试我们可以这样说,将进程原本指向标准输入、输出或错误的 file* 更改指向到其特定的文件,就是重定向的原理 :
使用 dup2 系统调用

dup2 返回值:成功时返回新的文件描述符 newfd,失败返回 -1,并设置 errno
dup2 参数 oldfd :需要复制的源文件描述符(必须已打开且有效)
dup2 参数 newfd :目标文件描述符(用户指定的数值)
- 若 newfd 已打开,dup2() 会先自动关闭它,再复制 oldfd
- newfd 的取值需在文件描述符的有效范围内
通过 dup2 函数可以更规范的使用重定向:
c
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int ret = open("myFile.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (ret == -1)
{
perror("open:");
return -1;
}
dup2(ret, 1);
const char* info = "use dup2 function.\n";
int count = 3;
while (count--)
{
printf("%s", info);
}
fflush(stdout);
close(ret);
return 0;
}

四、" Linux 中一切皆文件 "
Linux 中除了本来就是文件,其它结构如磁盘、显示器、键盘这样的硬件设备也被抽象成文件,可以使用访问文件的方法访问它们获得信息。
这样做最明显的好处是:开发者仅需要使用一套 API 和开发工具,即可调用 Linux 中绝大部分的资源。
举个简单的例子,Linux 中几乎所有读(读文件,读系统状态)的操作都可以用 read 函数来进行;几乎所有更改(更改文件,更改系统参数)的操作都可以用 write 函数来进行。
上述部分我们讲过,当打开一个文件时,操作系统为了管理所打开的文件,都会为这个文件创建一个 file 结构体。
值得关注的是 struct file 中的 f_op 指针指向了一个 file_operations 结构体,这个结构体中的成员除了 struct module* owner 其余都是函数指针:
而 file_operation 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从而完成 Linux 设备驱动程序的工作。从面向对象的语言来说,这种方法运用了多态的思想:
上图中的外设,每个设备都可以有自己的 read、write 函数,但一定是对应着不同的操作方法。通过 struct file 下 file_operation 中的各种函数回调,让开发者只用 file 便可调取 Linux 系统中绝大部分的资源,这便是 " Linux 中一切皆文件 " 的核心理解。
五、缓冲区
缓冲区概念
缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来临时存放输入或输出的数据,则被称为缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
缓冲区的意义
读写文件如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用。
而执行一次系统调用将涉及到 CPU 状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的 CPU 时间。频繁的磁盘访问对程序的执行效率造成很大的影响。为减少使用系统调用的次数来提高效率,我们就可以采用缓冲机制。
比如从磁盘里取信息,在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用。等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作快于对磁盘的操作(因为缓冲区在内存,磁盘为外存),故应用缓冲区可提高计算机的运行速度。
又比如使用打印机打印文档,由于打印机的打印速度相对较慢,先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时 CPU 可以处理别的事情。
可以看出缓冲区就是一块内存区,它用在输入输出设备和 CPU 之间,用来缓存数据。它使得低速的输入输出设备和高速的 CPU 能够协调工作,避免低速的输入输出设备频繁占用 CPU,让 CPU 能够高效率工作。
缓冲类型
标准 I/O 提供了 3 种类型的缓冲区:
-
全缓冲区 :这种缓冲方式要求填满整个缓冲区后才进行 I/O 系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
-
行缓冲区 :在行缓冲情况下,输入或输出中遇到换行符 时,标准 I/O 库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准 I/O 库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行 I/O 系统调用操作,默认行缓冲区的大小为 1024 。
-
无缓冲区 :无缓冲区是指标准 I/O 库不对字符进行缓存,直接使用系统调用。标准错误流 stderr 通常是不带缓冲区的,这使得出错误信息能够尽快地显示出来。
除了上述列举的默认刷新方式,下列特殊情况也会引发缓冲区的刷新:
-
缓冲区满时刷新
-
进程退出
-
执行 flush 语句刷新(例如上面 C 语言的 fflush 函数强行刷新缓冲区)
C 语言缓冲区 与 缓冲区数据丢失
C 语言标准库中也有内置的行缓冲区,针对于标准输出 stdout 输出数据,若不带换行符且先关闭了内核的文件缓冲区,会导致数据丢失:
c
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int ret = open("myFile.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (ret == -1)
{
perror("open:");
return -1;
}
dup2(ret, 1);
const char* info = "this is test.";
int count = 3;
while (count--)
{
printf("%s", info);
}
//fflush(stdout); // 关闭强制刷新
close(ret);
close(1); // 将文件缓冲区关闭
// 当 C 语言缓冲区内容因为进程结束后刷新
// 但文件缓冲区关闭了无法写入
// 从而导致数据丢失
return 0;
}

这是因为 C语言的缓冲区规定只要将数据交给内核的文件缓冲区就算完成了它的任务,对于内核缓冲区使用何种刷新方式由操作系统决定。若内核缓冲区先一步关闭,C语言缓冲区刷新时只能将内部数据丢弃:
C语言缓冲区交给内核缓冲区时,使用的方法是拷贝。站在更高的角度上说,计算机数据流动的本质都是拷贝!
C 语言 FILE 结构体 与 fork 重复打印
因为 IO 相关函数与系统调用接口对应,并且 C 语言库函数都是封装的系统调用,所以本质上访问 Linux 文件都是通过 fd 访问的。
则 C 标准库当中的 FILE 结构体内部必定封装了 fd。
有兴趣的读者可以在 Linux 文件 /usr/include/stdio.h
当中查看更多有关 FILE 信息,这里就不展开了。
另外 C 语言缓冲区写入文件时是全缓冲,写入显示器是行缓冲,我们可以对比两者和系统调用 write 函数的差异:
c
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
int main()
{
const char* info = "use printf";
int count = 3;
while (count--)
{
printf("%s %d \n", info, 3 - count);
}
const char* info2 = "use write\n";
write(1, info2, strlen(info2));
fork();
return 0;
}

我们发现对 myFile.txt 文件输出时,printf 循环的输出了两次,而 write 只输出了一次,这是因为:
-
一般 C 的库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
-
printf 等库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。
-
其中放在缓冲区中的数据,即便 fork 之后也不会被立即刷新。
-
而进程退出之后,会统一刷新写入文件当中。
-
但调用 fork 时,父子数据会发生写时拷贝,所以当父进程准备刷新的时候,子进程也就有了同样的一份数据,两者刷新随即产生两份数据。
-
write 系统调用函数没有变化,说明没有所谓的缓冲。
综上所述,printf 等 C 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。