[apue] linux 文件系統那些事兒

来源:https://www.cnblogs.com/goodcitizen/archive/2022/03/23/things_about_linux_filesystem.html
-Advertisement-
Play Games

本文嘗試通過解釋 api 介面底層做了什麼來闡釋 linux 文件系統在設計層面的一些考慮,配合通俗易懂的日常命令和簡單程式來進行驗證,踐行“紙上得來終覺淺,絕知此事要躬行”的理念,目的是做一個 linux 文件系統的引入… ...


前言

说到 linux 的文件系统,好多人第一印象是 ext2/ext3/ext4 等具体的文件系统,本文不涉及这些,因为研究具体的文件系统难免会陷入细节,甚至拉大段的源码做分析,反而不能从宏观的角度把握文件系统要解决的问题。一个通用的 linux 文件系统都包含哪些概念?接口如何使用?设计层面需要考虑什么问题?这都在本文的讨论范围。当然了,内容都是从 apue 搬运过来的,经过了一点点梳理加工,原书还是基于比较老的 UFS (Unix File System) 进行说明的,有些东西可能已经过时了,不过原理层面的东西还是相通的,看过之后举一反三就好。

文件系统总览

开始详细说明之前,先看下文件系统的总体结构,对一些基本的概念有个大体印象。书上有个不错的图直接盗过来:

从图中可以看出,磁盘可以由多个分区组成,每个分区可以设置不同格式的文件系统。分区在 windows 上比较容易观察,就是常说的 C/D/E/F……这些,一块磁盘也可以只设置一个分区,不过一但系统重装时,用户数据就容易丢失,从这里可以看出,分区及其上的文件系统是可以跨操作系统存在的。把系统分区从  windows 重装成 linux,数据分区也能正常读取 (linux 也能识别 NTFS),说明文件系统是独立于操作系统的。

一个分区由多个柱面组成,柱面是多个盘片在同一个磁道上形成的存储面,这样设计是为了减少寻道时间提高性能。每个柱面存储了若干数据块与对应的 inode 节点,它们都是固定长度的。inode 可以看作是文件的元数据,存放了与文件的大部分关键信息,它们连续存放在一起形成 inode 表,这主要是为了提高读取大量文件信息的性能,另外也简化了 inode 的定位过程,直接使用下标就可以了,一般称之为 inode 编号。每个柱面还存放了 inode 位图与块位图,方便查找空闲的 inode 节点或数据块。

inode 存放的信息包括:

  • 文件类型
  • 文件长度
  • 文件权限位
  • 文件链接数
  • 文件时间
  • 文件数据块编号
  • 设备号
  • ……

注意文件名是不存放在 inode 中的,文件名是变长的,最长的文件名 (255) 可能都要超过 inode 的固定长度了,不适合存储在 inode 中。文件是包含在目录中的,所以文件名与其对应的 inode 编号都存放在目录的数据块中,目录是一种特殊的文件,其数据块由系统维护,用户不能直接读写它的内容。

 

从上图可以看到,目录 inode -> 目录数据块 -> 文件 inode -> 文件/子目录数据块 形成了一个闭环,通过这样不断迭代可以读取到文件系统中的任意文件。

对于这个过程,可能有人会问了,inode 不是固定长度的吗,如何保存一个文件的所有数据块编号呢?这就涉及到数据块寻址了,当文件比较大的时候,光编号占用的空间就直接超过 inode 本身的长度了,所以不能直接存储在 inode 中,而要通过二级甚至三级寻址来查找全部的数据块,过程和内存的多级寻址有异曲同工之处,受主题限制就不深入展开了,感兴趣的读者可以参考文末链接。

inode 与数据块数量比例如何分配是另外一个问题,通常它们不是 1:1 的关系,这样当 inode 消耗光的时候,即使还有数据块,文件系统也不能创建新的文件了,这方面的案例可以参考这篇文章《[apue] Linux / Windows 系统上只能建立不超过 PATH_MAX / MAX_PATH 长度的路径吗? 》;但 inode 节点太多也会造成可观的容量损失,一般没有大量小文件的应用场景是不会将 inode 比例设置太多的。可以通过 df 来查看 inode 使用情况:

$ df -i /
Filesystem       Inodes  IUsed    IFree IUse% Mounted on
/dev/sda5      61022208 284790 60737418    1% /

这个比例可以在创建文件系统时指定,例如对于 mkfs.ext3 是通过  -i 参数指定:

-i bytes-per-inode
    Specify the bytes/inode ratio.  mke2fs creates an inode for  ev‐
    ery  bytes-per-inode bytes of space on the disk.  The larger the
    bytes-per-inode ratio, the fewer inodes will be  created.   This
    value  generally  shouldn't be smaller than the blocksize of the
    filesystem, since in that case more inodes would  be  made  than
    can  ever  be used.  Be warned that it is not possible to change
    this ratio on a filesystem after it is created,  so  be  careful
    deciding the correct value for this parameter.  Note that resiz‐
    ing a filesystem changes the number of inodes to  maintain  this
    ratio.

关于这方面更多细节请参考文末链接。

文件权限

