Linux的基础IO
本篇博客里所有的代码在当前位置
打开的文件
前言
文件=内容+属性,文件分为打开的文件和未打开的文件,文件是由进程打开的,打开的文件必须加载到内存,操作系统内部一定存在大量被打开的文件,每一个被打开的文件都有一个描述文件属性的结构体对象,里面包含很多的属性,称为FCB,与PCB类似
文件库函数接口
当前路径:进程的当前路径cwd,进程当时在哪个工作路径下运行,当前路径就是哪个,比如说下图就是当前路径找不到对应文件,就在当前路径下创建一个log.txt文件,当我们的工作路径变了,新建的文件就会到另外一个路径下
fopen
特别是关于w模式,在每次执行程序的时候会对文件进行清空处理,所以不会只覆盖文件前面的内容,而是全部清空
关于a模式是追加写,是不会清空文件的内容的,而是在文件原有内容后写入新的内容
fwrite
size表示一块的大小是多少字节,count表示的是要写入多少块,返回值返回的是成功写入的块数
这里的strlen得到的长度是不能加1的,不然会得到一个乱码,文件里’\0’是C语言的规定,而不是文件的规定
输出流
c程序默认启动的时候会打开三个标准输入输出流,包括
stdin:标准输入,也就是键盘文件
stdout:标准输出,也就是显示器文件
stderr:标准错误,也就是显示器文件
可以看到这已经不再是写到文件里了,而是写到显示器上
文件系统调用接口
文件其实是在磁盘上的,磁盘是外部设备,访问磁盘就是访问硬件,所以我们调用的库函数fprintf,fwrite等库函数,底层肯定都有封装系统调用接口,下面介绍这些接口
open
第一个参数是文件名,第二个参数是打开的模式,第三个参数mode是文件的权限
用法
下面是一个失败案例
我们这是以只写方式打开的,但最后我们会发现根本没有产生log.txt文件,所以我们还要添加O_CREAT,如果这个宏的或看不懂的话可以去看我贴在最下面仓库的pos.c文件,里面有一个案例,这里面的其实类似于a模式,而不是w模式,因为这个代码在写文件的时候不会清空文件内容
但我们可以看到,这个新产生的文件的权限是乱码,因为我们正常的文件权限只有’r’,‘w’,‘x’,‘t’,所以我们使用第三个参数了,这第三个参数的用法和chmod的用法一样
这样就获得了正确的文件权限
但这个权限和我们写的0666不一样啊,这里是0664,这是因为umask在作用,这个umask可以定义在进程里,也可以定义在代码里,遵循就近原则
这样得到的log.txt的文件的权限就是0666了
close
当我们close的时候,其实是对文件结构体里的引用计数做减一操作,再判断是不是减到0要释放文件
所以可以直接向close函数传入open返回值
write
上层的printf,fprintf和fwrite都是C语言封装的库函数,最后底层都要调用这个write接口
文件访问的本质
操作系统内部操作文件是会给文件定义一个struct file,里面包含了很多属性,比如说文件的名字,在磁盘的哪个位置,还有count引用计数等等,所有的文件的指针被放在一个数组里,文件指针返回的数字就是这个文件在这个数组的位置的下标
无论我们怎么打印文件的open返回的文件描述符,最少都从3开始,因为前三个文件是stdin,stdout,stderr,这就是我们上面说的c程序开始的时候默认会打开的三个文件,这个特点是操作系统的特性,不是C语言的特性,这里要注意,C语言里的FILE和内核里的结构体指针是不一样的,C语言的FILE是C语言自己封装的,C语言这个FILE必须包含文件描述符
重定向
引入
当我们关掉1号文件之后,log.txt文件的文件描述符就占据了一号文件的位置,我们会发现当我们往1号文件写入的时候,并不是写入到显示器上,而是写入到log.txt上,这就是重定向
上述的过程被封装为dup2接口
dup2
重定向的本质就是拷贝当前的文件标识符到另外一个位置进行覆盖
覆盖1号文件
1号文件是标准输出,是显示器文件
所以我们根本没必要先关掉1号文件,这个接口可以实现把我们的3号文件直接覆盖1号文件的数组内容,newfd指向的文件是oldfd文件的一份拷贝,也就是说最后数组里1号和3号位置都是oldfd,也就是说,我们要传的oldfd是我们自己的文件,而newfd是1号文件,可以看到下面和上面两个效果是一样的
覆盖0号文件
0号文件是标准输入,是键盘文件,这里我创建了一个log0.txt文件,在里面输入了一串a
执行之后的结果是:
因为fd文件,也就是log0.txt文件覆盖了原本0号文件的位置,而且log0.txt文件里有数据,所以就log0.txt文件里的数据直接被读取上来了
所以我们以前有见过’>‘(输出重定向),’>>‘(追加重定向),’<'(输出重定向)其实和这个原理是一样的,只是封装了,可以参考上一篇的简单shell的实现
这样,我们现在的体系结构如下图所示,具有鲜明的解耦关系
其实还有一层,就是底层的外设其实也都要有struct进行描述,文件也是有一个struct结构体封装的,文件调用底层的外设也需要结构体方法,相当于调用进程->调用文件->调用对应的方法->调用外设,中间调用对应的方法其实就是外设驱动设备里的方法
stderr和stdout的区别
引例
当我们重定向的时候,会发现1号文件正确重定向了,而2号没有
而我们往显示器上打的时候又都是正常的
因为我们重定向的时候只把1重定向了,但2号文件没有重定向,所以只有写入1号文件的重定向成功,而2号没有,只有我们都指定的才能2个文件都重定向成功
我们在打印的时候有时候正常消息很多,我们要在里面找错误信息,所以我们可以通过这种方法找到错误消息,如果我们想把两个打印的内容放在一起的话
运行文件 1>写入的文件 2>&1
#简写
运行文件 >写入的文件 2>&1
2>&1表示把1号文件描述符的地址指向2号文件描述符的数组位置里面,就是说最后文件描述符的数组的1和2的内容里面数据是一样的,都和原本的1一样
缓冲区
引入
有一个现象,如果我们带了换行符的时候,即使最后关掉1号文件,也会正常输出,但当我们去掉’\n’的时候,关掉1号文件,最后什么都不输出,因为显示器文件刷新方案是行刷新,在printf的时候加入换行符,就相当于在执行完printf的时候,调用1号stdout文件,调用write接口,写入我们的内核中,我们去掉换行符的时候,相当于全部存在缓冲区,但1号文件标准输出已经关闭了,所以不会打印出来,由此可见,printf,fprintf,fwrite这些C语言对应的库函数缓冲区并不是在系统内核里的,因为如果是在系统内核里,在close(1)的时候,也应该能刷新到磁盘(显示器文件),我们也应该可以看到数据,下面是两次运行的结果,第一次注释掉了系统接口write,只留下printf,fprintf,fwrite库函数,第二次没有注释掉系统接口
所以fflush底层一定是有封装write接口的
C语言缓冲区
C语言缓冲区问题包含三种,第一种是无缓冲,第二种是行缓冲,第三种是全缓冲,FILE结构体(比如说stdout)里不仅有fd文件标识符,还包含缓冲区字段和维护信息
在usr/include/stdio.h中存在一个FILE结构体内容
1.无缓冲:当我们把数据写到缓冲区里,就直接写到内核里
2.行缓冲:一直不刷新,直到碰到’\n’或者进程退出的时候会刷新,比如说显示器文件
3.全缓冲:缓冲区满了才能刷新,比如说普通文件写入
结论
上述的代码如果我们正常执行的时候,是没问题的
但如果我们重定向到一个文件里就会出现问题了,明明我们只打印了四条语句,但最后log1.txt文件里面居然有7条语句,而且write只打印了一次
这里的缓冲方式是全缓冲,先刷新出来的是write打印的东西,是因为剩下的库函数写的东西还在C语言缓冲区,而write写到了是内核的缓冲区,然后迅速刷新到文件,所以system write是第一个,然后先执行fork创建子进程,数据会写时拷贝,各自私有一份缓冲区,接下来进程退出了,双方都要对缓冲区做刷新,所以库函数输出的数据会被打印两次
没打开的文件
文件=文件属性(inode存储)+文件内容(数据块存储)这两个是分开存储的
inode一般是一个128B的数据块,里面包含文件的所有属性
磁盘是我们计算机中唯一的机械设备,也是外设
磁盘硬件
物理结构
盘片是双面的,且不止一片,每一面都有一个磁头,有多少面就有多少个磁头,磁头是整体移动的,且磁头和盘面不接触,和内存不一样,内存是掉电易失性存储介质,硬盘是永久性存储介质,磁头可以通过电极特性把数据写入盘片,相当于磁铁,不同的硬件有不同的电极特性,但它们都把不同的极解释为0/1
磁盘里存在寄存器,包括控制寄存器(r/w),数据寄存器,地址寄存器(填写LBA),状态寄存器(就绪/未就绪),链接到DMA芯片
存储结构
磁盘的数据是在黑线上存,而不是在黄色空格存,里外扇区的大小都是一样的,一个扇区大部分都是512B,有的其他磁盘可能是4KB,柱面就是磁道合起来的样子,因为磁头是连在一起的
CHS寻址方式:当我们想访问一个扇区的时候,要先确定是哪一面(定位哪一个磁头),再确定是哪一个磁道,最后确认是哪一个扇区,磁盘之所以慢,第一方面就是磁头左右摆动的时间,第二个方面是磁盘旋转消耗时间,所以我们的磁盘就不能胡乱存储,最好将相关数据放在一起,否则就任意消耗更多的时间,过多的去进行机械运动
逻辑结构
任意一个扇区都有下标,通过对下标除+取模得到扇区在哪一面,在哪一块,在逻辑结构中,扇区可以拉长,看成数组
比如说编号500的扇区,对应的磁盘,每一个盘面有200个扇区,每个盘面有20个磁道,每个磁道有10个扇区,所以
500/200=2 500%200=100
所以在2号盘片上
100/10=10 100%10=0
所以在10号磁道上,第0个扇区
所以这就是操作系统的CHS地址,在Linux中称为LBA(逻辑块)地址
设备的独立性
inode
磁盘很大,所以利用分治的思想管理,操作系统会把大磁盘划分成小分区,再在这个区域再划分分block group n,inode保存的是单个文件所有的属性,但不包含文件的名称,一般为128字节
struct inode//一个inode 128B,一个扇区512B,所以应该扇区可以存储4个inode
{inode number;文件类型权限引用计数拥有者所属组时间block[15]//前12存的是直接索引,12,13存储两级索引,14存储三级索引//这只是举例,这样可以扩大单个文件的存储空间
}
data block用于存数据,以物理块的形式呈现,一般是4KB
inode table保存的是很多inode,并且给所有inode编号
block bitmap表示块被使用的情况,用比特位表示
inode bitmap用比特位表示这个inode有没有效
super block表示的是文件系统的基本信息,被称为超级块,在一个分区里会会存在很多个,可以避免一些错误(关于判断一个super block是否有错,可以用"")但不会每一个组都有,因为还要考虑到存储空间大小,super block被用来描述整个分区里的存储空间情况,这个块存储的是规则,用于表示一个分区中哪一个部分用来存储block bitmap,哪一个地方用来存储数据块,其实就是用来描述这个分区里面空间基本使用情况,哪一个属于第一组,哪一个属于第二组,每一组里的信息分布等等,都需要超级块来描述
group descriptor table(GDT)用于记录这个组里所有的属性,就像这个组里已经被使用了多少内存,用了多少个inode,还有多少个inode,还有多少空间等等属性
格式化:所以每一个分区在使用之前必须先将部分文件系统属性信息提前设置进对应的分区中,方便我们后续的分区或者分组,组内前面四块是用来管理后面两块的
查看
stat 文件名
使用
当创建一个文件的时候,操作系统会先去查看GDT查看还有多少inode,然后生成一个inode编号,申请出一个inode,把文件属性写进去,如果我们创建文件的时候往里面写了数据,那我们需要去查看block bitmap里找到空闲数据块,申请数据块后再把数据写进去
当我们删除一个文件的时候,其实就是对inode bitmap和block bitmap做修改,并没有修改数据块内容,当恢复的时候,把inode bitmap和block bitmap里的位图置1,所以删除其实是允许被覆盖
查找一个文件就是拿着文件的inode去找文件,然后找到文件的数据块
修改一个文件就是先查找这一个文件,然后找到文件对应的数据块进行修改,如果数据块不够就去查block bitmap申请空闲数据块,再重新修改文件
目录
目录也是文件,有自己独立的inode,也有属性,目录=属性+内容,目录的数据块里存放着文件名和对应inode的映射关系
1.所以同一个目录下不能有同名的文件,因为文件名是作为key值去找到inode
2.目录如果没有w权限,就无法创建文件,也无法删除文件
3.如果目录没有r权限,我们就无法查看目录中的文件
4.如果目录没有x权限,我们就无法进入这个目录
5.因为每一个目录都有上一级目录,查找目录的inode的时候,会溯源到根目录,用递归的方式返回,最终就找到对应的inode
软硬链接
引入
ln -s 文件 链接名#建立软连接
软连接的inode和原文件的inode是不一样的,所以软链接是一个独立的文件
ln 文件名 链接名
unlink 链接名 #删除链接
权限名后面的那个数字叫做硬链接数,当硬链接数等于1的时候就相当于当前文件没有其他别名了,硬链接的inode和原文件的inode是一样,所以硬链接不是独立的文件
硬链接
本质上就是在特定的目录块中新增文件名和指向文件的inode编号的映射关系,相当于对原文件取别名,实际上操作的是同一个文件,所以inode里面必须还要有一个引用计数的计数器
应用场景
目录的硬链接数默认是2,因为在目录里还存在当前目录和上级目录
以此类推,如果深一层,那么硬链接数就会变成3,因为当前目录的下一级目录的…目录指向当前目录
Linux里用户不能给目录建立硬链接,但可以给目录建立软链接,原因就是在查找的时候可能形成环路问题
软链接
软链接可以看作一个快捷方式,指向对应的文件,文件是本体,软链接只是一个类似于指针的东西,软链接保存的是文件名
如果我们删除掉软链接指向的文件,那软链接就会出错
应用场景
当我们某一个文件在很深的路径底下的时候,我们可以拿一个软链接指向当前这个文件,这样不需要找到路径地下也可以执行这个文件
补充
当我们在进行内存和磁盘进行交互的时候,是以4KB为基本单位的,内存的一个4KB是一个页,这个4KB是用一个struct page管理起来的,整个内存其实就是一个page数组,如果一个页越小,这个数组就会越大,所以内存的管理就转化为对一个page的管理,磁盘的一个页是一个页帧,所以Linux里存在局部性原理的预加载机制,可以提高效率,等我们需要在操作系统里面访问对应的磁盘的时候,可以找到这个页帧对应的内存中的页框,从而找到对应的数据
操作系统底层的进程有PCB,mm_struct等结构,在使用完后操作系统并不会回收这个数据结构,因为操作系统觉得用户可能下一次还会用,所以操作系统会把这些数据结构用链表存储起来,分门别类的管理起来,等下一次需要申请的时候就不需要再重新申请内存,操作系统里面分配内存可能涉及到伙伴系统和slab分派器等等
例如操作系统一个分区里的super block其实是在内存里一个链表,存在super block的组的super block位置指向这个链表的相应位置,操作系统里面很多都是像这样预加载的
所以,Linux中,每一个进程打开的每一个文件,都要有文件页缓冲区和自己的inode属性
等把物理内存的页写满了后,操作系统就要把内存页的数据刷新到磁盘当中,那么操作系统里面就会有很多I/O请求,我们就可以用struct request来管理这些请求,比如说访问的哪一个逻辑块,哪一个扇区等等,然后把这个数据结构链接成队列,一个一个处理
动静态库
静态库的后缀是.a,动态库的后缀是.so
创造静态库
如果我们不想要我们的源代码给别人看到,可以把我们的源代码打包成库,再提供头文件,也就是说明书,静态库其实就是提供源文件编译完后的所有.o文件,因为我们在编译多个文件的时候会把最后一个.o文件链接,这样就可以形成一个exe文件
静态库其实是直接加载到程序里面的,所以不需要-fPIC,因为不需要动态移动自己的地址
这样我们就有了自己的静态库,如果我们想发布的话,可以按如下操作
但如果我们直接这样编译的话其实是会报错的,但是main.o是有存在的,所以是链接错误,因为我们找不到相应的方法实现
即使是指明了方法实现的路径,但依旧存在链接错误,因为当前目录下可能不止有一个库,所以我们必须指明我们要链接哪个库
以上的-I 和-L其实可以把路径拷贝到系统的/lib/include/下和/lib64/下,这样我们就不需要在编译的时候还要自动指明头文件和静态库的路径,但这样会污染系统的库,所以我们也可以在库里建立软链接
使用静态库
当我们把库打包好后,就可以直接把lib给用户
gcc -o main main.c -I ./lib/include/ -L ./lib/testlib/ -ltest1
#虽然我们的静态库名字叫做libtest1.a,但实际上我们使用的是test1这一个部分,而且-l后面最好紧接静态库的名字,也就是-ltest1
如果我们直接按照注释的哪一行代码写的时候,error永远是0,因为实例化是从右往左实例化的,所以error先实例化,然后div(10,0)再实例化,
动态库
gcc默认形成动态库,动态库会像可执行权限一样加载到内存里,所以默认会带x可执行权限,但不能单独执行,因为没有main函数,这只是一个工具包
gcc -fPIC -c log.c#-fPIC表示与地址无关码
gcc -fPIC -c test.c
gcc -shared -o libmethod.so *.o#形成动态库
如果要用gcc编译的话可以按下面步骤,其实和动态库是一模一样的
我们就形成了一个可执行文件,但我们在执行main的时候动态库报错了
解决找不到动态库的方法
1.可以直接把动态库拷贝到lib64目录下
2.建立软链接
3.创建环境变量LD_LIBRARY_PATH,但这个变量并不是长期保存的,当我们重启xshell的时候这个环境变量就会消失,除非我们在系统中配置
4.在/etc/ld.so.conf.d/路径下创建一个文件,ldconfig是用来重新加载conf文件的
区别
动态库和静态库的区别:在我们编译成功后,即使删除了静态库,程序依然可以运行,但动态库编译的程序,关联的动态库必须能找的到的,即使是程序早就编译好了,我们删除对应路径下的动态库,这个程序也无法执行,所以动态库在程序运行的时候,是要被加载的,常见的动态库是被所有的可执行程序使用的,动态库在系统中加载后,会被所有进程共享,所以动态库也叫共享库,放在代码段的共享区里,这样,我们执行的所有代码都是在进程地址空间中进行的,我们执行的代码段会在进程地址空间跳来跳去
在多进程之间,如果其中一个进程中,对于共享库的一些标记,比如说我们之前在写div函数发生除0错误的时候,会把error进行修改,但这并不会影响其他进程种的error,而是发生写时拷贝
-fPIC
-fPIC表示与地址无关
程序编译好之后,还没有加载到内存中的时候:
程序中是有地址的概念的,每一条代码都有地址,在载入之前编译器就会把代码分为代码段等等,为载入内存做准备
其实在这个阶段的地址已经是逻辑地址(磁盘)了
//入口地址
//.code段
0x1 main
0x2 func()
0x3 int a
0x4 int b
....
//.rdonly区
0x44 ...
//.data区
0x92 ...
//.bss
....
如果我们想查看这个过程,可以使用下方的指令,cpu内置了指令集,jump,mov等这些其实是助记符,是映射到二进制的指令的
objdump -S 可执行程序
程序载入内存的时候(进程):
程序执行的时候在cpu的pc上从上往下一个个执行,这样一步步读到的全部都是虚拟地址
如果遇到磁盘程序没有加载到内存里,即缺页中断的时候
但这里其实有一个问题,就是磁盘里的可执行程序里printf链接的动态库地址是0x44,但我们如果把这个可执行程序加载到内存里的时候,这个代码是不能改的,所以内存里动态库也必须放在0x44这个位置,我们不可能做到这一点,所以必须让库在共享区的任意位置加载,这样,我们就不能采用绝对编址,所以我们表示每个函数在库中的偏移量即可,就是说,我们知道库的起始位置,加上偏移量就是这个函数的地址,所以-fPIC就是产生这种偏移量对库中函数进行编址,在编译的时候-fPIC对我们自己写的库进行编址,才能正常加载到内存里