在学习C语言的时候学习过如何打开关闭一个文件,可以参考一下以前的博客C语言文件操作,在学习了进程之后看待文件的角度就不能只停留在语言层面上了,下面从两个角度来认识一下操作系统对文件的管理
在C语言中我们可以用多少种方法往屏幕上打印字符串?
int main()
{
const char* p = "hello world\n";
printf(p);
fwrite(p, strlen(p), 1, stdout);
fprintf(stdout, p);
}
在C语言中我们可以用多少种方法从屏幕上读取字符串?
int main()
{
char p[20] ;
scanf("%s", p);
fread(p, strlen(p), 1, stdin);
}
C语言中的stdout和stdin代表的是标准输出和标准输入流,另外还有一个stderr标准错误。这三个流是每个C默认会打开的。如果你观察仔细你会发现这三个流类型都是FILE*
,也就是fopen
的返回值类型——文件指针,我们查看一些FILE结构体里面的内容:
struct _iobuf {
char *_ptr; //文件输入的下一个位置
int _cnt; //当前缓冲区的相对位置
char *_base; //指基础位置(即是文件的其始位置)
int _flag; //文件标志
int _file; //文件的有效性验证
int _charbuf; //检查缓冲区状况,如果无缓冲区则不读取
int _bufsiz; //文件的大小
char *_tmpfname; //临时文件名
};
typedef struct _iobuf FILE;
这是在语言层面对文件的描述,接下来了解一下系统文件IO
上面提到每个程序都会打开三个流:标准输入、标准输出、标准错误。在C语言中是三个宏定义的FILE结构体。那么系统层面,每个进程也要打开这三个流,这三个流在进程的眼里是以文件的形式看待并进行管理的,进程除了这三个文件以外可能还会打开其他文件,这些打开的文件是把部分数据从磁盘中加载到内存中,这是因为如果cpu从内粗中读取数据的效率要大于从磁盘中读取数据,磁盘和内存中存在一个缓冲区,所以在系统上像文件中修改数据,是在缓冲区中修改然后刷新到磁盘上。一个进程至少要打开三个文件,那么进程是如何管理这些打开的文件,管理这些文件和操作系统管理进程所用的方法是一样的——“先描述,再组织”。所以进程中要定义一个结构体来描述在内存中打开的文件 ,这个结构体要包括:
所以进程对这些结构体进行管理,就能同时管理多个在内存中打开的文件了,那么这些结构体是如何组织的?
进程会创建一个结构体指针数组,这个数组中存储的指针会指向这些结构体,这样只要遍历这个数组就可以管理这些结构体了
上图就是进程如何管理打开的文件的,可以看出进程默认打开的三个文件在结构体指针数组中的下标为:0
,1
,2
,记住这三个数字非常重要,由于一个进程可以打开和关闭任意多个文件,所以需要一个标识来标识每个打开的文件供上层识别,这里就是选取结构体指针数组的下标作为标识符——文件标识符 ,一个文件只有一个文件标识符,一个进程可以有多个文件标识符。系统层面对文件的操作其实都是对文件标识符的操作。例如:我们要关闭某个文件,只需要调用关闭文件的函数输入该文件的文件标识符即可。
从图中我们可以知道 一个进程指向一个 file结构体,这个结构体作用是替该进程管理文件。除了上面说的file * fd_array[]外还有一个非常重要的变量——文件偏移量
这个变量是记录文件读取的位置,这样下次读取的时候就可以就可以直接接着上次读取结尾继续读取。每次打开一个文件(不是以O_APPEND
形式打开),偏移量会默认设置为0,每次读取多少字节,偏移量就会加多少,下次再读的时候就是在这个偏移量的基础上继续读。
下面认识几个文件的系统调用函数:
#include
#include
#include
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
参数:
pathname
:打开的文件名,默认是建立当前路径下flags
:打开文件时附加的条件,可以传入多个参数 用按位或|
进行链接O_RDONLY
:只读打开O_WRONLY
:只写打开O_RDWR
:读、写打开 (以上三个必须指定一个,且只能有一个,下面的选项用|
进行添加连接)O_CREAT
:若文件不存在,则创建他。需要使用mode选项来指定新文件的权限O_APPEND
:追加写O_TRUNC
:清空文件从头写mode
:创建的文件的权限返回值:
这里说一下文件描述符的分配规则:未被分配的最小的文件描述符。
#include
#include
#include
#include
#include
#include
#include
int main()
{
close(0);
int fd=open("log.txt",O_CREAT|O_WRONLY ,0644);
printf(" %d \n",fd);
}
这个程序被创建出来默认打开三个文件,文件标识符分别是0,1,2 然后我们关闭标识符为1的文件(标准读),然后再打开一个文件,我们发现这个文件的标识符为0
#include
int close(int fd);
0
:代表关闭成功1
:代表关闭失败#include
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
参数
fd
:要读取文件的文件标识符buf
:存储读取文件内容的字符指针 或 存储要写入文件的字符指针count
:期望 读取/写入 的 字节 个数返回值
注意:
在同一次open的文件里,每次read都是从文件偏移量往后读取。如果read读到文件结尾的时候就会返回0,这种返回值常作为文件是否读取完的一种判断条件
#include
int dup2(int oldfd, int newfd);
参数
oldfd
:替换的文件的文件标识符newfd
:要被替换文件的文件表示符返回值:正确返回非零的值,错误返回-1
缓冲区是一个非常重要的概念,修改数据不是立刻刷新到硬件上的,而是先写到缓冲区里面,然后再由缓冲区刷新到硬件上。这时因为用户通过系统调用访问硬件资源的时候要从用户态切换成 内核态 ,然后访问完之后再切换回用户态 。整个切换过程是非常耗时、低效率的。打个比方:你是一个快递员要将快递从北京送到西藏,现在你是收到一个快递就送一个(一有数据输入就把数据刷新给系统),还是将收到的快递先存在一起(将数据先刷新到缓冲区中),等到一定量的时候在送出去(缓冲区满了在刷新给操作系统)?缓冲区就是这个道理。
下面来验证一下缓冲区:
void fun7() //缓冲区
{
int count=3;
while(count)
{
sleep(2);
printf("hello word!");
count--;
}
return ;
}
这个程序运行的时候会出现一个很奇怪的现象,运行程序之后不是没过两秒打印一个hello world
,而是等完六秒之后直接打印出三个hello world
这其中的原理就是缓冲区,每过两秒hello world
是不会刷新到 标准输出stdout(屏幕上),而是先刷新到缓冲区,最后由缓冲区刷新到屏幕上
内核缓冲区
注意上面的一切IO都是语言层面和操作系统的IO,而IO的真正目的地是硬件,所以这里操作系统和硬件之间还有一层缓冲区。当我们调用系统调用read的时候,不是从硬件上读取,而是从内核缓冲区里面读取。再例如:我们调用scanf的时候,写入的数据先刷新到进程的缓冲区,然后操作系统选择时机在刷新到内核缓冲区,最后再刷新到硬件上(磁盘上)。
建立内核缓冲区的原因:
是因为内存和硬件之间的IO效率相差过大,为了效率问题会提前将硬件的一些数据刷新到内存上(内存的IO效率是磁盘的百倍),在内存上建立了一个内核缓冲区,这个方法叫做异步处理,当系统将一个文件的内容加载到内核缓冲区,如果文件没有被修改过,则在内存区段的文件数据会被设置为【干净(clean)】,但如果文件被修改过了此时数据在内存中就会被设置为【脏(dirty)】,此时所有的操作都还是在内存上进行,并没有写入磁盘中。系统会时不时把内存中设置为 “脏” 的数据写回磁盘,保持磁盘文件的一致性!
所以我们总结一下:
用户级缓冲区是减少系统调用的次数减少系统开销
内核缓冲区不同硬件的IO效率差别过大,提高整体IO的效率
行缓冲
顾名思义就是每次刷新的单位是一行,如果出现\n
(换行符)就会将缓冲区刷新到屏幕上
全缓冲
这个相对于行缓冲,不是以一行为刷新单位,而是以文本为刷新单位,全缓冲区的刷新条件是:
fflush(stdout——文件指针)
就会把缓冲区里的数据刷新到对应文件指针指向的文件中例子:
void fun6() //缓冲区刷新
{
int fd1=open("log.txt",O_CREAT | O_APPEND | O_WRONLY);
dup2(fd1,1);
const char* p1="hello write\n";
const char* p2="hello printf\n";
const char* p3="hello fprintf\n";
write(1,p1,strlen(p1));
printf("%s",p2);
fprintf(stdout,p3);
int ret=fork();
fflush(stdout);
if(ret)
{
waitpid(ret,NULL,0);
}
return ;
}
这里使用了三个文件操作的函数,两个是C库函数:fprintf
、printf
,一个是系统调用函数:write
,我们将标准输出的文件结构体替换成了log.txt
的文件结构体,这样向屏幕上打印的就会向文件里面打印。打印后我们又创建了一个子进程,最后父进程等待子进程并回收。但是这里会出现一个很奇怪的结果
两个库函数被打印了两次,这其实非常好解释。
首先 注意向文件里面写入数据是采用全缓冲 ,由于write是系统调用函数所以进程直接切换成内核态将 "hello write\n"
写入文件,但是生下来两个库函数会写入缓冲区,缓冲区不会立即刷新到文件里,等到int ret = fork()
之后就会有父子两个进程,由于子进程是拷贝父进程的数据和代码,所以缓冲区里面存的内容是一样的,然后父子进程分别刷新两次缓冲区才会导致结果出现了两次hello printf\n
、hello fprintf\n
在学习bash使用的时候遇到过两个符号>
(输出重定向)和 >>
(和追加重定向),学习完了文件之后,这两个符号的底层原理就变得非常简单了。
输出重定向 和 追加重定向 的功能是: 把本来应该输出到屏幕上的数据重定向到 另外一个文件里 在学习完了文件之后,我们知道屏幕其实就是标志输出,本质上也是一个文件。所以 输出重定向在文件中的理解是: 把本来应该输出到 标准输出文件 的数据重定向到 另外一个文件里
如何完成这个替换?
从上面的学习中 不管是 语言层面 还是 系统调用函数 层面,对一个打开的文件上层只关心这个文件的文件标识符,拿到文件标识符就确定了结构体指针数组的下标,也就确定了文件结构体。所以我们可以对结构体指针数组指向的文件结构体做手脚,替换底层的结构体,这样上层用的文件标识符所对应的指针数组就指向了别的文件
这样本来应该输出到屏幕上的内容就输入到了文件上。
重定向的基本原理知道了那么如何区分 输出重定向>
和 追加重定向>>
两个符号呢?
这就要用到open函数里面的flag了,如果是 输出重定向>
那么open 文件时flag就为O_CREAT | O_WRONLY | O_TRUNC
,如果是追加重定向>>
那么open的flag就为O_CREAT | O_WRONLY | O_APPEND
。
输出重定向>
代码
int main()// 输出重定向>
{
int fd1=open("log.txt",O_CREAT | O_WRONLY);
dup2(fd1,1);
const char *p="hello world!\n";
write(1,p,strlen(p)); //系统调用 ,向文件标识符为 1 的文件输入
printf(p); //
fprintf(stdout,p); //printf、fprintf都是语言层面的调用,向stdout输出
return ;
}
执行程序后如果当前目录下没有log.txt文件就会创建一个,然后原本应该写入到屏幕上的数据就会写到log.txt文件中
追加重定向>>
代码
int main()// 追加重定向
{
int fd1=open("log.txt",O_CREAT | O_APPEND | O_WRONLY);
dup2(fd1,1);
const char *p="hello world!\n";
write(1,p,strlen(p));
printf(p);
fprintf(stdout,p);
return ;
}
一个磁盘有多个盘片,一个磁盘有两个盘面,每个盘面都可以存储数据,由每个盘面的磁头进行读取。每个磁盘都被分为了若干个同心圆,每一圈的同心圆叫做一个磁道。每个磁道又被切割成多个扇形区域,叫做扇区,每个扇区存储的字节数是固定的。所有碟片上面的同一个磁道可以组合成所谓的柱面。
磁盘的结构比较复杂,读取一个数据要先找到磁盘的盘片,其次要找到盘面,最后要找到对应的扇区。操作系统把整个磁盘看做一个数组,这样磁盘的寻址的过程在操作系统看来就是在数组上寻址的过程
分区的定义
所有碟片的同一个磁道我们称为柱面,通常那是文件系统的最小单位,也就是分区的最小单位
磁盘的分区在windows操作系统上的体现是磁盘被分成了C盘、D盘、E盘、F盘。
为什么要磁盘分区?
首先复习一下Linux中目录树的概念:以根目录为主,然后向下呈现为分支状的目录结构的一种文件架构,整个目录树架构最重要的就是那个根目录,这个根目录的表示方式就是/
所以挂载解决的本质问题实际上是 目录树这个抽象的概念 和 磁盘上的文件系统 如何结合
挂载的定义
就是利用一个目录当成进入点,将磁盘分区的数据放置在该目录下——也就是进入该目录就进入了该分区的的意思。注意:挂载点一定是目录,该目录为进入该文件系统的入口
磁盘上存储了大量文件,于是也需要一套管理体系来管理磁盘上面的文件,例如我们在BASH上输入ls -a -l
,就会出现文件的详细信息:
这些存储在磁盘上的文件信息是如何管理的呢?
我们可以认为:
文件 = 属性 + 内容
在Linux中文件的属性是由一个 inode结构体维护的,每个inode结构体都有一个编号来唯一标示这个文件,一个文件对应一个inode并且只有一个inode编号。 inode结构体记录文件的权限及相关属性
,同时记录数据所在的区块的号码。
内容是由 数据区块 data block的结构体维护 ,数据区块是实际记录文件内容的地方。若文件太大会占用多个区块
inode与数据区块的关系
每个inode和数据区块都有一个编号,而每个文件都会占用一个inode,inode中有文件数据放置的区块号码。所以我们知道这个文件的inode编号就能找到该文件的inode,就能知道数据区块的号码就能找到对应的数据区块读取文件的数据
我们知道文件系统一开始就将inode和数据区块规划好了,除非重新格式化,否则数据区块与inode固定后就不在变动。但是将inode和数据区块刚在一起放在一起非常难于管理,所以Linux ext2 文件系统,把磁盘分成了若干个group,每个Group包含了存储数据若干信息,每个Group包含的信息类型都是相同的
在介绍所有group成员之前先介绍一下inode,inode记录的数据至少有下面这些:
注意:
inode还有如下特性:
存放文件系统本身的结构信息。记录的有
描述块组的属性,以及说明每个区块(超级区块、对照表、inode对照表、数据区块)分别介于那一个区块间,这部分也能够用dumpe2fs来观察
记录着Data block 中那块数据块已经被使用,哪个数据块没有被占用
每个bit表示一个inode是否空闲可用
上面介绍了inode中除了要存储文件的属性信息,还要存储对应的区块号码,但是上面在介绍inode时候说过一个inode的大小是固定的(128B)。如果一个文件内存足够大的话,一个号码就占4B,所以inode可能存不下这么多区块号。
inode存储的区块号分为三个种类:直接存在inode中的12个区块编号、间接记录区、双间接记录区、三间接记录区。
这里的间接记录区有点类似于进程地址空间中的页表,可以为每个inode省下很多空间。
数据区块ext2文件系统中只有1k、2k、4k三种在格式化的时候就固定了,且每个区块都有固定的编号,以便被inode记录。不过注意:单个区块大小的差异会导致该 ** 文件系统能支持的最大磁盘容量** 与 最大单一文件容量不同
block大小 | 1kb | 2kb | 4kb |
---|---|---|---|
最大单一文件限制 | 16GB | 256GB | 2TB |
最大文件系统总容量 | 2TB | 8TB | 16TB |
区块的基本限制:
由于inode数是固定的,所以会出现空间还剩很多但是有许多内存很小的文件占用了inode的位图导致无法在创建文件的情况
所以创建一个文件的过程为:
在了解了文件系统中的文件后,我们要了解一下一个特殊的文件——目录
目录本质上也是一个文件所以文件系统也会给他分配一个inode和对应的数据区块,而他和普通文件不同的是目录对应的数据区块中存储的是:目录中的 文件名 和 inode的映射关系(就是根据文件名能找到inode,根据inode能找到文件名)
这里就可以解释在以前博客在关于 文件权限时候的内容了:以前说对文件名的修改、新增、删除操作与文件的权限无关,而是与文件所在的目录有关,这是因为目录中存储的是 文件名 和 inode 的对应关系,删除一个文件无需对文件的内容做任何处理,只需要将inode和文件名的映射关系删除就可以了,新增一个文件只需要添加一段映射关系即可。同时还解释了另一个问题:为什么一个上百G的文件在下载的时候可能要几个小时,而删除的时候却只要几秒钟,那是因为下载的时候是纯纯的数据写入(向block中的数据写入),每个数据都要申请对应的区块,而删除的时候只要释放inode位图和区块位图中对应的编号,让文件系统认为 inode 和 区块 未被使用,但是存在区块上的数据未被删除。
从文件系统的角度如何读取一个文件
例如我们要读取的文件的路径为:/root/test.c
,这是读取的步骤
/
的inode:通过挂载点找到根目录的inode号码,并找到根目录的inode,读取inode查看目前用户是否有权限/
的区块:在/
的inode所对应的区块中找到文件名为root 的文件的inode的号码(例如:1234)上面了解了目录这个文件中存储的是 inode和文件名的映射关系,也就是你知道文件名通过目录里的映射关系找到了文件的inode,找到inode也就知道了文件的 属性 和 内容。
Linux中允许把多个文件名同时对应一个inode,而这些多出来的关联记录叫做——硬链接,由于这里只是新添加了一个映射关系所以并不会建立新的文件,所以链接文件和源文件的inode号是相同的。
如何建立一个硬链接
ln 被链接的文件的路径 建立链接的文件名
例如:我们在目录dir的当前目录中建立一个与目录dir中的log.txt
文件的硬链接文件hardlink
,关系图如下:
执行的指令是:ln /dir/log.txt hardlink
我们分别查看这两个文件的inode号:
发现两个inode号是相同的,所以硬链接并不建立新的文件,只是inode多了一个“别名”
如果我们修改hardlink
里面的内容的话,原文件的内容也会被修改!,其次硬链接不能链接目录(因为链接目录之后就要链接目录里面的所有文件,会很复杂)
硬链接的原理如下图:
软链接(Symbolic Link):软链接就是建立一个独立的文件,而这个文件会让数据的读取指向它链接的那个文件的文件名。
注意
ln -s 被链接的文件的路径 建立链接的文件名
我们在dir所在的目录里面输入如下指令,建立一个名叫softlink的软链接
ln -s dir/log.txt softlink
查看两个文件的inode号你会发现完全不一样,证明了软链接实际上是创建了一个全新的文件
现在我们文件的目录结构为:
如果我们将log.txt
删除,在尝试打开软链接的文件就会发现:
但是硬链接hardlink依然存储的是删除的log.txt的内容(因为他依然链接的是log.txt对应的inode)
我们创建一个目录test如下:
以前我们说过test前面的七个属性的具体含义,详情可见Linux 权限的理解,但是这七个属性有一个没有说明那就是 链接数,链接数在这里的含义就是该文件的文件名和inode的对应关系的数量。
test和1051556只有一个对应关系为什么链接数是2? 这是因为test目录里面还有一个文件叫做 ./
这个代表的文件的inode也是1051556 所以这里就会有两个对应关系,而test目录里面的../
的inode与test的上级目录一样,所以计算上级目录的链接数的时候也要算上
例如我们写了下面的这个程序:
#include
int main()
{
printf("hello world\n");
return 0;
}
我们用gcc编译成可执行程序之后,用命令ldd
查看a.out
链接所用到的库:
再用命令file
查看链接的方式:
我们可以看出我们正常生成的可执行程序用的都是动态库,那么动态库和静态库有什么区别吗?
动态库的结尾一般以.so
结尾,动态库是一个目标模块,在运行或加载的时候,可以加载到任意内存的地址,并和一个在内存中的程序链接起来。(这个过程称为动态链接),是由一个叫做动态链接器的程序来执行的。
动态库的实现原理是将磁盘中的动态库内容先加载到物理内存上,然后不同的进程通过映射共享动态库里面的代码,这样库函数里面的代码就不用加载到程序里面,而是执行到库函数代码时跳转到共享区域进行执行,这种连接方式的优点是:当存在多个进程使用同一个库函数时由于库函数时共享的会节省很多空间,但是缺点很明显,如果你电脑上没有安装第三方库的话,你就无法调用库里面的函数
如何将一个代码使用静态库进行链接,只需要在编译时加一个-static
即可
于是我们就生成了一个使用静态库编译的可执行程序static,我们首先与用动态库进行编译的可执行程序进行比较:
首先文件大小使用静态库的要比使用动态库大差不多100倍,这和静态库的链接原理有关系,静态链接十分的容易理解,就是把你的源文件和库函数的.o文件进行链接,而库函数的.o文件一般会打包成一个库,这个库就叫做静态库,每次生成可执行程序都要与库函数进行链接。
静态库的缺点:
静态库的优点:独立性很强,不依赖第三方库
库的结构十分简单,一个库里面有两个目录,一个目录存放.h
结尾的头文件,一个目录里面存放打包好的.o文件的集合——也就是.a的压缩包。
例如我们要写一个加减法的库,这个库里面有两个头文件分别是加法和减法,首先我们要写好四个文件:add.h
add.c
sub.h
sub.c
add.h:
int myadd(int x,int y);
add.c:
int myadd(int x,int y)
{
return x+y;
}
sub.h:
int mysub(int x,int y);
add.c:
int mysub(int x,int y)
{
return x-y;
}
我们写好之后要将所有.c文件编译成.o文件并使用命令ar
打包成一个库文件:
gcc -c add.c #也可以写成: gcc -c add.c -o add.o 但是默认生成的就是add.o
gcc -c sub.c
ar -rc libmath.a add.o sub.o
注意:这里创建出来的库的文件名叫做libmath.a
,但是实际上编译器识别的库的名字为去掉前缀lib和后缀.a之后的math,这个库的名字其实是math
我们按照上图中的库结构,将相应的文件移入目录:
mv add.h sub.h mylib/include
mv libmath.a mylib/lib
最后我们要说明一下用静态库编译时要注意的选项:
例如我们写了一个test_lib.c的文件要用到我们写出的两个库:
#include
#include"add.h"
#include"sub.h"
int main()
{
int x=20;
int y=10;
printf("add(20,10)的结果是: %d\n",myadd(20,10));
printf("sub(20,10)的结果是: %d\n",mysub(20,10));
return 0;
}
我们在编译的时候,要添加如下选项:
gcc 源文件名 -o 可执行程序名 -I 头文件的路径 -L 自定义库的路径 -l 库的名字 -static
我们这里的编译就要写成:
gcc test_lib.c -o test_lib -I ./mylib/include -L ./mylib/lib -l math -static
这样就生成了一个用静态库生成的可执行程序test_lib,执行程序(也可以将刚刚建立的库mylib删掉,程序依然可以执行,但是用动态库的可执行程序就不行):
动态库在编译.c的文件时与静态库略有不同:
gcc -fPIC -c add.c #动态库编译时要加上-fPIC的选项
gcc -fPIC -c sub.c
gcc -shared add.c sub.c -o libmath.so #动态库的文件名和静态库的文件名命名方式相同,就是后缀不同
于是我们就生成了一个静态库,我们把动态库放到mylib/lib
目录里面:
我们这时候就可以对test_lib.c使用动态库进行编译,动态库编译和用静态库编译的指令都差不多(去掉-static选项即可):
gcc -o test_lib test_lib.c -I ./mylib/include -L ./mylib/lib -l math
这样就生成了一个用动态库链接的可执行程序,但是这个可执行程序并不能直接被执行:
我们用指令ldd
查看:
结合这两个结果我们就知道了在编译的时候要链接动态库,在执行的时候也要链接动态库,操作系统是通过环境变量LD_LIBRARY_PATH 来链接动态库的目录,所以我们只需要把动态库的目录添加到环境变量里面即可:
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./mylib/lib
这时候动态库就可以被操作系统识别