篇幅很长,如果看起来觉得枯燥无味或晦涩难懂,我建议就不要接着看了。。。
相信点进来的各位学到这里也必然是学过C语言的文件IO操作的。
如果有些忘记了,没关系,这里会简单回顾一下。
如果你对C语言的文件IO操作很熟悉,可以自行选择跳过。
我这里就直接给出我学习C语言阶段时的博客,对于复习一下是绝对够用了。
【C】文件操作
运行,生成了 log.txt 文件,展示其中内容。
成功写入。
再读一下:
现在我将log删去,然后将文件打开方式改为a。
再运行:
再运行几次:
可以看到a的方式打开就是不断追加。也就是append这个单词。
而以w的方式打开,运行只会将原来文件中的内容清空,并重新写入新数据。
C程序会默认打开三个输入输出流:stdin stdout stderr。
如下:
我们用man手册来查看一下这三个流:
可以看到三个都是FILE*类型的。
也就是说,C语言中将键盘和显示器也当作文件来看待了。
那么我们也可以向这两个“文件”当中读写。
看:
向stdin中读
运行:
hello wor再带上\0就是十个字符。
fgets这个函数功能是从流中读取字符并将其作为 C 字符串存储到 str 中,直到读取 (num-1) 个字符或到达换行符或文件末尾,以先发生者为准。
stdout和stderr都是显示器,那么有什么区别呢?
照样可以在屏幕打印,但是>重定向不了。
输出重定向的本质是把stdout中的内容放到文件中。
C的就复习到这,下面开始正式进入主题。
在C++中,cin, cout, cerr 也是标准输入、出、错误流。
我们前面可以看到fputs向一般文件或者硬件设备都能读写,而一般文件是在磁盘上放着的,也是硬件,所以可以说一切皆文件。
也可以说,访问文件,最终其实都是访问硬件:显示器、键盘、磁盘。
操作系统是硬件的管理者,所有的语言上对“文件”的操作都必须要贯穿操作系统。
而操作系统不相信任何用户,访问操作系统是要通过系统调用接口的,而本篇重点就在于讲解操作系统的系统调用接口中对于文件IO的接口。也就是上图中的第三层 system call。
开讲:
还是讲文件,只不过不用C语言的那套函数了,用的是系统自己的函数。
其实几乎所有的语言,都是在系统函数的接口上做了封装,用起来比直接用系统的函数要方便的多,所以学好系统调用接口对于我们今后的学习很有很大的帮助的,底层的东西搞清楚了,再去看上层的C、C++、Java等等语言,就能更加的得心应手。
接口说的太高大上了,直接说它是函数就行,只不过是系统的函数。
open,这个函数就是打开文件的函数,也就是那个什么接口,我们先用man手册来看看:
可以看到有两个open。
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
二者的区别等会说,先把参数大概意思给出来。
首先pathname,其实就是文件的全路径,也就是路径+文件名。
flags这个东西传值是有讲究的,其实对应到C中的fopen就代表你想以什么形式打开文件。
man手册中有这么一句话:
The argument flags must include one of the following access modes: O_RDONLY, O_WRONLY, or O_RDWR. These request opening the file read-only,write-only, or read/write, respectively.
意思就是参数flags传参时必须包含这三个中的一个:O_RDONLY,O_WRONLY,O_RDWR,这三个参数的意思分别是只读、直接、可读可写。
其实不止这三个,还有很多,这里再说两个:
O_CREAT:文件不存在就创建
O_APPEND:文件内容中追加,对应C语言中就是a选项。
这几个东西等会专门讲
mode,这个参数决定了创建出来的文件的权限。注意是创建出的,类似与我们C中以w/a形式打开不存在的文件时,就会创建一个文件。如果文件已经存在了,不会改变已存在文件的权限。
概念性的东西先不说那么多,给个不带mode的函数的例子:
然后我们再用带mode参数的函数来实现一下:
此时我还没删除刚刚的文件:
我把文件删除后,再运行:
可以看到新建了一个log.txt文件。
而且这个 log.txt 的权限不是乱的,是正常的。上面带上mode参数的函数,参数传的是0664,也就是八进制数,这个就是文件权限设置的一种方法,如果不懂的话,可以看我这篇博客:
【Linux】对于权限的理解
所以说mode这个参数是用来控制创建出的文件的权限的。
打开文件的说了,下面说说往文件中写入。
ssize_t write(int fd, const void *buf, size_t count);
这个函数参数中,fd就是上面打开文件的那个fd,buf就是你要写入的内容,count就是你要将buf中的count个字节写入到文件中。
返回值是你实际写入的字节数。
代码如下:
运行:
hello world就被写入到了文件中。
这个函数不会把 \0 写入到文件中,因为 \0 作为字符串的结束标志,只是C语言的龟腚,不是系统的龟腚,对于系统而言只是无用内容。
再看下读:
ssize_t read(int fd, void *buf, size_t count);
这个函数,fd还是打开文件的那个fd,buf是你要从fd对应打开的文件中想要读取的数据,读取count个字节。
返回值是实际读取的字节数。
代码如下:
上面读取fd文件中的内容,放到buf数组中,总共读取63个字节(实际读取个数要看情况而定)。
记得要把文件打开方式改为O_RDONLIY。
然后运行:
这里读取的时候是读到文件末尾时结束了,不会真正读取63个字符,只是读取了log.txt中的hello world\n这12个字符。
然后说一下fd这个东西是啥,也就是open的返回值。
可以看到前面我们用到了一些参数,O_CREAT,O_WRONLY,O_RDONLY。
有时打开文件传参时,是两个参数进行了或运算,为什么要这样做呢?
其实这些O_什么什么的参数,每个代表一个数,而且这些数都是2的幂,我们可以看一下库里的这几个东西值为多少。
O_CREAT
O_RDONLY
O_WRONLY
好了,就演示这么多个,可以看到,都是用某一位来代表一个参数值。
那么就好说了,当你打开一个文件的时候,如果如果想用多个功能,那么就可以用过按位或运算,就可以使多个二进制位变成1,然后open的时候,通过参数中二进制位的某一位是否为1来判断是否要执行某种功能。
就比如说我们在打开文件的时候,想要创建并写入文件,就可以 O_CREAT | O_WRONLY 来实现这样的功能。
那么这个参数就说到这里。
如果想要了解的更详细一点,可以看这篇博客:文件IO——open函数的参数flags详解
看一下man手册中是如何描述open的返回值的。
会返回一个文件描述符(下面都用fd来表示)。出错了就返回-1。
我现在多打开几个文件,将每个open的返回值打印出来看一看。
此处打开了5个文件,每个文件的fd都是不一样的。
我们运行起来看看:
通过open的返回值就能对打开的文件进行管理。
但是我么可以看到 fd 是从3开始的,前面的数都跑哪去了?
前面说了C程序会默认打开三个流,stdin,stdout,stderr。
系统也会默认打开三个流,也就是标准输入,标准输出和标准错误。分别用 0,1,2 来代表。
再加上这三个,连起来就是0,1,2,3,4,5,6,7…
那么根据这个大家能想到什么东西呢?
数组下标。
为什么说这个?
肯定是有用么。
首先,从进程开始。
所有的文件操作,表现上都是进程执行对应的函数,也就是进程在对文件进行操作,再进一步,要操作某个文件,首先就要打开那个文件,打开文件又得将文件相关的属性信息(这个属性信息等会说)加载到内存中,前面的例子中,我们可以通过一个进程打开多个文件,那么就可以出现 进程 : 打开的文件 = 1 : n 的情况,那么系统中是可以存在大量进程的,也就是说,存在更多的打开的文件,而操作系统需要对进程进行管理,打开的文件比进程还要多,那也必须得对打开的文件进行管理。
怎么管理文件呢?跟进程管理一样,先描述再组织。
如果你不懂先描述再组织这六个字,可以看看我这篇博客:【Linux】进程概念
上面那段话中,我将打开的文件加粗了一下,为什么是打开的文件呢?
因为文件不打开的话,就还在磁盘中放着呢,打开的文件,数据啥的才会被加载到内存中,这样系统才能够在内存中对文件进行管理。
管理文件,我这里就按照,先描述,再组织的方式来讲了。
先描述。文件怎么描述?
首先要问大家一个问题:空文件(里面什么都没有)占用空间吗?
答案是占用的,我在C盘中创建一个空的txt文件给大家看看:
可以看到,大小为0kb,里面什么都没写。
那这个txt文件怎么就占空间了呢?
其实已经有答案了,上面文件名称、修改日期、类型,不都是文件的数据吗?
我们还可以看的更详细一点,查看文件的属性:
点进去:
这些也是数据,也是要占空间的。
这里的这些,就是前面说的文件的属性信息。
ok,接着前面的那个数组下标说。
系统想要描述文件,和进程一样,也是用的结构体。
大概长这样:
struct file
{
// 文件相关的属性信息
}
注意,这里的file是系统中的,不是C中的那个FILE,两个不是一回事。
这就是描述,很短,但是很精炼,之间需要记住加载到内存中的文件会有一个对应的结构体就行。
描述完了就要组织,怎么组织?
也像管理进程那样,用双向链表把所有打开的文件的struct file给串起来。
如果我们把前面所讲的标准输入、出、错误还有打开的文件对应起来,就长这样:
这就是管理。
我们还能用这个把前面讲的那一大段话中的 进程 和 文件 关联起来。
前面有一篇博客中讲了进程概念,里面有进程pcb,与之关联的进程虚拟地址空间,页表,实际物理内存,今天又能添加一个和pcb关联的东西。
但要先介绍个东西:struct files_struct。
这个结构体里有一个指针数组 struct file* fd_array[] (结构体里还有别的成员,不止这一个指针数组),这个数组中的元素,就是指向struct file的,也就是描述打开文件的那个结构体。
而我们的进程pcb中还有一个指针,大概长这样:struct files_struct* fs 。这个指针指向的就是上面途中绿色的那个结构体。
然后还要讲个跟那个双向链表有关的东西。
学过C++的同学,应该知道多态这个概念,这里要用一下多态的思想。
Linux是用C写的,只能说是类似于多态的东西。
我们把前面的那个双向链表放倒:
每一个struct file中放有一个指针const struct file_operations *f_op,这个指针指向的东西中还有两个函数指针,这两个函数指针指向的两个函数一个是用来对文件进行读的,一个是对文件进行写的。
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
也就是这两个。
这两个函数指针,指向了“文件”中的实际进行读写的操作。
而这两个函数指针指向的就是指向驱动层中的实际操作的函数。
而每一个驱动层中的函数的功能肯定是不一样的。
而在struct file这样的上层来看,所有的文件,读写时根本就不用关心其操作的是什么文件。
同样的函数指针,调用时实现不同的功能,这种方式就类似于多态。
证明一下,012是标准输入、出、错误流。
运行:
这里其实有点门道的。
我们输入的不光是hello world,而是hello world\n
打印的时候也是直接打印的两个hello world\n
很简单,先上代码:
我这里进去就直接把0给关了,也就是直接把标准输入给关了。
然后打开文件,什么也不干,就打印一下fd。
运行:
可以看到,fd不是3了,变成了0。是不是因为关了0导致的呢?
例子就不再举了,我觉得上面这两个已经足以说明了。
我就直接说结论了:
给新的文件分配fd时,是从fd_array中找一个最小的、没有被使用的下标来作为新的fd。
细心的同学估计已经发现了,我故意没有close(1),为什么?
我们来close(1)试一下:
记得最下面先别带上close(fd); 至于为什么我等会讲缓冲区的时候再说。
运行:
可以看到,屏幕上没显示,但是将内容打印到 log.txt 里面了。
这个很像输出重定向。
我们前面也演示了输出重定向 > ,其功能是让打印在标准输出中的内容重定向到别的 “文件” 中去。
在我前面的博客中,我也是提到过重定向的概念的。
这里也演示一下:用echo演示一下。
echo XXX 可以将XXX打印到显示器上。
我们再来个关1的例子:
C语言中我们打开文件时,用的时FLIE*来接收一个文件指针,其实也就对应系统中的fd。但是我们并没有怎么了解过FILE是什么。
我们C语言中的stdin、stdout、stderr的类型都是FILE*的。把这三个对应到系统中的0、1、2,可以看到二者其实是有关联的。而我们的C中的东西必须要基于操作系统来进行封装,所以stdout,所指向的FILE中也是必定含有系统中的1的。
空口无凭,我们来看下代码:
C中也是能打印出来系统中的0、1、2的。
而我们前面重定向的代码中,用的是printf打印,这个是C中的函数,是上层的语言,功能是往stdout中打印,但是这是基于stdout没有关闭的,这里的stdout也是语言上层的标准输出,我们系统中的标准输出是1。
1代表的是标准输出,当我们关闭了1时,fd_array[1] 就不会再指向标准输出了,但是此时我们打开的文件 log.txt 就起作用了,这个文件的 fd 变成了1,所以此时 fd_array[1] 就指向了 log.txt 这个文件。
把C和系统联系一下,printf打印的时候只认识stdout,也就是系统中 fd_array[1] 所指向的文件,而此时 fd_array[1] 已经指向 log.txt 了。所以不会往显示器上打印,而是直接打印到 log.txt 中,这也就是输出重定向的原理。
我们再来给个例子验证一下:
运行:
不断在增加,其实就对应C语言中打开文件时的 -a 选项。
前面的重定向方式是通过关闭流来实现的,但是如果我不想关闭流还能实现重定向呢?
int dup2(int oldfd, int newfd);
可以看到有 oldfd 和 newfd。
fd前面已经见怪不怪了。
现在又来两个fd,一个新一个旧。
什么意思?
看一下man中的解释:
翻译翻译就是将 oldfd 拷贝给 newfd 。
看图:
此时 fd_array[1] 和 fd_array[3] 会指向同一个文件。
那么就可以实现重定向了。
看代码:
这里将 fd_array[fd] 拷贝给了 fd_array[1],进行输出重定向。
本应向stdout输出,现在变成了向 log.txt 中输出。
代码如下:
这里将 fd_array[fd] 拷贝给了 fd_array[0],来实现输入重定向。
本应从stdin输入,现在变成了从 log.txt 中输入。
代码如下:
这里将 fd_array[fd] 拷贝给了 fd_array[1],并加上 O_APPEND 选项,来实现追加重定向。
此时运行,会不断往 log.txt 中追加内容。
然后讲个跟前一篇进程替换相关的东西。
首先把前面那张图给出来:
如果我们现在要发生进程替换了。
我们蓝色部分以及绿色部分的东西会被影响吗?
答案是不会的。
因为pcb、files_struct、mm_struct、页表这些东西都是内核中的,而蓝色的是文件。进程替换只是替换进程的代码和数据。
如果我现在创建一个子进程,父子会同时打开相同的文件吗?
会的。因为父进程创建子进程,子进程会继承父进程,二者几乎完全相同的,那么他们的内核就几乎完全一样,所以子进程的 files_struct 和父进程也一模一样。也就是说,父进程打开了0、1、2,子进程也会打开。而我们命令行上的进程,父进程都是bash,而bash想要运行必须得打开0、1、2,不然我们没办法输入东西和获取信息,所以说我们看到的所有进程就会默认打开0、1、2。
父子进程所指向的文件不会在内存中加载多个。
这里又要用到我前一篇C++中的一个东西,叫做引用计数,当n(n >= 1)个进程打开同一个文件时,系统就会为该文件搞一个引用计数,计数的值就为n。而当一个进程关闭时,n的值就要-1,当n的值减为0时,也就是没有进程打开这个文件了,这个文件才会关闭。
我前面讲C语言文件操作的那篇博客,在最后的时候也提到了缓冲区的概念,但是那时候没法讲的很深入,只是一带而过了,这样也没什么办法,因为站在语言角度想要把缓冲区的知识讲清楚是很难的。
那么这里就可以深入讲讲了。
前面在讲输出重定向的时候说了不要再最后的时候close(fd),没有说为什么,这里就把前面讲close(fd)的那个坑给填上。不过先把代码给出来:
两个例子:
第一个:
如果我们在最后close(fd)
运行:
既不会在显示器打印,也不会在文件中打印。
等会再讲为什么。
再看个例子:
第二个
然后用一下重定向:
这里 > 就是将标准输出中的内容打印到文件中。
我们也可将标准错误的也打印到文件中:
上面的例子中的 2>&1 意思就是将 fd_array[1] 拷贝到 fd_array[2] 中,但是fd_array[1] 已经指向了 log.txt 了,所以此时二者就都指向了 log.txt ,打印标准错误时也就直接打印到标准输出中了,然后再重定向,就打印到文件中了。
但是如果我们在最后加上 close(1) 呢?
可以看到,hello 标准错误打印到了显示器上,hello 标准输出打印到了文件中,但是 hello printf 和 hello fprintf 去哪了呢?
好,例子已经给完了,下面讲讲出现上面情况的原因:
首先说缓冲区有什么用:
所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的。
意思就是你传过去的数据不会第一时间给到磁盘上的文件,而是先给到缓冲区中,然后等到缓冲区内的数据填满了之后再传到磁盘上的文件中。但是你可以手动刷新缓冲区,就可以输出缓冲区的数据到文件中。
我们语言层面上的在用户操作接口那一层,其中有我们语言层面上的缓冲区。
而操作系统层面,也有一个缓冲区,是内核的缓冲区。
当我们向外设写入数据的时候,不会直接写到外设中。
如果我们用C写的文件操作,用到了printf / fprintf 这种C函数,就会先将写入的内容放到C语言专属的缓冲区,然后再通过系统提供的函数接口将C缓冲区中的内容刷新到系统的缓冲区中,然后才能刷新到外设当中。而如果想要将C缓冲区中的内容转入到系统的缓冲区中,必须要通过文件描述符来实现。
但如果你用了系统提供的文件写入的函数(例如write),就会直接将内容写入到系统的缓冲区中,然后再刷新到外设当中。
而C中stdout所指向的FLIE类型,也就是那个结构体中,封装了与fd相关的内容,维护了与C缓冲区相关的内容(printf、fprintf等向stdout中输出的内容都会先保存到FILE内),以便将C缓冲区刷到系统缓冲区中。
当我们用户级别(C)的缓冲区向OS的缓冲区刷新时,有以下三种刷新策略:
上述的刷新策略对OS缓冲区向硬件刷新也适用。
前面进程控制的那篇博客中说过:进程在退出的时候会自动刷新FLIE内部的数据到os缓冲区中,也就是将C缓冲区内容刷新到os缓冲区中。
概念就讲完了,下面说前面的两个例子的原因。
这里先关闭1,然后打开log.txt,此时fd值为1,printf打印时只认识 fd_array[1],但printf还是认为自己是向stdout打印的,所以此时打印的内容就先转入了C的缓冲区中,这里打印完后并没有close(fd),所以此时进程退出时可以通过fd来将C缓冲区中的内容刷新到OS缓冲区中。
所以此时我们进行重定向时,所有的内容都可重定向到 log.txt 中:
但是加上close(fd)之后,此时进程退出时无法通过fd来将C缓冲区中的内容刷新到OS缓冲区中,因为fd所指向的内容已经找不到了。
第二个:
因为我们此时是向显示器打印,C缓冲区向OS缓冲区刷新的刷新策略是行刷新,hello printf放到缓冲区后遇到\n就被刷新到OS缓冲区了,hello fprintf也是。
所以在这两个之后不管是否close(1),都没什么用了。
而wirte函数是系统的函数,写的内容会被直接放到OS缓冲区中。
所以我们此时可以直接打印到显示器上。
但是如果此时重定向的话就不一样了。
这里的根本原因是刷新策略发生了改变,由向显示器写入转变为向磁盘文件写入,由行缓冲变成了全缓冲。
没有close(1),hello printf 和 hello fprintf 打印
printf和fprintf的内容都先写入C缓冲区中,由于是全缓冲且数据较少,缓冲区并没被填满,所以进程结束时才刷新。但此时并没有关闭1,所以刷新时可以通过1来将C缓冲区中的内容刷新到OS缓冲区中。
有close(1),hello printf 和 hello fprintf 没有打印
printf和fprintf的内容都先写入C缓冲区中,由于是全缓冲且数据较少,缓冲区并没被填满,所以进程结束时才刷新。但此时关闭了1,刷新时找不到1,C缓冲区中的内容就没法刷新到OS缓冲区中,也可以说C中的内容作废了。
而write中的内容一个是往标准输出打,一个是往标准错误中打,所以输出重定向时就只有hello 标准输出打印到了文件中,标准错误打印到了显示器上。
和前面学的对接一下,如果在最后加上fork()来创建一个子进程呢?
我们重定向一下看看:
可以看到,用write写的内容被打印了一次,但是用C函数写的内容却被打印了两次。这是为什么?
原因如下:
原因还是缓冲区的刷新策略改变了,直接运行是向显示器打印,而重定向是向磁盘文件打印。由行缓冲变成了全缓冲。
子进程创建时会继承父进程绝大部分的代码和数据,缓冲区也是。
重定向的:
行缓冲,内容不多,先存起来,进程退出时才会刷新缓冲区。
父进程在fork()前,C缓冲区内部的数据没有被刷新,fork()后,子进程继承了父进程的C缓冲区,内部数据和父进程的一样,此时两个进程退出时才会刷新缓冲区,所以两个进程,两个缓冲区都被刷新到了OS缓冲区,子进程也是 log.txt 打印,所以出现C函数打印两次的情况,但是write打印了一次的情况。直接运行的:
行缓冲,遇到\n就停止了。
所以父进程的C缓冲区在fork(),时已经清空了, 没有东西,此时再创建子进程,子进程的C缓冲区也是没有东西的。 所以刷新不出来东西。
ok,缓冲区就讲到这。
上面说的用户级缓冲区都是C语言的缓冲区,别的语言也是有的,像C++中,endl也就像C中的\n,也是将C++的缓冲区进行行缓冲。
上面讲的都是打开的文件,如果一个文件没有被打开呢?
文件 = 文件内容 + 文件属性
这里要讲点偏硬件的东西了。
不需要说那么复杂,就看中间的那个磁盘就行。
那个银白色的大圆片,各位小时候看动画片的时候应该见过cd,这两个东西长得很像,但是还是有区别的,磁盘是可读可写的,而cd是只读的(只能让我们看动画片,我们没法直接改动画片中的内容)。
我们的文件就都放在磁盘上。
怎么放呢?
各位如果想象力够丰富的话,可以将中间的磁盘想象成很多个圆圈,一大堆圆圈就组成了这么个磁盘。
磁盘可存储的空间非常大的,有1T的,有几百个G的,但是我们可以通过上述的理想化模型来将磁盘分成一个个圆圈,这样一个圆圈的存储空间就小一点了。而这里的圆圈,在专业术语中就叫做磁道。
分好磁道后,每个磁道又可看成是线性的,就是把圆圈剪开,变成一条线。然后这条线中个一小段再划分一个区间,然后不断分割区间,就可将空间再划分的小一点。这里划分的小区间,专业术语叫做扇区。
然后磁盘的话,还有盘片,盘面,柱面等等专业术语,但是这里要讲的东西,只需要我前面的这点就够了,如果各位还感兴趣,可以看看这篇博客:5 分钟图解 磁盘的结构(盘片、磁道、扇区、柱面)
然后就开讲:
在操作系统眼里,磁盘是一个线性的结构。
把所有的线都连到一块。
操作系统通过LBA(Logical Block Addressing)逻辑块寻址模式,这个就可以直接通过类似于直接通过数组下标的方式来找某一段空间。
我们电脑中,可能有什么C盘D盘E盘等等,但是我们大多数电脑实际的物理内存就一个盘:
而这个划分就叫做分区,就是将大磁盘分成小区间。分区的同时还要写入文件系统(格式化)。
就类似于我国,分为好多个省份(分区),每个省份都有专门的政府领导班子(文件系统)。
但是如果我们能管好一个分好的盘,就能管好其他的盘,而不像我们生活中一个省份的领导班子不一定能管好其他省份。
那么就好说了,几个盘的文件系统都一样(不考虑多文件系统),我们就挑出来一个盘说说:
然后这个盘还能继续划分为一个Boot Block和好多个Block group:
然后每一格Block group还能继续划分:
到这,就可以先停一停了。要讲一讲上面这些分的小块。
对于Super Block和Group Descriptor Table只需要知道有这个就行,不是重点,重点是后面四个。
下面这些看不懂没关系,重点不在这
超级块(Super Block):存放文件系统本身的结构信息。记录的信息主要有:bolck 和 inode的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了
GDT,Group Descriptor Table:块组描述符,描述块组属性信息,有兴趣的同学可以自己了解一下,里面放的有当前Block group的信息,比如当前Block group的位置,从哪开始到哪结束,当前是第几个Block group等等
其中,inode Table中的一个小块,代表一个inode,Data blocks中的一个小块,代表一个block。
最开始的时候说了,文件 = 文件内容 + 文件属性。这里就要用到了。
inode里面放的是文件的属性信息,block里面放的就是文件的内容。
一个inode为512字节大小,一个block为4KB大小。
假如我们此时创建了一个文件 log.txt,此时还没往里面写东西,但是文件已经有属性了,所以此时就要在inode Table中找一个无效的inode并将其作为存放新文件属性的inode,然后找到的这个inode就变成了有效的inode。
然后我们在 log.txt 中写入了 hello world,然后这个文件就有内容了,所以此时就要在Data blocks中找一个无效的block,然后将其作为存放新文件内容的block,这个新的block就有效了(如果内容很多,就会找很多的block,从第一个block开始通过指针的方式把每一个block都连起来,就能存放更大的空间了)。
文件的 inode 中存放有文件对应的inode编号。
在Linux中,文件名对于系统来说是没有实际意义的,只是给用户使用的。
Linux中真正识别文件的,是通过文件的inode编号。
我们可以直接将inode看作是一个结构体。
其中包含了很多信息,包括上面的inode编号,文件内容存放的block(inode和block就产生关联了)在哪里等等文件的属性数据。
此时 log.txt 的文件内容和属性就被记录了。磁盘中就有这个文件了。
然后再讲前两个:
都叫做XXX Bitmap,就是XXX 位图 的意思。
位图是啥,就是比特位中的0和1的一堆编号。
拿inode Bitmap来说:
前面说inode的时候说创建一个文件,找一个无效的inode,意思就是这个inode未被占用,找的时候就直接遍历位图,找一个0的位置,然后就直接将该位置对应再inode Table中的位置处的inode给对应的文件,然后这个inode就被新文件占用了。
那么Block Bitmap也就同理了。
注意一点:block申请成功就将该block的位置信息放到对应文件的inode的结构体中了。
我们可以通过文件的inode编号来找到对应的文件inode,然后再通过inode来找到文件的block,这样就能对文件的内容进行修改了。
然后说一下目录。
请问,目录是文件吗?
是,Linux下一切皆文件。
只要我们使用Linux,就必须要从根目录开始创建文件,所以说,所有的文件都一定有其所在的目录的。
目录文件中存放的内容是什么?
该目录下的 文件名 和 其对应的inode编号的映射关系。
映射关系说的有点高大上了,就是文件和其对应的inode编号是相互关联的而已,通过文件名能找到文件inode编号,通过inode编号能找到文件名。
用一下上面的知识:
如果我现在想用 cat log.txt 来查看文件内容,系统会做些什么?
首先会在 log.txt 对应的目录下的data block,先找到其中的 log.txt ,然后查看 log.txt 其对应的inode编号,然后通过 inode 编号去 log.txt 的inode中找到 log.txt 的block,然后再将block中的内容打印到显示器上。
如果我现在想用 rm 删除文件呢?
同理,先找到inode编号,然后再找到inode,然后再找到block,将block对应到block Bitmap 中的比特位置零,然后再将inode对应到inode Bitmap中的比特位置零,然后这个文件就删除了。
而不是还要释放空间什么的。直接通过该位图中的二进制位就可实现文件的删除。非常的快。而原来文件的内容其实是还在的,只不过已经无效了,如果此时创建一个新文件,这个新文件就有可能将原来文件的位置直接覆盖掉了。这也就是为什么删文件特别快,但是拷贝文件很慢。
注意:这里的 rm 相当于Windows中的清空回收站了,而不是将文件放到了回收站中。Windows下的删除,放到回收站里,只不过是将一个文件转入到了另一个文件中,清空回收站才叫真正意义上的删除。
如果因为误操作将很重要的文件用 rm 删除了,而且你不知道怎么找回时,这时候千万不要随便搞,尤其是在被删文件的目录下创建文件,小心原来的文件内容直接被覆盖掉。最好的办法是什么都不要做,去找专业的人给你找回来。花点钱,省心。
如果不是什么重要的文件,可以用debugfs等方式来恢复,但是我还没学过,不是什么专业人士,这里也就不说那么多误人子弟了。
用到 ln 操作。
概念性的东西讲不了,我感觉讲了难以理解,就直接给例子了。
建立软链接方式:
ln -s [要链接的文件] [新文件]
删除链接方式:
unlink [新文件]
看例子:
可以看到生成了一个 log_soft 文件,权限前面的l,代表这是个链接文件。
软链接有什么用呢?
上面的例子不好,再换一个例子,我现在搞一个路径非常深的文件。
然后将文件搞成一个可执行程序,功能是打印10个数。
代码如下:
然后我在原来的那个目录下对该test.sh搞一个软链接。
然后我们可以通过test_s来直接运行很深的路径下的那个 test.sh,看:
可以直接运行。
那么软链接在干嘛呢?
其实就相当于Windows当中的创建快捷方式:
桌面上放的是快捷方式,实际上在某个盘的特定目录下。
在Linux中如果有一个路径特别深的程序,就可以通过软链接的方式来执行文件。
软链接是有自己的独立的inode编号的:
也就能说明:软链接文件是一个独立的文件,有自己的inode属性,也有自己的数据块,其数据块保存的是自己所指向的文件的路径和文件名。
建立硬链接的方式:
ln [要链接的文件] [新文件]
看到上面建立的硬链接,inode编号和原文件一模一样。
那也就能说明:
硬链接本质上就不是一个独立的文件,而是一个文件名和inode编号的映射关系,因为自己没有独立的inode。
再看下图中标红的部分:
若我现在删除 log.txt 会发生什么?
可以看到软链接失效了。
但是硬链接还在那里,而且 log_hard 的inode没有发生改变。
还是59。
其实这里就相当于给原来的文件改名了。
从原来的 log.txt 改成了 log_hard。因为inode都一样,只不过有两个映射关系,删除原来的那个,剩下一个映射关系,留下的那个就成唯一的文件名了。
但是可以发现那个数字变成了1。
这个数字就是硬链接数。
硬链接数为一,因为就只有一个映射关系。
但是如果我们随便创建一个目录文件:
这里初始的链接数就是2。
因为dir_tmp中还有一个 . 来表示当前目录。
如果我们再在dir_tmp中创建一个目录:
硬链接数变成了3。
因为dir2中还有一个…来表示上级目录,也就是dir_tmp。
然后这个就讲到这。
我们stat命令可以查看文件的inode,还可以看到三个时间:
下面讲讲这三个时间。
叫ACM就是取的每个时间的首字母。
这个表示文件最近被访问的时间。
但是如果我们用cat命令查看文件内容,这个时间却不会改变:
我们再vim这个文件加点内容,还是不变:
原因如下:
在较新的Linux内核中,Access时间不会被立即更新,而是有一定时间间隔,OS才会自动进行更新。因为文件的访问操作是比较高频的,如果更新太快,OS的操作次数就会变多,这样就会导致系统变卡,等于说是优化了一下。
这个表示文件内容最近一次修改的时间。
还是刚刚的文件,我们往其中加点东西:
可以发现,如果改了内容,modify和change时间都会改变。
Change改变是因为文件也是有大小属性的。当我们修改为你教案内容时,有可能修改文件的属性,比如文件的大小属性。
这个表示文件属性最近一次被修改的时间。
看:
上面将文件的权限修改了一下。
可以看到modify没有改,但是change改了。
说一下这几个时间的一个应用场景:
如果我们用make来编译文件时。
第一次可以,后面就不能连续编译了。
原因是,makefile与gcc会根据时间问题来判断指定源文件和可执行程序谁的modify时间更新,从而指导系统哪些源文件需要被重新编译。
此时Test的modify时间比test.c更新,就不要重新编译。
ok,ACM时间就到这。
然后讲动静态库:
下面的内容非常多,而且第一次接触可能会非常晦涩,我尽力讲的透彻一点。
为什么要做库呢?
为了让我们以后更好的使用别人的东西(库或者组件),我们就要对库的制作要有清晰的认识。
而像我们C中提供的,我们平时用的printf这个函数方法,使用的时候需要包含对应的头文件stdio.h,这就是在用别人的库,但为什么要这样做?
下面讲的搞清楚,上面的你就理解了。
直接造库。
制作库要有两个重要的文件,一个是头文件一个是库文件。
什么意思呢?
头文件就是文件声明啊什么的。
库文件是我们所有的函数实现的文件编译形成的 .o 文件,那么什么是 .o 文件,我这里就不讲了,如果不知道,看我的这篇博客:点击目录中的编译和链接,看那一块就够了。这些所有的.o文件打个包,生成的这个包就是库文件。不同的打包方法就可以生成不同的库类型(动态或者静态)。上面的这点看不懂没关系,等会给例子就能看懂了。
我前面的博客中也是略微提到过动静态链接的,想看的可以看看:点击目录中的细链接,看那一块就够了
然后就交代这么多,正式开始。
首先创建两个目录mklib和uselib,一个专门用来做库,一个专门用来用库。
做库前,要先把库中的所有方法以及对应的头文件写出来(mklib中):
这里写了两个函数,一个函数是打印字符串并打印时间戳;一个函数是打印一个累加数。
myprint.h
myprint.c
mymath.h
mymath.c
然后通过这两个函数就可以做库了。
记住一点,库中是不能包含main函数的,因为引头文件的时候会将文件中的内容展开,若文件中有main函数,就会冲突。
但是要讲一点,不用做库我们也是可以用上面的方法的:
创建一个.c文件main.c,里面放有main函数,代码如下:
几个.c文件同时编译链接形成可执行程序:
运行结果是正确的。
记住一点,只把 .c 文件编译形成的 .o 文件和头文件给别人,别人就能用了。
看例子:
我们现在将.c文件编译好
将.h文件和.o文件转到另一个目录uselib中,然后我们直接将 main.c 文件编译形成 .o 文件,再一块编译这三个 .o 文件就可以生成可执行文件:
结果同样对。
然后再来说库的制作。
记住做库不是给自己用的,要是给自己用就直接上面的方式就够了,做库是给别人用的,但是我们这里的目的也不是为了将来能过做库,是为了理解库是怎么做的,以便我们以后用别人做的库。
首先是制作静态库。
上面的函数中只有两个函数方法,如果现在一个库中有非常多的函数方法呢?
全部形成.o文件然后再给别人吗?
.o 太多了,库本来就是方便别人使用的,而不是让人用起来很麻烦。
我们就可以将上面形成的.o文件打包成一个文件,然后再给别人就行了。
而打包命令为 ar(archive files)后面加-rc选项。
像下面这样:
将所有的.o文件打包形成一个文件,这个里的打包就是生成静态库。
但是这样制作库的过程未免有些潦草,我们用Makefile来实现一下:
实现了Makefile就能一劳永逸了。如果对于Makefile不熟悉的可以看我这篇博客:点击目录中的【Linux项目自动化构建工具 — make/Makefile】
然后我们就能通过Makefile实现以下的功能:
然后我们这里的库就做好了。别人就能用了。
再把库相关的文件(output目录)转到uselib目录下,就相当于是别人把库下载好了。
但是库下载好了就要用了,怎么用呢?
这里静态库的使用,讲两种方法:
头文件和库文件要拷到不同的系统路径下。
这里把库拷贝到系统的默认路径下就是库的安装。
不管是Windows下还是Linux下,安装软件的本质就是拷贝。
只不过Windows下做了些图形化的界面、进度条什么的,看起来高大上了点。
此时再打开我们的main.c就没有报错了。
但是gcc还是会报错:
要加上 -l库名 选项,这里的库虽然叫做libhello.a,但是真正的库文件名是hello。所以这里要加上 -lhello 选项。
库文件名:去前缀lib,去后缀.a或者.so,剩下的就是库文件名。
因为gcc是专门编C语言的,所以链接C语言库的时候不需要加-l,但是我们自己写的库放到系统库路径下,里面库非常多,gcc不知道要链接 /lib64 下的哪一个库,所以要手动指明。
我们自己写的库是第三方库,不是语言提供的,也不是系统自带的。后两个可以直接运行编译。
上面形成的 a.out 好像并没有那么大,这是因为文件中不是所有的库都使用的是静态链接,只是我们写的库用了,别的库用的还是动态链接,比如我们两个头文件中都引用了C中的stdio.h,这些库用的还是动态链接的方式,至于为什么等会会讲到。
上面的做法不提倡,因为库路径下的文件不要随意增删,如果我们忘记了加过什么文件,以后使用的时候可能出现意想不到的问题。
此时再加 -l 选项也没有用:
找不到头文件,因为此时头文件是搜不到的。
头文件搜索顺序如下:
1.先搜索当前目录( 这里注意,只有用#include "headfile.h"时才会搜索当前目录 )
2.接着搜索-I指定的目录
3.然后找gcc的环境变量 C_INCLUDE_PATH,CPLUS_INCLUDE_PATH,OBJC_INCLUDE_PATH
4.再找内定目录: /usr/include, /usr/local/include
5.最后找gcc的一系列自带目录,如:
CPLUS_INCLUDE_PATH=/usr/lib/gcc/x86_64-redhat-linux/4.8.5/include
————————————————
版权声明:本文为CSDN博主「hankern」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/hankern/article/details/120663223
这几个选项意思解释一下:
-I:告诉gcc头文件的位置。
就是刚刚上面的第二条:当前目录没有就接着搜索-I指定的目录。
-L:告诉gcc要链接的库的位置
让gcc到这个路径下去找库文件。
关于库文件的查找顺序,可以看这篇博客:gcc编译链接时头文件和库文件的搜索顺序
-l:告诉gcc要链接某路径下的哪一个库文件。
跟上面第一种方法一样。-L给的路径下可能存在大量的库文件,所以要告诉gcc应该链接哪一个。
静态库先到这,下面说动态库。
跟静态库很相似,都是先生成 .o,再打个包。
但是两个.o是不一样的,动态库 .o 在制作的时候要加选项,打包的方式也不一样。
.o制作:
也是用gcc但是要加上 -fPIC 选项。
这里就生成了 .o 的动态库。
然后再把别的 .c 也搞成 .o 再编译一下:
加这个 -fPIC 选项后生成的 .o 文件和不加的有什么区别?
加上该选项, .o 文件可以生成一个位置无关的二进制文件。
什么意思呢?
要讲一讲进程地址空间了,不懂进程地址空间的同学看这篇:点击目录最下方的进程地址空间 。最重要的一点:动态库和静态库的链接方式是不一样的。
静态库:当生成可执行文件时采用的是静态链接方式时,生成的可执行程序(假如说是a.out)a.out 是拷贝了所有的静态库的代码的,这也是以静态链接方式生成的文件比较大的原因。而这样也就导致当执行一个可执行程序时,所形成的进程中,会将静态库的代码和包含main函数文件的代码全部加载到进程地址空间的代码区中。进程在运行期间,代码执行时都是按照代码区中所加载的代码顺序执行的。
如图所示:
动态库:动态链接时,a.out和动态库的代码是可以不用同时加载到内存中的。
生成a.out时含main的代码在a.out中,动态库中的代码不会被拷贝到a.out中,但a.out包含了动态库的代码加载到内存时的代码起始地址。
…
./a.out后,动态库加载到内存中,库代码在内存中的地址会通过 a.out 的页表放到到 a.out 进程地址空间中的共享区(栈和堆之间)中,此时就形成了映射关系。当 a.out 执行到某处代码且需要库中的方法时,就会先跳转到共享区中限制性库中的代码(通过页表),当库中方法执行完毕后,再跳回到代码区中继续执行原来的代码。
…
动态库会加载到内存的任意位置,只要生成映射关系就可以了。
当多个可执行程序需要动态库时,只需要再内存中加载一份动态库代码,然后个进程通过页表产生映射关系即可,所以动态库也叫共享库,比较省空间。
当多个静态链接形成的可执行程序被加载到内存中时就会比较占用空间。因为每一个可执行程序中都有静态库的代码,都还是只读的,只读的东西只需要一份即可,所以更推荐用动态链接,比较省空间。
然后上面并没有真正意义上的生成一个动态库,包还没打。
这里动态库打包可直接用gcc,但是要加上 -shared 选项。
gcc -shared mymath_d.o myprint_d.o -o libhello.so
我这里就直接在Makefile中写,这里续着前面的静态库的Makefile写了,直接将动静态库都生成:
讲一下里面的:
没有标注的:
然后就可通过Makefile实现以下功能:
我们还可以将output这个目录用tar命令打包一下,生成一个真正的包,然后传到网上就是库了,别人就能下载了:
然后库就做好了,我们此时想用的话就可以下载。也就是将库拷贝到uselib下。
我这里就直接将目录拷过去了,不整那个tgz的包。
动态库写好了。
下面就要说怎么使用动态库了:
跟静态库差不多:
第二点说完再说点总结的本篇就结束了,看到这的同学坚持坚持。
用 -I -L -l 选项暴力链接
这里有一个问题,我现在有两个库,一个动态,一个静态,当我想这样直接链接的方式,会链接到哪个库?
答案是动态库:
但是又可以看到这里说我的动态库找不到,是因为什么?
原因其实前面已经讲到了。
因为我们的进程和动态库不是同时加载到内存中的,a.out中并没有动态库中的代码,而是以保存加载到内存中的库代码地址的方式来使用库代码的。此时我直接运行,就会报错,因为动态库的代码还没有加载到内存中:
有的同学可能又有疑问了:我们前面gcc main.c -I output/include/ -L output/lib/ -lhello 的时候不是已经给了库文件位置和库文件名了吗?
我们的文件加载到内存中是由系统来帮我们完成的,但这里的库文件位置和库文件名是给gcc说的,而不是给系统说的,所以系统是不知道我们的库文件在哪里的,那怎么才能告诉系统呢?
先不说,先把前面挖的一个坑给填上。
前面讲动态库制作的时候说了,链接的时候会首选动态链接。这里讲一下这个。
三种情况:
由此可以类推以下,当我们C库中只有静态库时,就只能用静态链接了。
如果动静态库同时存在,默认情况下使用的就是动态库。
这个刚讲,就不演示了。
如果动静态库同时存在,若想使用静态库,只能加上 -static 选项。
-static选项:摒弃默认优先使用动态库的原则,而是直接使用静态库。
看:
此时所有的库全部都采用静态链接的方式,ldd查看不到动态链接库。
也可从文件的大小来看出,此时采用的是静态链接。
下面再说怎么让系统加载动态库
四种方法:
挨着演示一下:
- 将动态库拷贝到 /lib64
- 导环境变量 LD_LIBRARY_PATH
先看一眼:
可能有的同学这里面是空的,我这里是配了一点的,不一样是正常的,影响不大。
然后往这个环境变量中添加动态库的绝对路径。
然后就能运行了:
但是这个有缺点,就是只在当前会话中有效,是内存级的环境变量,只适用于临时方案。我们此时退出再登陆就没有了:
所以这个方法只适用于临时方案。
但这里要记得删掉这个文件,跟第一种方法一样,防止以后忘了。出现意外。删掉了之后还要再 ldconfig 一次,让原来的配置文件失效,不然还能执行。
系统路径下建立软连接
这里其实就和第一种方法一样了,只不过是不把库文件放到系统路径下,而是直接再系统路径下创建一个软连接,给二者关联起来。
为什么要有库?
关于上面的链接,我看到了一篇不错的文章,感兴趣的同学可以看看:Linux下c/c++头文件和库文件的查找路径
到此结束。。。