文件是什么想必很少有人没有听过,但是真的了解文件吗?他在计算机中真正的面目是什么?在计算机内以什么样的形式存在?
文件其实分为两种,一种是被加载到内存的动态文件,一种是磁盘上的静态文件。这篇文章带大家很好的了解文件的本质。
所有的语言都有文件操作,文件操作都不一样,但是都有一种统一视角看待文件操作。
- 一个文件分为内容和属性,对文件的操作分为对内容或者属性的操作,打开一个文件的本质,就是把一个文件的属性从磁盘加载到内存中。
- 而内存中存在大量被打开的文件,操作系统需要统一管理这些被打开的文件,管理文件和管理进程是一样的,都是先描述,再组织。在把文件加载到内存中时,操作系统会先创建一个struct file文件的结构体,用于对文件的管理,文件的属性会被加载到结构体内。加载一个文件,OS就有一个struct file* next指向它,所以对文件的管理就变成了对链表的增删查改。
用C语言函数打开文件流:
FILE* fp = fopen("路径","打开方式");
fputs("要写入的字符串",文件流);
int printf(const char *format, ...);//默认打印到显示器文件
int fprintf(FILE *stream, const char *format, ...);//输出到stream流指向的文件
int snprintf(char *str, size_t size, const char *format, ...);//打印n个字节数据到str缓冲区内
用系统接口打开文件:
用系统接口先了解一下标志位:
int fun(int flag1,int flag2,int flag3.....);
我们传统传递标志位的方法就是需要几个标志位就传递几个参数,如果标志位很多呢?
int fun(int flag)
一个int整数有32个比特位,可以用一个bit代表一个标志位;就可以代表32个标志位;例如:#include
#define ONE 0X1
#include
#define ONE 0x1
#define TWO 0x2
#define THREE 0x4
#define FOUR 0x8
#define FIVE 0x10
void Print(int flag){
if(flag & ONE)printf("hello 1\n");
if(flag & TWO)printf("hello 2\n");
if(flag & THREE)printf("hello 3\n");
if(flag & FOUR)printf("hello 4\n");
if(flag & FIVE)printf("hello 5\n");
}
int main(){
Print(ONE);
Print(TWO);
Print(THREE);
Print(FOUR);
Print(FIVE);
return 0;
}
想利用系统接口对文件进行操作,需要先认识几个接口
open接口第一个参数是文件的路径,绝对或者相对路径。
open接口的第二个参数是传入标志位,目的是表明要对文件进行什么操作,以下是一些参数选项。
open标志位参数
- O_CREAT : 如果文件不存在,创建文件。
- O_WRONLY:对文件进行写入操作
- O_TRUNC:对文件进行初始化清空
- O_APPEND:对文件追加
- O_RDONLY:对文件进行读取
open接口可以传入第三个参数,也可以不传,第三个参数是文件权限掩码,如果不知道权限掩码什么意思可以看看这篇文章,会有详细介绍。 文件的权限掩码
open接口的返回值是一个int类型。一个进程有可能打开多个文件,打开文件多了就需要进行管理,每个文件都有一个唯一的fd编号,用来对文件进行管理。具体原理下面会讲。
3.read接口是从文件中读取数据
接口第一个参数是传文件的fd,就是open接口的返回值,第二个参数是把文件数据读取到buf的缓冲区,第三个参数是读取内容的字节数。
这是三个接口的具体使用:
//把arr数组的内容写到bite文件
int fd = open("bite",O_WRONLY | O_CREAT | O_TRUNC,0666);
char arr[]="i like linux\n";
write(fd,arr,strlen(arr));
//读取bite文件的内容
int fd1 = open("bite",O_RDONLY);
char buffer[1024];
int n =read(fd1,buffer,sizeof(buffer)-1);//使用系统调用注意\0问题
if(n>0){ //如果读取失败返回-1
buffer[n]='\0';
write(1,buffer,n);
}
//关闭文件
close(fd);
close(fd1);
补充:
任何一个进程,在启动的时候,默认会打开三个文件:
例如:C语言:stdin,c++:cin
,默认对应键盘文件例如: C语言:stdout,c++:cout
,默认对应显示器文件例如:C语言:stderr,c++:cerr
,默认对应显示器文件因为一个进程默认会打开这三个文件,可以直接调用输入输出,不用再调用open接口去打开。
一个文件被加载到内存中,是因为某个进程需要访问这个文件,所以调用了open接口,进程需要对被自己打开的文件进行管理,PCB中会保存一个struct files_struct
的结构体类型的指针,这个结构体类型中有一个数组会保存每个被进程打开的文件的struct file
的结构体的地址,数组下标为0是标准输入、1是标准输出、2是标准错误,如果进程有新打开的文件,就会把内存中的文件结构体的地址保存到下标最小且内容为空的位置。所以open接口的返回值fd就是这个数组的下标。
电脑中的外设,例如:键盘、显示器、网卡、显卡等等这些设备,这些设备都可以有对数据最基本的IO操作,每个设备都有对应的驱动程序,驱动程序会提供对应的接口对数据进行IO操作,例如键盘的read和write方法,调用read可以从键盘读取数据,不能向键盘数据,所以write方法可以为空。显示器的read和write方法,调用write可以向显示器打印数据,不能从显示器读取数据,read方法可以为空。
所以每个外设在内存中也是有一个
struct file
结构体,外设的struct file
内会有函数指针指向设备驱动程序的read和write等方法,进程需要的时候只需要把这个设备的对应的struct file
地址存储到PCB管理文件结构体的数组空间内。虽然每个设备的实现的read和write方法不一样,但是只要通过
struct file
调用即可,不需要关注每个外设怎么实现数据的读写操作。
编程语言对文件的封装
FILE *fopen(const char *path, const char *mode);
这是C语言中的函数,作用是打开一个文件流进行IO,其实是对系统接口open的封装,fopen函数返回值是FILE* ,这个FILE类型是C语言标准库定义的一个自定义类型,C语言的文件类型就是FILE,例如:
extern FILE *stdin;//都是FILE*类型 extern FILE *stdout; extern FILE *stderr;
使用系统接口read或者write的时候必须传入open返回的fd,fd其实就是文件操作符,也就是进程中存储文件结构体地址的数组下标,没有这个下标就无法访问
struct file
也就无法访问文件,使用C语言的时候传入了FILE*类型的指针给函数,没有传fd,所以这个FILE结构体类型中一定封装了fd,才能通过fd这个文件结构体地址的数组下标找到对应的地址,所以不管是任何编程语言,只要能在Linux下运行,就必须遵守这个规则,只不过对fd的封装方式不同。
一个进程新打开一个文件,就会把内存中的struct file
的地址保存在进程存储文件指针的数组内,保存的规则是从0号下标开始找,如果发现下标所指向的空间没被占用,那么就会把地址存储进去。数组的0号下标是stdin,1号下标是stdout,2号下标是stderr,之后打开的文件要存储只能在后面找位置,但是如果把前三个close掉呢?例如:
把1号文件流也就是stdout关闭掉,再打开一个新的文件,试着用printf函数打印输出一下,printf默认是用stdout向显示器打印的,也就是说默认找的是fd为1的文件结构体,但是结果却打印在了log.txt文件内,就证明了stdout空出来的数组空间被log.txt占用了,所以对显示器这个文件输出数据,就变成了对log.txt输出数据,这是输出重定向。所以重定向的原理就是**在上层无法感知的情况下,在OS内部,更改进程对应的文件描述符表中,特定下标的指向。**
重定向的操作:
./a.out > log.txt 2>&1 //先把fd为1重定向为log.txt,然后再把fd为2重定向为1,1已经被重定向了,所以都会重定向到log.txt
./a.out > log.txt //把打印信息输出重定向
./a.out 1>log.txt 2>err.txt //把fd为1输出重定向到log,fd为2错误信息重定向到err文件
上面的看看就行了,一般不用,C有对应的接口
int dup2(int oldfd, int newfd);//会把oldfd下标的内容拷贝到newfd下标的空间中,newfd原来指向的文件流会关闭
语言缓冲区
printf和fprintf返回值都是FILE* ,FILE这个结构体内出了封装了fd,还有一段缓冲区空间,用printf或者fprintf向显示器文件或者其他文件写数据时,其实是向C标准库定义的FILE类型内的缓冲区写数据,然后C标准库会有一定的规则去刷新缓冲区内容到内存中struct file
的缓冲区内,刷新策略一般有:
显示器文件采用的是行缓冲、普通文件是全缓冲。
内核缓冲区
当FILE 这个结构体内的缓冲区中的数据刷新到内存中struct file
的缓冲区中时,数据还没有写道磁盘内的文件中,还需要把struct file
缓冲区中的内容刷新到磁盘中,刷新策略是由操作系统决定的,用户不可见。但是C语言提供了一个接口函数可以把内存文件缓冲区的数据强制刷新到磁盘中。
NAME
fsync, fdatasync - synchronize a file's in-core state with storage device
SYNOPSIS
#include
int fsync(int fd);//把内存文件缓冲区的数据强制刷新到磁盘中。
缓冲区的意义:
系统调用是一件成本很高的事情,如果没有缓冲区,写一个数据就要传输一下,时间成本很高。缓冲区可以根据一定的策略进行数据的一次性传输,所以意义就是可以节省调用者的时间。
上面说的文件都是被加载到内存中的文件,那么文件没有被加载到内存中的时候在哪里存放呢?答案是当然是磁盘。
那么文件在磁盘中是以怎样的方式存储的呢?
了解磁盘
磁盘是计算机上唯一一个机械设备,磁盘和我们现在电脑上的固态硬盘(SSD)不一样,固态硬盘是电子的,比起磁盘要快的多,但是价格比磁盘贵。磁盘现在只有一些公司存储大量数据的时候会用,因为成本低。
机械磁盘中主要的部件有:马达、磁盘、磁头等,磁盘有一摞,数据就记录在磁盘上面,一个磁盘有两面,就会有两个磁头,一个磁头负责一面的数据读写。磁头负责读写磁盘上的数据,马达负责转动磁盘。磁头和磁盘是不接触的,但是距离非常近,如果磁头接触了磁盘,就有可能把磁盘上的数据抹掉,造成数据丢失。所以磁盘是不能碰撞的,一旦磕碰就有可能会发生数据丢失,这也是被淘汰的原因
计算机是只能识别0和1的,那么在磁盘上怎么区分0和1呢?不同的设备区分0和1的方法不同,在磁盘上就是以南极北极区分0和1,磁盘的盘片上有无数的基本单元,每一个基本单元就是一个磁铁,磁铁就有南极和北极。所以写入数据的过程就是把N编程S,删除数据的本质就是S编程N。所以磁头读取数据就是读取南北极,写入数据就是更改南北极。
具体存储结构
数据在盘面上存储的,而盘面是一个同心圆,那么根据什么规则来找到数据所在的区域呢?
把盘面分为多个扇面,每个扇面都有磁道划分扇区,数据就是存储在无数个扇区里面,每个扇区大小不同,但是存储的bit位是相同的,都是512字节,那么怎么找到对应的扇区呢? 一个磁盘有多个盘面,一个盘面对应一个磁头,所以可以给磁头定一个编号,根据磁头能找到盘面,而盘面相对位置都是一样的,所以就会有一个柱面,这个柱面就是磁道,然后根据扇面的编号就可以确定一个扇区。磁头(head)、柱面(cylinder)、扇区(sector)这种就是一种CHS定位法。根据这种方法可以定位任意一个扇区,把文件写入磁盘的本质就是给一个或者多个扇区写入二进制,或者读取多个扇区的二进制。
一个磁盘通过CHS地址能够访问到任何一个扇区,那么OS内不能是不是通过CHS地址访问磁盘中的数据呢?并不是。一旦磁盘物理结构发生改变,OS就不能访问数据了,这样是为了OS和磁盘之间解耦。
一个扇区也就是512字节,但是OS读取数据的时候基本单位是4kb,哪怕OS只修改一个bit位OS也会把这一个bit位所在4kb全部读取,然后修改完成后再把4kb整体放回到原来的地方。所以操作系统需要有一套新的地址进行IO操作。
那么操作系统想要一次读取4kb(也就是8个扇区),怎么读取的呢?
把一个盘面抽象成一个数组。LBA方式读取数据
磁盘的数据存储再盘面上,而盘面是一个同心圆,从最外圈到最内圈有无数个磁道,一圈磁道有许多扇区,如果把磁道拉伸成一个线性的,就像磁带一样,最开始读取数据是在最外圈,最后读取的数据是在最内圈。一个磁道拉伸出来就像一个长条,然后这个长条内有无数个扇区就像数组的空间,这样一圈磁道就被拉伸成了一个数组,内侧磁道拉伸成数组头部跟在外侧磁道尾部的后面,这样就可以把一个同心圆抽象成一个数组。
计算机要读取一个内置类型或者自定义类型时,通常是起始地址+偏移量(数据类型)。OS读取4kb也就是8个扇区就是读取8个扇区空间的首地址。这4kb大小的类型,被称为块。把这个数据块看做一种数据类型。块的地址就是一个下标。这种读取数据的方式被称为LBA。
LBA转换成CHS方法,简单的转换,实际的转换方法一定要复杂得多。
//假设一个盘面有10圈磁道,一圈有500个扇区,一个盘面有5000个扇区。读取6500号数组下标
C:1500/500 = 3
H:6500/5000 = 1
S: 1500%500 = 0
到现在操作系统对磁盘的管理就变成了对数组的管理。
OS对磁盘抽象为数组管理,每个数组空间为4kb,但是磁盘有着很大的空间,是以GB甚至是TB为单位的。用一个简单数组直接管理难度太大。所以会对磁盘进行分区,类似分C盘D盘,而每个区还是很大,所以每个区会分组(group)管理。
每个组内又分了各种区域存储不同的信息,把一个组管理模式可以复刻到每一个分组,这样就能管理好整个分区。
super block
这个分区内存放的是:
- 文件系统的类型
- 整个分组的情况
super block在每个分组里都存在,而且都存了文件系统的类型,是同时更新的同样的数据。主要是为了做多个备份,如果super block这个区域的数据没有备份而且损坏,直接导致整个分区的数据不能被使用。
Group Descriptor Table
简称GDT:组描述符,主要是记录组内详细统计等信息。例如每个区域的大小等等
inode table
linux系统中,内容和属性是分开存储的,一个文件的所有属性集合就是一个inode(128kb)节点,一个分组内也有大量的文件也就有大量的inode节点,这些inode节点都存储在inode table表中,每一个inode节点都有自己的indoe编号,也属于对应文件的属性id。
Linux中查看inode编号
ls -il
Date block
主要存储文件的内容数据,所有的文件的内容都被存储在Date block这个区域内。Linux查找一个文件必须先找到这个文件对应的inode节点的编号,通过indoe节点映射关系找到文件的内容。
inode bitmap
这个区域内的一个bit位表示一个inode的使用情况,0表示空可以使用,1表示被占用。
data block
表示data block中每一个块的使用情况。
Linux系统只认识inode编号,并不存在文件名,文件名是给用户看的。
目录也是文件,有inode,也有datablock,在目录的datablock的数据块中保存的是该目录下文件名和对应的inode编号。当我们访问这个目录下的文件时,找到目录数据块中存放的文件的inode编号,通过inode编号和文件内容的映射关系,找到文件的数据块,加载到OS,并显示到显示器。
通过一个目录中的inode编号找到属于该目录数据块中存放的文件的inode编号和文件名。想要对该文件进行删除操作。通过文件的inode编号找到inode 表中的inode number属性,通过inode number有inode bitmap对应的映射关系,把这张map表中的标志位改为0,这样就把文件的属性删除了。通过inode number映射的block bitmap中把标志位改为0,就把该文件的内容删除了。
首先OS会扫描inode bitmap 找到一个标志位为0的inode编号,把文件的属性填充到inode table中,如果需要像文件中写入内容,会先对内容大小进行判断,然后在block bitmap中若干个数据块的标志位置为1,再在data block中申请若干个数据块。
补充细节:
- 如果文件被误删了,如果datablock和inode没有被新文件覆盖是可以被恢复的。
- inode编号只在该分区内有效,不能跨分区。
- 如果一个inode内只能存放有限的data block数据块地址,那么就会有二级索引,甚至三级索引。意味着inode存放一个data block的地址,但是data block中也有大量其他data block地址。
- 文件系统存在inode没用完,数据块用完了,或者数据块用完了,inode没用完。这种情况没办法解决。
什么叫做库
库这个东西我们一直都在使用,例如:C/C++标准库。
在我们下载C/C++的集成开发环境的时候,其实也会下载头文件和库文件,头文件负责方法声明,库文件负责方法实现,要一一对应。
头文件在预处理阶段就会把头文件内容全部拷贝到.c文件里,库文件则是在链接的时候查找。
ls /lib64/lib* //Linux下库文件存放的目录,以so为后缀的就是动态库,.a为后缀的就是静态库。
ls /lib64/ //windows下静态库后缀是.lib ,动态库后缀是.dll。
为什么要有库
主要还是提高开发人员的效率,如果没有库,我们不管实现什么功能,都要自己实现各种接口,例如最简单的打印输出,如果我们没有标准库,就要自己造轮子,及其不利于我们的开发。
设计一个静态库
一个库的真实名称是要去掉前缀和后缀的,例如:
/lib64/libstdc++.so.6 这是一个库的路径加名称,这个库的真实名称就是stdc++,去掉了lib这个前缀和第一个.之后的后缀加版本号。这是规则,静态库就是去掉.a。所以编辑库名我们需要自己加前缀和后缀,系统会自己帮我们识别并去掉。
假设我们自己实现了一些接口,想要给别人用,怎么办呢?简单除暴的方法就是把头文件和源文件都给对方,但是这样我们的源码就暴露出来了,不利于安全性,所以最好的办法就是把源文件打包成库。这样源码既不会暴露,对方还可以正常使用。
因为找头文件中的函数实现是在链接的时候,所以我们可以把源文件编译成.o的二进制文件。把头文件和.o文件一起给使用者,然后一起生成可执行文件,也是也已运行的。但是如果有很多的源文件,这种方法就不太好用了。所以我们可以对他进行打包。
[root@localhost linux]# ls
add.c add.h main.c sub.c sub.h
[root@localhost linux]# gcc -c add.c -o add.o
[root@localhost linux]# gcc -c sub.c -o sub.o
生成静态库
[root@localhost linux]# ar -rc libmymath.a add.o sub.o
ar是gnu归档工具,rc表示(replace and create)
[root@localhost linux]# gcc main.c -L. -lmymath
-I 指定头文件路径
-L 指定库路径
-l 指定库名
测试目标文件生成后,静态库删掉,程序照样可以运行
总结一下就是如果我们自己写了一个库,系统是不认识的,它只认识系统默认自带的库,如果我们自己写了一个库想要使用,需要先把函数实现的.c/.cpp源文件编译成.o的二进制文件,然乎用ar命令打包生成静态库,编译我们自己写的main函数时,需要用库文件和头文件的时候,gcc/g++时需要指定头文件路径和库文件路径和库文件名。
如果不想指定头文件和库文件路径,直接拷贝到系统默认查找的路径下就可以,Linux下头文件默认的路径/usr/include/
,库文件默认查找的路径/lib64/
,因为是第三方库,所以还是需要指定库名,指定库名称时需要去掉前缀和后缀。把库拷贝到系统默认路径下这个行为就叫做安装库。卸载就是删除库文件。
生成动态库
生成动态库的时候也是先编译.o二进制文件,但是需要带一个命令选项,例如:gcc -fPIC -c math.c
生成了一个或者多个.o文件,打包成动态库的指令,例如:gcc -shared -o libmath.so math.o
生成库之后,动态库像静态库一样-L找路径时是找不到的,运行时会报错。静态库能找到的原因是因为链接时会把二进制代码直接拷贝到程序中,动态库不会。有三种解决方案。
系统会默认去LD_LIBRARY_PATH这个环境变量中去找路径,export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:动态库路径
这样就可以了。这是一种临时方案。环境变量再次进入会刷新。
可以把生成的库文件生成一个软链接,放到/usr/lib64/
这个系统默认查找的路径下。
配置文件在/etc/ld.so.conf.d/
目录下,在这个目录下创建一个文件,文件内容就是动态库文件的路径,然后执行ldconfig
,意思是让配置文件立马生效。
动态库的加载
动态库也成为共享库,当一个可行性程序中用到动态库中的函数时,并不会像静态库一样把代码拷贝到可执行程序里面,会存放一个动态库中的虚拟地址(因为虚拟地址在程序编译好的时候就存在了)。当这个可执行程序被加载到内存中,并且执行到这个动态库中的方法时,我们的可执行程序并没有这个方法的实现,但是有这个动态库和库中方法的地址,所以这个动态库会被加载到内存中,然后通过页表映射把这个库文件映射到进程的虚拟地址空间中(共享区内),然后就能通过库中的虚拟地址找到这个代码。这样就算执行动态库中的方法也是在自己进程的虚拟地址空间中完成跳转。
如果还要用别的动态库,也是加载到内存,然后映射到虚拟地址空间中的共享区。通过动态库里的虚拟地址找到代码。别的进程如果也需要调用这个动态库,而这个动态库已经加载到内存中了,也是只需要加载到虚拟地址空间中完成调用。内存中存在这个动态库,所有的进程调用只需要映射内存中这个库文件到自己进程的地址空间内即可。库文件只需要在内存里存在一份就可以了。
动静态库中地址的理解和区别
程序使用静态库时,是把静态库中的代码拷贝到程序中的,所以静态库代码的地址在程序中是按照从上到下依次给地址,是绝对编址。
而把一个静态库打包成库的时候,需要用到与位置无关码(-fPIC
)进行编译,因为程序使用动态库时,需要把动态库加载到虚拟地址空间中才能使用,但是动态库加载到各个进程中的地址不会一样,所以,程序使用动态库代码的地址就是相对编址,是相对于库开始位置的偏移量。当一个库被加载到共享区中时,它在地址空间的起始位置才能确定。库文件的地址都是偏移量。