inode 存储了文件的权限设置,主要就是文件权限位。关于文件权限,这是另一个可以单独写一篇的话题了,请参考文章《[apue] linux 文件访问权限那些事儿》。这里重点罗列一下与本文相关的结论:

  • 访问一个文件,需要文件路径上的每个节点都可以访问,即所有目录的 x 权限位;对于文件需要有相应的 r/w/x 权限位,具体需要哪些权限位和操作有关
  • 新增文件,需要直属目录的 w 权限位,新文件的权限位由给定的权限位和进程 umask 作用产生
  • 删除文件,需要直属目录的 wx 权限位,不需要具有文件的权限位,如果直属目录指定了粘住位 (sticky),则还需要以下条件之一成立:
    • 拥有该文件
    • 拥有直属目录
    • 超级用户
  • 遍历文件,需要直属目录的 r 权限位

访问文件元数据 inode 的权限与数据块的大部分相同,一些不同点将在出现时特别指出。

文件链接

inode 中的文件链接数表示有多少目录包含了该文件,删除文件时,只是将链接数减 1,当链接数减为 0 时才真正的删除文件并释放数据块,这种链接称之为硬链接。文件系统支持的最大硬链接数可通过 pathconf(_PC_LINK_MAX, ...) 查询,可以参考这篇文章:[apue] 一个快速确定新系统上各类限制值的工具,在我的 Ubuntu 上这个值是 65000。文件链接到不同的目录中时使用的文件名也可以不同,这也是第二个不将文件名放在 inode 中的原因。

由于 inode 是在每个文件系统(分区)单独编号的,所以在进行文件链接时,只能指定本分区的文件,跨文件系统的硬链接是不被支持的 (inode 编号可能冲突)。为了消除这种限制,引入了一种新的链接方式——符号链接,也称为软链接,建立这种链接时不修改目标文件的链接数,而是新建一个独立的文件,这个文件与普通文件有以下几点不同:

  • 文件类型为 S_IFLINK,系统会对它做特殊处理
  • 数据块存储的是目标文件的路径,可以是绝对路径,也可以是相对路径,使用后者时会基于进程的当前路径进行查找
  • 链接文件本身的权限位一般是被忽略的,权限检查时只看目标文件的权限

之前说过目录是一种特殊的文件,在链接方面也是如此,请看下面这个例子:

图中有两个 inode,1267 是父目录,2549 是子目录 testdir,每个目录都有两个默认项 '.' 和 '..',前者代表自己后者代表父目录。一个目录至少会被自己和 '.' 项引用,这样一来目录的链接数至少是 2,如果有子目录的话,子目录的 '..' 项又会增加自己的链接计数。所以从一个目录项的链接数就可以知道有几个子目录:

$ ls -lh
total 200K
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 01.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 02.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 03.chapter
drwxrwxr-x 5 yunh yunh 4.0K Oct 30 18:02 04.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun 20  2021 05.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 06.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 07.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 08.chapter
drwxrwxr-x 3 yunh yunh 4.0K Jun  6  2021 09.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 10.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 11.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 12.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 13.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 14.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 15.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 16.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 17.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 18.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 19.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 20.chapter
drwxrwxr-x 2 yunh yunh 4.0K Jun  6  2021 21.chapter
-rw-rw-r-- 1 yunh yunh  32K Jun  6  2021 apue.c
-rw-rw-r-- 1 yunh yunh 3.5K Jun  6  2021 apue.h
-rw-rw-r-- 1 yunh yunh  35K Jun  6  2021 LICENSE
-rw-rw-r-- 1 yunh yunh  671 Jun  6  2021 log.c
-rw-rw-r-- 1 yunh yunh  143 Jun  6  2021 log.h
-rw-rw-r-- 1 yunh yunh 1.2K Jun  6  2021 Makefile
-rw-rw-r-- 1 yunh yunh 9.8K Jun  6  2021 pty_fun.c
-rw-rw-r-- 1 yunh yunh 1.6K Jun  6  2021 pty_fun.h
-rw-rw-r-- 1 yunh yunh  116 Jun  6  2021 README.md
drwxrwxr-x 3 yunh yunh 4.0K Jun  6  2021 root
-rw-rw-r-- 1 yunh yunh 3.2K Jun  6  2021 tty.c
-rw-rw-r-- 1 yunh yunh  174 Jun  6  2021 tty.h

ls 输出的第二列就是链接数啦,从输出中可以猜到 04.chapter 这个目录链接数是 5,根据公式:

dirs = refs - 2

得知该目录有 3 个子目录,你学会了吗?为了防止文件系统形成循环,大多数实现不允许创建目录的硬链接,这也就让上面的公式更能立得住脚了。

关于目录硬链接导致文件系统形成循环的情况,动动脚趾头也能想出来:一个叶子节点硬链接到自身路径中任意一个节点都能成环,这是比较直观的例子;还有 A 子树叶子链接到 B 子树,B 子树叶子又链接到 A 子树的八字环;如果参与的子树超过 2 个,那就更难以探测和避免了。所以一般文件系统的实现对目录硬链接会严防死守,书中说超级用户可以创建目录的硬链接,man ln 也是这样讲: 

-d, -F, --directory
    allow  the  superuser to attempt to hard link directories (note:
    will probably fail due to system restrictions, even for the  su‐
    peruser)

