前言:
在生活里,我们常和各种文件打交道,像用 Word 写文档、用播放器看视频,这些操作背后都离不开文件的输入输出(I/O)。在 Linux 系统中,文件 I/O 操作更是复杂且关键。
接下来我们将深入探讨Linux 基础 IO,不仅包含了 C 语言文件 I/O 操作函数,如打开文件的fopen
、读取文件的fread等常见接口
,还详细讲解了系统文件 I/O 的函数和原理,以及文件描述符、重定向、缓冲区等重要概念(内容太多了,分成两篇博客介绍)。
目录
一. 文件
1.相关概念
1.0 理解文件
狭义理解
- 文件在磁盘里
- 磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的(当然,时间久了肯定会有损害)
- 磁盘是外设(即是输出设备也是输入设备,有文件从内存里读,就有文件向内存里写)
- 对磁盘上所有文件的操作本质都是内存对外设的输入和输出,简称IO
广义理解
- 操作系统内一切皆文件
- 操作系统为了方便管理各种各样的外设,选择将他们都看成文件便于管理。
1.1文件组成:
文件由属性和内容构成。
对于OKB的空文件是占用磁盘空间的,这是为啥呢???
大家可以想一想,我们平常在文件管理器上看到的KB大小,是不是指的文件大小,如果大小为0,为啥还会占用空间呢?很简单,磁盘当中存储的是我们文件的属性和内存,即使是空文件他还是要有文件本身的属性的。空文件在磁盘当中,存储的是文件的属性。
所以 文件=属性+内容
1.2文件路径:
(关于这一点我们在fopen那里会进一步讲解)
标定文件的唯一性需使用绝对路径(路径+文件名),未指定路径时默认当前目录。
1.3文件访问:
只有被打开的文件才能被进程访问,未被打开的文件存储在磁盘中。(后一部分 由文件系统管理,在下一篇博客中会专门讲解)
1.4进程与文件关系:
文件操作的本质是进程与被打开文件间的交互,由操作系统管理。多文件管理则是操作系统通过 `files_struct`统一 管理打开的文件,其中包含文件描述符表(fd数组),fd是数组索引。
就如图所示,每一个被打开的文件,操作系统都会分配一个file_struct的结构,管理并存储对应文件的属性与内容,而进程则是在PCB当中保存着一个可以指向被打开文件数组的指针,然后通过特定文件描述符访问或修改文件。
1.5文件管理机制:
操作系统通过“先描述再组织”管理文件,内核中每个文件对应一个数据结构(`struct file`),包含文件属性和内容指针。
正如在1.4所说的,文件被操作系统用file_struct管理起来,如何管理不就是通过各种数据结构简单而高效的管理吗?这就是“再组织”的过程。通过对文件的结构的管理,不就实现了对文件的管理吗。
2.系统调用接口
2.0 回顾C语言的函数接口
在C语言中,文件I/O操作主要是通过C语言的标准库提供的FILE *
指针和一系列文件操作函数来实现的。这些函数为开发人员提供了更高层的文件操作接口,使得文件读写变得简单和方便。
2.01 从文件的打开到文件的关闭
打开文件:fopen()
filename
:表示文件的路径,文件可以是相对路径或绝对路径。mode
:文件打开的模式,决定文件的读写方式。- 返回值:该函数成功返回打开文件的指针,失败着返回NULL。
"r" | 以只读模式打开文件,文件必须存在。 |
"w" | 以写模式打开文件,若文件已存在,且文件有内容,则文件会被清空。 没有文件,则创建文件。 |
"a" | 以追加模式打开文件,若文件不存在则会创建文件。 |
"rb" | 以二进制模式只读打开文件,文件必须存在。 |
"wb" | 以二进制模式写入文件,若文件已存在则会被清空。 |
代码示例
1 #include<stdio.h>
2 #include<unistd.h>
3
4
5 int main()
6 {
7 FILE* pf=fopen("text.txt","w");
8 if(pf)
9 printf("创建文件成功\n");
10 return 0;
11 }
~
结果
读取文件:fread()
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
ptr
:指向内存的指针,用于存储读取的数据。size
:每个数据元素的大小(单位:字节)。count
:读取的元素个数。stream
:文件指针,指定从哪个文件读取数据。- 返回值:返回读取成功的字节个数
代码及结果
1 #include<stdio.h>
2 #include<unistd.h>
3
4
5 int main()
6 {
7 FILE* pf=fopen("text.txt","r");
8 if(!pf)
9 {
10 perror("open file failed:\n");
11 return 99;
12 }
13 printf("打开文件成功\n");
14 char buffer[100];
15 size_t n=fread(buffer,sizeof(char),100,pf);
16 if(n)
17 {
18 printf("读取成功\n");
19 //之所以把数组的最后置成'\0',那是因为C语言的接口只能识别以’\0‘为结尾的字符串。
20 buffer[n]='\0';
21 printf("这是text.txt文件的内容:%s\n",buffer);
22 }
23 return 0;
24 }
文件修改:fwrite()
size_t fwrite(const void *ptr, size_t size, size_t nmemb,FILE *stream);
ptr
:指向内存中数据的指针。size
:每个数据元素的大小(单位:字节)。count
:写入的元素个数。stream
:文件指针,指定将数据写入哪个文件。
代码演示
1 #include<stdio.h>
2 #include<unistd.h>
3
4
5 int main()
6 {
7
8 FILE* pf=fopen("text.txt","r");
9 if(!pf)
10 {
11 perror("open file failed:\n");
12 return 99;
13 }
14 printf("打开文件成功\n");
15 char buffer[100]={"这是一段用于演示的代码,验证fwrite的功能\n"};
16 size_t n=fwrite(buffer,sizeof(char),100,pf);
17 if(n)
18 {
19 printf("读取成功\n");
20 //之所以把数组的最后置成'\0',那是因为C语言的接口只能识别以’\0‘为结尾的字符串。
21 buffer[n]='\0';
22 printf("这是text.txt文件的内容:%s\n",buffer);
23 }
24 return 0;
25 }
结果
注:fwrite
函数本身既不是覆盖写也不是追加写,它的写入方式取决于文件的打开模式(是由'r'还是'w'决定的)。
关闭文件:fclose()
int fclose(FILE *fp);
- fp:一个已经打开的文件指针
代码示例
结果
2.02 文件的错误处理
在文件操作中,对于错误的处理是非常重要,C语言提供了两个函数来帮助开发者检测错误:ferror()
和feof()
ferror()与feof()
int feof(FILE *stream);
int ferror(FILE *stream);
- ferror(FILE *stream):判断是否发生了文件I/O错误。
- 函数返回值:无错误出现时返回0;有错误出现时,返回一个非零值。
- feof(FILE *stream):判断文件是否到达了末尾。
- 函数返回值:如果没有到文件尾,返回0;到达文件尾,返回一个非零值。
这两个错误处理的函数,主要是判断文件错误的类型。
2.03 默认的IO流
都说Linux下一切皆文件,也就是说Linux下的任何东西都可以看作是文件(至少在操作系统看来),那么显示器和键盘当然也可以看作是文件。我们能看到显示器上的数据,是因为我们向“显示器文件”写入了数据,内存能获取到我们敲击键盘时对应的字符,是因为内存从“键盘文件”读取了数据。
那么不有一个问题吗?文件的打开不是要进程主动打开使用吗?那为什么我们向“显示器文件”写入数据以及从“键盘文件”读取数据前,不需要进行打开“显示器文件”和“键盘文件”等相应操作?
那自然是操作系统为我们打开了基础文件,任何进程在运行的时候都会默认打开三个输入输出流文件,即标准输入、标准输出以及标准错误,对应到C语言当中就是stdin、stdout以及stderr。
查阅man手册
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
当我们的C程序被运行起来时,操作系统就会默认使用C语言的相关接口将这三个输入输出流打开,之后我们才能调用类似于scanf和printf之类的函数向键盘和显示器进行相应的输入输出操作。
当然在默认的情况下,stdout,stderr对应的外设都是显示器,stdin则是键盘。
代码演示
1 #include<unistd.h>
2 #include<sys/types.h>
3 #include<stdio.h>
4
5
6 int main()
7 {
8 fclose(stdout);
9 printf("这是一段用于演示,关闭标准输出流后,无法向显示器打印\n");
10 return 0;
11 }
结果
其实不止是C语言当中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。
这也揭示了这种特性并不是某种语言所特有的,而是由操作系统所支持的。
2.1. open,write,read,close函数:
操作文件除了C语言接口、C++接口或是其他语言的接口外,操作系统也有一套系统接口来进行文件的访问。(各个语言对文件的访问,本质上都是对系统接口封装)。
我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,
在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,
这样做使得语言有了跨平台性,也方便进行二次开发。
(在不同的编译环境当中,C语言的库编写方式不同,因此在语言库的方面上保证C语言的可移植性)。
2.11 open函数
man手册原型
1 int open(const char *pathname, int flags);
2 int open(const char *pathname, int flags, mode_t mode);
参数分析
pathname
:要打开的文件的路径。flags
:文件打开模式,决定文件的访问方式。mode
:文件权限,通常在文件创建时使用。
第一个参数pathname
若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建。
若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。(注意当前路径的含义)
还记得我们在“1.3文件路径”中所说的区别
什么是当前路径?
我们知道,当fopen以写入的方式打开一个文件时,若该文件不存在,则会自动在当前路径创建该文件,那么这里所说的当前路径指的是什么呢?我们通过代码来验证一下
代码
1 #include<stdio.h>
2 #include<unistd.h>
3
4
5 int main()
6 {
7 FILE* pf=fopen("cwd.txt","w");
8 fclose(pf);
9 return 0;
10 }
结果
这是我们处于“cwd.exe”进程的相同的路径,结果与预想的差不多
那是否意味着这里所说的“当前路径”是指“当前可执行程序所处的路径”呢?
, 但如果我们处于与进程不同的路径下再次运行程序结果会如何呢??我们返回上级路径在测试一遍
这时我们可以发现,该可执行程序运行后并没有在FileIO目录下创建cwd.txt,而是在我们当前所处的路径下创建了cwd文本。
为了解释这一现象,我们调出该进程的各项属性
我们可以发现两个明显像是路径的变量,cwd就是进程运行时我们所处的路径,而exe就是该可执行程序的所处路径
总结: 实际上,我们这里所说的当前路径不是指进程运行时所处的路径,而是指该进程运行时我们所处的路径。
第二个参数flags
常见的flags
参数包括:
标志 | 描述 |
---|---|
O_RDONLY | 以只读模式打开文件 |
O_WRONLY | 以只写模式打开文件 |
O_RDWR | 以读写模式打开文件 |
O_CREAT | 文件不存在时创建文件 |
O_TRUNC | 如果文件已存在,清空文件内容 |
O_APPEND | 以追加模式打开文件 |
在C语言中我们经常用一个整形来传递选项,但是如果如果选项较多时,就会造成空间的浪费,这里我们可以通过使用一个比特位表示一个标志位,这样一个int就可以同时传递至少32个标志位,此时的flag就可以看待成位图的数据类型。而上面的参数就是一个又一个不同位置的宏,代表的是不同的位。
在打开文件的时候,利用“open”函数也可以达到“fopen”的效果,但是如果想要改变访问文件的方式就得利用flag参数。
代码:
1 #include<unistd.h>
2 #include<stdio.h>
3 #include <sys/types.h>
4 #include <sys/stat.h>
5 #include <fcntl.h>
6 #include<string.h>
7
8
9 int main()
10 {
11 int fd=open("open.txt",O_WRONLY | O_CREAT ,0664);
12 if(fd == -1)
13 {
14 perror("open file falied\n");
15 return -1;
16 }
17
18 const char str[]="这是一段用于检测open函数参数的示例\n";
19 write(fd,str,sizeof(str));
20 return 0;
21 }
结果
第三个参数mode
利用open函数的mode参数,可以对创建的文件进行权限管理,如果打开的文件已存在,那么该参数也无需去专门设置,设为0就好,但是不能不设置,否则会出现文件的权限错误。
例如,将mode设置为0666,则文件创建出来的权限如下:
按照之前的权限理解,我们创建的文件应该是具有所以的读写执行权限的。
但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。
若想创建出来文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0。
当然我并不建议对系统默认的值进行太多的修改。
open的返回值
open函数的返回值是新打开文件的文件描述符。
当然啥是文件描述符在第三部分会有专门的介绍。
2.2 write函数
接口
1 #include <unistd.h>
2 ssize_t write(int fd, const void *buf, size_t count);
man手册
参数分析
fd
:文件描述符。buf
:指向的是要写入数据的内存空间。count
:要写入的字节数。
我们可以使用write函数,将buf位置开始向后count字节的数据写入文件描述符为fd的文件当中。
- 如果数据写入成功,实际写入数据的字节个数被返回。
- 如果数据写入失败,-1被返回。
演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
int main()
{
int fd=open("Write.txt",O_WRONLY | O_CREAT,0666);
if(fd < 0)
{
perror("open");
return 1;
}
const char* message ="Hello world 0.6\n";
for(int i=0;i<10;i++)
write(fd,message,strlen(message));
return 0;
}
注:在上面的例子当中,我们不能使用sizeof函数充当write的第三个参数,因为message是一个指针,它不是一个字符数组。
注: 向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新到磁盘当中
2.3 read函数
接口:read()
:读取文件
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
man手册
参数:
fd
:文件描述符(由open
返回)。
buf
:数据读取的缓冲区地址。
count
:期望读取的字节数。返回值:实际读取的字节数(可能小于
count
),0 表示文件结束,-1 表示错误。
演示:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/stat.h>
5 #include<fcntl.h>
6 #include<unistd.h>
7 #define SIZE 1024
8
9 int main()
10 {
11 int fd=open("Write.txt",O_RDONLY,0666);
12 if(fd < 0)
13 {
14 perror("open failed:\n");
15 return 1;
16 }
17 char message[1024];
18 ssize_t n=read(fd,message,sizeof(message));
19 printf("这是读取到的数据:%s",message);
20 return 0;
21 }
2.4close函数
接口:close()
1 #include <unistd.h>
2 int close(int fd);
参数:要关闭的文件描述符。
返回值:0 成功,-1 失败。
当然这个函数是在是太简单了,这里就不在赘述。
演示:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/stat.h>
5 #include<fcntl.h>
6 #include<unistd.h>
7 #define SIZE 1024
8
9 int main()
10 {
11 int fd=open("Write.txt",O_RDONLY,0666);
12 if(fd < 0)
13 {
14 perror("open failed:\n");
15 return 1;
16 }
17 char message[1024];
18 ssize_t n=read(fd,message,sizeof(message));
19 printf("这是读取到的数据:%s",message);
20 close(fd);
21 return 0;
22 }
三、文件描述符fd
3.1文件描述符的概念
文件是由进程运行时打开的,一个进程可以打开多个文件,而系统当中又存在大量进程,这就导致了,在系统中任何时刻都可能存在大量已经打开的文件。
因此,操作系统务必要对这些已经打开的文件进行管理,操作系统会为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双链表的形式连接起来,之后操作系统对文件的管理也就变成了对这张双向链表的增删查改的操作。而在我们学习过进程相关概念后,我们也明白进程之间也是存在一个PCB结构体的。
而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程结构体和文件结构体之间的对应关系。
进程和文件之间的对应关系是如何建立的?
我们知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。
而task_struct当中有一个指针,该指针指向一个名为files_struct的结构体,在该结构体当中就有一个名为fd_array的指针数组(又被称为文件描述表),该数组的下标就是我们所谓的文件描述符。
当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件描述符表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。
这也是为啥文件描述符是一个整数,因为:它本质上是一个数组下标。
3.2文件描述符与FILE*的区别
虽然C语言提供了FILE *类型和相关的标准库函数来处理文件操作,但底层实际上是通过文件描述符来进行管理的。理解FILE *与文件描述符的区别对于深入理解文件I/O非常重要。
- 特性 FILE *(C标准库) 文件描述符(Linux操作系统)
- 类型 由C标准库提供的类型 操作系统内核使用整数值标识
- 管理者 由C标准库管理 由操作系统内核管理
- 主要用途 提供更高级别的文件操作接口 提供更低级别的文件操作接口
- 缓冲区管理 提供缓冲区管理,提高效率 不提供缓冲区管理
- 数据访问方式 适用于文本文件的高级操作 适用于二进制文件和直接内存映射操作
四.文件描述符fd分配规则
4.1. 最小可用原则:
新打开的fd选择当前未被使用的最小整数【如关闭fd (0)后新文件占用0】。
4.2演示:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/stat.h>
5 #include<fcntl.h>
6 #include<unistd.h>
7
8 int main()
9 {
10 int fd1=open("Write.txt",O_RDONLY,0666);
11 printf("这是关闭其他文件描述符之前的fd:%d\n",fd1);
12 close(0);
13 close(fd1);
14 close(2);
15 int fd2=open("Write.txt",O_RDONLY,0666);
16 printf("这是关闭其他文件描述符之后的fd:%d\n",fd2);
17 return 0;
18 }
解释:
先打开一个文件,发现该文件的描述符是3【也很好理解,毕竟上面还有标准输入/输出/错误3者】,然后关闭标准输入、标准错误与该文件描述符【不关闭二,是我们后面还要打印fd观察现象】 ,在打开文件,发现描述符fd变成了0。