linux文件系统

inode 结构

了解文件操作命令例如rm、mv、cp的底层原理时,需要先了解 linux 中文件系统的基本原理。

在linux 系统中,磁盘通常被格式化为 ext3 或 ext4 格式,这两种文件系统对文件的存储和访问是通过一种被称为 inode 即 i 节点的机制来实现的。

除文件名和文件内容之外,一个文件还有哪些信息呢?其实这些额外的信息和文件的存储及读写方式有关。当我们读写文件时,通常是以流的形式,即认为文件的内容是连续的。但是在磁盘上,一个文件的内容通常是由多个固定大小的数据块即block构成的,并且这些数据块通常是不连续的。这时就需要一个额外的数据结构来保存各数据块的位置、数据块之间的顺序关系、文件大小、文件访问权限、文件的拥有者及修改时间等信息,即文件的元信息,而维护这些元信息的数据结构就被称为 i 结点。可以说,一个i 节点中包含了进程访问文件时所需要的所有信息。由于一个文件的数据块是不连续的,属于同一文件的数据块可能遍布整个磁盘,因而可以认为 i 节点中含有一个帮助定位文件数据块的 “目录结构”。

avatar

可见,i节点中主要有两大部分,一部分是i 节点号与文件名的对应表,另一部分就是i 节点对应文件的元信息,其中的指针指向了磁盘上的构成文件的多个数据块。在图中可以看出,每个i 节点号对应一个文件。

由于i节点存储了文件的元信息,因而 i节点本身也是要占用磁盘空间的。 i 节点的内容独立于文件内容,这里有必要区分“更改文件本身”的内容和更改“文件对应的i节点” 的内容。当对文件执行写入、编辑后保存等更改文件内容的操作时,我们更改的是文件内容本身。而更改i节点信息通常有如下情形:

更改文件内容后,导致文件元信息发生了变化,例如文件的大小、文件的访问时间等,这时文件对应i 节点的信息也会发生变化。
文件的拥有者、访问权限发生了变化,例如对文件执行了 chown、chgrp、chmod 等命令。
因而,inode 内容发生了变化,对应的文件内容不一定发生了变化。

在 linux 系统中用 i 节点号来标识一个i 节点。 i 节点号是一个整数,在单个磁盘分区中是唯一的。linux 中挂载多个磁盘分区时,不同的分区中可能有相同的 i 节点号,本文只考虑单一的磁盘分区,因而认为 i 节点号是唯一的。

硬链接

一般情况下,一个文件名对应一个i 节点。但是linux 提供了一种共享 i 节点的方法——硬链接。例如对 data.txt 创建一个硬链接,之后查看 data.txt 的 i 节点的信息:

avatar

可见,这时有两个文件名链接到同一个i 节点上,这里的i 节点的链接数是2。可以认为,硬链接文件和原文件就是同一个文件,只不过有两个名字,类似于 C++ 中的引用。当一个文件有多个链接时,删除其中一个文件名并不会删除文件本身,而只是减少文件的链接数。当链接数为 0 时,文件内容才会真正被删除。

符号链接

除了硬链接,linux 系统还提供了一种符号链接。符号链接并不增加目标文件 i 节点的链接数。符号链接本身也是一个文件,其中存储了目标文件的完整路径,类似于windows系统中的快捷方式。符号链接与硬链接的另一个区别是符号链接可以对目录建立链接,而硬链接不能对目录建立链接。因为如果允许对目录建立硬链接,有可能形成链接环。符号链接的使用及属性如下:

avatar

可见,符号链接并不增加 i 节点的链接数。

unlink 命令

unlink 用于删除文件名。删除文件名是指在原目录下不再含有此文件名。要注意的是,这里的表述是删除文件名,并不一定删除磁盘上文件的内容。只有在文件的链接数为1,即当前文件名是文件的最后一个链接并且有没有进程打开此文件的时候,unlink() 才会真正删除文件内容。用 unlink 真正的删除一个文件内容,必须同时满足以上两个条件。

如果文件链接数为1,但是仍然有进程打开这一文件,那么 unlink 后,虽然在原目录中已经没有了被删除文件的名字,但是实际上系统还是保留了这一文件,直到打开这一文件的所有进程全部关闭此文件后,系统才会真正删除磁盘上的文件内容。由此可见,用unlink直接删除打开的文件是安全的。删除已经打开的文件,对使用此文件的进程,不会有任何影响,也不会导致进程崩溃(注意这里讨论的是删除已被打开的文件,通常是数据文件,并未讨论删除正在运行的可执行文件)。

对于符号链接,unlink 删除的是符号链接本身,而不是其指向的文件。

rm 命令

rm 命令也是删除文件。为了查看rm 与 unlink 的区别,用 strace 跟踪执行 rm 命令时使用的系统调用:

