Linux 基础I/O
文章目录
文件基础
对于文件,我们需要有几点认知:
- 文件=内容+属性。谈一个文件,不能只谈它的内容,文件的属性和文件的内容是绑定起来的,当对文件的内容进行修改或访问时,该文件的ACM属性(最近一次获取时间、最近一次访修改内容的时间、最后一次修改属性的时间)也会自动修改。
- 文件是需要打开和关闭的。例如C语言中访问文件前,需要
fopen()打开文件;读写文件时,通过fwrite()/fread()等执行。打开文件本质上是将磁盘上的文件加载到内存,关闭文件是将内存中的文件数据释放。为什么要将文件加载到内存?因为CPU负责执行文件操作,CPU不能直接访问硬件对文件进行修改,只能先将文件加载到内存中,对内存中的文件数据进行修改,再通过操作系统将修改同步到磁盘中。 - 进程负责打开文件,每个进程可以打开多个文件。因此进程需要管理文件,管理的方式为先描述、再组织。所以系统中存在描述文件的结构体对象,并且这些对象是通过某种方式组织起来的。
- 系统中的文件分为两部分,内存级文件和磁盘级文件。打开的文件会加载到内存中,叫做内存级文件;没有打开的文件在磁盘中,叫做磁盘级文件。
文件接口
与文件相关的接口主要分为四部分
- 打开文件类:
fopen()open()等 - 输出类:
fwrite()fprintf()fputs()write()等 - 输入类:
fread()fscanf()fgets()read()等 - 关闭文件类:
fclose()close()等
C语言文件接口
C语言的文件接口有fopen() fwrite() fprintf() fputs() fread() fscanf() fgets() fclose()等。
通过fopen()打开文件,path为文件名,mode为打开方式
FILE *fopen(const char *path, const char *mode);
参数说明:
| mode | 说明 |
|---|---|
| w | 只能写入,文件不存在创建一个文件 |
| r | 只能读出 |
| a | 只能追加写入,文件不存在创建一个文件 |
| w+ | 可以写,可以读.不存在时可以创建文件 |
| r+ | 可以写,可以读 |
| a+ | 可以追加写,可以读。不存在时可以创建文件 |
重定向和打开方式的关系
code:创建一个文件并进行写入
#include <stdio.h>
#include <string.h>
int main()
{
FILE* fp = fopen("log.txt", "w");//以读方式打开文件
const char* str = "hello fwrite\n";
fwrite(str,strlen(str), 1,fp); //写入数据
fclose(fp); //关闭文件
return 0;
}
[sy1@VM-16-11-centos lesson17]$ ll
total 20
-rwxrwxr-x 1 sy1 sy1 8520 Jul 11 18:45 filetest
-rw-rw-r-- 1 sy1 sy1 257 Jul 11 18:43 filetest.c
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ ./filetest
[sy1@VM-16-11-centos lesson17]$ ll
total 24
-rwxrwxr-x 1 sy1 sy1 8520 Jul 11 18:45 filetest
-rw-rw-r-- 1 sy1 sy1 257 Jul 11 18:43 filetest.c
-rw-rw-r-- 1 sy1 sy1 13 Jul 11 18:45 log.txt
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ cat log.txt
hello fwrite
运行代码后,成功创建文件log.txt并写入数据。
还是以w方式打开文件,写入新的数据This is new message
#include <stdio.h>
#include <string.h>
int main()
{
FILE* fp = fopen("log.txt", "w");//以读方式打开文件
//const char* str = "hello fwrite\n";
const char* str = "This is new message\n";
fwrite(str,strlen(str), 1,fp); //写入数据
fclose(fp); //关闭文件
return 0;
}
sy1@VM-16-11-centos lesson17]$ ll
total 24
-rwxrwxr-x 1 sy1 sy1 8520 Jul 11 18:49 filetest
-rw-rw-r-- 1 sy1 sy1 306 Jul 11 18:49 filetest.c
-rw-rw-r-- 1 sy1 sy1 13 Jul 11 18:45 log.txt
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ ./filetest
[sy1@VM-16-11-centos lesson17]$ cat log.txt
This is new message
如果以w方式打开文件,文件中原来的数据hello fwrite被新数据覆盖了。我们现在将以a方式打开文件,再次写入数据This is new message
#include <stdio.h>
#include <string.h>
int main()
{
//FILE* fp = fopen("log.txt", "w");//以读方式打开文件
FILE* fp = fopen("log.txt", "a");//以追加方式打开文件
//const char* str = "hello fwrite\n";
const char* str = "This is new message\n";
fwrite(str,strlen(str), 1,fp); //写入数据
fclose(fp); //关闭文件
return 0;
}
[sy1@VM-16-11-centos lesson17]$ ll
total 24
-rwxrwxr-x 1 sy1 sy1 8520 Jul 11 18:49 filetest
-rw-rw-r-- 1 sy1 sy1 375 Jul 11 18:52 filetest.c
-rw-rw-r-- 1 sy1 sy1 20 Jul 11 18:49 log.txt
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ make
gcc -o filetest filetest.c
[sy1@VM-16-11-centos lesson17]$ ./filetest
[sy1@VM-16-11-centos lesson17]$ ./filetest
[sy1@VM-16-11-centos lesson17]$ ./filetest
[sy1@VM-16-11-centos lesson17]$ ll
total 24
-rwxrwxr-x 1 sy1 sy1 8520 Jul 11 18:52 filetest
-rw-rw-r-- 1 sy1 sy1 375 Jul 11 18:52 filetest.c
-rw-rw-r-- 1 sy1 sy1 80 Jul 11 18:52 log.txt
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ cat log.txt
This is new message
This is new message
This is new message
This is new message
运行三次程序,在log.txt文件中新增三条数据,以a方式打开文件,写入的数据会添加到文件末端。
以w方式和a方式打开文件正好对应的是Linux中的输出重定向和追加重定向。
| 输出重定向 | 追加重定向 | w方式打开 | a方式打开 |
|---|---|---|---|
| 用新的内容覆盖文件中原有的内容 | 在文件末尾添加新内容 | 用新的内容覆盖文件中原有的内容 | 在文件末尾添加新内容 |
**重定向是通过文件的打开方式决定的。**如果以w方式打开,那么就是输出重定向;如果以a方式打开,那么就是追加重定向。
当前路径cwd
如果以w或a方式打开文件,当文件不存在时,会在当前工作目录下自动创建一个指定名称的文件。我们的进程是怎么知道当前路径是什么的呢,不会在其他路径创建该文件的呢?当一个进程启动时,进程会将自己所在的路径记录到cwd(current work directory)中,打开文件时,自动将cwd指向的目录和文件名进行拼接获取目标文件的绝对路径。
[sy1@VM-16-11-centos lesson17]$ ./filetest
pid:8578
[root@VM-16-11-centos ~]# ll /proc/8578
total 0
#...
lrwxrwxrwx 1 sy1 sy1 0 Jul 11 19:21 cwd -> /home/sy1/lesson17 #指向filetest进程的目录
[sy1@VM-16-11-centos lesson17]$ ll
total 24
-rwxrwxr-x 1 sy1 sy1 8464 Jul 11 19:20 filetest
当我们没有以绝对路径打开文件时,文件绝对路径需要通过进程对应的cwd拼接指定的文件名获取。
C语言默认打开的文件流
C标准库会默认打开stdin(标准输入流)、stdout(标准输出流)、stderr(标准错误流)三个流
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
标准输入流对应的文件为键盘,标准输出流、标准错误流对应的文件为显示器。我们向显示器打印数据、从键盘读取数据时,都没有显示的打开stdou 、stdin,这是因为标准库帮我们默认打开了,我们可以直接使用,不需要自己打开。
默认打开这三个流的好处是让程序员编码简单了,不需要自己打开关闭文件。向显示器中输出、从键盘中读取数据,这都是很常见行为,所以用标准库在程序启动时就会自动帮我们将这些文件打开。
printf()/scanf()都是隐式地向stdout/stdin中输出/读取数据。我们可以显示指定向stdout/stdin中输出/输入数据。
#define SIZE 1024
int main()
{
char str[SIZE];
fprintf(stdout, "hello fprintf\n");
//fscanf(stdin, "%s", str);//scanf會忽略空格
fgets(str, SIZE, stdin);
printf("%s", str);
}
文件的系统调用
0x01.引入
任何一个文件操作都涉及访问硬件上的数据,而在计算机体系结构中,用户不能直接访问硬件,只有操作系统才能访问硬件,作为软硬件资源的管理者,我们应该通过OS访问硬件,用户也不能直接访问OS,必须通过系统调用访问OS,所以对于用户来讲,使用的fopen()、fwrite()等文件操作函数必定封装了系统调用,本质通过系统调用完成对硬件资源的访问。
换句话说,OS必须提供一系列的文件操作相关的系统调用,进而用用户层的函数封装这些系统调用,用户才能访问文件。
为什么用户不自己使用系统调用访问文件,而要通过语言层的函数例如fopen()等来访问文件呢?
- 一是因为使用系统调用必然需要了解和系统底层相关的一些概念,比如说需要用户了解系统传递标志位、文件权限等信息,这意味着使用成本需要提高。
- 二是语言层的函数具有跨平台性,不同的操作系统对应的系统调用不一样,如果我将Linux下使用的系统调用复制到windows下,那么可能会报错。正确的做法是将系统调用通过条件编译封装到语言层的函数中,编译时,对应哪个OS,就使用匹配的系统调用。这样在Windows和Linux中虽然系统调用使用方法不一样,但是语言层的函数已经通过条件编译确定使用哪种系统调用了,所以在Windows上使用的函数可以移植到Linux中,编译器会根据当前所在的OS编译对应的系统调用,使用者不需要关心具体的系统调用是哪种,只需要按照规范使用函数即可。
0x02.open接口

