文章目录
一、Linux文件系统
1.存储设备文件系统
提到文件系统时,我们首先想到的通常是Windows下的FAT32、NTFS、exFAT以及Linux下常用的ext2、ext3和ext4的类型格式。这些文件系统都是为了解决如何高效管理存储器空间的问题而诞生的。
从EEPROM、Nor FLASH、NAND FLASH、eMMC到机械硬盘,各种各样的存储器本质就是具有多个能够存储0和1数据单元的设备,存储内容时,程序需要直接访问这些存储单元的物理地址来保存内容。这样直接存储数据会带来极大的不便,如难以记录有效数据的位置,难以确定存储介质的剩余空间,以及应以何种格式来解读数据。就如同一个巨大的图书馆无人管理,杂乱无章地堆放着各种书籍,难以查找。
为了高效地存储和管理数据,文件系统在存储介质上建立了一种组织结构,这些结构包括操作系统引导区、目录和文件,就如同图书馆给不同类的书籍进行分类、编号,放在不同的书架上。不同的管理 理念引出了不同的文件系统标准,上述的 FAT32、 NTFS、 exFAT、ext2/3/4就是指不同类型的标准,除此之外,还有专门针对NAND类型设备 的文件系统jffs2、yaffs2等等。
下面简单介绍一下各种不同标准文件系统的特性:
- FAT32格式:兼容性好, STM32等MCU也可以通过Fatfs支持FAT32文件系统,大部分SD卡或U盘出厂 默认使用的就是FAT32文件系统。它的主要缺点是技术老旧,单个文件不能超过4GB,非日志型文件系统。
- NTFS格式:单个文件最大支持256TB、支持长文件名、服务器文件管理权限等,而且NTFS是日志型 文件系统。但由于是日志型文件系统,会记录详细的读写操作,相对来说会加快FLASH存储器的损 耗。文件系统的日志功能是指,它会把文件系统的操作记录在磁盘的某个分区,当系统发生故障时,能够 尽最大的努力保证数据的完整性。
- exFAT格式:基于FAT32改进而来,专为FLASH介质的存储器 设计(如SD卡、U盘),空间浪费少。单个文件最大支持16EB,非日志文件系统。
- ext2格式:简单,文件少时性能较好,单个文件不能超过2TB。非日志文件系统。
- ext3格式:相对于ext2主要增加了支持日志功能。
- ext4格式:从ext3改进而来,ext3实际是ext4的子集。它支持1EB的分区,单个文件最大支持6TB,支持无限的子目录数量,使用延迟分配策略优化了文件的数据块分配,允许自主控制是否使用日志的功能。
- jffs2和yaffs2格式: jffs2和yaffs2是专为FLASH类型存储器设计的文件 系统,它们针对FLASH存储器的特性加入了擦写平衡和掉电保护等特性。由于Nor、NAND FLASH类 型存储器的存储块的擦写次数是有限的(通常为10万次),使用这些类型的文件系统可以减少对存储器的损耗。
Linux内核本身也支持FAT32文件系统,而使用NTFS格式则需要安装额外的工具如ntfs-3g。所以使用开发板出厂的默认Linux系统时,把FAT32格式的U盘直接插入到开发板是可以自动挂载的,而NTFS格式的则不支持。主机上 的Ubuntu对于NTFS或FAT32的U盘都能自动识别并挂载,因为U buntu发行版安装了相应的支持。目前微软已公开exFAT文件系统的标准,且已把它开源至Linux,未来Linux可能 也默认支持exFAT。
对于非常在意FLASH存储器损耗的场合,则可以考虑使用jffs2或yaffs2等文件系统。
在Linux下,可以通过如下命令查看系统当前存储设备使用的文件系统:df -T
上图可以看出,主机的硬盘设备为“/dev/vda1”,它使用的文件系统类型为ext4,挂载点是根目录“/”。
2.伪文件系统
除了前面介绍的专门用于存储设备记录文件的文件系统外,Linux内核还提供了procfs、sysfs和devfs等伪文件系统。
伪文件系统存在于内存中,通常不占用硬盘空间,它以文件的形式,向用户提供了访问系统内核数据的接口。用户和应用程序可以通过访问这些数据接口,得到系统的信息,而且内核允许用户修改内核的某些参数。
a).procfs文件系统
procfs是“process filesystem”的缩写,它也被称为进程文件系统,procfs通常会自动挂载在根目录下的/proc文件夹。procfs为用户提供内核状态和进程信息的接口,功能相当于Windows的任务管理器。
在主机或开发板上执行如下命令:
#查看CPU信息
cat /proc/cpuinfo
#查看proc目录
ls /proc
如下图:
刚才我们查看了CPU的信息,而上图表示/proc包含了非常多以数字命名的目录,这些数字就是进程的PID号,其它文件或目录的一些说明见下表。
文件名 | 作用 |
---|---|
pid* | *表示的是进程的 PID 号,系统中当前运行的每一个进程都有对应的一个目录,用于记录进程所有相关信息。对于操作系统来说,一个应用程序就是一个进程 |
self | 该文件是一个软链接,指向了当前进程的目录,通过访问/proc/self/目录来获取当前进程的信息,就不用每次都获取pid |
thread-self | 该文件也是一个软链接,指向了当前线程,访问该文件,等价于访问“当前进程pid/task/当前线程tid”的内容。一个进程,可以包含多个线程,但至少需要一个进程,这些线程共同支撑进程的运行。 |
version | 记录了当前运行的内核版本,通常可以使用命令“uname –r” |
cpuinfo | 记录系统中CPU的提供商和相关配置信息 |
modules | 记录了目前系统加载的模块信息 |
meminfo | 记录系统中内存的使用情况,free命令会访问该文件,来获取系统内存的空闲和已使用的数量 |
filesystems | 记录内核支持的文件系统类型,通常mount一个设备时,如果没有指定文件系统并且它无法确定文件系统类型时,mount会尝试包含在该文件中的文件系统,除了那些标有“nodev”的文件系统。 |
我们可以用ps
指令来查看当前bash的进程号:
根据这个pid号,查看proc/3042目录的内容,它记录了进程运行过程的相关信息。
文件名 | 文件内容 |
---|---|
cmdline | 只读文件,记录了该进程的命令行信息,如命令以及命令参数 |
comm | 记录了进程的名字 |
environ | 进程使用的环境变量 |
exe | 软连接文件,记录命令存放的绝对路径 |
fd | 记录进程打开文件的情况,以文件描述符作为目录名 |
fdinfo | 记录进程打开文件的相关信息,包含访问权限以及挂载点,由其文件描述符命名。 |
stack | 记录当前进程的内核调用栈信息 |
status | 记录进程的状态信息 |
syscall | 显示当前进程正在执行的系统调用。第一列记录了系统调用号 |
task | 记录了该进程的线程信息 |
b).sysfs文件系统
Linux内核在2.6版本中引入了sysfs文件系统,sysfs通常会自动挂载在根目录下的sys文件夹。sys目录下的文 件/文件夹向用户提供了一些关于设备、内核模块、文件系统以及其他内核组件的信息,如子目录block中存放了所有的块设备,而bus中存放了系统中所有的总线类型,有i2c,usb,sdi o,pci等。下图中的虚线表示软连接,可以看到所有跟设备有关的文件或文件夹都链接到了device目录下,类似于将一个大类,根据某个特征分为了无数个种类,这样使得/sys文件夹的结构层次清晰明了。
表 /sys各个文件的作用
文件名 | 作用 |
---|---|
block | 记录所有在系统中注册的块设备,这些文件都是符号链接,都指向了/sys/devices目录。 |
bus | 该目录包含了系统中所有的总线类型,每个文件夹都是以每个总线的类型来进行命名。 |
class | 包含了所有在系统中注册的设备类型,如块设备,声卡,网卡等。文件夹下的文件同样也是一些链接文件,指向了/sys/devices目录。 |
devices | 包含了系统中所有的设备,到跟设备有关的文件/文件夹,最终都会指向该文件夹。 |
module | 该目录记录了系统加载的所有内核模块,每个文件夹名以模块命名。 |
fs | 包含了系统中注册文件系统 |
概括来说,sysfs文件系统是内核加载驱动时,根据系统上的设备和总线构成导出的分级目录,它是系统上设备的直观反应,每个设备在sysfs下都有唯一的对应目录,用户可以通过具体设备目录下的文件访问设备。
3.虚拟文件系统
除了前面提到的存储器文件系统FAT32、ext4,伪文件系统/proc、/sys、/dev外,还有内存文件系统ramfs,网络文件系统nfs等等,不同的文件系统标准,需要使用不同的程序逻辑实现访问,对外提供的访问接口可能也稍有差异。但是我们在编写应用程序时,大都可以通过类似fopen、fread、fwrite等C标准库函数访问文件,这都是虚拟文件系统的功劳。
Linux内核包含了文件管理子系统组件,它主要实现了虚拟文件系统(Virtual File System,VFS),虚拟文件系统屏蔽了各种硬件上的差异以及具体实现的细节,为所有的硬件设备提供统一的接口,从而达到设备无关性的目的,同时文件管理系统还为应用层提供统一的API接口。
在Linux下,一个与文件操作相关的应用程序结构如下图所示:
上图解析如下:
- 应用层指用户编写的程序,如我们的hello.c。
- GNU C库(glibc)即C语言标准库,例如在编译器章节介绍的libc.so.6文件,它包含了printf、malloc,以及本章使用的fopen、fread、fwrite等文件操作函数。
- 用户程序和glibc库都是属于用户空间的,本质都是用户程序。
- 应用层的程序和glibc可能会调用到“系统调用层(SCI)”的函数,这些函数是Linux内核对外提供的函数接口,用户通过这些函数向系统申请操作。例如,C库的printf函数使用了系统的vsprintf和write函数,C库的fopen、fread、fwrite分别调用了系统的open、read、write函数,具体可以阅读glibc的源码了解。
- 由于文件系统种类非常多,跟文件操作相关的open、read、write等函数经过虚拟文件系统层,再访问具体的文件系统。
如果我们要打开一个文件,流程如下:
总的来说,为了使不同的文件系统共存, Linux内核在用户层与具体文件系统之前增加了虚拟文件系统中间层,它对复杂的系统进行抽象化,对用户提供了统 一的文件操作接口。无论是ext2/3/4、FAT32、NTFS存储的文件,还是/proc、/sys提供 的信息还是硬件设备,无论内容是在本地还是网络上,都使用 一样的open、read、write来访问,使得“一切皆文件”的理念被实现,这也正是软件中间层的魅力。
4.文件系统的结构
Linux 是用位图的方式管理空闲空间,用户在创建一个新文件时,Linux 内核会通过 inode 的位图找到空闲可用的 inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块,并分配。
下图给出了 Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布:
最前面的第一个块是引导块,在系统启动时用于启用引导,接着后面就是一个一个连续的块组了,块组的内容如下:
- 超级块,包含的是文件系统的重要信息,比如 inode 总个数、块总个数、每个块组的 inode 个数、每个块组的块个数等等。
- 块组描述符,包含文件系统中各个块组的状态,比如块组中空闲块和 inode 的数目等,每个块组都包含了文件系统中「所有块组的组描述符信息」。
- 数据位图和 inode 位图, 用于表示对应的数据块或 inode 是空闲的,还是被使用中。
- inode 列表,包含了块组中所有的 inode,inode 用于保存文件系统中与各个文件和目录相关的所有元数据。如文件大小,所有者,最近修改时间等。
- 数据块,包含文件的有用数据。
data block - 数据块
data block 是用来存放文件内容的地方,EXT2 中所支持的 block 大小有 1k、2k 及 3k 三种。在格式化时 block 的大小就固定了,并都有编号,方便 inode 的记录。
由于 block 大小的差异,会导致该文件系统能够支持的最大磁盘容量与最大单一文件容量并不相同。限制如下:
block 的基本限制如下:
- 原则上,block 的大小与数理在格式化完成就不能够再改变了(除非重新格式化)
- 每个 block 内最多只能放置一个文件的数据
- 如果文件大于 block 的大小,则一个文件会占用多个 block 数量
- 若文件小于 block ,则该 block 的剩余容量就不能够再被使用(磁盘空间会浪费)
原理如上,那么假设你的 ext2 文件系统使用 4k block,有 10000 个小文件(均为 50 bytes),此时硬盘浪费多少容量?
一个 block 只能存储一个文件,每个 block 会浪费:4096 - 50 = 4046 byte
所有文件总量为:50 bytes * 10000 = 488.3 kbytes
此时浪费容量为:4046 bytes * 10000 = 38.6 MBytes
总共不到 1 MB 的总文件容量却浪费近 40 MB 的容量
在什么场景下回出现以上所说的问题?比如在 BBS 网站中的数据,使用纯文本记录每篇留言, 当留言内容都都很少时,就会产生很多的小文件(留言越多产生小文件越多)。
那么将 block 设置为 1k ,可能也不妥当,因为大型文件会占用数量更多的 block, 而 inode 也需要记录更多的 block 号码,此时将可能导致文件系统不良的读写效能。
所以在进行文件系统的格式化时,需要按你的使用场景来预计使用情况,如基本上都是几百兆的文件,那么就选择 4k 的(目前硬盘容量都很大了,所以一般都会选择 4k,而不管场景了)。
inode table - inode列表
inode 的数量与大小在格式化时以及固定,还有以下特点:
- 每个 inode 大小均固定为 128 bytes(新的 ext4 与 xfs 可设定到 256 bytes
- 每个文件仅会占用一个 inode
- 因此文件系统能建立的文件数量与 inode 的数量有关
- 系统读取文件时需要先找到 inode,并分析 inode 所记录的权限与用户是否符合,符合才会读取 block 的内容
下面简略分析 ext2 的 inode、block 与文件大小的关系。
inode 记录的数据非常多,但是仅 128 bytes,记录一个 block 号码花掉 4 byte; 假设有一个文件有 400 MB 且每个 block 为 4k 时,至少需要 10 万笔 block 号码要记录,但是 inode 的 128 byte 怎么能够记录下这么多的号码?
系统将 inode 记录 block 号码的区域定义为 12 个直接、一个间接、一个双间接、一个三间接记录区,如下图所示:
- 直接:该区域内直接存取 block 号码
- 间接:该区域内记录了一个 block 号码,该 block 才是记录文件内容的 block 号码
- 双间接:当文件太大时,在第二层中来记录 block 号码
- 三间接:当文件更大时使用,在第三层中记录数据内容的 block 号码
这样子的 inode 能够指定多少个 block 呢?以 1k block 来说明:
12 个直接指向:12 * 1k = 12k
总共可以记录 12 笔记录,总额为 12k
间接:256 * 1k = 256k
每个 block 号码需要 4 byte 来记录,因此 1k 的大小能够记录 256 个。
双间接:256 * 256 * 1k = 256 的 2 次方
第一层 block 会指定 256 个第二层,每个第二层可以指定 256 个号码
三间接:256 * 256 * 256 * 1k = 256 的 3 次方
总额:直接 + 间接 + 双间接 + 三间接
12 + 256 + 256 * 256 + 256 * 256 * 256 = 16 GB
在 ext2 中,当 block 格式化为 1k 大小时,能够容量的最大单文件为 16 GB, 在前面的文件系统限制表总的说明大小一致!
超级块和块组描述符表
你可以会发现每个块组里有很多重复的信息,比如超级块和块组描述符表,这两个都是全局信息,而且非常的重要,这么做是有两个原因:
- 如果系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。如果有冗余的副本,该信息是可能恢复的。
- 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,这可以提高文件系统的性能。
- 不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组 0、块组 1 和其他 ID 可以表示为 3、 5、7 的幂的块组中。
5.软硬链接
有时候我们希望给某个文件取个别名,那么在 Linux 中可以通过硬链接(Hard Link) 和软链接(Symbolic Link) 的方式来实现,它们都是比较特殊的文件,但是实现方式也是不相同的。
硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。
软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。
我们可以用ln
指令来建立软硬链接:
ln [选项] 源文件 目标文件
选项:
-s:建立软链接文件。如果不加“-s”选项,则建立硬链接文件
-f:强制。如果目标文件已经存在,则删除目标文件后再建立链接文件
我们可以验证上面所说:建立硬链接时,链接数由1变为了2,而建立软连接时,链接数不变。
二、文件I/O
1.I/O分类
a).缓冲与非缓冲 I/O
文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O:
- 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
- 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。
这里所说的「缓冲」特指标准库内部实现的缓冲。
比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来(暂存在库中函数的缓冲区中),这样做的目的是,减少系统调用的次数,因为系统调用是有 CPU 上下文切换的开销的。
b).直接与非直接 I/O
我们都知道磁盘 I/O 是非常慢的,所以 Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是「页缓存」,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求。
那么,根据是「否利用操作系统的缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O:
- 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
- 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。
如果你在使用文件操作类的系统调用函数时,指定了 O_DIRECT
标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O。
如果用了非直接 I/O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘?
以下几种场景会触发内核缓存的数据写入磁盘:
- 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
- 用户主动调用 sync,内核缓存会刷到磁盘上;
- 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
- 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;
c).阻塞与非阻塞 I/O VS 同步与异步 I/O
先来看看阻塞 I/O,当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。
阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。过程如下图:
知道了阻塞 I/O ,来看看非阻塞 I/O,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。过程如下图:
举个例子,访问管道或 socket 时,如果设置了 O_NONBLOCK
标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。
应用程序每次轮询内核的 I/O 是否准备好,感觉有点傻乎乎,因为轮询的过程中,应用程序啥也做不了,只是在循环。
为了解决这种傻乎乎轮询方式,于是 I/O 多路复用技术就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。
这个做法大大改善了应用进程对 CPU 的利用率,在没有被通知的情况下,应用进程可以使用 CPU 做其他的事情。
下图是使用 select I/O 多路复用过程。注意,read 获取数据的过程(数据从内核态拷贝到用户态的过程),也是一个同步的过程,需要等待:
实际上,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。
真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。
当我们发起 aio_read 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:
下面这张图,总结了以上几种 I/O 模型:
2.Linux中I/O系统调用函数
实际上,Linux提供的系统调用包含以下内容:
- 进程控制:如fork、clone、exit 、setpriority等创建、中止、设置进程优先级的操作。
- 文件系统控制:如open、read、write等对文件的打开、读取、写入操作。
- 系统控制:如reboot、stime、init_module等重启、调整系统时间、初始化模块的系统操作。
- 内存管理:如mlock、mremap等内存页上锁重、映射虚拟内存操作。
- 网络管理:如sethostname、gethostname设置或获取本主机名操作。
- socket控制:如socket、bind、send等进行TCP、UDP的网络通讯操作。
- 用户管理:如setuid、getuid等设置或获取用户ID的操作。
- 进程间通信:包含信号量、管道、共享内存等操作。
从逻辑上来说,系统调用可被看成是一个Linux内核与用户空间程序交互的中间人,它把用户进程的请求传达给内核,待内核把请求处理完毕后再将处理结果送回给用户空间。它的存在就是为了对用户空间与内核空间进行隔离,要求用户通过给定的方式访问系统资源,从而达到保护系统的目的。
a).open/write/read
open/write/read 的定义如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
pathname: 要打开或创建的目标文件
flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写
mode:文件的权限(读、写、执行)
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
返回值:On success, the number of bytes written is returned
On error, -1 is returned, and errno is set appropriately.
ssize_t read(int fd, void *buf, size_t count);
返回值:On success, the number of bytes read is returned
On error, -1 is returned, and errnois set appropriately
这些系统调用的使用实例如下:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#define FILENUM(number) "log.txt"#number
int main()
{
int fd0 = open(FILENUM(0),O_WRONLY | O_CREAT,0664);
int fd1 = open(FILENUM(1),O_WRONLY | O_CREAT,0664);
int fd2 = open(FILENUM(2),O_WRONLY | O_CREAT,0664);
int fd3 = open(FILENUM(3),O_WRONLY | O_CREAT,0664);
if(fd0==-1)
perror("open");
const char* msg = "my name is wml!\n";
int cnt = 5;
char buffer[128];
while(cnt)
{
sprintf(buffer,"%s:%d\n","hello",cnt--);
write(fd1,buffer,strlen(buffer));
}
printf("fd0:%d\n",fd0);
printf("fd1:%d\n",fd1);
printf("fd2:%d\n",fd2);
printf("fd3:%d\n",fd3);
int fd = open(FILENUM(0),O_RDONLY);
char buffer1[128];
ssize_t num = read(fd,buffer1,sizeof(buffer1)-1);
// printf("%d\n",num);
if(num>0) buffer1[num] = 0;
printf("%s",buffer1);
close(fd0);
close(fd1);
close(fd2);
close(fd3);
return 0;
}
运行得到的结果如下:
这里我们提到了文件描述符:
文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files,指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2。
文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
b).dup2
#include <unistd.h>
int dup2(int oldfd, int newfd);
其中对于函数的描述:
dup2函数成功返回时,newfd(dup2函数的第二个参数)将变成oldfd(dup2函数的第一个参数)的复制品,换句话说,两个文件描述符现在都指向同一个文件,并且是函数第一个参数指向的文件。
int oldfd,newfd;
oldfd = open("app_log", (O_RDWR | O_CREATE), 0644);
newfd=dup2( oldfd, 1);//因为目的是重定向标准输出,所以一般不用保存复制出的描述符。
close( oldfd );
本例中,我们打开了一个新文件,称为“app_log”,并收到一个文件描述符,该描述符叫做oldfd。我们调用dup2函数,参数为oldfd和1,这会导致用我们新打开的文件描述符替换掉由1代表的文件描述符(即stdout,因为标准输出文件的id为1)。任何写到stdout的东西,现在都将改为写入名为“app_log”的文件中。
dup2函数可以让用户自己指定文件描述符,它的作用也是复制文件描述符,将newfd描述符所对应的文件表改成oldfd所对应的文件表项。之后,newfd与oldfd指向同一个文件表,这样就将newfd重定向到oldfd。
c).FILE
- 因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,本质上,访问文件都是通过fd访问的。
- 所以C库当中的FILE结构体内部,必定封装了fd。
我们用下段代码来测试:
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg0="hello printf\n";
const char *msg1="hello fwrite\n";
const char *msg2="hello write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg0), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
return 0;
得到的结果如下:
我们发现 printf 和 fwrite (库函数)都输出了2次,而 write 只输出了一次(系统调用)。为什么呢?肯定和fork有关!
- 一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
- printf / fwrite 库函数会自带缓冲区,当发生重定向到普通文件时,缓冲方式由行缓冲变成了全缓冲。
- 而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后但是进程退出之后,会统一刷新,写入文件当中。但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。write 没有变化,说明没有所谓的缓冲。
printf / fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区,
都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。那这个缓冲区谁提供呢? printf / fwrite 是库函数, write 是系统调用,库函数在系统调用的“上层”, 是对系统调用的“封装”,但是 write 没有缓冲区,而 printf / fwrite 有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供。
我们可以来看看库中 FILE 的定义:
typedef struct _IO_FILE FILE; 在/usr/include/stdio.h
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符
};
三、动静态库
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
- 静态库(.a、.lib)
- 动态库(.so、.dll)。
所谓静态、动态是指链接。回顾一下,将一个程序编译成可执行程序的步骤:
1.静态库
定义:
之所以成为【静态库】,是因为在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。
Linux静态库命名规范:
必须是"lib[your_library_name].a":lib为前缀,中间是静态库名,扩展名为.a。
静态库的特点如下:
- 静态库对函数库的链接是放在编译时期完成的。
- 程序在运行时与函数库再无瓜葛,移植方便。
- 浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件。
接下来我们实现一个简单的进行算术运算的静态库,其中头文件中的函数定义如下:
我们用下面的指令来生成一个静态库:
ar是gnu归档工具,rc表示(replace and create), t:列出静态库中的文件, v:verbose 详细信息
然后我们链接这个库:-L 指定库路径 -l 指定库名
此时我们就得到了一个静态链接的可执行程序,即使删掉静态库,这个程序照样能运行。
2.动态库
为什么需要动态库,其实也是静态库的特点导致。
- 空间浪费是静态库的一个问题。
- 另一个问题是静态库对程序的更新、部署和发布页会带来麻烦。如果静态库lib.a更新了,所有使用它的应用程序都需要重新编译、发布给用户(对于玩家来说,可能是一个很小的改动,却导致整个程序重新下载,全量更新)。
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新。
linux动态库的命名规则
动态链接库的名字形式为 libxxx.so,前缀是lib,后缀名为“.so”。
动态库的特点:
- 动态库把对一些库函数的链接载入推迟到程序运行的时期。
- 可以实现进程之间的资源共享。(因此动态库也称为共享库)
- 将一些程序升级变得简单。
- 甚至可以真正做到链接载入完全由程序员在程序代码中控制(显示调用)。
Linux下gcc编译的执行文件默认是ELF格式,不需要初始化入口,不用函数做特别的声明,编写比较方便。与创建静态库不同的是,不需要打包工具(ar、lib.exe),直接使用编译器即可创建动态库。
- 首先,生成目标文件,此时要加编译器选项-fPIC
-fPIC 创建与地址无关的编译程序(position independent code),是为了能够在多个应用程序间共享。
- 然后,生成动态库,此时要加链接器选项-shared
-shared指定生成动态链接库。
在执行的时候是如何定位共享库文件的呢?
- 当系统加载可执行代码时候,能够知道其所依赖的库的名字,但是还需要知道绝对路径。此时就需要系统动态载入器(dynamic linker/loader)。
- 对于elf格式的可执行程序,是由ld-linux.so*来完成的,它先后搜索elf文件的 DT_RPATH段—环境变量LD_LIBRARY_PATH—/etc/ld.so.cache文件列表—/lib/,/usr/lib 目录找到库文件后将其载入内存。
如何让系统能够找到它?
- 如果安装在/lib或者/usr/lib下,那么ld默认能够找到,无需其他操作。
- 如果安装在其他目录,需要将其添加到/etc/ld.so.cache文件中,步骤如下:
1.编辑/etc/ld.so.conf文件,加入库文件所在目录的路径
2.运行ldconfig ,该命令会重建/etc/ld.so.cache文件
我们将动态库拷贝至/usr/lib
下,然后再链接就可以使用动态库了!
ldd命令可以查看一个可执行程序依赖的共享库,例如我们编写的四则运算动态库依赖下面这些库: