共识原理
在讲文件操作之前, 我们先形成一个共识
1 文件 = 内容 + 属性
2 文件分为打开的文件 和 没打开的文件
3 打开的文件是谁打开的? 进程!! – 研究文件操作本质是研究进程和文件的关系!
4 没打开的文件:在哪里放着?在磁盘上, 我们最关注什么问题? 没有被打开的文件非常多, 文件如何被分门别类的放置好 – 我们要快速的进行 增删查改
5 文件被打开, 必须先被夹在到内存! (属性一定要加载, 内存取决于是否要进行修改)
6 一个进程可以打开多个文件
5,6==》操作系统内一定存在大量的被打开的文件! — 这么多文件操作系统要不要管理呢? — 要! — 怎么管理? — 先描述再组织。
在操作系统中一个被打开的文件都必须有自己的文件打开对象
,里面包含了文件的很多属性, struct XXX(文件属性)
回忆c语言打开文件的接口
fopen
打开一个文件, 第一个参数是文件的路径, 第二个参数是打开的方式, 打开成功就返回文件的地址, 否则返回空。
当我们以写的方式打开一个文件的时候, 如果这个文件不存在就会在当前路径下新创建这个文件 – 其中当前路径指的是进程所在的路径
这个路径就是/proc目录下进程目录中的cwd指向的地方
w
打开文件时, 是先将文件内容清空, 然后再去写入
a
打开文件时, 是指向文件的末尾, 此时写入是从文件的末尾追加写入
fclose
关闭一个已经打开的文件, 只需要传入该文件的文件指针即可
fwrite
往一个文件里写入信息
第一个参数传要写进去的内容
, 第二个参数传一次要写的大小
, 第三个参数传要写几次
, 第4个参数传要往哪里写
其中 第二个参数只需要保证字符内容能被包含即可, 不需要考虑\0
这是因为以\0
结尾是c语言的规定, 与文件无关, 写入文件后该内容还有可能会被别的语言使用,而他们的规定又和c不同。
fprintf
往指定的文件里写入, 与printf使用方法一样, 只是第一个参数要传一个文件指针
指向你要写入的文件
不仅仅是c语言, 所有的语言在启动时都需要打开对应的设备文件,那这三个到底是什么, 为什么这么重要呢? 我们后面再讲
过渡到系统, 认识文件系统调用
文件其实是在磁盘上的, 磁盘是外部设备, 访问磁盘文件本质上就是在访问硬件。
几乎所有的库, 只要是访问硬件设备, 必定要封装系统调用
!!
例如 printf/fprintf/fscanf/fwrite/fread/fgets…
Linux中关于文件系统相关的系统调用有哪些呢?
open
补充知识
题外话:比特位方式的传递方式
我们尝试使用这段代码来打开一个不存在的文件
可以发现打开失败了, 这是以为O_WRONLY
遇到不存在的文件的时候, 不会帮我们自动创建, 所以我们还得加上O_CREAT
此时就正常的创建出了文件, 但是我们仔细看可以发现, 文件的权限是一串看都看不懂的乱码, 为什么呢?
这是因为我们使用的是两个参数的open
没有给操作系统传该文件所对应的权限, 所以操作系统就以乱码的形式给我们创建了。
因此我们得知了 在Linux中使用open创建文件时, 需要使用三个参数的open给操作系统传递该文件对应的权限
更改后我们采用这份代码
但是我们又发现了一个问题, 为什么我们创建的这个文件的权限是664
而不是666
呢? 这个知识我们之前就讲过了, 这是因为umask
权限掩码
我们设置的权限其实是默认权限, 该权限要与umask做对比, 在umask里出现的权限是不能出现在最终文件中的。
但是如果我们不想管umask
就想创建一个权限为666的文件呢?
uamsk
我们还有一个系统调用叫做umask
他可以设置我们文件内的权限掩码
这样, 在我们进程中创建的文件就会使用这个权限掩码
了
这个设置不会影响系统的umask但是会影响整个进程的umask
可以看到我们创建出来的文件权限就变成了666
open的参数(常用的)
O_RDONLY
:以只读方式打开文件
O_WRONLY
:以只写方式打开文件
O_RDWR
:以可读可写方式打开文件
O_CREAT
:如果 pathname 参数指向的文件不存在则创建此文件
O_TRUNC
:调用 open 函数打开文件的时候会将文件原本的内容全部丢弃,文件大小变为 0;
O_APPEND
:调用 open 函数打开文件,当每次使用 write()函数对文件进行写操作时,都会自动把文件当前位置偏移量移动到文件末尾, 从文件末尾开始写入数据,也就是意味着每次写入数据都是从文件末尾开始。
close
close 用于关闭一个已经打开的文件
close只有一个参数就是传入文件描述
write
write用于向一个文件中写入内容
第一个参数是文件描述符, 第二个参数是要写入的内容, 第三个参数是要参入参数的大小
我们成功的用系统接口向文件中写入了这串字符
我们反复的执行这个程序
可以看到即使我们执行了很多遍, 但是还是只有这一串字符, 所以这里其实是从文件的开始写
我们将字符串改小一点, 再运行一下试试
可以看到文件里的内容变成了aaalo Linux!
, 不会清空文件, 而是从文件开始, 覆盖着往后写并不会对文件内容做清空
。
为了能每次写入时 都把原始数据清空, 我们需要在打开方式的flags
参数中添加O_TRUNC
此时再运行, 就可以发现只有aaa了, 可以看到在写入前已经做了清空
的处理。
如果不想清空, 而是想在文件后进行追加写的话, 把O_TRUNC
改为O_APPEND
就好
多运行几次, 就发现了,内容都被追加到了文件的末尾。
结论
所以, 我们的fopen(), fclose()等等其实都是封装上诉的系统调用
不同的语言, 他们只要在Linux中进行文件操作, 就一定是封装的上述系统调用
访问文件的本质
structfile
structfile
是Linux中的内核数据结构, 用于描述一个被打开文件的信息
直接或间接包含了如下信息:
1 文件在磁盘的哪里
2 文件对于的基本属性:权限, 大小, 读写位置, 谁打开的…
3 文件的内核缓冲区信息
4 structfile* next指针
我们知道, 一个进程是可以打开多个文件的。
为了管理这些文件, 进程的pcb
中有一个指针为struct files_struct* files
指向一个结构体struct files_struct
这个filestruct里面会包含一个数组structfile* fd_array[]
他是一个指针数组,当我们进程在打开文件的时候, 他会将这个文件的structfile的地址填入fd_array
中空闲的位置,所以以后, 我们的进程pcb就可以通过这个fd_array
(文件描述符表)去找到他所打开的文件structfile。
所以我们在调用open时, 他会创建文件的structfile
结构体, 然后将这个结构体的地址放在进程pcb的fd_array中, 最后将该地址在fd_array中的下表返回给用户
, 最后在用户手中拿到的就是一个类型为int
的数了, 后续用户也是通过这个fd来管理这个打开的文件了。
结论
open的返回值, 也就是我们用的fd 其实本质上就是fd_array
的下标
图中, 左边是Linux对进程的管理, 右边是Linux对文件的管理。
他们之间的联系是通过fd_array
来实现的。
我们使用printf查看下这个id到底是多少
可以看到值是3, 并不是很大, 所以确实可能是数组的下标。
这次我们尝试一下多打开几个文件, 看看他们的fd到底是多少
可以看到是连续的小整数, 确实应该是数组的下表
我们发现下表最小的是3 而不是0, 0, 1, 2
跑哪去了?
其实我们之前就讲了,
c语言会默认在启动的时候, 打开三个文件stdin , stdout, stderr
在Linux中, 虽然不叫这三个名字, 但是功能是对应的, 他们分别被放与下标为0 1 2 的位置
怎么来证明呢?
我们直接使用这串代码想显示器文件里, 写入信息;
确实是写进去了, 可以知道 下标为1的地方确实装的是显示器文件。
read()
同理我们也可以使用read()从0 处读取信息
为什么操作系统要默认打开这三个文件?
因为操作系统需要给用户使用, 而这三个的文件是每个用户都必须要使用的。
FILE* 与 open的返回值(int)有什么联系?
FILE 是 c库自己封装的结构体!!
由于系统调用只认文件描述符
, 所以这个结构体里, 一定含有文件描述符
可以看到确实fd是0 , FILE里封装了文件描述符
关闭文件的本质
structfile里有一个count计数, 表示该文件被几个进程所使用(引用计数
)
当我们在进程中调用close时, 先讲文件的count – , 然后再将该文件在fd_array中的位置置空, 最后如果改文件的count为0 就将该文件也释放掉。
文件描述符的分配规则
如果我们不关闭0号文件的话, 那么fd就是3, 而关闭0号文件的情况下, fd就是0了, 所以
文件描述符的分配规则就是从小到大扫描, 直到找到一个空着的位置, 就用这个位置
重定向
重定向的原理
这串代码的逻辑很简单, 就是打开了一个文件, 然后循环的往显示器上打印。
当我们将1号文件关闭后, 我们发现, 显示器上不会打印了, 而原本应该打印在显示器上的字符却打印进了文件里
这就是我们的输出重定向
为什么这样能完成我们的输出重定向呢?
因为我们先关闭了1号文件, 那么fd_array中下标为1的地方就是空的, 而由我们的文件描述符分配规则得知,新打开的文件的文件描述符是1, 所以就完成了我们本该写入显示器的字符写入了log.txt中
重定向的本质就是对fd_array
中的内容进行修改。
dup2
将前者的文件描述符, 拷贝到后者的文件描述符处
可以发现已经完成了重定向
。
如果我们将文件的打开类型改为追加写, 那么也就变成了追加重定向;
同理, 我们来试验一下输入重定向
读取到了文件里的内容
我们在指令上使用的> < >> <<
等重定向符号, 和我们刚刚写的代码有什么联系?
其实这些符号被我们命令行解释器读取到后, 他就想上述一样, 帮我们做重定向
这里就有了一个新问题了, 当我们这样做好重定向后, 后续进行进程替换
后, 会有影响吗?
fd_array是属于内核数据结构, 文件替换的时候, 不会修改内核数据结构
,所以进程替换是不会影响我们替换前执行的重定向的。
解决之前的问题
1
1号文件和2号文件的区别(c语言中的stdout VS stderr)
我们执行这段代码
可以发现输出全都打在了显示器上, 因为他两都是显示器文件嘛, 所以这并不奇怪, 可是当我们重定向后就会有不一样的事情发生。
可以看到, 打印在stderr
上的内容, 依旧显示在了显示器上, 而打印在stdin
上的内容被重定向到了文件里面。
其实很好解释, 因为我们在重定向的时候, 改变的是stdin
的所在位置指向的文件, 而stderr
还是指向的自己。
在指令里也是可以用语法实现重定向的,比如这里, 就实现了将1重定向到了normal文件里 将2重定向到了err文件里。
本质上1号文件 和 2号文件, 没啥区别, 都是显示器文件, 而存在他两的原因, 就是为了区分正常输出和错误输出
,我们在调试代码的时候, 更加关注的是错误信息, 然而一次直接打印出来会附带很多的正常信息, 干扰我们的调试。
这个2>&1
表示的其实就是将1指针的内容拷贝给2, 所以2也指向了1指向的内容也就是这个all文件了。
2
如何理解Linux中的一切皆文件
磁盘, 键盘, 显示器, 网卡。。 等等的外设都会提供自己的写入, 写出方法。
而在Linux中管理文件的结构体struct file
有一个指针叫做f_ops
他指向了一个结构体struct operation_fuc
而这个结构体里包含了两个函数指针, 一个指向设备的输入方法, 一个指向设备的写入方法,
这样我们就用c语言实现了用统一的方法, 调用不同的函数即我们c++的多态
其中上面的这写struct file 叫做虚拟文件系统
他实现了我们Linux对设备写入写出的统一调用,
所以当我们在调用write的时候, 其实是通过进程的pcb中的一个指针找到指向文件tructfile的fd_array然后找到该文件的structfile再通过其中的f_ops找到operation_fuc, 在通过其中的函数指针, 去调用设备里的写入方法。
我们通过 查看Linux内核的源码, 可以发现, 确实是有这么一个结构体的, 它里面装的是一大堆的函数指针
这就是指向写入写出方法的指针拉;
缓冲区
一些莫名其妙的问题
我们运行一下这串代码
结果不出所料, 打印出了这4串字符
如果我们进行一下重定向呢?
结果也没问题, 字符都进入了log文件里
如果我们在程序的末尾加一个fork呢?
还是正常的打印啊, 你也许会很奇怪, 这个fork能有啥用, 但是先别急, 我们进行重定向试一试。
可以看到, 不仅打印的顺序乱了, 而且c接口的函数还多打印了一遍, 这是为什么呢?
接下来我们改一改代码, 可以看到我们是先打印了再去关闭的1号文件
结果不出所料, 正常的打印了出来, 但是
如果我们将要打印的字符串的\n
去掉后会发生什么呢?
可以看到啥也没打印出来?? 这是为什么呢???
我们以同样的方式, 去试一试系统调用接口。
可以发现, 还是正常的打印了出来
我们在学习写进度条的时候, 也遇到过类似的问题, 就是如果我们不加\n
刷新缓冲区的话, 字符就不会显示出来, 这里也是一样
当我们执行到箭头指向的位置的时候, 数据一定是被写到了缓冲区里面,
这个缓冲区一定不在操作系统内部
, 因为如果写到了内核的缓冲区里, 我们在调用close(1) 的时候, 他就可以找到内核的缓冲区(因为close是系统调用), 然后刷新他。为什么write
能看到呢? 因为write是系统调用, 他直接将数据写在了内核的缓存区
里。 而我们库函数所使用的缓冲区是语言层的
, 用的是c语言给我们提供的缓冲区。
只有当, 我们使用强制刷新, 或者是'\n'刷新, 或者是fclose等等, c语言才会去刷新这个缓冲区, 然后一次将缓存区里的内容全部交给write这个系统调用, 最后由fwrite将数据写在在我们的内核级缓冲区上
所以我们打印不出来的问题就很明显了:
当我们在调用 玩这些函数的时候, 内容其实都还在用户级缓存区里, 然而这是我们将显示器文件给关闭了, 最后进程退出的时候, c语言想刷新这个缓存区都刷新不了了, 因为显示器文件已经被关闭了
为什么我们带了\n
后就可以正常打印
用户级缓存区刷新的本质其实就是将缓冲区里的数据通过write写入内核中
exit vs _exit
现在我们回头来看exit和_exit的区别, exit是c语言为我们提供的接口, 它是可以看到c语言为我们准备的用户级的缓存区的, 所以当我们在使用exit退出函数的时候, 他可以帮我们把缓冲区刷新, 然而_exit是系统调用, 它是不能看到我们用户级的缓冲区的, 所以, 在调用_exit 的时候是不会帮我们刷新用户级缓冲区的, 也就出现了我们当时的测试结果: 调用_exit无打印结果而调用exit就有打印结果。
缓冲区的刷新问题(用户层)
为什么要有这个缓冲区
解决效率问题(提高效率)
1 对于c语言, 我们不会每次都去调用系统调用, 直接将数据拷贝到缓冲区然后就可以继续去做自己的事。
2 对于操作系统, 不会频繁的去执行write, 执行一次write就可以刷新大量书库,节约时间。
配合格式化, 比如
我们需要将%d格式化为123
这个缓冲区在哪里?
答案是:
在c语言为我们提供的FILE结构体里面。
这个FILE是属于操作系统还是用户?
用户, 语言都属于用户层。
我们来解释一下之前的问题
我们在fork后, c接口的函数打印了两遍。
当我们不进行重定向是, 此时很好解释, 由于我们的字符串都是带了\n
的, 所以数据一被输入进缓存区, 就立马会刷新,所以我们会直接在显示器上看到正常的打印
而当我们进行重定向后, 此时就不一样了,
本来应该想显示器打印, 现在变成向文件打印了此时, 缓存区的刷新方案变成了全缓冲, 缓冲区不是遇到\n就刷新了, 而是直到缓存区满了才会刷新, 所以, 当我们执行完所有的打印后, 缓冲区依旧没有刷新, 最后在fork后, 操作系统会创建一个子进程, 子进程和父进程共享一份代码, 数据进行写实拷贝, 由于我们的缓冲区是用户级别的是在FILE里被malloc出来的一段空间, 自然子进程也会有这一段数据, 最后在进程结束的时候, 父子进程都想文件刷新了缓冲区里的内容
于是就有了, 这个现象。
而为什么不重定向的时候就是正常的呢?因为不重定向的时候是想显示器打印, 这时候是行缓冲, 在fork之前缓冲区里的内容就已经被刷新了, 所以计时fork后也不会多打印