
👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
- 一、C语言文件操作
- 1.1 打开文件 --- fopen
- 1.2 关闭文件 --- fclose
- 1.3 文件写入 --- fwrite
- 二、文件系统调用
- 2.1 文件系统调用和函数的关系
- 2.2 打开文件 --- open
- 2.3 文件关闭 --- close
- 2.4 文件写入 --- write
- 三、文件描述符
- 3.1 什么是文件描述符
- 3.2 三个标准流
- 3.3 文件描述符的分配规则
- 3.4 引用计数
- 四、再谈重定向
- 4.1 输出重定向
- 4.2 追加重定向
- 4.3 输入重定向
- 4.4 系统调用接口 --- dup2
- 五、补充知识
- 5.1 stdout和stderr的区别
- 5.2 为什么Linux下一切皆文件(以源码的角度剖析)
- 六、相关代码
一、C语言文件操作
1.1 打开文件 — fopen
#include <stdio.h>
FILE* fopen(const char* path, const char* mode);
-
path:要打开文件的相对路径或绝对路径。注意:如果不带路径默认会在当前进程所在路径下创建/打开文件。 -
mode:打开文件的模式常用的模式包括:"r":只读模式。打开文件用于读取。注意:文件不存在表示打开文件失败。"w":写入模式。文件内容截断为零长度,即清空文件内容,再写入内容。(如果文件不存在,系统会新建对应的文件)"a":追加模式。不会清空原有文件内容,而在文件内容末尾追加。(如果文件不存在,系统会新建对应的文件)- 还有很多模式请查看手册:
man fopen
-
返回值
-
打开文件后,将返回一个指向
FILE类型的文件指针,指向打开的文件流。 -
如果打开失败,则返回
NULL。
-


为什么不带路径默认会在当前进程所在路径下创建文件?为什么不在其他路径创建文件?
首先大家需要明白一个道理:文件一定是由进程通过调用函数(如fopen())打开的,因此,文件与进程有关。所以,默认情况下,如果你不带路径,新建文件所处的路径将取决于进程当前的工作目录。
当然了,如果你的程序在运行时改变了当前工作目录,那么新建文件所处的路径也会相应地改变。


1.2 关闭文件 — fclose
在C语言中,当你完成了对文件的读写操作后,最后应该使用fclose函数关闭文件。这是一个很重要的步骤,因为它确保了操作系统释放文件资源,并且在某些情况下,确保了写入的数据被刷新到磁盘上。不关闭文件可能会导致资源泄露或数据丢失。
int fclose(FILE* stream);
-
如果成功关闭文件,
fclose函数返回0。 -
如果关闭失败,返回
EOF(-1),表示出现了错误。
1.3 文件写入 — fwrite
C语言对于文件写入有这几种方式:fputc、fputs、fwrite、fprintf 和 snprintf(大家可以去man手册查看用法)
int fputc (int character, FILE* stream);
int fputs (const char* str, FILE* stream);
int snprintf ( char* s, size_t n, const char* format, ...);
这里以fwrite函数为例
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
这个函数将 nmemb 个元素,每个元素大小为 size 字节,从指针 ptr 所指向的内存块写入到给定的文件流 stream 中。
-
ptr:指向待写入数据的指针。 -
size:数据写入的字节数。 -
nmemb:以size为单位,待写入数据的数量。 -
stream:指向FILE对象的指针。 -
返回值:如果返回值与
nmemb不相等,说明写入时发生错误。

这里有一个细节问题:strlen需要+ 1将’\0‘写入到log.txt文件中吗?
我们执行以上代码看看log.txt里的内容(不+ 1)

然后我们再+ 1看看效果

后面多了一个类似于乱码的字符^@,这其实就是'\0'的二进制。因为vim是一个文本编辑器,对于二进制来说当然显示的是乱码啦。 这也说明了我们在向文件写入字符串的时候不需要处理'\0'(只需将字符串的内容写入即可),因为字符串以'\0'结尾是编程语言的规定,和文件没半毛钱关系 ~
接下来我们再连续执行程序,再看看log.txt的内容

我们不是向文件中写入了多个字符串hello file吗?为什么只写入了一个?
这是因为我们设置打开文件的模式是w,即在向文件写入之前会先对文件进行清空处理,然后再写入。
这不由得让我们联想到输出重定向>

因此输出重定向>的底层一定要以w的方式打开一个文件,即fopen("文件/文件路径", "w"),即将文件内容清空,最后再用写入函数进行向文件写入。
接下来我们再以a的方式打开一个文件并写入


我们发现:以a的方式打开一个文件并写入,不会清空文件,而是在文件内容末尾追加。
这不由得让我们联想到追加重定向>>

因此追加重定向>>的底层一定要以a的方式打开一个文件,即fopen("文件/文件路径", "a"),即不将文件内容清空,最后再用任意一个写入函数向文件内容进行追加。
综上所述,输出重定向>和追加重定向>>主要区别就是在于打开文件的方式不同,一个以w,一个以a。

二、文件系统调用
2.1 文件系统调用和函数的关系
我们知道,文件是在磁盘上存储的,磁盘作为外设(外部设备),所以访问文件其实也是在访问磁盘(硬件)!而我们计算机体系结构是分层的,如下所示:

而我们知道底层硬件一定要被操作系统管理的,作为普通用户无法直接访问底层硬件的相关信息,那么就只能通过管理者,也就是操作系统来获取,而操作系统根本不相信用户,因此提供了系统调用接口来访问,以此来保护操作系统。因此,几乎所有的库函数只要访问硬件设备,必定要封装系统调用接口。因此,以上文件操作的函数底层必定封装了文件系统调用接口!
2.2 打开文件 — open
在Linux中,open函数是一个系统调用,用于打开或创建文件。
#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:控制文件打开方式。以下是常见的打开方式。(还有很多打开方式,可以通过man 2 open来查看)O_WRONLY:以只写模式打开文件。O_CREAT:如果文件不存在,则创建文件。O_APPEND:在写入时将数据追加到文件的末尾。O_TRUNC:清空文件内容。
实际上,以上的选项是在
<fcntl.h>头文件中定义的宏。我们可以在路径/usr/include/bits查看,此路径通常包含了与体系结构相关的宏定义和声明。

-
mode:用于指定新创建文件的权限,只有在O_CREAT标志位被设置时才有效。一般采用八进制表示的文件权限值,例如0666。 -
返回值:
-
如果成功,返回一个文件描述符(非负整数),该描述符可以在后续的读写操作中传参。
-
如果失败,返回
-1。
-


当我们将程序运行起来,发现open打开文件失败,原因是:O_WRONLY只会读文件,而不会新建文件。那就非常简单了,再加一个宏O_CREAT,让文件不存在时自动创建即可。可是,open的第二个参数flag是整型,如何传两个参数?
这就要涉及到比特位方式的标志位传递方式。
还记得诸如O_WRONLY这些宏吗?它们的共同特点:二进制序列中最多只有一个比特位上是1,并且1所处的比特位是不同的。而一个整型是4个字节,也就是32个比特位(标志位),那么我们可以使用按位或|运算符来组合多个标志位。比如0001 | 0010 = 0011。然后open函数内部实现可以使用按位与操作符&来检查是否设置了某个标志位,比如


因此,我们可以对一开始的代码进行修改,使用按位或|增加O_CREAT选项


如上,我们确实把一个不存在的文件log.txt创建好了,可是此文件的权限都是乱码,原因在于你新建一个文件,此文件要受Linux权限约束,你新建文件的权限必须得告诉操作系统。因此,只有在 O_CREAT 标志位被设置时,需要指定新文件的权限。
在往期博客讲过,文件默认的权限是0666,即rw-rw-rw-


