目录
- 1. 引子
- 2. C语言文件接口
- 2.1 文件的打开与关闭的操作
- 2.2 文件写入读取操作
- 2.3 当前路径
- 3. 文件I/O操作与系统调用
- 3.1 程序默认打开的文件流
- 3.2 操作系统访问文件的系统调用接口
- 3.2.1 文件打开与关闭操作
- 3.2.2 写入与读取操作
- 4. 什么是文件描述符fd
- 4.1 进程与文件的关系
- 4.2 C语言对文件fd与接口封装载使用的意义
- 4.3 Linux操作系统下一切皆文件的设计理念
- 5. 文件fd的分配规则与重定向
- 6. C语言库缓冲区
- 6.1 C语言库缓冲区的存在意义
- 6.2 C语言缓冲区的物理位置与存在证明
1. 引子
- 在前面关于文件的学习中,我们只有文件由内容与属性两部分构成,因此,空文件只是文件内容为空仍然包含着其的属性信息,所以大小并不为空。
- 在C语言的学习中,我们学习过文件操作相关的接口,在对文件操作进行操作前每次都需要使用
fopen
函数的指定模式打开对应需要修改的文件。而在操作系统中,我们想要编辑修改文件时,也要提前需要将对应的文件使用编辑器打开。那么,
<1> 为什么每每当我们想要访问文件都要将文件打开呢?
<2> 而这一文件的打开操作又具体做了些什么?
<3> 文件又具体是由谁来打开,谁来修改的呢?
- 系统的文件常态下都存储在磁盘之中,而在磁盘中的文件无法直接进行修改(CPU只处理内存中的数据),所以,每当要对磁盘中的文件进行修改都必须要将其加载至内存中,这一操作我们就称之为文件的打开。
- 修改文件的内容,我们可以通过C语言中文件操作相关的接口来实现,而这些函数的使用都是以代码的方式被编写在程序之中,此后再将程序编译生成可执行程序加载到内存中成为进程。当进程被执行到相应二进制指令时,才会对文件进行指定打开修改等操作。
<1> 因此,我们可以理解为是进程打开了文件,对文件进行了相应一系列的操作。
<2> 一个进程可以打开多个文件
- 综上所述,我们可以下定一个如下结论:一定时间段内,系统内会存在多个进程,可能同时更多被打开的文件。
- 操作系统是计算机软硬资源的管理者,这些被打开的文件也是计算机资源,因而操作系统也必然对其进行管理。操作系统对计算机资源的管理方式与里面为
先描述,再组织
,所以,可以明确的是,内核中,一定存在着描述被打开文件的结构体,并有着使用其定义的对象。
- 因为操作系统的描述组织的管理方式,所以我们对文件相关知识的学习,实质上就是研究进程与文件的关系。(struct task_struct 与 struct file)
- 操作系统中,并给所有文件都被打开了,文件根据所处位置的不同,可以区分为两种:
<1> 内存文件(被打开的文件)
<2> 磁盘中的文件(没有被打开的文件)
2. C语言文件接口
2.1 文件的打开与关闭的操作
- fopen(打开)
FILE* fopen(const char* filename, const char* mode);//stdio.h
- fopen函数第二个参数决定了被打开文件的打开模式,常用有如下三种:
"w"
模式(写入模式)
<1> 若文件不存在,则会在当前目录中新建此文件。
<2> 打开文件后,会将文件原有内容清空,从文件开头写入
<3> 输出>
重定向的被指就是向文件中写入"a"
模式(追加模式)
<1> 本质上也是对文件进行写入,只是不会将文件内容清空
<2> 从文件结尾开始写入
<3> 追加>>
重定向"r"
模式(读取模式)
- fclose(关闭)
int fclose(FILE* stream);//stdio.h
- 关闭指定的已打开的文件
- 关闭成功返回0,关闭失败返回
EOF
2.2 文件写入读取操作
- fputs
int fputs ( const char* str, FILE* stream );
- 以字符串的形式向指定文件中写入
- 此种写入方式可以识别出字符串,以字符串为单位,遇到字符串结束标志
\0
就会停止写入
#include <stdio.h>
int main()
{
FILE* fp = fopen("log.txt", "w");
char buf[] = "hello\0 world";
fputs(buf, fp);
fclose(fp);
return 0;
}
2. fwrite
size_t fwrite ( const void* ptr, size_t size, size_t count, FILE* stream );
- 参数ptr:存储需要写入数据空间的地址
- 参数size:写入数据的基本单位大小(字节)
- 参数count:一次写入多少个基本单位
- 以二进制流的形式进行写入,写入时需要指定写入数据大小
- C语言中,将数据视作一条"河流",从一个设备流向另一个设备
#include <stdio.h>
int main()
{
FILE* fp = fopen("log.txt", "w");
char buf[] = "hello\0 world";
fwrite(buf, sizeof(buf), 1, fp);
fclose(fp);
return 0;
}
2.3 当前路径
- 什么叫做进程的当前路径?
<1> 在进程被执行时,会在系统目录proc
中创建一个相应的临时目录,目录名为进程的pid码,其中记录着诸多这一进程的相关信息。
<2> 进程启动时会记录当前所处的路径,进程目录中的cwd对应标识信息。
- C语言
fopen
接口以"w"
模式打开一个文件,若此文件不存在则会在当前路径下创建一个同名文件。
<1> 进程的当前路径默认为程序所在目录
<2> 当前路径可以被改变,我们可以通过chdir
函数来改变进程的当前路径
<3> 进程与文件之间有着莫大的关系,进程的路径就决定了新建文件的位置
#include <stdio.h>
#include <unistd.h>
int main()
{
chdir("./dir");
FILE* fp = fopen("log.txt", "w");
fclose(fp);
return 0;
}
3. 文件I/O操作与系统调用
3.1 程序默认打开的文件流
- 在Linux操作系统下,一切皆是文件,我们平时用来输入信息的键盘,查看打印信息的显示器也不例外。因此,硬件设备的数据输入输出,我们就可以将其理解为向相应的文件中写入或者读取数据。
- C语言文件操作的相关接口中,
fopen
函数的返回值为一个类型为FILE
的结构体指针,这个FILE类型即为C语言概念中的描述文件的类型。而C语言库中默认存在着三个输入输出相关的文件流,这三个文件流会在进程运行的时候默认被打开,分别为:
<1> 标准输入:stdin
,对应硬件设备:键盘
<2> 标准输出:stdout
,对应硬件设备:显示器
<3> 标准错误:stderr
,对应硬件设备:显示器
- 标准输入,输出相关C语言函数:
<1> 标准输出:fputc
,fprintf
,fwrite
<2> 标准输入:scanf
,fscanf
,fread
3.2 操作系统访问文件的系统调用接口
- <1> 文件的I/O操作必定要与硬件设备产生关联,诸如显示器,磁盘,键盘等。
<2> 而我们是无法直接操控访问计算机的硬件设备的,必须要通过操作系统贯穿整个计算机软硬件层次结构才可以实现。
<3> 也就是说,即使我们是通过调用C语言库函数来实现文件操作的,实质上也是需要通过操作系统来实现的。
<4> 通过操作系统来实现文件操作的方式为使用操作系统提供的系统调用接口。
- 综上所述,我们也就可以得知C语言对应的文件操作函数在底层一定封装了相应操作系统的系统调用接口,接下来进行相关的了解与学习。
3.2.1 文件打开与关闭操作
- open系统调用接口
//头文件sys/types.h,sys/stat.h,fcntl.h
int open(const char* pathname, int flag);
int open(const char* pathname, int flag, mode_t mode);
- 参数pathname:打开文件的所在路径
- 参数flag:标志位,通过传递不同的标志为可以以不同的访问方式打开文件
- 参数mode:设置文件的初始权限位,可以使用系统接口
umask
来提前设置创建文件的权限掩码- 返回文件描述符fd,数据类型为整形
- flag标志位的常用参数
O_RONLY
,只读O_WRONLY
,只写O_RDWR
,读写O_TRUNC
,清空文件,覆盖式写入O_APPEND
,向文件结尾写入,追加O_CREAT
,创建文件
- 系统所给出的flag参数实质上为一个个定义好的宏,flag参数的类型为
int
,其有着32个bit位。
<1> 这些宏都是32个bit位中只有一位为1的int类型数据,并且它们的含1bit位都互相错位。
<2> 通过此种位操作宏定义与按位与|
的方式,可以达到一次性传递多个标志位参数的效果,Linux操作系统中常用。
#define ONE 1
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
#define FIVE (1<<5)
void mytest(int flag)
{
if(flag & ONE)
printf("this is ONE\n");
if(flag & TWO)
printf("this is TWO\n");
if(flag & THREE)
printf("this is THREE\n");
if(flag & FOUR)
printf("this is FOUR\n");
if(flag & FIVE)
printf("this is FIVE\n");
}
int main()
{
mytest(ONE);
printf("----------------------------------\n");
mytest(ONE | TWO);
printf("----------------------------------\n");
mytest(ONE | TWO | THREE);
printf("----------------------------------\n");
mytest(ONE | TWO | THREE | FOUR);
printf("----------------------------------\n");
mytest(ONE | TWO | THREE | FOUR | FIVE);
return 0;
}
3. open系统调用接口的使用方式
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
//模拟fwrite的"w"模式,mode参数传递三位8进制数,文件权限位默认设置为111 111 111
int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
//模拟fwrite的"a"模式
int fd = open("log.txt", O_RONLY, 0666);
//模拟fwrite的"r"模式
- close系统调用接口
//头文件unistd.h
int close(int fd);
- 传递参数文件fd关闭指定文件
- 关闭成功返回0,关闭失败返回-1
3.2.2 写入与读取操作
- write系统调用接口
//头文件unistd.h
ssize_t write(int fd, const void* buf, size_t count);
- 参数fd:文件描述符
- 参数buf:写入数据存储地址,缓冲区
- 参数count:写入数据的大小,单位字节
- 返回值为signed size_t,有符号整形,写入成功返回0,写入失败返回-1
- read系统调用接口
ssize_t read(int fd, const void* buf, size_t count);
- 参数fd:文件描述符,指定读取内容的文件
- 参数buf:读取的内容存储空间的地址
- 参数count:读取多少个字节
- 返回值ssize_t,有符号整形,读取成功返回读取了多少个字节,读取失败返回-1
4. 什么是文件描述符fd
4.1 进程与文件的关系
- 从上面我们新学习到的Linux系统文件相关系统调用接口,可以看出其对文件进行的一系列操作都是通过open接口的返回值fd来实现的,通过fd来找到对应的文件进行写入,读取,关闭等操作。
- fd是一个整形数据,其被称作文件描述符,可是这一个整形数据是如何标识表明不同文件的呢,打开不同文件时其的值又有什么不同吗?
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
umask(0);
int fd1 = open("log1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
int fd2 = open("log2.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
int fd3 = open("log3.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
int fd4 = open("log4.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
int fd5 = open("log5.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);
printf("fd5 = %d\n", fd5);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
close(fd5);
return 0;
}
- 我们可以发现,打开文件的返回值fd是从3开始递增的连续整数
<1> 这又代表着什么意义呢?
<2> 返回值有为什么是从3开始的整数而不是0?
<2> 如果有3之前的返回值整数,那么它们是在哪里被使用了呢?
- <1> 系统调用接口open给我们返回值,文件描述符fd,其是用来标识进程内打开的各个文件,我们需要借助其对指定文件进行访问。
<2> 因为对底层硬件的访问必要借助操作系统贯穿整个计算机软硬件层次结构,所以C语言lib也必然在底层封装了操作系统提供的系统调用接口。
<3> C语言库中文件操作相关的函数,fopen打开文件的函数其返回值是一个FILE类型的指针,这一C语言自定义用来描述文件的数据类型其内也必定封装了文件描述符fd。
<4> 在一个进程中,每当其被执行时,就会默认帮我们打开3个常用的文件流,stdin
,stdout
,stderr
,也正是这三个打开的文件占用了文件fd返回值的前三位。
#include <stdio.h>
int main()
{
//stdio.h头文件中存在这这三个FILE类型的全局变量
//其中的成员变量fileno正对应着文件描述符fd
printf("%d\n", stdin->fileno);
printf("%d\n", stdout->fileno);
printf("%d\n", stderr->fileno);
return 0;
}
- <1> 从0开始的fd返回值,这一组返回值其的数据特征极其类似于数组的下标
<2> 系统调用接口为什么又仅仅能通过这一整形数据就找到文件
<3> fd又是如何表示描述各个被打开文件的,接下来,就让我们对进程与打开文件的底层数据结构与关系进行了解与学习。
- 我们通过前面的学习可以知道,进程由内核数据结构,代码与数据组成。
<1>其中操作系统对一个进程进行控制,主要就是通过其的内核数据结构进程PCB(struct task_struct)来达成的, 这一数据结构使用来记录描述各种进程相关信息的。
<2> 而对于文件操作系统也采用了类似的方式,创建一个数据结构用来描述文件的各种属性信息,并通过调整控制这些信息来达到控制文件效果。
4.2 C语言对文件fd与接口封装载使用的意义
- 为什么已经有了系统调用接口与可以直接找到文件的描述符fd,C语言还要对接口与文件fd进行封装后再使用?
- 不同的操作系统对文件管理的方式都会有所不同,提供给用户的文件相关系统调用接口也都有所不同
- 因此,直接使用系统调用接口和与其提供相关的文件描述符fd,都会使得C语言提供的接口不具备跨平台性,可移植性性
- C语言库通过这种封装的方式,将各个操作系统底层实现的差异通过库的方式屏蔽掉,这样我们所编写的代码就可以在各种平台上编译运行
4.3 Linux操作系统下一切皆文件的设计理念
- 从初识Linux操作系统时,就了解到了Linux一切皆文件的设计哲学,通过这种统一的描述方式,将计算机中的各种软硬件资源都以文件的方式描述,组织,管理起来。
- 可是,我们对此并没有实感,也并不清楚Linux操作系统是如何去做的,为此又做了哪些工作?
- 接下来,就让我们通过Linux中对硬件设备的文件描述与管理方式来初步理解一下,Linux下一切皆文件的设计理念
5. 文件fd的分配规则与重定向
- 我们运行进程时,会默认打开三个标准输入输出文件流
- 我们通过上面对于文件相关底层数据结构的学习得知,文件fd的本质为数组的下标,以fd为索引取对应的文件指针数组中去找到对应的进程打开的文件
- 对于这些先后打开的文件,它们在对应进程的文件指针数组中的分配方式为:
<1> 当前最小的没有被使用的数组下标,分配给最新打开的文件
- C语言库函数printf,其是向标准输出文件流中写入数据,在底层实现上它的机制十分简单
<1> 因此,在进程运行时,stdout会被默认打开并分配到文件指针数组fd为1的下标位置
<2> 所以,printf会默认项文件指针数组下标为1位置所对应的文件写入
<3> 着也意味着无论此位置上对应的是否为stdout,其都会进行写入,因此,我们可以进行如下操作
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(1);
open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
//改变打开模式,即可调整为追加重定向
//open("log.txt", O_WRONLY|O_CREAT|O_APPEN, 0666);
printf("hello world!\n");
//write,fprintf,fwrite指定文件
//以读取模式打开,O_RDONLY,输入重定向
//scanf
//read,fscanf,fread
return 0;
}
- 如上的操作,实质上也就是输出重定向的原理,改变写入数据的文件流向,除开上述此种方式外,还有另外一种更加便捷的方式:
<1> 将需要需要接收数据的文件指针拷贝覆盖至指定位置
<2> Linux操作系统提供了相应接口,其可以很好处理拷贝操作附带产生的一系列问题
//头文件fcntl.h,unistd.h
int dup2(int oldfd, init newfd);
- 系统调用接口dup2,将oldfd下标对应的文件指针,拷贝至newfd位置
int main()
{
int fd = open("log.txt", O_RDONLY);
dup2(fd, 0);
char buf[1024] = {0};
scanf("%s", buf);
printf("%s\n", buf);
return 0;
}
- 补充:在当前目录寻找指定名称的文件
find . -name [文件名]
6. C语言库缓冲区
6.1 C语言库缓冲区的存在意义
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello Linux!");
sleep(3);
return 0;
}
- 由上述代码的运行结果可以看到,我们向显示器上打印的内容没有被立即写入打印,而是等待3秒后进程运行结束才被写入,这是怎么回事呢?
- C语言中存在着一块缓冲区,这块缓冲区是用来存储使用C语言接口写入各个文件中数据的。
- 为什么要存在这一语言级别的缓冲区呢?
<1> 每次将数据写入操作系统中的文件缓冲区,本身这一过程中就会有着不小的系统的开销,而时时写入,频繁拷贝的方式无疑会大大增加系统开销。
<2> 因此,C语言库中就设计了这一语言级别的缓冲区,先将需要写入的数据统一暂存在缓存中,等待积累了一定的数据量再统一刷新缓冲区进行拷贝。
<3> 这样的方式本质上是一种用空间换时间的设计技巧,这样不仅提升了数据拷贝的效率减少了消耗,并且加快了C语言接口调用速度。
- C语言缓冲区的刷新方式:
<1> 无刷新,无缓冲
<2> 行刷新(向显示器写入)
<3> 全缓冲,全部刷新(普通文件写入)
特殊情况:
<1> 主动强制刷新(fflush)
<2> 进程退出时,主动刷新
6.2 C语言缓冲区的物理位置与存在证明
- 技术层面上,我们一直所说的缓冲区与操作系统中的内核缓冲没有关系,是语言层面的缓冲区,C语言自带。
- 那么,这一块缓冲区与物理空间上究竟存在于哪里呢?
<1> 使用C语言文件操作相关接口打开文件时,其会返回一个描述文件相关信息的FILE结构体指针
<2> 这一结构体内不仅仅包含着打开文件的相关信息,并且还为我们开辟以一块用来存储向此文件写入数据的缓存区(FILE结构体中)
- 验证此块C语言级别缓冲区存在,且缺位独属于C语言的语言级别缓冲区:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
char buf1[] = "this is buf1\n";
char buf2[] = "this is buf2\n";
char buf3[] = "this is buf3\n";
write(1, buf1, strlen(buf1));
fwrite(buf2, strlen(buf2), 1, stdout);
fprintf("%s", buf3, stdout);
fork();
return 0;
}
- 根据运行结果论证:
<1> write为系统调用接口,其会将数据直接写入到系统内核中的文件缓冲区
<2> fwrite与fprintf为C语言库函数,它们会将数据先写入C语言缓冲再随后刷新
<3> 向显示器写入时,C语言缓冲区的刷新方式为行刷新,向普通文件写入时则是全刷新
<4> fork创建的子进程会继承父进程的全部数据与代码,也会继承父进程执行流,即父进程执行至那一条语句,那么其也从那一条语句开始执行
<5> 父子进程谁先执行并不确定,这一点由调用器决定,单子进程必定比父进程先结束,当子进程结束时会强制刷新缓冲区
<6> 我们知道C语言的缓冲区由FILE对象开辟,在内存上,当子进程结束刷新缓冲区时,这一操作也相当于对父子进程共用的数据做修改,所以会触发写时拷贝。
<7> 因为显示器写入时,其刷新方式为行刷新,所以子进程在创建后与结束时时,C语言的缓冲区为空,而向普通文件写入的刷新方式为全刷新,所以子进程继承了父进程C语言缓冲区中的内容,并进行了写时拷贝与刷新。
- 补充: printf与scanf系列函数,格式化输入输出的内核
<1> 键盘与显示器是两个字符设备,即存储于它们中的数据形式都是字符,显示器想要打印数据就必须以字符的形式进行打印
<2> 可是,很多时候我们想要读取与写入的数据并不是字符类型,所以,我们进行写入读取时都必须要指定数据类型与格式,这样才能够正确的对显示器与键盘进行数据的读写操作。