1
2
3
4
5
6
strace rm data.txt 2>&1 | grep 'data.txt' 
execve("/bin/rm", ["rm", "data.txt"], [/* 13 vars */]) = 0
lstat("data.txt", {st_mode=S_IFREG|0644, st_size=10, ...}) = 0
stat("data.txt", {st_mode=S_IFREG|0644, st_size=10, ...}) = 0
access("data.txt", W_OK) = 0
unlink("data.txt") = 0

跟踪 unlink 命令的结果:

1
2
3
strace unlink data.txt 2>&1 | grep 'data.txt'
execve("/bin/unlink", ["unlink", "data.txt"], [/* 13 vars */]) = 0
unlink("data.txt")

可以看出,在linux 中,rm 命令比 unlink 命令多了一些权限的检查,之后也是调用了 unlink() 系统调用。在文件允许删除的情况下,rm 命令和 unlink 命令其实是没有区别的。

rename 命令

rename 命令通常用于重命名文件,由于本文研究的是文件的移动和删除,因而只需关注 rename 最简单的使用方法:

1
2
3
strace rename data.txt  dest_file data.txt 2>&1 | egrep  'data.txt|dest_file'
execve("/usr/bin/rename", ["rename", "data.txt", "dest_file", "data.txt"], [/* 13 vars */]) = 0
rename("data.txt", "dest_file") = 0

可以看出,rename 就是对 rename() 系统调用的封装。

查看 man page 可以看出,当目标文件已经存在时,在权限允许的情况下,rename() 会直接覆盖原来的文件。这里“覆盖原有文件”可能有两种情况:

将原文件清空后写入
删除了旧文件后新建一个同名文件

在目标文件 dest_file 已经存在的情况下,执行 rename 后,dest_file 的 i 节点号发生了变化,因而rename() 系统调用的作用类似于上述第二种情形:即删除文件后再新建一个同名文件。

mv 命令

mv 命令通常用于重命名文件。当目标文件不存在时,跟踪其执行过程:

1
2
3
4
5
6
strace mv data.txt  dest_file 2>&1 | egrep  'data.txt|dest_file'
execve("/bin/mv", ["mv", "data.txt", "dest_file"], [/* 13 vars */]) = 0
stat("dest_file", 0x7ffe1b4aab50) = -1 ENOENT (No such file or directory)
lstat("data.txt", {st_mode=S_IFREG|0644, st_size=726, ...}) = 0
lstat("dest_file", 0x7ffe1b4aa900) = -1 ENOENT (No such file or directory)
rename("data.txt", "dest_file") = 0

当目标文件存在时:

1
2
3
4
5
6
7
8
strace mv src_data data.txt 2>&1 | egrep 'src_data|data.txt'
execve("/bin/mv", ["mv", "src_data", "data.txt"], [/* 13 vars */]) = 0
stat("data.txt", {st_mode=S_IFREG|0644, st_size=726, ...}) = 0
lstat("src_data", {st_mode=S_IFREG|0644, st_size=726, ...}) = 0
lstat("data.txt", {st_mode=S_IFREG|0644, st_size=726, ...}) = 0
stat("data.txt", {st_mode=S_IFREG|0644, st_size=726, ...}) = 0
access("data.txt", W_OK) = 0
rename("src_data", "data.txt") = 0

可以看出,mv 的主要功能就是检查初始文件和目标文件是否存在及是否有访问权限,之后执行 rename 系统调用,因而,当目标文件存在时,mv 的行为由 rename() 系统调用决定,即类似于删除文件后再重建一个同名文件。

cp 命令

对于cp 命令,当目标文件不存在时:

1
2
3
4
5
6
7
strace cp data.txt dest_data 2>&1 | egrep 'data.txt|dest_data'
execve("/bin/cp", ["cp", "data.txt", "dest_data"], [/* 13 vars */]) = 0
stat("dest_data", 0x7fff135827f0) = -1 ENOENT (No such file or directory)
stat("data.txt", {st_mode=S_IFREG|0644, st_size=726, ...}) = 0
stat("dest_data", 0x7fff13582640) = -1 ENOENT (No such file or directory)
open("data.txt", O_RDONLY) = 3
open("dest_data", O_WRONLY|O_CREAT, 0100644) = 4

如果目标文件存在,在执行cp 命令之后,文件的 inode 号并没有改变,并且可以看出,cp 使用了 open 及O_TRUNC 参数打开了目标文件。因而当目标文件已经存在时,cp 命令实际是清空了目标文件内容,之后把新的内容写入目标文件。

特别需要关注的是 cp 命令。当目标文件存在时,cp 命令并不是先删除已经存在的目标文件,而是将原目标文件内容清空后再写入。了