然而经过亲自尝试,这个后门已经被 Ubuntu 彻底堵死了,即使加了 -d 选项也不行:

yunh$ ln ../../ loop
ln: ../../: hard link not allowed for directory
yunh$ sudo ln ../../ loop
[sudo] password for yunh: 
ln: ../../: hard link not allowed for directory
yunh$ su
Password: 
root# ln ../../ loop
ln: ../../: hard link not allowed for directory
root# exit
exit

这一点和 man 括号中的说明一致。

引入符号链接后,api 接口操作的是链接本身还是目标文件?这是一个问题,这个问题可以用一个词来描述——跟随,下表列出了常用的 api 是否跟随符号链接:

api 跟随符号链接 不跟随符号链接
access *  
chdir *  
chmod *  
chown *  
lchown   *
creat *  
exec *  
link   *
stat *  
lstat   *
open *  
opendir *  
pathconf *  
readlink   *
remove   *
rename   *
truncate *  
unlink   *
symlink   *

可见大部分文件是跟随符号链接的,这样就为链接文件的透明性提供了基础。下面分组做个说明:

  • 以 l 开头明确表示要操作符号链接的 api 是不跟随的,如 lstat/lchown
  • 符号链接专用的 api 也不跟随,如 readlink/symlink 等
  • 一些 api 为了防止误操作,也是不跟随的,如 link/unlink/remove/rename 等
  • 一些 api 没有列出来,是因为它们在遇到符号链接就直接出错了,无所谓跟随不跟随的说法,这些有 mkdir/rmdir/mknod/mkinfo
  • 一些 api 是直接操作文件句柄的,也不存在跟随问题,它们包括 fstat/fchown ……

比较有趣的是 symlink,它本身是用来创建符号链接的,它不跟随目标路径的符号链接,下面举一个栗子:

$ ls -lh
total 4K
-rwxrwxr-x 1 yunh yunh  338 Jun  6  2021 rename.sh
$ ln -s rename.sh bar
$ ln -s bar foo
$ ls -lh
total 4K
lrwxrwxrwx 1 yunh yunh    9 Jan 23 21:04 bar -> rename.sh
lrwxrwxrwx 1 yunh yunh    3 Jan 23 21:05 foo -> bar
-rwxrwxr-x 1 yunh yunh  338 Jun  6  2021 rename.sh

这个例子构造了一个 foo->bar->rename.sh 的链接路径,如果 link 是跟随符号链接的,那么 foo 将直接指向 rename.sh,变为 bar->rename.sh 和 foo->rename.sh,而不需要经过 bar 传递一手。此时 cat foo,将能正常打印目标文件 rename.sh 的内容,可见链接的跟随也是递归的一个过程。

当符号链接悬空时,ls 可以看到文件,cat 却报告文件不存在,这可能会对用户造成一些困惑,为此可以使用 ls -l 来打印文件详情,除了第一个字符 'l' 标识了文件是符号链接外,文件名也通过 -> 指示了符号链接的目标文件,像上面展示的那样,比较直观。除此以外,还可以使用 ls 的 -F 参数来查看,符号链接将以 @ 结尾,以区别于普通文件:

$ ls -F
rename.sh  bar@      foo@

文件操作

文件操作如何影响文件系统中的各个元素,下面分类说明。

文件创建

这里按创建的文件类型先列一下使用的接口及必需的权限:

文件类型 接口 权限 说明
普通文件 creat/open (pathname, oflag = O_CREAT, mode)
  • 路径上每个节点:x
  • 直属目录:w
  • 只创建 pathname 的最后一个分量,路径中其它部分应当已经存在,否则出错返回 ENOENT
  • pathname 已存在,且 oflag 同时指定 O_EXCL,出错返回 EEXIST
  • pathname 为符号链接时,跟随符号链接,特别当 pathname 是悬空的符号链接时,会创建符号链接指向的文件 [注1]
  • 分配 inode 和数据块,并在直属目录中添加一条目录项指向新文件的 inode
  • 新文件的权限由 mode & ~umask 决定
硬链接 link (existingpath, newpath)
  • 路径上每个节点:x
  • existingpath:r
  • newpath 直属目录:w
  • 只创建 newpath 最后一个分量,路径中其它部分应当已经存在,否则出错返回 ENOENT
  • newpath 已存在,出错返回 EEXIST
  • existingpath 不存在,出错返回 ENOENT
  • existingpath 为目录,出错返回 EPERM
  • existingpath 与 newpath 跨分区,出错返回 EXDEV
  • 在 newpath 的直属目录中添加一条目录项指向 existingpath 的文件信息 (inode 编号和文件名),existingpath 文件 inode 的链接计数增 1 [注2]
软链接 symlink (actualpath, sympath)
  • 路径上每个节点:x
  • sympath 直属目录:w
  • 不要求 actualpath 已存在
  • 不要求 actualpath 与 sympath 位于同一个分区
  • sympath 已存在,出错返回 EEXIST
  • 为新文件分配 inode 和数据块,在 sympath 直属目录中添加一条目录项指向新文件的文件信息 (inode 编号和文件名)