文件创建出来了,并且权限也不是乱码了,但是,权限并不是我们一开说的rw-rw-rw-。实际上创建出来的文件会收到文件默认权限掩码umask的影响,实际创建出来文件的权限值为:mode & (~umask);而在Linux中,umask的默认权限值为0002,当我们设置mode值为0666时实际创建出来的文件权限为0666 & ~(0002) = 0664,即rw-rw-r--。
若想创建出文件的权限值不受umask的影响,即如上想让权限为rw-rw-rw-,那么系统专门提供了 umask系统调用接口,可以让你修改默认的权限掩码。
即在创建文件前使用umask函数将文件掩码umask设置为0。


2.3 文件关闭 — close
#include <unistd.h>
int close(int fd);
-
fd是要关闭的文件描述符,即open函数的返回值。 -
函数返回值为
0表示成功关闭文件,返回-1表示出现错误
关闭文件描述符后,相关的系统资源将被释放,包括文件表项和文件描述符本身。这通常是在不再需要使用文件描述符时执行的操作,以释放系统资源并防止资源泄漏。
2.4 文件写入 — write
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
fd是文件描述符(open函数返回值),表示要写入的文件。buf是一个指向要写入数据的缓冲区的指针。count是要写入的字节数。- 返回值是写入的字节数,如果出现错误,则返回
-1,
如果想要实现输出重定向的功能,不仅需要读O_WRONLY、创建文件O_CREAT,在写之前还需要将文件清空O_CREAT


如果想实现追加重定向也非常简单,由于清空和追加两个是冲突的,因此你只需要将清空O_CREAT替换成追加O_APPEND


【总结】

因此,只要是在 Linux 平台中编写的程序,无论是什么编程语言,在进行文件相关操作时,其文件操作函数都有封装系统调用接口。也就是说,要想与硬件(磁盘)打交道,必须经过 系统调用 -> 操作系统 -> 硬件 这条路线,否则无法直接与硬件进行交互。
三、文件描述符
3.1 什么是文件描述符
到目前为止,我们所知道的文件描述符就是open函数的返回值(非负整数),这个描述符可以在后续的读写操作中用作文件标识符,即找到对应的文件。
那不知道大家有没有思考过一个问题:为什么操作系统可以单单通过一个整数(文件描述符)就能找到对应的文件?
文件分为打开的文件和没打开的文件(这里先讨论打开的文件,等到文件系统再谈没打开的文件)。打开的文件是由进程使用函数(如fopen())打开的。根据冯诺依曼体系结构,被打开的文件一定会被加载到内存。而一个进程可以打开多个文件(1:n),那么被打开的文件也一定要受操作系统的管理,那就要请出管理的六字真言:先描述,后组织。
-
描述:一个被打开的文件一定要有自己的文件结构体对象
file,包含文件的信息,即struct file { 文件信息; struct file* next; struct file* prev;}; -
组织:通过双链表方式。
-
往后对被打开文件的管理,就转换成为了对链表的增删改查!
所以,当进程打开文件时,操作系统会在内核中创建数据结构来描述这个已打开的文件对象(和PCB类似)。这个数据结构通常被称为 file 或其他类似的名字,它包含了文件的各种信息,如文件位置、权限、状态等。
而进程可以打开多个文件,那进程PCB结构体对象就要存储哪些文件是由哪一个进程打开的。因此,每个进程PCB对象都要和打开的文件建立关系!所以进程PCB对象其实有一个指针struct files_struct* files,这个指针指向结构体files_struct,而这个结构体包含一个指针数组struct file* fd_array[],这个数组我们可以称之为文件描述符表。数组中的每个元素都是指向当前进程所打开文件的指针(地址)!
所以,本质上文件描述符就是指针数组的下标(索引)。所以,只要拿着文件描述符,就可以找到对应的文件。

【源码】

我们可以打印出文件描述符来看看


3.2 三个标准流
如上,文件描述符即指针数组的下标是从3开始连续递增的。那这里我有一个问题:为何不从0开始递增呢?难道下标0、1、``2存储了其他被打开文件的地址吗?你的猜测是正确的!
在C语言中,默认情况下,当一个进程启动时,操作系统会打开三个标准流(本质上就是文件,不同的编程语言都会打开它的三个标准流)
-
stdin:标准输入,文件描述符0。通常用来接收用户的输入,主要是键盘设备。 -
stdout:标准输出,文件描述符1。通常用来输出程序的正常运行信息,主要是显示器设备。 -
stderr标准错误,文件描述符3。通常用来输出程序的错误信息,stderr默认也将错误信息显示在终端上。主要也是显示器。

我们可以来验证:

FILE是C语言封装文件操作的结构体,当你使用 C 标准库中的函数来操作文件时,这些库函数底层必定会封装系统调用接口,那么FILE结构体内部一定要有对应的文件描述符来与操作系统进行通信,即需要有文件描述符来调用底层封装的系统调用接口,因此FILE结构体里一定会包含文件描述符成员_fileno(其他编程语言的文件操作也是如此)


3.3 文件描述符的分配规则
如果是直接打开文件某个文件,由于系统默认会打开三个标准流,因此新分配的文件描述符从3开始依次递增。


如果我在打开log.txt文件之前,把标准输入stdin关闭,其文件描述符还会是3吗?


我们发现:新文件的文件描述符占据了标准输入stdin的文件描述符。因此,文件描述符的分配规则是:从头遍历文件描述符表fd_array[],找到一个最小的且没有被使用的下标,它的下标就是新文件的文件描述符(保证数组空间不会被浪费)。
3.4 引用计数
如果我这里故意将文件标识符1给关掉,即显示器文件被关闭,那么打印结果就无法显示到屏幕上


那如果我在文件标识符1关掉的基础上,再向stderr流写入,即向显示器写入,还能写入成功吗?有的人想,肯定不成功,文件标识符1关掉,等同将显示器文件关掉,而stderr也是将结果显示在显示器,肯定打印不出来


我们发现:结果可以打印出来。
其实每个文件对象都包含一个引用计数字段count,记录了有多少个文件描述符指向该文件对象。当一个进程打开一个文件时,内核会增加该文件对象的引用计数count++。当进程关闭文件描述符时,内核会减少相应文件对象的引用计数count--,并且将文件描述符表对应下标的位置置空NULL。只有当引用计数减少到零时,内核才会释放该文件对象及其相关资源。

四、再谈重定向
重定向实际上是改变了进程的文件描述符表中指针数组对应下标内容的指向。这样,进程在进行I/O操作时,就会按照新的文件描述符表中的指向来进行,从而实现了重定向的效果。
常见的重定向操作包括
-
将一个文件描述符指向另一个文件描述符
-
或者将一个文件描述符关闭。
4.1 输出重定向
输出重定向是指将程序本来输出到标准输出
stdout设备的内容,重定向到其他文件


此时,我们发现,本来应该输出到显示器上的内容,输出到了文件log.txt当中,这不就是典型的输出重定向!
接下来我们来分析,为什么会出现这种情况
代码中,首先将stdout文件关闭,而后打开log.txt文件。根据文件描述符的分配规则是:从头遍历文件描述符表fd_array[],找到一个最小的且没有被使用的下标,它的下标就是新文件的文件描述符(保证数组空间不会被浪费)。那么log.txt的的文件描述符就是1,而后面写入文件是向文件描述符1中写入,即就是向log.txt文件写入。这就是重定向的原理。

4.2 追加重定向
追加重定向是指将程序本来输出到标准输出
stdout设备的内容,追加到指定文件的末尾,而不是覆盖原文件内容。
那我们只需将以上代码的打开方式O_TRUNC修改成O_APPEND就有追加重定向的效果


4.3 输入重定向
输入重定向是指将程序从标准输入
stdin中读取数据的方式改变为从其他文件读取数据
例如,让本应该从“键盘文件”读取数据,改从log.txt文件中读取数据,那么我们可以打开log.txt文件之前将stdin文件关闭,这样一来,根据文件描述符的分配规则,当我们后续打开log.txt文件时所分配到的文件描述符就是0。


