IO与文件系统
IO与文件系统
chengzi回顾c语言的文件操作
相关接口
简单的回顾下在c语言中,我们是如何使用相关的文件接口的。
C语言提供了一套强大而灵活的文件接口,使得程序能够在磁盘上读取和写入数据。这个文件接口是操作系统提供的API(应用程序编程接口)的一部分,这里则是简单介绍一下C语言中常用的文件接口,包括文件的打开、读取、写入、关闭等操作。
打开文件
在C语言中,要对一个文件进行操作,首先需要将其打开
。这可以通过使用fopen函数来实现。
1 | FILE *fptr; // 声明一个文件指针 |
声明了一个文件指针fptr,使用fopen函数将名为example.txt的文件以只读
模式打开,函数返回一个指向该文件的指针。
读取文件
一旦文件打开成功,我们可以使用fread函数来读取文件内容。
1 | char buffer[100]; // 声明一个用于存储数据的缓冲区 |
使用fread函数从打开的文件中读取了100个字符,然后将其存储到名为buffer的字符数组中。
写入文件
如果需要将数据写入文件,可以使用fwrite函数。
1 | char data[] = "Hello, World!"; |
将字符串”Hello, World!”写入到已经打开的文件中。
关闭文件
在完成文件操作后,应该及时关闭
文件,以释放资源。
1 | fclose(fptr); // 关闭文件 |
错误处理
可以通过检查函数的返回值
来判断文件是否成功打开或操作是否成功。
1 | FILE *fptr; |
c语言的输入输出流
1 | int main() |
fwrite函数将字符串 “hello world\n” 写入标准输出
。fwrite 函数的参数依次为字符串的指针(str),字符串的长度(strlen(str)),写入次数(1),和目标输出(stdout)。
printf 函数来打印 “hello linux”。printf 函数将格式化字符串写入标准输出。
fprintf 函数来打印 “hello world”。fprintf 函数的参数依次为目标输出(stdout)和格式化字符串。
上面三个函数都提到了stdout
,那么stdout到底是什么?
在C语言中,stdout是
指向标准输出的文件指针
。标准输出是一个特殊的文件流
,通常用于将程序的输出打印
到屏幕上。
在这个函数中,使用了不同的输出函数来将字符串打印到标准输出上。fwrite、printf和fprintf都可以接受文件指针作为参数
,用于指定输出的目标。
使用stdout可以方便地将输出打印到屏幕上,而不需要指定具体的文件或设备。这样,程序的输出就可以在控制台上可见,并且可以通过重定向等方式将输出保存到文件中。
C语言默认会打开三个
输入输出流,分别是stdin, stdout, stderr
。
stdout已经在上文解释过,当涉及到输入和错误输出
时,C语言提供了两个额外的标准流:stdin和stderr。
stdin是指向标准输入
的文件指针。标准输入用于接收来自用户的输入
,通常是通过键盘
输入。例如,使用scanf函数可以从标准输入中读取用户的输入。
stderr是指向标准错误
的文件指针。标准错误用于输出程序的错误消息或其他诊断信息
。与标准输出不同,标准错误的输出通常被发送到屏幕上的错误流中,而不会被重定向到文件。例如,使用fprintf(stderr, …)函数可以将错误消息输出到标准错误流。
linux下的文件操作
系统文件io
除了上面提到的C语言来进行文件操作,也可以通过系统调用
来对文件进行读或者写等等操作。
当使用系统调用进行文件I/O时,有几个常用的调用:
- open():用于打开文件。它接受文件路径和一些标志作为参数,并返回一个文件描述符(file descriptor),表示打开的文件。
- read():用于从文件中读取数据。它接受文件描述符、数据缓冲区和读取字节数作为参数,并返回实际读取的字节数。
- write():用于向文件中写入数据。它接受文件描述符、数据缓冲区和写入字节数作为参数,并返回实际写入的字节数。
- close():用于关闭文件。它接受文件描述符作为参数,并在操作完成后关闭文件。
打开一个名为 “example.txt” 的文件(如果不存在则创建),然后写入字符串 “Hello, world!”。最后关闭文件。
1 |
|
四个常用接口介绍
open
1 |
|
pathname: 要打开或创建的目标文件
flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
参数:
O_RDONLY: 只读打开。
O_WRONLY: 只写打开。
O_RDWR : 读,写打开。
这三个常量,必须指定一个且只能指定一个。
O_CREAT : 若文件不存在,则创建它。
O_APPEND: 追加写。
返回值:
成功:新打开的文件描述符(什么是文件描述符会在下文讲)。
失败:-1
mode参数用于指定新创建文件的权限模式。
close
1 | int close(int fd); |
这个函数用于关闭文件。它接受文件描述符(fd)作为参数,在操作完成后关闭文件。
参数:
fd:要关闭的文件描述符。
返回值:
成功:0。
失败:-1。
write
1 | ssize_t write(int fd, const void *buf, size_t count); |
这个函数用于向文件中写入数据。它接受文件描述符(fd),数据缓冲区指针(buf)和要写入的字节数(count)作为参数。
参数:
fd:要写入的文件描述符。
buf:指向要写入的数据的缓冲区的指针。
count:要写入的字节数。
返回值:
成功:实际写入的字节数。
失败:-1。
read
1 | ssize_t read(int fd, void *buf, size_t count); |
这个函数用于从文件中读取数据。它接受文件描述符(fd),数据缓冲区指针(buf)和要读取的最大字节数(count)作为参数。
参数:
fd:要读取的文件描述符。
buf:指向存储读取数据的缓冲区的指针。
count:要读取的最大字节数。
返回值:
成功:实际读取的字节数。
失败:-1。
接口演示
1 |
|
可以看到上文很多地方都出现了fd和文件描述符的概念(二者是一个东西),那么什么是文件描述符呢?
文件描述符
在文章开篇时,提到了c语言会打开三个输入输出流,那是如何得知这三个流被打开了呢?
文件描述符是一个非负整数
,用于唯一标识
一个打开的文件或I/O流。它是一个抽象的概念,可以是文件、管道、套接字等。
标准文件描述符
Linux系统通常会为每个进程自动分配三个标准文件描述符:
0(stdin):标准输入,通常用于从键盘或其他输入设备读取数据。
1(stdout):标准输出,通常用于向终端或其他输出设备输出数据。
2(stderr):标准错误,通常用于输出错误消息。
0,1,2对应的物理设备一般是:键盘,显示器,显示器
。
文件描述符主要用于进行文件和I/O操作,通常通过以下系统调用来操作文件描述符:
open():打开一个文件并返回一个文件描述符。
close():关闭一个文件描述符,释放相关资源。
read():从文件描述符中读取数据。
write():向文件描述符中写入数据。
lseek():移动文件描述符的读/写位置。
dup()和dup2():复制文件描述符,创建一个新的文件描述符与之关联。
也就是说,可以这样输出:
1 | int main() |
上面的代码实现了从标准输入读取数据,并将其写入标准输出和标准错误。
当打开一个文件时,操作系统在内存中要创建相应的数据结构来描述目标文件
(比如文件大小,在磁盘位置,打开计数等文件属性),也就是file结构体来表示并描述一个已经打开的文件对象。而如何管理一个个file结构体呢?Linux为其创建了文件描述符表(图中的File Struct),里面存储了一个指针数组,每个元素都指向一个已经打开的文件。
如图,当进程执行open系统调用打开文件时,当前进程结构中有一个指针*files
, 指向一张文件描述符表
files_struct(每个进程有自己的表),该表的指针数组中每个元素都是一个指向打开文件的指针。下标0指向默认的stdin文件,下标1指向stdout文件,下标2指向stderr文件。所以,文件描述符就是该数组的下标
。只要有文件描述符,就可以找到对应的文件。
1 | +-------------------+ +------------------------+ |
上图的File*列表仅存储打开的文件,也就是说,如果此时新建一个文件,那么分配给他的下标就会是3,如果在创建此文件前关闭了1,那么此文件的文件描述符就会被分配为1。这就是Linux系统下文件描述符基本的分配规则。
当关闭掉文件描述符1:
1 | int main() |
c语言printf函数底层封装的输出接口仍然是下标1,哪怕stdout被关闭,系统也只认识1,他要做的就是将需要打印的内容输出到1代表的这个文件。这也是linux环境下文件管理的一个显著特征,层层封装,再由系统统一调用,有点多态的意思在里面。
将原本该打印到屏幕的内容打印到file里,这就叫重定向。
文件描述符的重定向允许将一个文件描述符与另一个文件或设备相关联。例如,可以使用>将命令的输出重定向到文件,或使用<将文件内容作为输入。
1 | //输出重定向:将命令的标准输出保存到文件。 |
但在本质上,重定向更改的是文件描述符所指向的内容。如上文所画的图,当执行了close(1)操作,再执行新建file文件,那么此时文件描述符为1的坑位就指向了file而不是stdout。
可以这么说,重定向的魅力在于操作文件描述符
,将它们连接到不同的位置,从而改变了命令的输入和输出源
,使得命令行操作更加灵活多变。
举一个例子:
1 |
|
因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd
访问的。所以C库当中的FILE结构体内部,必定封装了fd
。
缓冲区
1 |
|
执行三个输出,将信息打印到屏幕上,再执行fork新建一个子进程。
如果将此文件重定向到一个普通文件中时:
1 | int main() |
输出会变成这样:
1 | write |
write仍然只执行一次,而printf和fwrite被执行了两次。
首先,需要明白一个概念:printf和fwrite库函数有自带的缓冲区,而write作为系统调用则没有缓冲区。
这些缓冲区都是用户级
缓冲区。
printf和fwrite都是库函数,这个缓冲区由C的标准库来提供。而write是系统调用,库函数则是在系统调用的“上层”, 是对系统调用的“封装”,所以write没有这个缓冲区也不足为奇。
一般C库函数写入文件时是全缓冲
(进程结束统一刷新)的,而写入显示器是行缓冲
(遇到\n刷新)。当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。那么缓冲区中的数据就不会被立即刷新,甚至fork之后也不会刷新。当进程退出
之后,就会被统一刷新,再写入文件当中。
父进程角度
在执行 fork 之前,代码中按照顺序先调用了 printf(“printf\n”);,此时 “printf\n” 字符串被放入了标准 I/O 缓冲区,尚未真正输出(因为缓冲区未满且没遇到强制刷新等情况)。
接着执行fwrite(p1, strlen(p1), 1, stdout);,数据也被放入 fwrite 对应的标准 I/O 缓冲区,同样没实际输出。
再执行 write(1, p2, strlen(p2));,由于write是系统调用,直接将 “write\n” 输出到了 output.txt 文件(因为标准输出已重定向到该文件),这就是为什么输出结果中第一个出现的是”write”。
当执行 fork 函数后,父进程继续往下执行,在父进程返回时(return 0; 之前),标准 I/O 缓冲区中的数据(之前 printf 和 fwrite 缓冲的数据)会被刷新输出到 output.txt 文件,所以会输出 “printf” 和 “fwrite”。
子进程角度
子进程复制了父进程的状态,包括标准 I/O 缓冲区的内容(是复制,不是共享,子进程有自己独立的缓冲区副本),当子进程继续执行到最后返回(return 0; 之前),同样会刷新自己的标准 I/O 缓冲区,把缓冲的 “printf” 和 “fwrite” 也输出到 output.txt 文件,这就导致了后面又出现一次 “printf” 和 “fwrite” 的输出结果。
linux如何管理文件
概述
在Linux系统中,文件系统以一种层次化的树状结构
组织和描述所有的文件和目录。以根目录
为起点,所有的文件和目录都从这里开始。
也许你听过一句话,在Linux系统中,一切皆为文件
。这包括了普通文件、目录、设备文件、链接等等,并且不同类型的文件具有不同的属性和用途。
Linux将硬件底层封装为文件
,并允许进程通过文件指针
来进行调用和访问。
在Linux中,硬件设备通常由设备文件来表示,这些设备文件位于/dev目录下。每个硬件设备都有一个相应的设备文件,例如硬盘设备可以表示为/dev/sda,串口可以表示为/dev/ttyS0等。
同时,每个进程都有一个文件描述符表,它是一个索引到文件的整数数组。文件描述符是进程用来访问文件的句柄。通常,标准输入、标准输出和标准错误分别对应文件描述符0、1和2。
进程可以通过系统调用来操作文件。例如,open系统调用用于打开一个文件,read和write用于读取和写入文件数据,close用于关闭文件。进程通过这些系统调用来请求操作文件或设备。
当进程打开一个文件时,操作系统维护一个文件指针(或文件偏移量),它指示文件中下一个读取或写入操作的位置。文件指针可以通过系统调用来移动,如lseek。
在Linux中,每个文件都有一个唯一的路径
,用于描述其在文件系统中的位置。路径可以是绝对路径(从根目录开始的完整路径)或相对路径(相对于当前工作目录的路径)。每个文件和目录都有权限设置,这确定了谁可以对其进行读、写和执行操作。此外,每个文件都有一个所有者和所属的用户组
。
Linux支持多种文件系统类型,包括ext4、XFS、btrfs等。每个文件系统类型都具有不同的特性和性能。
Linux系统通过系统调用
提供了一组API,用于管理文件和目录
。这些系统调用包括打开、读取、写入、关闭、创建和删除文件,以及更改文件属性等。
以下几点作为了解:
链接和挂载: Linux支持硬链接和符号链接,允许多个文件名引用同一个文件。此外,Linux还支持文件系统挂载,使不同的文件系统可以被组合到同一个目录结构中。
特殊文件: Linux系统还包括特殊文件,如设备文件(用于与硬件设备通信)、套接字文件(用于进程间通信)和管道文件(用于进程间数据传输)。
文件系统维护: Linux系统中有一系列工具用于文件系统维护,如fsck用于文件系统检查和修复,du用于查看磁盘使用情况,df用于查看磁盘空间等。
文件系统
linux的文件都存储在磁盘上,那么谈文件系统就不能跳过磁盘。
文件系统将文件数据分成小块(通常是扇区或块),这些块以逻辑方式组织在磁盘上,这样操作系统就可以进行有效地读取和写入。
当在Linux上进行创建、编辑或删除文件的操作时,实际上是在磁盘上进行操作。文件数据被写入到磁盘的数据块中,文件的元数据(如inode)被更新以反映文件的更改。
了解下磁盘的物理结构。
一块磁盘通常由多个盘面(Platters)组成,每个盘面分为多个磁道(Tracks),而每个磁道又分为多个扇区(Sectors)。
扇区是磁盘上最小的可寻址存储单元,通常为512字节或4KB。
操作系统和文件系统使用扇区来读取和写入数据。
磁头则是用于读写数据的磁盘表面上的读/写头。磁头的数量取决于磁盘驱动器的设计。
不知道大家小时候有没有用过播放英语听力的磁带,一圈圈的绕起来,和磁盘的结构很像。类比一下,磁盘也是如此,你可以想象磁盘上的磁道是一整条磁带
,而数据就按照特定的顺序排列在上面。可以这么讲,磁盘的逻辑结构是线性
的。而文件数据也线性地分布在上面。
文件系统是一种在磁盘上创建的组织数据的结构,这样使得数据存储、访问和管理更加方便。磁盘则是实际的存储媒介,而文件系统为数据在磁盘上的组织和操作提供了接口和规则。
以下是文件系统的组成图示以及每个组成部分的主要用途:
引导块(Boot Block)通常位于文件系统之前
,是文件系统的前导部分。引导块的主要目的是引导
操作系统,从而启动计算机。引导块与文件系统的关系在于它是文件系统之前的一部分,但不直接与文件系统的组件(如超级块、inode 表等)交互。引导块包含有关如何加载操作系统的信息,这个引导加载程序负责启动操作系统,并在需要时加载文件系统。在计算机开机时,会先去ROM中寻找自举装入程序,通过此程序再找到位于磁盘的引导块,将完整的自举程序
对入内存,完成初始化。
而Block Group则是文件系统根据分区的大小划分为数个Block Group。每个Block Group都有着相同的结构组成。可以联想到国家设立省市来实行分级管理。
Superblock: 超级块包含文件系统的元数据,如文件系统大小、块大小、inode 数量或者一些磁盘数据,如磁道数,扇区数等。它是文件系统的核心信息存储,用于初始化和维护文件系统。
Group Descriptor Table (GDT): 组描述符表包含了关于每个数据块组(data blocks)的信息,如块位图、inode 位图和inode 表的位置。
Block Bitmaps: 块位图跟踪哪些数据块已经被使用,哪些还没有被使用。
[图源王道操作系统强化讲义]
Inode Bitmaps: inode 位图用于跟踪哪些inode节点已被使用,哪些还没有被使用。原理图同Block Bitmaps。
Inode Table: inode 表存储文件和目录的元数据,如文件权限、拥有者、文件大小等。每个文件和目录在inode表中都有一个对应的inode条目
(这点很重要)。
拿inode1举例,inode1号节点存储着某个文件(暂称为目录文件A)的相关数据,如A的所有者是谁,什么时间创建,以及该文件占用几个块等。从图中可以得到信息,A共有两块,6号块和7号块。
Data Blocks: 数据块存储实际的文件内容和目录结构
。它包含文件的实际数据,以及目录中文件和子目录的引用。
[图源王道操作系统强化讲义]
还是拿A举例,A如果是一个目录文件,那么A中就保存了该目录下的所有文件以及其对应的inode号。
如果A是一个普通文件,那么直接存储数据即可。
综上,文件系统遵循着将文件数据和属性分开存放
的思想。数据通通存在data blocks中,而文件属性则单独放在inode表里。二者的联系则借助于GDT。
如何从文件系统角度新建,删除文件?
当需要新建文件时,首先会在Inode Bitmap中寻找一个未使用
的索引节点。接着在Inode Table中分配这个索引节点,记录文件的元数据信息,比如文件名、权限等。然后会在Block Bitmap中寻找一些未使用的数据块(block Bitmaps也会随着更新)。最后,将文件的数据写入这些数据块中,完成文件的创建。
当需要删除文件时,首先会释放文件占用的数据块,在Block Bitmap中标记这些数据块为未使用状态。
然后释放文件的索引节点,在Inode Bitmap中标记这个索引节点为未使用状态。最后,在Inode Table中删除文件的元数据信息,完成文件的删除。
打开文件
文件都在磁盘中进行保存,当要打开指定文件时,操作系统会将文件(包括其inode与需要的文件内容)统统调入内存中。如果学习过计算机组成原理相关课程,应该不会对结构体page感到陌生,page也就是常说的页
或者物理内存页,常取大小4KB。当文件被调入内存,操作系统会通过page结构体来对其进行描述和组织。一个文件常常会占用多个页(页之前可能不连续)。
1 | struct page { |
每一个struct page都构成内存数组(struct page mem_array[])的一部分!
物理内存从整体上看是一个连续的字节数组,为了方便管理,内核将物理内存划分为固定大小的页,每个页对应一个 struct page 结构体来进行描述和管理。从这个角度看,所有的 struct page 结构体所代表的页,就像是连续排列的元素,共同构成了一个对整个物理内存进行描述的数组。
就像普通数组可以通过索引快速定位到特定的元素一样,在内存管理中,内核可以通过一定的机制将物理页框编号(PFN)等信息转换为 struct page mem_array[] 的索引,从而快速定位到对应的 struct page 结构体。也就是说只要给定一个物理内存地址,内核可以通过计算得到该地址所在页对应的 struct page 在 mem_array 中的位置,更通俗的讲,访问内存就是在访问内存page数组
。
当应用程序发起对文件的访问请求时,操作系统借助文件系统来执行读取操作。文件系统首先查找文件的元数据,例如 inode,以此确定文件在磁盘上的物理位置。Linux 系统设有页缓存(Page Cache)机制,它会将文件系统中的数据缓存至内存。若所需文件数据已存在于页缓存中,则无需从磁盘重新读取;若不存在,则需从磁盘读取数据并缓存至内存。
物理内存与磁盘均以 4KB 作为基本存储单元来存储数据,出于效率考量,二者之间进行数据交换时亦以 4KB 为最小操作单位。例如,对于一台拥有 4GB 物理内存的计算机,以 4KB 为最小分片单位,可划分出约一百万个分片(可类比为具有一百万个下标的数组),这就是上文提到的内存页数组。操作系统通过特定的地址转换方法,能够实现虚拟地址与页下标的转换,进而精准定位到正确的物理地址空间以存放数据。这一过程遵循“先描述再组织”的设计理念,每个 4KB 的页均由 struct page 结构体进行描述,该结构体中存储着页的各类必要属性信息。
当一个进程尝试打开文件时,如前文所述,会在进程对应的文件描述符表中查找对应的指针。
1 | +-------------------+ +------------------------+ |
上文已经提到过,0,1,2 分别是这个指针数组的下标,从上往下依次指向 stdin,stdout,stderr 所代表的三个已打开的文件。更具体的说指向各自的struct file结构体。而file结构体里有指向inode结构体和address_space结构体的指针。
例如,当打开文件描述符fd为7的文件时,文件属性存储在 inode 结构体中,而文件内容则以特定形式封装于 address_space 结构体中。struct address_space 内部包含了一棵红黑树page_tree,用于组织和管理与该文件相关的所有页面缓存(也就是文件的页缓冲区)。通过这种树型结构,内核可以快速地查找、插入和删除页面缓存。当需要查找某个文件偏移量对应的页缓存时,可以通过红黑树快速定位到相应的 struct page 结构体。
软硬链接
当使用ls -l命令时,会看到类似下图的信息:
1 | -rw-r--r--. 1 root root 654 "11月 9 13:06" chengzi.c |
从左到右,分别是:模式,硬链接数,文件所有者,组,大小,最后修改时间。inode是文件系统中的数据结构,包含着文件的属性信息。
硬链接(Hard Links)指一个文件可以有多个文件名指向同一个inode
。inode作为文件系统中的数据结构,包含了文件的元数据(比如权限、所有者、文件大小等)以及指向实际数据块的指针。
所有硬链接文件都指向同一个inode,因此它们实际上共享相同的数据块。
删除任意一个硬链接并不影响其他硬链接,只有当所有链接都被删除时,inode的引用计数
才会变为零,文件系统才会释放相关的数据块。
但硬链接也有自己的缺点,硬链接不能跨越文件系统,只能在同一文件系统内创建。也不能对目录创建硬链接
。
不能对目录创建硬链接的主要原因是防止文件系统的树形结构出现循环
。
举个例子:你在目录a下查找文件b,但同时目录a中存在目录c,目录c是指向目录a的硬链接,这会导致什么后果?
1 | /home/user |
/home/user/b/directory_c 和 /home/user/a 实际上指向同一个目录,这就导致了在进行遍历查找时,可能会陷入循环的问题。
创建硬链接:
1 | ln source_file hard_link_name |
空
的文件夹引用计数是2
。但空的文件夹其实并不是空的, 其中含有两个隐藏的文件.和..。这两个文件分别指向了本目录和上层目录,都是硬链接。如果一个目录引用计数是17,那就代表这个目录下还有15个子目录。
与硬链接相对应的就是软链接。
软链接可以类比成windows系统下的快捷方式
。
软链接是一种特殊类型的文件(所以他有自己的inode),它创建一个指向另一个文件或目录的符号链接。软链接类似于快捷方式,它只包含目标文件的路径信息
,而不是实际的数据。如果源文件或目录被删除或移动,软链接将失效。
创建软链接:
1 | ln -s /path/to/source /path/to/symlink |