目录 mkdir (pathname, mode)
  • 路径上每个节点:x
  • 直属目录:w
  • 路径名已存在,出错返回 EEXIST [注3]
  • 自动创建新目录的 . 和 .. 目录项,并将它们分别指向自己和父目录,增加父目录的链接计数
  • 为新目录分配 inode 和数据块,在 pathname 直属目录中添加一条目录项指向新目录的文件信息 (inode 编号和文件名)
  • 新目录的权限由 mode & ~umask 决定,注意不要关闭目录的 x 权限位,否则将不能经过该目录访问目录中的文件
  • 新目录的 uid 和 gid 的设置有一系列复杂的规则,详情可参考文件权限那篇文章的内容

注1:举个栗子,符号链接 foo 指向不存在的文件 bar,则指定 pathname 为 foo 时,将新建文件 bar,使 foo 不再悬空

注2:创建链接文件与增加链接计数必需是原子的,当跨文件系统(分区)时,这一操作的原子性很难得到保证,这是硬链接不能跨文件系统的第二个原因

注3:路径名为悬空软链接时 mkdir 也会失败,而不是像 open/creat 一样创建链接文件指向的文件,关于这一点可以参考上一节中 mkdir 对符号链接的跟随说明

文件创建后使用 open 打开读取内容,对于软链接和目录而言,有专门的接口,这主要是为了隐藏实现细节:

文件类型 接口 权限 说明
普通文件 open (pathname, oflag, ...) rwx:与 oflag 相关
  • 使用 O_CREAT | O_EXCL 打开悬空的软链接时,出错返回 EEXIST [注1]
软链接 readlink (pathname, buf, bufsize) r
  • 一个函数包含了 open/read/close 三个操作,但用户不能通过这三个函数来模拟,这主要是由于 open 总是跟随符号链接,且没有 lopen 这种东东
  • 注意 buf 并不以 nul 结尾,需要手工添加 (根据 readlink 返回的长度)
目录 DIR* opendir (pathname) r
  • 早期的系统支持直接读取目录文件数据,当时目录项是固定长度的,随着文件系统支持的文件名越来越长,目录项因包含文件名也变为不定长了,新系统为了隐藏实现细节,已不支持直接打开目录文件 [注2]
dirent* readdir (DIR*)
closedir(DIR*)

注1:单使用 O_CREAT 打开悬空的软链接会创建软链接指向的文件(使之不再悬空,参考上一小节),但同时指定 O_CREAT 和 O_EXCL 则会失败。这主要是为了堵塞一个安全漏洞:防止具有特权的进程被诱骗对不适当的文件进行写操作,关于这一点 man open 中也有特别说明:

       O_EXCL Ensure that this call creates the file: if this flag is specified in conjunction  with  O_CREAT,  and
              pathname already exists, then open() fails with the error EEXIST.

              When  these two flags are specified, symbolic links are not followed: if pathname is a symbolic link,
              then open() fails regardless of where the symbolic link points.

注2:有的人可能会用 struct dirent 的定义来反驳:

struct dirent
  {
#ifndef __USE_FILE_OFFSET64
    __ino_t d_ino;
    __off_t d_off;
#else
    __ino64_t d_ino;
    __off64_t d_off;
#endif
    unsigned short int d_reclen;
    unsigned char d_type;
    char d_name[256];		/* We must not include limits.h! */
  };

d_name 字段不是 256 个字符固定长度么?还真是。文件名最大长度由文件系统决定 (pathconf),这个长度一般不超过 256,不过一些系统文件名长度是会随文件系统而改变,所以这里不过是一种巧合,这里的 d_name[256] 只是 char* 的另一种表示法,实际长度可以超过或不足 256,真正占用空间要看结尾 0 的位置。换种说法就是,这里定义成 char d_name[1] 也是可以的 (书中原意如此,没有做过验证)。

文件删除

删除场景主要分普通文件与目录两个类型:

文件类型 接口 权限 说明
普通文件 unlink (pathname)
  • 路径上每个节点:x
  • 直属目录:w
  • 直属目录设置了粘住位时,需要额外以下三个条件之一:
    • 拥有该文件
    • 拥有直属目录
    • 超级用户
  • pathname 为目录时,unlink 出错返回 EISDIR [注 2]
  • pathname 为符号链接时,只处理符号链接自身,不跟随符号链接 [注3]
  • 直属目录的数据块中移除 pathname 的目录项
  • 将文件的链接数减 1,链接计数达到 0 时
    • 文件打开的进程数为 0,删除文件,释放数据块与 inode
    • 打开的进程数大于 0,延迟删除 [注4]
remove (pathname) [注1]
目录 rmdir (pathname)
  • pathname 不是目录,rmdir 出错返回 ENOTDIR
  • 目录不为空 [注5],出错返回 ENOTEMPTY
  • 直属目录的数据块中移除 pathname 的目录项
  • 将目录的链接数减 1,链接计数达到 1 时
    • 删除目录下的 . 和 .. 目录项,此时链接计数达到 0
    • 目录打开的进程数为 0 时,删除目录,释放数据块与 inode
    • 目录打开的进程数大于 0 时,延迟释放目录空间,此时在该目录下无法再创建新文件,尝试创建将出错返回 ENOENT [注6]