4.4 系统调用接口 — dup2
以上操作都需要先关闭再打开一个文件来实现重定向,操作过于繁琐,而且每次你写这样的代码都要向别人解释,因此操作系统提供了系统调用接口来实现重定向操作
#include <unistd.h>
int dup2(int oldfd, int newfd);
-
oldfd:是要复制的文件描述符。 -
newfd:是要被覆盖的文件描述符。 -
如果
newfd已经打开,则操作系统首先会关闭newfd,即释放文件对象。然后,dup2()会使newfd指向oldfd所指向的文件对象。
下面是演示了如何使用 dup2() 函数将标准输出重定向到一个文件中:


下面是演示了如何使用 dup2() 函数将标准输出追加重定向到一个文件中:


下面是演示了如何使用 dup2() 函数将标准输入重定向到一个文件中:


为往期模拟实现bash添加重定向功能:点击跳转
五、补充知识
5.1 stdout和stderr的区别
我们至今所认知的标准输出流和标准错误流并没有区别,都是将数据向显示器打印。

但若是将程序运行结果重定向输出到文件hello.txt当中,我们会发现hello.txt文件当中只将标准输出的打印语句重定向到hello.txt文件中,而向标准错误流输出的两行数据并没有重定向到文件当中,而是仍然输出到了显示器上。

实际上我们使用重定向时,默认重定向的是文件描述符是1的标准输出流,而并不会对文件描述符是2的标准错误流进行重定向。
此外,我们也可以将标准错误流进程重定向,通常使用 2> 或 2>> 来重定向 stderr。详细命令如下:
./proc 1> hello.txt 2>stderr.txt
//不要在重定向操作符和文件描述符之间添加空格。
// 1可以被省略,因为它是默认的输出流。
上述指令做了两次重定向,第一次把标准输出重定向到了文件描述符为1的显示器,第二次是把标准错误重定向到了文件描述符为2的显示器,,通过重定向可以分别将它们发送到不同的目的地,使得对程序输出和错误信息的处理更加灵活和有效。
前面已经提到,重定向只会默认把标准输出的进行处理,并不会重定向标准错误,如果我想让标准输出和标准错误一同混合起来到一个文件显示,如下命令:
./myfile 1>all.txt 2>&1
这个命令将 myfile的标准输出重定向到 all.txt 文件,2>&1 是将标准错误重定向到与标准输出相同的地方,也就是all.txt 文件中。
5.2 为什么Linux下一切皆文件(以源码的角度剖析)
一切皆文件其实是在Linux系统中一致访问资源的方式,即将所有资源抽象为文件。这包括了硬件设备等各种资源。(文件很容易理解,这里主要谈为什么硬件也要当做文件来看待)
当一个进程在Linux系统中运行时,操作系统会为每个文件创建一个文件对象struct file,这个文件对象跟踪了该文件的状态和相关信息。
因此,当一个进程打开硬件,那么操作系统就会管理这个硬件,即为该硬件创建文件对象struct file,而此文件对象其实提供了对设备的操作接口。

f_op指向对文件操作的各种函数指针。这些函数指针对应了文件的不同操作,例如读取、写入、定位等。

所以所谓的一切皆文件,就是相当于在文件这一层封装了一个文件对象,让文件对象中的函数指针结构体 file_operations 指向不同设备的操作方法。在这种情况下,不同类型的文件对象(比如普通文件、设备文件等)都具有相同的接口,即结构体file_operations。当应用程序调用文件操作函数时,操作系统根据文件对象的实际类型来调用相应的方法,这种行为类似于多态的表现,即同一个接口可以根据不同的对象类型展现出不同的行为。
因此,一切皆文件可以被视为在操作系统级别实现的一种多态思想,使得不同类型的资源都能够以统一的方式进行访问和管理。
六、相关代码
本篇博客相关代码:点击跳转