open()是一个系统调用,类似C语言中的fopen(),访问文件时,需要通过open()打开文件。
open()通过可变长参数支持传递两个参数和三个参数。
- ***pathname:***打开文件的所在路径,不是绝对路径时会用文件名拼接当前进程的
cwd - ***flags:***系统传递标志位
- ***mode:***创建新文件的文件权限
系统传递标志位
系统传递标志位决定打开文件的方式。
常见的系统传递标志位:
| 标志位 | 说明 |
|---|---|
| O_RDONLY | 只读方式打开 |
| O_WRONLY | 只写方式打开 |
| O_RDWR | 可以读、写的方式打开 |
| O_CREAT | 写方式打开时,如果文件不存在就创建 |
| O_TRUNC | 写方式打开时,将旧文内容删除后再写入 |
| O_APPEND | 写方式打开文件时,再就内容尾部写入新内容 |
我们可以发现,对比fopen()函数的文件打开方式,fopen()以w方式打开相当标志位O_WRONLY、O_CREAT、O_TRUNC共同作用,以a方式打开文件相当于标志位O_WRONLY、O_CREAT、O_APPEND共同作用。以r方式打开文件相当于标志位O_RDONLY。在参数传递上,假设我们打开文件"log.txt,可以这么写
open("log.txt", O_WRONLT|O_CREAT|O_APPEND);//以a方式打开文件
open("log.txt", O_WRONLY|O_CREAT|O_TRUNC);//以w方式打开文件
open("log.txt", O_RDONLY);//以r方式打开文件
传递多个标记为时,通过|运算符将标记位组合起来传递给flags参数。
open()是如何实现传递标志位的判断的呢,为什么传递多个标志位需要将它们|连接再传递?
标志位通过位图实现的,变量flags的每一个bit位表示一个标记位