remove (pathname)

注1:remove 针对普通文件等价于 unlink;针对目录等价于 rmdir

注2:书上说超级会员针对目录也可以使用 unlink,等价于 rmdir,实测不通过

注3:没有直接删除符号链接指向文件的 api,可以结合 readlink 与 unlink 自己写个 (注意需要处理递归的场景)

注4:延迟删除指的是目录项会从目录的数据块中移除,但是文件数据和 inode 仍可以被打开的进程访问,这样做主要是为了防止进程后续访问无效的句柄导致未定义行为甚至崩溃。文件会在进程关闭文件句柄时彻底删除,进程退出时系统会自动关闭所有打开的文件句柄。unlink 的这种延迟删除能力常用于临时文件的清理,避免进程崩溃时遗留下不必要的中间文件,具体做法就是 open 或 creat 文件成功后,立即 unlink 该文件。

注5:目录为空是指目录中只包含 . 与 .. 两个目录项

注6:空目录删除时如果还有进程打开该目录,同普通文件一样需要延迟删除,此时禁止新文件的创建主要是为了保证在目录关闭时可以正常释放空间 (仍保持空目录)

最后单独列一下进程关闭时清理文件的过程:

  • 进程退出前系统会自动关闭进程打开的所有文件句柄
  • 关闭普通文件时,如果链接数为 0,且无其它进程打开该文件,删除文件,释放数据块与 inode
  • 关闭目录文件时,如果链接数为 0,且无其它进程打开该目录,删除目录,释放数据块与 inode

 

文件移动

分区内的文件移动不需要移动文件数据,只修改相关文件直属目录的数据块即可,这里也主要分普通文件与目录两个类型说明:

文件类型 接口 权限 说明
文件 rename (oldname, newname)
  • 路径上每个节点:x
  • oldname 直属目录:w
  • oldname 直属目录设置了粘住位时,需要额外以下三个条件之一:
    • 拥有该文件
    • 拥有直属目录
    • 超级用户
  • newname 直属目录:w
  • newname 已存在需要删除时,需要 x 权限位,如果 newname 直属目录设置了粘住位时,还需要额外以下三个条件之一:
    • 拥有该文件
    • 拥有直属目录
    • 超级用户
  • newname 与 oldname 为同一个文件,什么也不做,返回成功
  • oldname 与 newname 跨分区时,出错返回 EXDEV
  • newname 已存在时
    • newname 为目录,出错返回 EISDIR
    • 删除 newname 文件
  • oldname 与 newname 指向符号链接时,只处理符号链接本身,不跟随符号链接 [注1]
  • 修改 newname 直属目录数据块,添加 newname 的文件信息 (inode 编号和文件名)
  • 修改 oldname 直属目录数据块,删除 oldname 的文件信息,这个过程文件的数据块不需要变动,inode 仅部分字段变动,例如文件时间
目录
  • newname 与 oldname 为同一个目录,什么也不做,返回成功
  • oldname 与 newname 跨分区时,出错返回 EXDEV
  • newname 已存在时
    • newname 为非目录文件,出错返回 ENOTDIR
    • newname 为非空目录,出错返回 ENOTEMPTY
    • newname 包含 oldname 作为前缀,出错返回 EINVAL [注2]
    • 删除 newname 目录
  • 修改 newname 直属目录数据块,添加 newname 的文件信息 (inode 编号和文件名)
  • 修改 oldname 直属目录数据块,删除 oldname 的文件信息,这个过程目录的数据块仅 .. 目录项的指向需要变动,inode 仅部分字段变动,例如文件时间

注1:因为这一特性,符号链接和符号指向的文件对 rename 来说不是一个文件,假设符号 foo 指向文件 bar,那么 rename foo bar 并不会被视为对同一个文件进行操作,结果将是 bar 文件被删除,foo 文件指向了它自己,这是一个悬空符号链接,结果和 ln -s foo foo 差不多

注2:举个例子,rename /usr/foo /usr/foo/bar 中的 newname (/usr/foo/bar) 包含了 oldname (/usr/foo) 作为前缀,当删除 oldname 时会将 newname 赖以存在的一部分删除,导致后面新建时出错,对于这种明显有问题的逻辑系统会提前出错

跨文件系统(分区)的移动通常需要移动数据块和重新分配 inode,mv 命令实现它的时候可以理解为 cp + rm 的组合。

文件修改

文件内容被修改时,直属目录不受影响,相对要简单一些:

  • 更新文件数据,此时文件数据和 inode (文件长度、文件时间…) 都会更新
  • 只更新 inode,例如修改权限位、链接数,此时只更新 inode

文件访问时也分两种情况:

  • 访问文件数据,此时会更新 inode 中的访问时间
  • 只访问 inode,此时文件不受影响

关于文件时间的内容请参考下一节。

api vs command

上面罗列的都是系统提供的 api,有些和系统命令同名,如 mkdir、rmdir,有些不太一样,如 unlink/remove vs rm、link/symlink vs ln、rename vs mv。

需要注意的是命令和 api 并不是一一对应的关系,有些命令在内部实现过程中并不是直接调用 api 的,所以会造成命令执行的结果与 api 有出入,这里我都是自己写程序直接调用 api 来验证的,关于命令和 api 的异同,以后有空再补充这方面的内容。