每一个标志位都是一个整形,且不同标志位处于不同的位置,所以经过|运算后可以将这些标志位对应的位置都设置为1

通过位图的方式,可以在函数中通过一个参数控制传递多个标记位,下面看一段示例代码
#define A_FLAG 0x00000001
#define B_FLAG (0x00000001<<1)
#define C_FLAG (0x00000001<<2)
#define D_FLAG (0x00000001<<3)
void Print(int flags)
{
if (flags & A_FLAG)
{
printf ("Print A\n");
}
if (flags & B_FLAG)
{
printf ("Print B\n");
}
if (flags & C_FLAG)
{
printf ("Print C\n");
}
if (flags & D_FLAG)
{
printf ("Print D\n");
}
}
int main()
{
Print(A_FLAG);
printf("============================\n");
Print(A_FLAG|B_FLAG);
printf("============================\n");
Print(A_FLAG|B_FLAG|C_FLAG);
printf("============================\n");
Print(A_FLAG|B_FLAG|C_FLAG|D_FLAG);
}
output:
Print A
============================
Print A
Print B
============================
Print A
Print B
Print C
============================
Print A
Print B
Print C
Print D
传递多个标记位时,将它们进行或运算传递给flag参数即可。通过位图的方式可以通过一个参数判断多个标记是否成立。
新建文件权限
open()接口还可以接收第三个参数mode,指明了创建文件时的权限是什么。
open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);创建的log.txt文件权限为666
运行结果:

实际上创建文件的权限为664,为什么不是666?因为我们指定的文件权限需要收到umask的影响,默认情况umask为0002,0666通过掩码0002转换后刚好就是0664。如果我们想让当前创建的文件不受权限掩码影响,我们可以在创建文件前设置umask为0000
umask(0000);//该进程创建文件的掩码为0000
open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);创建的log.txt文件权限为666
运行结果:
注意:掩码的有效范围是设置掩码的所在进程,我们在filetest进程中设置的掩码,所以只有filetest进程创建的文件掩码才为0000。
read
write
文件描述符fd
0x01.引入
open()接口的返回值是一个整形,并且对文件进行读写的接口例如read()、``write() 和close()`,它们通过一个整形来指定目标文件。

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int close(int fd);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
OS可以通过一个整数找到对应的文件,这个整数叫做文件描述符fd ( F i l e D e s c r i p t o r ) (File Descriptor) (FileDescriptor)。
0x02.文件描述符的分配规则
通过系统调用open()打开文件时,会返回一个文件描述符。可以通过返回的文件描述符对该文件进行访问。
- 一个文件对应一个文件描述符:一个文件有唯一的文件描述,一个文件描述符指向唯一的文件
- 标准输入流、标准输出流、标准错误流的文件描述符默认为0、1、2
- 当关闭一个文件后,它对应的描述符可以分配给下一个打开的文件
- 打开新的文件时,从最小的没有被使用的文件描述符开始分配
code:证明上述文件描述符的分配规则
int main()
{
umask(0000);
int fd1 = open("log1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
int fd2 = open("log2.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
close(fd1);
int fd3 = open("log3.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
int fd4 = open("log4.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
printf("fd1->%d\n", fd1);
printf("fd2->%d\n", fd2);
printf("fd3->%d\n", fd3);
printf("fd4->%d\n", fd4);
}
output:
fd1->3
fd2->4
fd3->3
fd4->5
0、1、2文件描述符分配给了标准流,所以第一个打开的文件分配3,第二个打开的文件描述符为4,打开log3.txt前关闭了log1.txt,因此文件描述符3空了出来,分配给log3.txt,打开log4.txt时会分配文件描述符5。
code:验证标准流对应的文件描述符
int main()
{
printf("stdin->%d\n", stdin->_fileno);
printf("stdout->%d\n", stdout->_fileno);
printf("stderr->%d\n", stderr->_fileno);
}
output:
stdin->0
stdout->1
stderr->2
为什么stdin内部会有有文件描述字段呢?stdin是FILE类型的指针,FILE是C语言中的文件类型。C语言的文件接口是以FILE*为参数进行调用的,也就是通过FILE*指定目标文件,系统调用文件接口是通过int型的fd变量指明目标文件,同时C语言的文件函数一定封装了系统调用接口,所以FILE结构体 中一定中包含fd,这样才能封装系统调用
0x03.文件描述符和进程之间的关系
文件描述符的本质是一个整数,这个整数代表了什么含义呢?这就牵扯到了进程PCB结构。
文件描述符本质是数组的下标!
一个进程可能打开多个文件,所以进程需要管理内存级文件,管理的方式是先描述,再组织。
对于内存级文件使用file结构体描述,file结构体包括的属性有:文件属性mode(文件的权限)、文件标志位flag(决定文件打开的方式)、文件指针pos(记录当前在文件中的读写位置)、文件缓冲区buffer(内核级的缓冲区,通过系统调用将数据写入内核级缓冲区,再从缓冲区刷新到指定文件)。
struct file
{
int mode;
int flag;
int pos;
struct file* next;
}
现在我们知道了进程是怎么管理内存级文件的,那怎么理解文件描述符是数组下标呢?进程的task_struct结构体中存在一个成员struct file_struct* files,files指向一个file_struct结构体,file_struct结构体包含一个指针数组struct file* fd_array[],文件描述符就是fd_array数组的下标,对应的内存级文件为数组元素指向的struct file结构体。fd_array称作文件描述符表。
通过数组fd_array就可以建立进程和文件的映射关系,以后一个进程想要访问文件,只需要提供文件对应的fd,进程会在它的task_struct结构中找到files指向的结构体,在到结构体中找到对应的文件描述符表,最后文件描述符表中对应的元素就指向目标文件。
当我们open打开文件时,OS会在内核创建一个file结构体,并在该进程的文件描述表中找到一个最小且没有被使用的数组元素,让该元素指向创建的file结构体,并向用户返回该数组元素的下标。
当write或read时,OS通过进程对应的文件描述表找到指定文件,对该文件进行写入或读取。
当close关闭文件时,OS会让文件对应的引用计数-1,如果引用计数为0,说明没有进程打开这个文件,就可以在内核中释放该文件。
总结:文件描述符的本质是特定进程的文件描述符表数组的下标,通过文件描述符可以映射到对应的内存级文件。
0x04.重定向
使用open打开一个文件时,OS会在该进程的文件描述符表中找到一个最小且没有被使用的下标作为fd分配给该文件,close关闭文件时会将指定fd作为下标,让文件描述符表中该位置的元素值置为NULL,表示在内存中删除了该文件。
我们来看一段代码
int main()
{
close(1);
open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
const char* str = "hello write!\n";
write(1, str, strlen(str));
return 0;
}
我们向文件描述符为1写入数据,标准输出流的fd为1,所以我们应该向标准输出流(显示器)写入数据,我运行结果后我们应该在显示器上看见"hello write\n"。
运行代码:
[sy1@VM-16-11-centos lesson17]$ ll
total 8
-rw-rw-r-- 1 sy1 sy1 2448 Jul 26 11:00 filetest.c
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ make
gcc -o filetest filetest.c
[sy1@VM-16-11-centos lesson17]$ ./filetest
[sy1@VM-16-11-centos lesson17]$ ll
total 24
-rwxrwxr-x 1 sy1 sy1 8512 Jul 26 11:03 filetest
-rw-rw-r-- 1 sy1 sy1 2448 Jul 26 11:00 filetest.c
-rw-rw-r-- 1 sy1 sy1 13 Jul 26 11:03 log.txt
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ cat log.txt
hello write!
运行后并没有在屏幕上打印信息,而是创建了一个名为"log.txt"的文件,并对其写入消息。明明向文件描述符1写入数据,1对应的是标注输出,最后数据怎么写入到了文件中呢?
当close关闭文件时,会让该文件的fd可以重新分配给新打开的文件。代码中close(1)后,在open("log.txt", ...)后,相当于将文件描述符1分配给了log.txt,通过write指定文件描述符为1的文件就定位到log.txt而不是显示器,所以信息会输出到log.txt中。
既然write(1, …)会将数据写入到文件描述符为1的文件中,那我使用printf总能让数据打印到屏幕上了吧,printf不就是把数据格式化打印到显示器的吗?
int main()
{
close(1);
open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
const char* str = "hello write!\n";
//write(1, str, strlen(str));
printf("%s", str); //不指定文件描述符,只是想要无脑往屏幕上打印
return 0;
}
运行结果:
[sy1@VM-16-11-centos lesson17]$ ll
total 8
-rw-rw-r-- 1 sy1 sy1 2473 Jul 26 11:11 filetest.c
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ make
gcc -o filetest filetest.c
[sy1@VM-16-11-centos lesson17]$ ./filetest
[sy1@VM-16-11-centos lesson17]$ ll
total 24
-rwxrwxr-x 1 sy1 sy1 8464 Jul 26 11:12 filetest
-rw-rw-r-- 1 sy1 sy1 2473 Jul 26 11:11 filetest.c
-rw-rw-r-- 1 sy1 sy1 13 Jul 26 11:13 log.txt
-rw-rw-r-- 1 sy1 sy1 75 Jul 11 17:55 Makefile
[sy1@VM-16-11-centos lesson17]$ cat log.txt
hello write!
奇了怪了,明明我没有指定往文件描述符为1的文件写入数据,只想通过printf向显示器写入数据,但最后还是向文件log.txt写入数据而不是显示器,这又是为什么呢?
表面上我没有指定文件描述符,实际上printf里面指定了文件描述符,printf默认向标准输出流中写入数据,C语言的标准输出流是stdout,类型是FILE*,而FILE结构体中一定封装了文件描述符,并且printf内部是通过系统调用write写入数据的,在进程运行时,OS会自动为标准输出流分配文件描述符1,此时C语言就将stdout->file_no赋值为1,printf内部一定是通过write(stdout->file_no, ...)写入数据的,看似我们并没有显示指定向指定fd的文件写入数据,但printf内部已经指定了向fd为1的文件写入数据,所以我们能看见数据被写入到log.txt文件而不是屏幕上。
上面的现象就是:将本来往屏幕上写入的内容写入到文件上了,这不就是重定向吗?
上面实现重定向的做法就是,先关闭某个文件描述符,再打开一个文件,将关闭文件的文件描述符重新分配给新文件。重定向的本质是对fd_array[]数组中的元素值进行拷贝,从而改变上层通过文件描述符访问所指向的文件。
系统调用dup专门用来完成重定向操作。系统调用dup包括dup、dup2、dup3
#include <unistd.h>
int dup(int oldfd); // 申请一个最小的文件描述符,指向的文件和oldfd指向的文件一样,成功返回申请到的文件描述符
int dup2(int oldfd, int newfd); // 让newfd指向的文件和oldfd指向的文件一样,改变的是newfd指向的文件
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <fcntl.h> /* Obtain O_* constant definitions */
#include <unistd.h>
int dup3(int oldfd, int newfd, int flags); // 与dup2类似,可以指定如何复制文件描述符。

0x05.一切皆文件
"Linux下一切接文件"是Linux的真言,具体是怎么是实现的呢?将所有的设备(键盘、显示器、磁盘、网卡的等)都使用struct file描述
struct file
{
ssize_t (*p_read)(int fd, const void* buffer, size_t count); //文件读方法
ssize_t (*p_write)(int fd, void* buffer, size_t count); //文件写方法
//其他属性
}
不同设备具体的读方法和写方法一定不一样,但不论是读取键盘还是文件,都是通过read完成,不论是向显示器写入还是文件写入,都是通过write完成。这是因为在file结构体中用函数指针模拟“多态”的实现,函数指针的类型是一样的,但是不同的设备底层一定对应不同的读写方法,让函数指针指向不同的方法,这样在外层就可以通过read、write方法读写不同类型的文件,不需要考虑不同文件设备之间读写方法的差异,因为在file结构体中已经让函数指针指向具体的方法了。
打开文件时,OS将在内核中创建file结构体,同时会用文件对应的读写方法初始化file结构体中的读写方法。让指针指向差异化的代码,在上层用户看来,所有的设备读写都是调用file结构体中的方法。
也就是说,访问各种外设时,都是通过struct file访问,file中的读写方法具体指向谁的方法,就对应该文件,所以在上层看来,可以不关心外设的读写方法的差异性,通过统一的struct file中的方法进行读写即可。所以对于用户来讲,一切皆文件!!
缓冲区
0x01.引入
通过一段代码,理解缓冲区的存在
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello, buffer!");
sleep(3);
return 0;
}
运行代码后,我们并没有立马看见”hello,buffer!“被显示在屏幕上,而是3s过后才看见这条消息。这是因为我们通过printf函数将输出的数据暂存到一个临时空间中,只有当特定的条件触发时才会将临时空间中的数据刷新到显示器上,这个临时空间叫做缓冲区,触发条件叫做刷新策略。
0x02.缓冲区是什么
缓冲区就是内存中的一块空间,用于临时保存输入输出的数据。当满足刷新策略时,才会将缓冲区中的数据进行读取打印。
缓冲区分为内核级缓冲区和用户级缓冲区
- 内核级缓冲区:由操作系统维护的一段内存空间,在系统调用中会将输入输出的数据保存在内核级缓冲区。
- 用户级缓冲区:由语言层/用户层定义的内存空间,在库函数中将输入输出的数据保存在用户级缓冲区中。
内核级缓冲区存在于内核空间中,用户级缓冲区存在于用户空间中。用户可以通过系统调用访问内核级缓冲区。

当我们调用IO库函数时,实际上将数据拷贝到用户级缓冲区,当刷新策略满足时,我们才会调用系统调用write将用户及的缓冲区拷贝到内核级缓冲区,一旦数据进入到内核级缓冲区了,具体的刷新策略就与我们无关了,由操作系统决定,一般我们认为只要数据进入到内核缓冲区就会立马刷新到显示器上。

0x03.缓冲区的作用
缓冲区是用于暂存需要进行输入输出的数据,它为什么要临时存储输入输出的数据?不可以一旦有输入输出的数据就直接调用系统调用进行IO操作吗?
缓冲区的存在提高了系统IO操作的效率。
我们知道,CPU与硬件直接进行IO交互的成本是比较高的,如果我现在需要多次输出,且每次输出的内容很少,如果输出一次就调用一次系统调用与硬件直接进行IO交互,那么我需要将进行多次的系统调用且每一次的成本都不小。我完全可以将所需要输出的所有数据集中在一个缓冲区中,当满足刷新策略时,再通过系统调用将缓冲区的数据刷新到硬件上,这样我只需要进行一次系统调用,就可以完成多次输出操作。
缓冲区的存在提高了使用者的效率。
在与IO有关的库函数中,一定封装了IO相关的系统调用,因为只有系统调用才能够直接访问内核部分以及硬件资源。当缓冲区存在时,库函数只负责将数据拷贝到用户及缓冲区以及是否需要刷新即可,库函数不需要考虑如何与硬件进行IO操作,这样做的好处就是提高了库函数的效率,库函数的执行速度大大提高。
举一个例子来理解缓冲区的作用:
假如现在你要给同学买一个生日礼物,有两种方案:你买完礼物后亲自给去同学的城市给他;通过快递系统将礼物寄给同学。很明显,第二种方案的效率跟高,你只需要小楼将礼物递交给快递员,后续你就不需要操心礼物如何送达了,这是快递公司考虑的问题,其次,在快递公司看来,它可以先收集更多的礼物,再进行派送,这样这样整个快递系统也能够提高效率。
再这个例子中,你将礼物交给快递员这个过程相当于库函数,快递公司相当于缓冲区。正是因为快递公司(缓冲区)的存在,你才能将礼物交给快递员后去执行其他的任务,这提高了你(库函数)的效率,此外,再快递公司看来,它可以一次性分发更多的快递,这也提高了整个系统的效率。
刷新策略
我们这里缓冲区刷新指的是用户及的缓冲区合适刷新到内核级缓冲区,而不是内核级缓冲区合适刷新到硬件上。
C语言标准库提供如下几种刷新策略:
- 全刷新:只有当缓冲区满了,才会进行IO操作。这是文件IO的默认刷新方式
- 行刷新:遇到换行符
\n,会进行IO操作。标准输出默认使用行刷新,除非重定向到文件。 - 无刷新:每次IO操作都会立即进行,不使用缓冲区。标准错误流通常使用无刷新。
- 强制刷新:通过fflush函数显示刷新缓冲区。
- 退出刷新:当进程退出时,会将缓冲区的数据进行刷新
下面看一个代码理解刷新策略。
int main()
{
// 系统调用
write(1, "hello write!\n", 14);
// 库函数
fwrite("hello fwrite!\n", 15, 1, stdout);
fprintf(stdout, "hello fprintf!\n");
fork();
}
sy@hcss-ecs-bd8b:~/linux/lesson23$ make
gcc main.c -o test
sy@hcss-ecs-bd8b:~/linux/lesson23$ ./test
hello write!
hello fwrite!
hello fprintf!
sy@hcss-ecs-bd8b:~/linux/lesson23$ ./test > a.txt
sy@hcss-ecs-bd8b:~/linux/lesson23$ cat a.txt
hello write!
hello fwrite!
hello fprintf!
hello fwrite!
hello fprintf!
当往显示器上打印时,刷新策略为行刷新,不论执行的是系统调用还是库函数都会直接刷新,在显示器上就能看见。将结果重定向到文件中时,刷新策略变为全刷新,执行write后会直接将数据送入内核缓冲区,前面说过,当数据进入内核缓冲区时可以理解为数据已经成功显示。当执行库函数时,由于是全刷新,因此不会立即将数据送入内核缓冲区,在执行完fprintf函数后,用户级缓冲区的数据为"hello fwrite!\nhello fprintf!\n",此时fork后创建子进程,假设父进程先退出,进程退出时会刷新缓冲区,父进程的缓冲区数据就被显示,子进程发生写实拷贝,子进程的用户级缓冲区内容也为"hello fwrite!\nhello fprintf!\n",当子进程退出时也会刷新缓冲区,所以"hello fwrite!\nhello fprintf!\n"会被显示两次。

模拟实现fwrite函数
IO库函数需要做的操作:
- 将数据拷贝到用户及缓冲区
- 根据刷新策略判断是否需要拷贝到内核级缓冲区
// mystdlib.h
#pragma once
#define BUFFERSIZE 24
#define LINE_FLUSH (0x00000001)
#define FULL_FLUSH (0x00000001<<1)
#define NO_FLUSH (0x00000001<<2)
typedef struct _MyFILE
{
int fileno; // 文件描述符
char* buffer; // 输出缓冲区
int cap; // 缓冲区容量
int pos; // 缓冲区中已使用的大小
int flush_mode; // 刷新策略
}_MyFILE;
_MyFILE* MyFopen(const char* pathname, const char* mode);
void MyFclose(_MyFILE* file);
int MyFwrite(_MyFILE* file, const void* str, unsigned size);
void DebugPrint(_MyFILE* file);
const char* toString(int code);
void MyFlush(_MyFILE *file);
// mystdlib.c
#include "mystdlib.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
const char* toString(int code)
{
if (code == LINE_FLUSH)
{
return "LINE_FLUSH";
}
if (code == FULL_FLUSH)
return "FULL_FLUSH";
if (code == NO_FLUSH)
return "NO_FULSH";
}
void DebugPrint(_MyFILE* file)
{
printf("outbuffer:%s\n", file->buffer);
printf("fileno:%d\n", file->fileno);
printf("pos:%d\n", file->pos);
printf("cap:%d\n", file->cap);
printf("flush_mode:%s\n", toString(file->flush_mode));
}
_MyFILE *MyFopen(const char *pathname, const char *mode)
{
// 打开文件
int flag = 0;
int fd = -1;
if (strcmp("r", mode) == 0)
{
fd = open(pathname, O_RDONLY);
}
else if (strcmp("w", mode) == 0)
{
umask(0000);
fd = open(pathname, O_CREAT | O_WRONLY | O_TRUNC, 0666);
}
else if (strcmp("a", mode) == 0)
{
umask(0000);
fd = open(pathname, O_CREAT | O_WRONLY | O_APPEND, 0666);
}
else
{
return NULL;
}
// 构建FILE结构体
_MyFILE *file = (_MyFILE *)malloc(sizeof(_MyFILE));
if (file == NULL)
return NULL;
file->fileno = fd;
file->cap = BUFFERSIZE;
file->pos = 0;
file->flush_mode = LINE_FLUSH;
file->buffer = malloc(file->cap);
return file;
}
void MyFlush(_MyFILE *file)
{
write(file->fileno, file->buffer, file->pos);
file->pos = 0;
}
void MyFclose(_MyFILE *file)
{
MyFlush(file);
free(file->buffer);
close(file->fileno);
free(file);
}
int MyFwrite(_MyFILE *file, const void *str, unsigned int size)
{
if (str == NULL || file == NULL || size == 0)
return 0;
// 1.拷贝到缓冲区
// 1.1判断扩容
if (file->pos + size > file->cap)
{
int new_cap = file->cap;
// 确保申请的容量一定够
while (file->pos + size > new_cap)
{
new_cap *= 2;
}
void* temp = realloc(file->buffer, new_cap);
if (temp == NULL)
{
// 申请空间失败
return -1;
}
file->buffer = temp;
file->cap = new_cap;
}
// 1.2拷贝数据
memcpy(file->buffer + file->pos, str, size);
file->pos += size;
// 2.判断是否需要刷新
if (file->flush_mode == LINE_FLUSH && ((char*)str)[size - 1] == '\n')
{
MyFlush(file);
}
else if (file->flush_mode == FULL_FLUSH && file->pos == file->cap)
{
MyFlush(file);
}
return size;
}
// main.c
#include "mystdlib.h"
#include <stdio.h>
#include <string.h>
#define FILE_NAME "./log.txt"
int main()
{
_MyFILE* file = MyFopen(FILE_NAME, "w");
char buffer[128];
for (int i = 0; i < 5 ;i++)
{
sprintf(buffer, "This is message:%d\n", i);
MyFwrite(file, buffer, strlen(buffer));
DebugPrint(file);
}
MyFclose(file);
return 0;
}
代码说明:
mystdlib.h中定义FILE结构,其中包括缓冲区、当前访问位置、容量、文件描述符、刷新方式等。缓冲区的容量默认为24方便后续验证扩容,刷新方式默认为行刷新。mystdlib.c中实现了相关函数,flush函数将缓冲区的数据进行刷新,从用户缓冲区刷新到内核缓冲区。fopen函数负责打开文件以及构建FILE结构体,fclose函数负责关闭文件以及释放缓冲区、结构体对象。fwrite函数负责拷贝数据到缓冲区并判断是否进行刷新,拷贝时判断是否需要扩容。toString函数和DebugPrint函数负责打印文件相关信息,进行调试功能。main.c中向文件写入5条消息,每次写入一条消息查看文件相关信息,验证行刷新方式以及文件扩容相关功能
输出结果:验证行刷新
sy@hcss-ecs-bd8b:~/linux/lesson23/Mystdlib$ make
gcc -o test main.c mystdlib.c
sy@hcss-ecs-bd8b:~/linux/lesson23/Mystdlib$ ./test
outbuffer:This is message:0
fileno:3
pos:0
cap:24
flush_mode:LINE_FLUSH
outbuffer:This is message:1
fileno:3
pos:0
cap:24
flush_mode:LINE_FLUSH
outbuffer:This is message:2
fileno:3
pos:0
cap:24
flush_mode:LINE_FLUSH
outbuffer:This is message:3
fileno:3
pos:0
cap:24
flush_mode:LINE_FLUSH
outbuffer:This is message:4
fileno:3
pos:0
cap:24
flush_mode:LINE_FLUSH
sy@hcss-ecs-bd8b:~/linux/lesson23/Mystdlib$ cat log.txt
This is message:0
This is message:1
This is message:2
This is message:3
This is message:4
输出结果:验证扩容,将main.c中数组的\n换成---
sy@hcss-ecs-bd8b:~/linux/lesson23/Mystdlib$ ./test
outbuffer:This is message:0---
fileno:3
pos:20
cap:24
flush_mode:LINE_FLUSH
outbuffer:This is message:0---This is message:1---
fileno:3
pos:40
cap:48
flush_mode:LINE_FLUSH
outbuffer:This is message:0---This is message:1---This is message:2---
fileno:3
pos:60
cap:96
flush_mode:LINE_FLUSH
outbuffer:This is message:0---This is message:1---This is message:2---This is message:3---
fileno:3
pos:80
cap:96
flush_mode:LINE_FLUSH
outbuffer:This is message:0---This is message:1---This is message:2---This is message:3---This is message:4---
fileno:3
pos:100
cap:192
flush_mode:LINE_FLUSH
sy@hcss-ecs-bd8b:~/linux/lesson23/Mystdlib$ cat log.txt
This is message:0---This is message:1---This is message:2---This is message:3---This is message:4---
更多推荐



所有评论(0)