文件时间

从上面的讨论已经知道文件是由两部分组成的,一部分存放真实的文件数据 (data),另一部分存放文件元数据 (inode),那么对这两部分的读写操作应该分别记录时间,可以整理下面的表格:

operation data inode
read access time (atime) n/a
write modify time (mtime) change time (ctime)

从表中可以看出没有"最近一次读 inode 的时间" 这种记录,所以一些操作 (如 access/stat...) 并不修改文件的任何时间。

mtime 级联更新 ctime

所有文件时间都存放于 inode 中,那 mtime/atime 本身被修改会不会导致 ctime 更新呢?理论上是不会,例如 cat 文件后,只有 atime 会更新,ctime 并不随 atime 更新而更新;但 write 文件后,除了 mtime 更新,ctime 也会更新。有的人会说追加文件数据后,文件长度变更了,需要更新 inode,所以 ctime 也会变更。为了验证这一点,专门写了一个程序用于验证:

#include "../apue.h"

int main (int argc, char *argv[])
{
    if (argc < 5)
        err_quit ("Usage: write_api path offset length char\n", 1); 

    char *buf = NULL; 
    int fd = open (argv[1], O_WRONLY);
    if (fd < 0)
        err_sys ("open file %s failed", argv[1]); 
    
    do
    {
        int off = atoi(argv[2]); 
        int len = atoi(argv[3]); 
        char ch = argv[4][0]; 
        buf = (char *)malloc(len); 
        memset (buf, ch, len); 
        if (buf == NULL) {
            printf ("alloc buffer with len %d failed\n", len); 
            break; 
        }

        if (lseek (fd, off, SEEK_SET) != off) {
            printf ("seek to %d failed\n", off); 
            break; 
        }

        if (write (fd, buf, len) != len) {
            printf("write %d at %d failed\n", len, off); 
            break; 
        }

        printf ("write %d '%c' ok\n", len, ch); 
    } while (0);

    free (buf); 
    close (fd); 
    return 0; 
}

这个程序 (write_api) 直接调用 write 写入文件中的一个字节,文件长度前后不会变化,像下面这样:

$ echo "def" > abc
$ stat abc
  File: abc
  Size: 4         	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35521031    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-05 17:23:25.223022200 +0800
Modify: 2022-03-05 17:23:25.223022200 +0800
Change: 2022-03-05 17:23:25.223022200 +0800
 Birth: -
$ ./write_api abc 1 1 o
write 1 'o' ok
$ stat abc
  File: abc
  Size: 4         	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35521031    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-05 17:23:25.223022200 +0800
Modify: 2022-03-05 17:23:48.270241647 +0800
Change: 2022-03-05 17:23:48.270241647 +0800
 Birth: -

然而 ctime 仍然更新了。从上面的输出可以看到,inode 中的其它字段除 mtime 外都没有变化,所以可以认为 ctime 是随 mtime '级联'修改的。有的人可能有疑问,如果 ctime 总要随 mtime 更新,那单独记录 ctime 的意义何在?其实有些场景只修改 inode 而不修改 data,此时就只更新 ctime,不更新 mtime。这种场景有很多,例如只修改文件权限 (chmod)、只增加文件链接计数 (ln)、只更新文件所有者 uid 或文件所在组 gid (chown/chgrp)。

api & file times

下面的表列出了更全面的 api 对文件时间影响的清单:

api 引用的文件 文件的直属目录 备注
atime mtime ctime atime mtime ctime  
access              
stat/fstat/lstat              
chmod/fchmod     *        
chown/fchown     *        
lchown     *        
creat * * *   * * O_CREAT 新文件
creat   * *       O_TRUNC 现有文件
open * * *   * * O_CREAT 新文件
open   * *       O_TRUNC 现有文件
link     *   * * 新文件的直属目录
symlink * * *   * * 新文件的直属目录
unlink     *   * *  
mkdir * * *   * *  
rmdir         * * 目录一般无硬链接,删除后 inode 也将销毁,可视作无变更
remove     *   * * 删除文件 = unlink
remove         * * 删除目录 = rmdir
mkfifo * * *   * *  
pipe * * *       一般无直属目录
truncate/ftruncate   * *        
exec * [注1]            
rename     * [注2]   * * 对于源和目的文件的直属目录都是如此
read *            
readlink *            
write   * *        
utime * * *        
readdir * [注3]            

除了直接影响引用文件的三个时间,当文件在直属目录中增删时,还会修改父目录的数据块,从而影响它的两个时间,上面分两列给出。

注1:exec 函数族用于启动可执行文件,这个过程会有读取文件数据载入内存的过程,因此理应影响文件的 atime,不过对于系统而言启动进程是再正常不过的事情,如果因此频繁更新 inode 中的 atime 则有些得不偿失,为此 linux 内核 2.6.30 之后做了优化,当满足下面条件之一时,atime 不更新:

  • mount 文件系统时指定了 noatime/nodiratime 选项;
  • mount 文件系统时指定了 relatime 选项且满足下面的条件之一:
    • atime < mtime
    • atime < ctime
    • atime 据上次更新达一天

仍可通过指定 strictatime 来恢复每次访问更新 atime 的行为,具体可参考 man mount 的这段说明:

relatime
    Update  inode  access  times  relative to modify or change time.
    Access time is only updated if the previous access time was ear‐
    lier  than  the  current  modify  or  change  time.  (Similar to
    noatime, but it doesn't break mutt or  other  applications  that
    need  to know if a file has been read since the last time it was
    modified.)

    Since Linux 2.6.30, the kernel defaults to the behavior provided
    by   this   option  (unless  noatime  was  specified),  and  the
    strictatime option is required to obtain traditional  semantics.
    In  addition, since Linux 2.6.30, the file's last access time is
    always updated if it is more than 1 day old.

注2:rename 按理说只调整直属目录数据块中的目录项的 inode 指向即可,重命名文件的 data 和 inode 本身并不发生改变,但是书上说这里 ctime 会变,特意验证了下:

$ echo "demo" > foo
$ stat foo
  File: foo
  Size: 5         	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35520865    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-05 18:28:57.208136638 +0800
Modify: 2022-03-05 18:28:57.208136638 +0800
Change: 2022-03-05 18:28:57.208136638 +0800
 Birth: -
$ ./rename_api foo bar
rename foo to bar
$ stat bar
  File: bar
  Size: 5         	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35520865    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-05 18:28:57.208136638 +0800
Modify: 2022-03-05 18:28:57.208136638 +0800
Change: 2022-03-05 18:29:05.621027413 +0800
 Birth: -

果然 ctime 变了。这里为了排除 mv 命令中调用其它 api 的干扰,专门写了一个程序 rename_api,内部只调用 rename。我的理解是 rename 本身可以做到不改变引用文件的任何内容,但是这是一个比较大的变动,需要"体现"出来,而修改 ctime 是一个不错的方式。

注3:目录的 atime 变更和文件类似,参考注1

调整文件时间

除了被动修改,文件时间也可以主动设置,这对于一些解压工具 (tar/cpio...) 非常有用,可以恢复文件压缩前的时间状态,这是通过上面表中列过的 utime 接口来实现的。目前系统只开放了两个时间项供修改:atime & mtime,ctime 是不能主动设置的,而且每次调用 utime 都会导致 ctime 自动更新。

#include <sys/types.h>
#include <utime.h>

struct utimbuf {
    time_t actime;       /* access time */
    time_t modtime;      /* modification time */
};

int utime(const char *filename, const struct utimbuf *times);

utime 有一些特殊的权限要求,这里分情况讨论一下:

  • utimbuf 为 NULL,atime & mtime 更新为当前时间,ctime 自动更新,需要满足以下条件之一:
    • 进程 euid == 文件 uid
    • 进程具有文件写权限
  • utimbuf 不为 NULL,atime & mtime 被更新为结构体中的 actime & modtime 字段,ctime 自动更新,需要以下条件之一:
    • 进程 euid == 文件 uid
    • 进程具有超级用户权限

可见更新文件时间为特定时间需要的权限更高一些。具体可以参考 man utime 中的说明:

    Changing timestamps is permitted when: either the process has appropri‐
    ate privileges, or the effective user ID equals  the  user  ID  of  the
    file,  or  times  is  NULL and the process has write permission for the
    file.

至于 utime 总是更新文件 ctime 的设计,同 rename 更新 ctime 一样,需要一个地方"体现"被设置了时间的文件。

命令中的文件时间

说了这么多,命令中是如何指定文件时间的呢?下面分别来看一下。

ls

除了直接使用 stat 查看文件三个时间外,还可以使用 ls -l,它默认显示的是文件的 mtime,-t  选项将输出按时间排序,-r 倒序输出:

$ ls -lhrt
total 840K
-rw-rw-r-- 1 yunh yunh  280 Feb 20 09:34 mkdir_api.c
-rw-rw-r-- 1 yunh yunh  314 Feb 20 14:02 link_api.c
-rw-rw-r-- 1 yunh yunh  324 Feb 20 14:22 symlink_api.c
-rw-rw-r-- 1 yunh yunh  367 Feb 20 14:33 readlink_api.c
-rw-rw-r-- 1 yunh yunh  272 Feb 20 14:48 unlink_api.c
-rw-rw-r-- 1 yunh yunh  445 Feb 20 14:52 open_api.c
-rw-rw-r-- 1 yunh yunh  272 Feb 20 15:21 remove_api.c
-rw-rw-r-- 1 yunh yunh  263 Feb 20 15:51 rmdir_api.c
-rw-rw-r-- 1 yunh yunh  317 Feb 20 16:37 rename_api.c
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 mkdir_api.o
-rw-rw-r-- 1 yunh yunh  67K Feb 20 19:37 apue.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 mkdir_api
-rw-rw-r-- 1 yunh yunh 7.0K Feb 20 19:37 open_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 open_api
-rw-rw-r-- 1 yunh yunh 6.7K Feb 20 19:37 link_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 link_api
-rw-rw-r-- 1 yunh yunh 6.7K Feb 20 19:37 symlink_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 symlink_api
-rw-rw-r-- 1 yunh yunh 6.9K Feb 20 19:37 readlink_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 readlink_api
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 unlink_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 unlink_api
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 remove_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 remove_api
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 rmdir_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 rmdir_api
-rw-rw-r-- 1 yunh yunh 6.7K Feb 20 19:37 rename_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 rename_api
-rw-rw-r-- 1 yunh yunh 1.6K Mar  5 16:30 Makefile
-rw-rw-r-- 1 yunh yunh 7.7K Mar  5 16:43 write_api.o
-rwxrwxr-x 1 yunh yunh  63K Mar  5 16:43 write_api
-rw-rw-r-- 1 yunh yunh  963 Mar  5 18:22 write_api.c
-rw-rw-r-- 1 yunh yunh    5 Mar  5 18:27 rename.sh
-rw-rw-r-- 1 yunh yunh    4 Mar  5 18:39 foo

同理还可以显示 atime (-u) 和 ctime (-c),排序也是基于当前显示的文件时间来的。

find

find 命令中直接通过 -atime/-ctime/-mtime 来指定要查找的文件时间,它们都只接收一个整数作为参数,表示 (-N-1, -N] 天时间区间内的 access/modify/change 的文件,例如当 N  为 0 时表示一天内的文件,当 N  为 1 时表示一天前两天内的文件:

$ date
Sat 05 Mar 2022 07:34:58 PM CST
$ find . -type f -mtime 0 | xargs ls -lhd
-rw-rw-r-- 1 yunh yunh    4 Mar  5 18:39 ./foo
-rw-rw-r-- 1 yunh yunh 1.6K Mar  5 16:30 ./Makefile
-rw-rw-r-- 1 yunh yunh    5 Mar  5 18:27 ./rename.sh
-rwxrwxr-x 1 yunh yunh  63K Mar  5 16:43 ./write_api
-rw-rw-r-- 1 yunh yunh  963 Mar  5 18:22 ./write_api.c
-rw-rw-r-- 1 yunh yunh 7.7K Mar  5 16:43 ./write_api.o
$ find . -type f -mtime 12 | xargs ls -lh
-rw-rw-r-- 1 yunh yunh  67K Feb 20 19:37 ./apue.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 ./link_api
-rw-rw-r-- 1 yunh yunh 6.7K Feb 20 19:37 ./link_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 ./mkdir_api
-rw-rw-

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 一 發佈者和訂閱者 很多時候都有這種需求,當一個特定的程式事件發生時,程式的其他部分可以得到該事件已經發生的通知。 發佈者/訂閱者模式可以滿足這種需求。 發佈者:發佈某個事件的類或結構,其他類可以在該事件發生時得到通知。 訂閱者:註冊併在事件發生時得到通知的類或結構。 事件處理程式:由訂閱者註冊到事 ...
  • Net6 Configuration & Options 源碼分析 Part2 Options 第二部分主要記錄Options 模型 OptionsConfigurationServiceCollectionExtensions類提供了對Options 模型與配置系統的Configure方法的擴展 ...
  • 1. 前言 WPF 的 TextBlock 提供了大部分常用的文字修飾方法,在日常使用中基本夠用。如果需要更豐富的表現方式,WPF 也提供了其它用起來複雜一些的工具去實現這些需求。例如這篇文章介紹的文字描邊,就有幾種方法可以在 WPF 中呈現。這篇文章將簡單介紹這實現文字描邊的方法。 2. 將文字轉 ...
  • 今天我們一起來探索一下ASP.NET Core框架中的Authorization。我們知道請求進入管道處理流程先會使用Authentication進行用戶認證,然後使用Authorization進行用戶授權。如果沒有看過認證過程的大家可以先轉到Authentication這一篇。 AddAuthor ...
  • Viewer.js庫是一個實用的js庫,用於圖片瀏覽,放大縮小翻轉幻燈片播放等實用操作 本文相關參考鏈接 JavaScript 模塊中的 JavaScript 隔離 Viewer.js工程 Blazor JS 隔離優勢 導入的 JS 不再污染全局命名空間。 庫和組件的使用者不需要導入相關的 JS。即 ...
  • 一 什麼是委托 可以認為委托是持有一個或多個方法的對象。可以執行委托,執行時委托會執行它所持有的方法。 從C++的角度理解,委托可以看成一個類型安全、面向對象的C++函數指針。 delegate void MyDel(int value); //聲明委托類型 class Program { void ...
  • 應用程式編程介面(API)是一組允許軟體組件進行交互的協議。中間介面通常用於簡化開發,使軟體團隊能夠重覆使用代碼。API還通過將應用程式與它們所運行的基礎設施脫鉤來抽象系統之間的功能。儘管API在現代商業中的好處和用例不斷增加,但固有的安全挑戰帶來了各種安全風險。 本文深入探討了與API漏洞相關的各 ...
  • 一、Nginx介紹 1.nginx是一個高性能HTTP伺服器,反向代理伺服器,郵件代理伺服器,TCP/UDP反向代理伺服器. 2.nginx處理請求是非同步非阻塞的,在高併發下nginx 能保持低資源低消耗高性能,主要用在集群系統中用於支持負載均衡. 3.nginx對靜態文件的處理速度也相當快,也可以 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...