[apue] linux 文件系统那些事儿,Linux调试分析诊断利器——strace,Linux tar命令解压时提示时间戳异常的处理办法

本文尝试通过解释 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-r-- 1 yunh yunh 6.6K Feb 20 19:37 ./mkdir_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 ./open_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 ./readlink_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 ./remove_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 ./rename_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 ./rmdir_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 ./symlink_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 ./unlink_api
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 ./unlink_api.o
$ find . -type f -mtime 13 | xargs ls -lh
-rw-rw-r-- 1 yunh yunh 314 Feb 20 14:02 ./link_api.c
-rw-rw-r-- 1 yunh yunh 280 Feb 20 09:34 ./mkdir_api.c
-rw-rw-r-- 1 yunh yunh 445 Feb 20 14:52 ./open_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 15:21 ./remove_api.c
-rw-rw-r-- 1 yunh yunh 317 Feb 20 16:37 ./rename_api.c
-rw-rw-r-- 1 yunh yunh 263 Feb 20 15:51 ./rmdir_api.c
-rw-rw-r-- 1 yunh yunh 324 Feb 20 14:22 ./symlink_api.c
-rw-rw-r-- 1 yunh yunh 272 Feb 20 14:48 ./unlink_api.c

以当前时间 2022/03/05 19:35 为例,文件时间 02/20 19:37 位于 [02/20 19:35, 02/21 19:35) 之间 (13 天之内,12 天之前),而文件时间 02/20 14~16 点位于 [02/19 19:35, 02/20 19:35) 之间 (14 天之内,13 天之前),当前时间 (19:35) 的选取恰好将这批文件切分成了两批。

N 只能指定一天的时间区段,那如何指定某个时间之前或之后的半开区间呢?这就用到 ‘+’ 和 ‘-‘ 来修饰了,-N 表示 (-N, 0] 天区间内的文件,例如当 N=3 时表示 3 天内的文件;+N 表示 (-∞, -N-1] 天区间内的文件,例如当 N=3 时表示 4 天前的文件。以上面的文件为例,假设当前时间不变,进行验证:

$ find . -type f -mtime +12 | xargs ls -lh
-rw-rw-r-- 1 yunh yunh 314 Feb 20 14:02 ./link_api.c
-rw-rw-r-- 1 yunh yunh 280 Feb 20 09:34 ./mkdir_api.c
-rw-rw-r-- 1 yunh yunh 445 Feb 20 14:52 ./open_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 15:21 ./remove_api.c
-rw-rw-r-- 1 yunh yunh 317 Feb 20 16:37 ./rename_api.c
-rw-rw-r-- 1 yunh yunh 263 Feb 20 15:51 ./rmdir_api.c
-rw-rw-r-- 1 yunh yunh 324 Feb 20 14:22 ./symlink_api.c
-rw-rw-r-- 1 yunh yunh 272 Feb 20 14:48 ./unlink_api.c
$ find . -type f -mtime -13 | xargs ls -lh
-rw-rw-r-- 1 yunh yunh  67K Feb 20 19:37 ./apue.o
-rw-rw-r-- 1 yunh yunh    4 Mar  5 18:39 ./foo
-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
-rw-rw-r-- 1 yunh yunh 1.6K Mar  5 16:30 ./Makefile
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 ./mkdir_api
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 ./mkdir_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 ./open_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 ./readlink_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 ./remove_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 ./rename_api
-rw-rw-r-- 1 yunh yunh 6.7K Feb 20 19:37 ./rename_api.o
-rw-rw-r-- 1 yunh yunh    5 Mar  5 18:27 ./rename.sh
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 ./rmdir_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 ./symlink_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 ./unlink_api
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 ./unlink_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 7.7K Mar  5 16:43 ./write_api.o
$ find . -type f -mtime -12 | xargs ls -lh
-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 +0 | 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  314 Feb 20 14:02 ./link_api.c
-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-r-- 1 yunh yunh  280 Feb 20 09:34 ./mkdir_api.c
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 ./mkdir_api.o
-rwxrwxr-x 1 yunh yunh  63K Feb 20 19:37 ./open_api
-rw-rw-r-- 1 yunh yunh  445 Feb 20 14:52 ./open_api.c
-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 ./readlink_api
-rw-rw-r-- 1 yunh yunh  367 Feb 20 14:33 ./readlink_api.c
-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 ./remove_api
-rw-rw-r-- 1 yunh yunh  272 Feb 20 15:21 ./remove_api.c
-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 ./rename_api
-rw-rw-r-- 1 yunh yunh  317 Feb 20 16:37 ./rename_api.c
-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 ./rmdir_api
-rw-rw-r-- 1 yunh yunh  263 Feb 20 15:51 ./rmdir_api.c
-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 ./symlink_api
-rw-rw-r-- 1 yunh yunh  324 Feb 20 14:22 ./symlink_api.c
-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 ./unlink_api
-rw-rw-r-- 1 yunh yunh  272 Feb 20 14:48 ./unlink_api.c
-rw-rw-r-- 1 yunh yunh 6.6K Feb 20 19:37 ./unlink_api.o

以 19:35 为分隔点,-13 和 +12 将它们分成前后两部分,所以想从某个时刻前转为这之后,不是简单的 -N 变 +N,而是 -N 变 +(N-1),因为 N 那一天由 -mtime N 代表了。最后 +0 表示一天前的文件也是符合预期的,一个特例是 find -mtime 0 等价于 find -mtime -1。除了指定单位天,还可以指定单位分钟,这由 -amin/-mmin/-cmin 指定,范围规则同上,下面综合一下:

参数 范围 单位 时间项
N (-N-1, -N]:N 天/分钟前,N+1 天/分钟内
    • -atime
    • -mtime
    • -ctime
  • 分钟
    • -amin
    • -mmin
    • -cmin
  • access
    • -atime
    • -amin
  • modify
    • -mtime
    • -mmin
  • change
    • -ctime
    • -cmin
-N (-N, 0]:N 天/分钟内
+N (-∞, -N-1]:N+1 天/分钟前

如果不想基于当前时间、而是基于每天的开始时间 (00:00),那么可以为 find 指定 -daystart 选项。

touch

与 utime 对应的命令是 touch,之前一直用这个命令创建空白文件,没想到它还有更新文件 atime 和 mtime 的能力,下面的表格列出了它的一些常用选项:

选项 含义
-a 只更新 atime 为当前时间 (ctime 同步更新为当前时间)
-m 只更新 mtime 为当前时间 (ctime 同步更新为当前时间)
-d DATE 同时更新 atime 和 mtime为指定时间 (ctime 同步更新为当前时间),DATE 遵循的格式非常广泛,一般可以指定 YYYY-MM-DD HH-mm-SS [注1],如果指定了 -a/-m,则只设置其中一个
-t TIME 同时更新 atime 和 mtime 为指定时间 (ctime 同步更新为当前时间),TIME 遵循格式:[[CC]YY]MMDDhhmm[.ss] [注2],如果指定了 -a/-m,则只设置其中一个
-r FILE 同时设置 atime 和 mtime 为指定文件的 atime 和  mtime (ctime 同步更新为当前时间) [注3/4],如果指定了 -a/-m,则只设置其中一个
-c 文件不存在时不自动创建文件

注1:还可以指定 3 day ago, next Thursday 这种宽松的相对时间,具体可参考 man touch 说明:

注2:此处时间格式与 -d 选项不同,如果指定绝对时间,-t 选项的格式相对简单易读一些

注3:ctime 不在可修改时间之列,这里的自动更新机制和 utime api 保持一致,其实 touch 底层就是调用的 utimensat

注4:超级用户权限进程默认只更新 mtime,-d/-t 也是如此,Ubuntu 实测结果

touch 一个比较有用的点是触发 make 命令,make 命令是否执行更新主要就是信赖于源文件的 mtime,因此 touch 更新文件的 mtime 必然会引发 make 的关注。这主要分两个方面,一是想单独触发某个目标的编译,二是避免某个意外的更新导致的潜在编译动作。

  1. 如果想单独触发某个目标的编译,有的人可能觉得通过 Make -B foo 也可以实现,不过这样是将 foo 所依赖的所有文件标记为脏进行重新生成,波及的面还是有点广,如果只想对几个源文件进行标记,就可以使用 touch  A B C… 的方式一次性标记,要比一个个打开它们再保存来的快一些
  2. 如果说上面那条还可以通过手动保存文件来实现,那么想取消一次 mtime 更新引发的潜在编译,则非 touch 莫属。可以通过 touch -t/d 选项来直接设置意外更新的源文件时间早于依赖文件,也可以直接指定 -r 来将两者设置为相同的修改时间:touch -r dest src

对于第二点,突发奇想:如果在 Makefile 规则中不小心更新了源文件的 mtime,那么可能导致目标永远是可被 make 的,像下面这个简单的例子所示:

all: foo

foo: bar
	touch foo

bar: bar.c
	touch bar 
	sleep 0.01  # make bar.c mtime newer than bar to trigger make next time...
	touch bar.c
    
clean: 
	@echo "start clean..."
	-rm -f foo bar
	@echo "end clean"

.PHONY: clean

bar 的生成规则中故意更新了源文件 bar.c 的修改时间,导致每次 make 时都会执行一遍。

tar

tar 解压时默认会恢复文件的 mtime:

$ date
Wed Mar 23 14:23:18 CST 2022
$ tar xzvf release.tar.gz 
release/
release/arm64-v8a/
release/arm64-v8a/libjni-kernel.so
release/arm64-v8a/libjni-kservice.so
release/armeabi-v7a/
release/armeabi-v7a/libjni-kernel.so
release/armeabi-v7a/libjni-kservice.so
$ ls -lhR release
release:
total 8.0K
drwxr-xr-x 2 rd rd 4.0K Dec  2 15:54 arm64-v8a
drwxr-xr-x 2 rd rd 4.0K Dec  2 15:54 armeabi-v7a

release/arm64-v8a:
total 26M
-rwxr-xr-x 1 rd rd 3.3M Mar 21 17:17 libjni-kernel.so
-rwxr-xr-x 1 rd rd  23M Mar 21 17:17 libjni-kservice.so

release/armeabi-v7a:
total 18M
-rwxr-xr-x 1 rd rd 2.7M Mar 21 17:17 libjni-kernel.so
-rwxr-xr-x 1 rd rd  15M Mar 21 17:17 libjni-kservice.so
$ ls -lhuR release
release:
total 8.0K
drwxr-xr-x 2 rd rd 4.0K Mar 23 14:23 arm64-v8a
drwxr-xr-x 2 rd rd 4.0K Mar 23 14:23 armeabi-v7a

release/arm64-v8a:
total 26M
-rwxr-xr-x 1 rd rd 3.3M Mar 23 14:23 libjni-kernel.so
-rwxr-xr-x 1 rd rd  23M Mar 23 14:23 libjni-kservice.so

release/armeabi-v7a:
total 18M
-rwxr-xr-x 1 rd rd 2.7M Mar 23 14:23 libjni-kernel.so
-rwxr-xr-x 1 rd rd  15M Mar 23 14:23 libjni-kservice.so
$ ls -lhcR release
release:
total 8.0K
drwxr-xr-x 2 rd rd 4.0K Mar 23 14:23 arm64-v8a
drwxr-xr-x 2 rd rd 4.0K Mar 23 14:23 armeabi-v7a

release/arm64-v8a:
total 26M
-rwxr-xr-x 1 rd rd 3.3M Mar 23 14:23 libjni-kernel.so
-rwxr-xr-x 1 rd rd  23M Mar 23 14:23 libjni-kservice.so

release/armeabi-v7a:
total 18M
-rwxr-xr-x 1 rd rd 2.7M Mar 23 14:23 libjni-kernel.so
-rwxr-xr-x 1 rd rd  15M Mar 23 14:23 libjni-kservice.so

上面的例子中,当前时间是 03.23,解压后的文件 mtime 为 03.21,atime 和 ctime 与解压时间保持一致。如果不想恢复文件压缩前的时间,可以使用 -m 选项:

$ tar xzvmf release.tar.gz 
release/
release/arm64-v8a/
release/arm64-v8a/libjni-kernel.so
release/arm64-v8a/libjni-kservice.so
release/armeabi-v7a/
release/armeabi-v7a/libjni-kernel.so
release/armeabi-v7a/libjni-kservice.so
$ ls -lhR release
release:
total 8.0K
drwxr-xr-x 2 rd rd 4.0K Mar 23 14:27 arm64-v8a
drwxr-xr-x 2 rd rd 4.0K Mar 23 14:27 armeabi-v7a

release/arm64-v8a:
total 26M
-rwxr-xr-x 1 rd rd 3.3M Mar 23 14:27 libjni-kernel.so
-rwxr-xr-x 1 rd rd  23M Mar 23 14:27 libjni-kservice.so

release/armeabi-v7a:
total 18M
-rwxr-xr-x 1 rd rd 2.7M Mar 23 14:27 libjni-kernel.so
-rwxr-xr-x 1 rd rd  15M Mar 23 14:27 libjni-kservice.so

常用于因机器时间不一致导致解压后的文件处于“未来”时刻。下表罗列了一些与文件时间相关的 tar 选项:

选项 作用
–atime-preserve 压缩时保持文件的 atime 不变
–mtime=DATE-OR-FILE 压缩时设置 mtime 为指定格式或某个文件的 mtime
–newer-mtime=DATE 压缩时选取 mtime 比指定时间新的文件
-m/–touch 解压时不恢复文件的 mtime

更多详情可参考 man tar。

sed

有时使用 stat 观察命令对文件做了哪些改动是一件很有趣的事,之前为了验证只更新文件内容不改变文件长度时 ctime 是否变更,曾经使用 sed 做等长度字符替换,发现了这样一幕:

$ echo "abc" > foo
$ stat foo
  File: foo
  Size: 4         	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35520843    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-06 17:15:18.780422763 +0800
Modify: 2022-03-06 17:15:18.780422763 +0800
Change: 2022-03-06 17:15:18.780422763 +0800
 Birth: -
$ sed -i 's/abc/def/' foo
$ stat foo
  File: foo
  Size: 4         	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35521013    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-06 17:15:30.668890159 +0800
Modify: 2022-03-06 17:15:30.668890159 +0800
Change: 2022-03-06 17:15:30.668890159 +0800
 Birth: -

本以为最多就是 mtime  和 ctime 变更,没想到三个时间全更新了,再仔细一看,文件 inode 都变了,顿时搞得有点怀疑人生,但是转念一想,sed -i 的实现可能就是新建了一个文件用来存储转换后的数据,再将这个文件重命名为源文件,使用 strace 查看;

......
openat(AT_FDCWD, "foo", O_RDONLY)       = 3
ioctl(3, TCGETS, 0x7ffe932f0b10)        = -1 ENOTTY (Inappropriate ioctl for device)
fstat(3, {st_mode=S_IFREG|0664, st_size=4, ...}) = 0
umask(0700)                             = 002
getpid()                                = 21268
openat(AT_FDCWD, "./sedoTrUFp", O_RDWR|O_CREAT|O_EXCL, 0600) = 4
umask(002)                              = 0700
fcntl(4, F_GETFL)                       = 0x8002 (flags O_RDWR|O_LARGEFILE)
fstat(3, {st_mode=S_IFREG|0664, st_size=4, ...}) = 0
read(3, "abc\n", 4096)                  = 4
fstat(4, {st_mode=S_IFREG|000, st_size=0, ...}) = 0
read(3, "", 4096)                       = 0
fchown(4, 1000, 1000)                   = 0
fgetxattr(3, "system.posix_acl_access", 0x7ffe932f09f0, 132) = -1 ENODATA (No data available)
fstat(3, {st_mode=S_IFREG|0664, st_size=4, ...}) = 0
fsetxattr(4, "system.posix_acl_access", "\2\0\0\0\1\0\6\0\377\377\377\377\4\0\6\0\377\377\377\377 \0\4\0\377\377\377\377", 28, 0) = 0
close(3)                                = 0
write(4, "def\n", 4)                    = 4
close(4)                                = 0
rename("./sedoTrUFp", "foo")            = 0
close(1)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++

果然如此,新建的临时文件为 sedoTrUFp,转换结束后有对应的 rename 调用,inode 不变才怪。

文件创建时间

linux 文件时间行文至此,有些熟悉 windows 的读者可能会问了,如何获取一个文件的创建时间呢?毕竟在 windows 上这才是 ctime 的真正含义啊。linux ext4 之后加入了文件的创建时间,区别于 ctime 叫 crtime,通过以下几步获取:

  • 确定文件所在的文件系统格式为 ext4 并获取文件系统名 (第 4 行)
$ df -T
Filesystem     Type     1K-blocks     Used Available Use% Mounted on
udev           devtmpfs   4003776        0   4003776   0% /dev
tmpfs          tmpfs       807452     1888    805564   1% /run
/dev/sda5      ext4     959862832 19523928 891510744   3% /
tmpfs          tmpfs      4037244        0   4037244   0% /dev/shm
tmpfs          tmpfs         5120        4      5116   1% /run/lock
tmpfs          tmpfs      4037244        0   4037244   0% /sys/fs/cgroup
/dev/sda1      vfat        523248       12    523236   1% /boot/efi
tmpfs          tmpfs       807448       64    807384   1% /run/user/1000
  • 获取要查询的文件 inode 号 (第 5 行)
$ ls -i Makefile
35520820 Makefile
$ stat Makefile
  File: Makefile
  Size: 1578      	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35520820    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-05 16:38:12.824249909 +0800
Modify: 2022-03-05 16:30:27.381022589 +0800
Change: 2022-03-05 16:30:27.709007045 +0800
 Birth: -
  • 通过 debugfs 查询对应的 crtime (倒数第 5 行)
$ sudo debugfs -R 'stat <35520820>' /dev/sda5
[sudo] password for yunh: 
debugfs 1.45.5 (07-Jan-2020)
Inode: 35520820   Type: regular    Mode:  0664   Flags: 0x80000
Generation: 3267144101    Version: 0x00000000:00000001
User:  1000   Group:  1000   Project:     0   Size: 1578
File ACL: 0
Links: 1   Blockcount: 8
Fragment:  Address: 0    Number: 0    Size: 0
 ctime: 0x62231fa3:a90a5b14 -- Sat Mar  5 16:30:27 2022
 atime: 0x62232174:c48438d4 -- Sat Mar  5 16:38:12 2022
 mtime: 0x62231fa3:5ad7c5f4 -- Sat Mar  5 16:30:27 2022
crtime: 0x6210abfe:5531517c -- Sat Feb 19 16:36:14 2022
Size of extra inode fields: 32
Inode checksum: 0x1027e200
EXTENTS:
(0):142124549

最后一步给 debugfs 传递的两个参数 filesystem 和 inode 就是从前面两步获取的。总体而言不太方便,仅供参考,详情见文末链接。

目录文件

目录遍历

前面讲过各个文件系统的实现均不支持目录的硬链接,主要是防止遍历时形成死循环,而目录的符号链接不存在这方面的问题,主要是对于后者一般就不继续递归了,像下面演示的这样:

$ ln -s ../../ testdir/super
$ ls -lhR
.:
total 8.0K
-rwxrwxr-x 1 yunh yunh  338 Jun  6  2021 rename.sh
drwxrwxr-x 2 yunh yunh 4.0K Jan 23 17:06 testdir

./testdir:
total 0
lrwxrwxrwx 1 yunh yunh 6 Jan 23 17:06 super -> ../../

find / grep 等命令都能正确的处理目录的符号链接,反而让人不知道怎么构造出有问题的场景了,书上是找了一个 Solaris 上的 ftw 命令来做验证的,在 Ubuntu 上没有找到对应的命令,不过有一个同名的 libc 函数,拿来做了一个类似的命令:

#include <stdio.h> 
#include <ftw.h> 

int ftw_func (char const* fpath, 
              struct stat const* sb, 
              int typeflag)
{
  //printf ("%s\n", fpath); 
  switch (typeflag)
  {
    case FTW_F:
      printf ("[R] %s\n", fpath); 
      break; 
    case FTW_D:
      printf ("[D] %s\n", fpath); 
      break; 
    case FTW_DNR:
      printf ("[DNR] %s\n", fpath); 
      break; 
    case FTW_NS:
      printf ("[NS] %s\n", fpath); 
      break; 
    default:
      printf ("unknown typeflag %d\n", typeflag); 
      return -1; 
  }
  return 0; 
}

int main (int argc, char *argv[])
{
  char const* dir = 0; 
  if (argc < 2)
    dir = "."; 
  else 
    dir = argv[1]; 

  int ret = ftw (dir, ftw_func, 1000); 
  return ret; 
}

再构造一个带循环的文件树:

$ mkdir A B
$ cd A
$ echo "abc" > foo
$ ln -s ../B loop
$ cd ../B
$ echo "def" > bar
$ ln -s ../A loop
$ cd ..
$ ls -lhR 
tmp:
total 12K
drwxrwxr-x 2 yunh yunh 4.0K Jan 23 17:27 A
drwxrwxr-x 2 yunh yunh 4.0K Jan 23 17:28 B
-rwxrwxr-x 1 yunh yunh  338 Jun  6  2021 rename.sh

tmp/A:
total 4.0K
-rw-rw-r-- 1 yunh yunh 4 Jan 23 17:16 foo
lrwxrwxrwx 1 yunh yunh 4 Jan 23 17:17 loop -> ../B

tmp/B:
total 4.0K
-rw-rw-r-- 1 yunh yunh 4 Jan 23 17:17 bar
lrwxrwxrwx 1 yunh yunh 4 Jan 23 17:17 loop -> ../A

用上面那个自制的 ftw 跑一下:

$ ./ftw tmp
[D] tmp
[R] tmp/rename.sh
[D] tmp/A
[R] tmp/A/foo
[D] tmp/A/loop
[R] tmp/A/loop/bar

居然没有死循环,不过看这遍历结果有点儿不对劲,缺少目录 B。查看 man ftw,针对 flags 有这样一条说明:

       FTW_PHYS
              If  set, do not follow symbolic links.  (This is what you want.)
              If not set, symbolic links are followed, but no file is reported
              twice.

              If  FTW_PHYS is not set, but FTW_DEPTH is set, then the function
              fn() is never called for a directory that would be a  descendant
              of itself.

看来这个 api 自己有缓存一些信息来防止文件重复输出,所以上面并不是没有目录 B,而是用 B 的等价物 A/loop 代替了 B。为了制造循环输出的场景,还是得老老实实用 opendir/readdir/closedir/lstat 自己写一个程序:

static int dopath (char const* path, Myfunc* func)
{
  struct stat statbuf; 
  struct dirent *dirp; 
  int ret; 
  DIR *dp; 
  char *ptr; 

  // may loop for dir soft links
  //if (stat (path, &statbuf) < 0)
  if (lstat (path, &statbuf) < 0)
    return func (path, &statbuf, FTW_NS); 

  char inbuf[PATH_MAX] = { 0 }; 
  char outbuf[PATH_MAX] = { 0 }; 
  char newpath[PATH_MAX] = { 0 }; 
  strcpy (newpath, path); 
  strcpy (inbuf, path); 
  while (S_ISLNK(statbuf.st_mode))
  {
    // handle symbolic to dir
    if (readlink (inbuf, outbuf, sizeof(outbuf)) < 0)
    {
        printf ("read symbolic path %s failed\n", inbuf); 
        return func (inbuf, &statbuf, FTW_NS); 
    }
    else 
    {
        if (lstat (outbuf, &statbuf) < 0)
            return func (outbuf, &statbuf, FTW_NS); 

        strcpy (newpath, outbuf); 
    }
  }

  if (S_ISDIR(statbuf.st_mode) == 0)
    return func (newpath, &statbuf, FTW_F); 

  if ((ret = func (newpath, &statbuf, FTW_D)) != 0)
    return ret; 

  ptr = newpath + strlen (newpath); 
  *ptr ++ = '/'; 
  *ptr = 0; 

  if ((dp = opendir (newpath)) == NULL)
  {
    ptr[-1] = 0; 
    return func (newpath, &statbuf, FTW_DNR); 
  }

  if (chdir (newpath) != 0)
    printf ("chdir %s failed\n", newpath); 

  while ((dirp = readdir (dp)) != NULL)
  {
    if (strcmp (dirp->d_name, ".") == 0 || 
        strcmp (dirp->d_name, "..") == 0)
        continue; 

    strcpy (ptr, dirp->d_name); 
    printf ("%s\n", newpath); 
    if ((ret = dopath (ptr, func)) != 0)
      break; 
  }

  ptr[-1] = 0; 
  if (chdir ("..") != 0)
    printf ("chdir back failed\n"); 

  if (closedir (dp) < 0)
    err_ret ("can't close directory %s", newpath); 

  return ret; 
}

上面就是遍历目录的核心逻辑了,需要注意以下几点:

  • 使用 lstat 而不是 stat 判断文件属性,以便得到目录的符号链接
  • 当文件是符号链接时,读取并判断指向内容是否为目录,注意这个过程是递归的

最后终于得到如愿以偿的输出了:

./A
A/foo
A/loop
../B/bar
../B/loop
../A/foo
../A/loop
……
../B/loop
../A/foo
../A/lo
Segmentation fault (core dumped)

太不容易了,可以看到由于陷入死循环程序最终崩溃掉了。不过这种循环比较容易破解,删除目录软链接即可,如果循环是由硬链接引起的就不太好处理了,并不是一个 rmdir 可以搞定的 (仔细想一想,删除目录的前提是目录为空,当形成循环时目录不可能为空,这导致删除的前提条件被破坏掉了),这是不引入目录硬链接的第三个理由。

进程工作目录

文件路径分绝对路径和相对路径,之前提到符号链接中既可以存放绝对路径,也可以存放相对路径。当使用相对路径时,将基于进程的工作目录进行查找。

与许多人设想的不太一样,内核并不存放进程完整的字符串工作路径,取而代之的是指向目录 vnode 的指针等目录本身的信息,当需要取得进程当前工作目录的完整路径时,我们需要一个函数来完成这件工作:getcwd,对它的逻辑作以下简单说明:

  • 通过 .. 得到父目录中所有目录项,遍历它们并与当前目录的 inode 编号作对比,得到匹配的目录名称作为 dirname
  • 按照上面的方法,不断遍历 .. 直到根目录,找到每一层目录的 dirname 拼接为完整的 pathname 就是最终的结果了

一个简单的 getcwd 底层居然做了如此多的工作,在最糟糕的情况下,它将遍历包含工作目录的整个文件树,效率是不高的。那内核为什么不存储一个字符串的完整工作路径呢?考察一下下面这个程序:

#include "../apue.h"
#include <limits.h> 
#include <unistd.h> 

void ch_dir(char const* dir)
{
  if (chdir (dir) < 0)
    err_sys ("chdir failed"); 

  printf ("chdir to %s succeeded\n", dir); 
  char path[PATH_MAX+1] = { 0 }; 
  char *cwd = getcwd (path, PATH_MAX); 
  printf ("getcwd = %s\n", cwd); 
}

int main (int argc, char *argv[])
{
  char dir[PATH_MAX] = { 0 }, *dir_name = NULL; 
  if (argc <= 2)
    strcpy(dir, argv[1]); 
  else if (argc <= 3)
  {
    strcpy(dir, argv[1]); 
    dir_name = argv[2]; 
  }
  else 
    err_quit ("Usage: dirch dir [dirname]", -1); 

  ch_dir(dir); 
  while (dir_name)
  {
    ch_dir(dir_name);
  }

  return 0; 
}

它接收两个参数,参数一表示第一次切换到的目录,参数二表示之后循环切换的目录。再复用上一节中制造的特殊目录结构:

$ ls -lhR 
tmp:
total 12K
drwxrwxr-x 2 yunh yunh 4.0K Jan 23 17:27 A
drwxrwxr-x 2 yunh yunh 4.0K Jan 23 17:28 B
-rwxrwxr-x 1 yunh yunh  338 Jun  6  2021 rename.sh

tmp/A:
total 4.0K
-rw-rw-r-- 1 yunh yunh 4 Jan 23 17:16 foo
lrwxrwxrwx 1 yunh yunh 4 Jan 23 17:17 loop -> ../B

tmp/B:
total 4.0K
-rw-rw-r-- 1 yunh yunh 4 Jan 23 17:17 bar
lrwxrwxrwx 1 yunh yunh 4 Jan 23 17:17 loop -> ../A

就可以这样启动它了:

$./dirch tmp/A loop

得到了如下的输出:

chdir to tmp/A succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/A
chdir to loop succeeded
getcwd = /home/yunh/code/apue/04.chapter/tmp/B
chdir to loop succeeded
……

进程工作目录将在 A 和 B  两个目录之前无限切换,一开始我怀疑当路径超过 PATH_MAX 时进程会异常退出,然而观察 getcwd 的输出,这一幕没有发生,当前工作路径的长度甚至没有变化!经过目录软链接跳转后,进程的当前目录节点被直接设置为目标目录的 vnode,压根不会感受到中间的 loop 符号链接节点,但是如果换作字符串路径呢?再做一个实验:

#! /bin/sh

main()
{
    if [ $# -lt 2 ]; then 
        echo "Usage dirch dirname [loop]"
        exit 1
    fi

    base="$1"
    dir="$2"
    cd "${base}"
    while true; do
        cd "${dir}"
        if [ $? -ne 0 ]; then 
            echo "cd ${dir} failed"
            exit 2
        else 
            echo "cd to `pwd`"
        fi
    done
}

main "$@"

抱着试试看的态度使用 shell 的 cd 和 pwd 来做实验,希望它有不一样的结果:

$ sh dirch.sh tmp/A loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
cd to /home/yunh/code/apue/04.chapter/tmp/A/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop/loop
dirch.sh: 14: cd: can't cd to loop
cd loop failed

这个脚本做了和之前程序一样的事情,结果却大相径庭,最终因路径超长失败退出。从 pwd 的输出看到 shell  貌似是存储了当前目录完整的字符串路径,从而在 builtin cd 作用下越加越长,直到出错。pwd 除了 bultin 版本,还有一个位于 /usr/bin 下面的 pwd 命令,将脚本中的 builtin pwd 替换为 /usr/bin/pwd,情况会不会改善呢?

$ sh dirch.sh tmp/A loop
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
cd to /home/yunh/code/apue/04.chapter/tmp/B
cd to /home/yunh/code/apue/04.chapter/tmp/A
dirch.sh: 14: cd: can't cd to loop
cd loop failed

答案是没有,虽然 /usr/bin/pwd 的输出改善了许多,但 cd 最终还是失败了。从这里可以得到以下结论:

  • builtin pwd 和 /usr/bin/pwd 实现不同,后者的实现更类似于 getcwd,其实就是调用了 getcwd (见下文)
  • builtin cd 的实现与 chdir 不同,更不可能调用后者
  • builtin cd/pwd 借助了字符串路径记录当前工作目录,在遇到目录符号链接时会出现超长出错的情况

为了避免上面出错的场景,内核不记录进程当前工作目录的字符串路径,这个道理你弄明白了吗?

最后补充一下 /usr/bin/pwd 内部调用 getcwd 的 strace 证据:

$ strace /usr/bin/pwd
……
brk(NULL)                               = 0x55e3c4b4e000
brk(0x55e3c4b6f000)                     = 0x55e3c4b6f000
openat(AT_FDCWD, "/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=8850624, ...}) = 0
mmap(NULL, 8850624, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fe433d93000
close(3)                                = 0
getcwd("/home/yunh/code/apue/04.chapter", 4096) = 32
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}) = 0
write(1, "/home/yunh/code/apue/04.chapter\n", 32/home/yunh/code/apue/04.chapter
) = 32
close(1)                                = 0
close(2)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++

另外没有非 builtin 的 cd 可用,这是因为更改子进程的当前目录对父进程毫无影响。

最后回到本节开始的话题,相对路径肯定是相对的,绝对路径却不一定是绝对的,它也可以是相对的,也就是说遇到 ‘/’ 开始的路径,也不一定从系统的根目录开始解释,具体以哪个路径作为根路径,可以通过 chroot 设置,这里就不展开说了。

设备号

每个文件都依托于设备存在,inode 中有两个设备号:

  • st_dev:标识文件所在文件系统,该文件系统包含了这一文件的文件名与 inode
  • st_rdev:标识字符文件/块文件所在的实际设备

每个设备号又分为主设备号与次设备号,分别通过宏 major 和 minor 获取,其中:

  • 主设备号:标识驱动程序,有时编码为与其通信的外设板
  • 次设备号:标识特定的子设备

同一硬盘上的文件系统主设备号相同,次设备号不同。ls 通常不打印任何设备号,除非目标是字符/块文件:

$ ls -l /dev/sd*
brw-rw---- 1 root disk 8,  0 Mar 19 07:59 /dev/sda
brw-rw---- 1 root disk 8,  1 Mar 19 07:59 /dev/sda1
brw-rw---- 1 root disk 8,  2 Mar 19 07:59 /dev/sda2
brw-rw---- 1 root disk 8,  5 Mar 19 07:59 /dev/sda5
brw-rw---- 1 root disk 8, 16 Mar 20 17:52 /dev/sdb
brw-rw---- 1 root disk 8, 20 Mar 20 17:52 /dev/sdb4
brw-rw---- 1 root disk 8, 32 Mar 20 18:08 /dev/sdc
brw-rw---- 1 root disk 8, 33 Mar 20 18:08 /dev/sdc1

其中第 5 列分别是主次设备号。stat 会打印普通文件的设备号,如果是字符/块文件,还会打印它的真实设备号:

$ stat dirch.sh /dev/sda5
  File: dirch.sh
  Size: 401       	Blocks: 8          IO Block: 4096   regular file
Device: 805h/2053d	Inode: 35263364    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-20 17:42:51.626251788 +0800
Modify: 2022-03-20 17:42:48.942182594 +0800
Change: 2022-03-20 17:42:48.978183522 +0800
 Birth: -
  File: /dev/sda5
  Size: 0         	Blocks: 0          IO Block: 4096   block special file
Device: 5h/5d	Inode: 334         Links: 1     Device type: 8,5
Access: (0660/brw-rw----)  Uid: (    0/    root)   Gid: (    6/    disk)
Access: 2022-03-20 15:45:18.474776286 +0800
Modify: 2022-03-19 07:59:48.326347594 +0800
Change: 2022-03-19 07:59:48.326347594 +0800
 Birth: -

第 3 行 Device 和 Device type 输出的就是,第一个文件 (dirch.sh) 显示它位于的文件系统设备号是 0x0805 (805h),第二个设备文件的真实设备号也是 0805 (8,5),这就说明 dirch.sh 这个文件是存储在设备 /dev/sda5 这个设备上面。lsblk 命令可以列出系统中所有的设备,对快速查看设备号很有帮助:

$ lsblk
NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINT
……
sda      8:0    0 931.5G  0 disk 
├─sda1   8:1    0   512M  0 part /boot/efi
├─sda2   8:2    0     1K  0 part 
└─sda5   8:5    0   931G  0 part /
sdb      8:16   1  28.9G  0 disk 
└─sdb4   8:20   1  28.9G  0 part /media/yunh/Ubuntu 20.0
sdc      8:32   0   1.8T  0 disk 
└─sdc1   8:33   0   1.8T  0 part /media/yunh/Backup Plus
sr0     11:0    1  1024M  0 rom  

其中 sda 是系统自带的硬盘,分为 3 个分区 sda1/2/3;sdb 是 U 盘 (vfat);sdc 是移动硬盘 (exfat),后两者只包含一个分区。lsblk 的输出内容有一些和 mount 相似,可以相互参考着看。查看 sda1 分区上的 efi 文件:

 stat /boot/efi/
  File: /boot/efi/
  Size: 4096      	Blocks: 8          IO Block: 4096   directory
Device: 801h/2049d	Inode: 1           Links: 3
Access: (0700/drwx------)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 1970-01-01 08:00:00.000000000 +0800
Modify: 1970-01-01 08:00:00.000000000 +0800
Change: 1970-01-01 08:00:00.000000000 +0800
 Birth: -

其设备号 0x0801 与 lsblk 的输出一致,再查看另外两个设备 sdb4 和 sdc1 上的文件:

$ stat /media/yunh/Ubuntu\ 20.0/ /media/yunh/Backup\ Plus/
  File: /media/yunh/Ubuntu 20.0/
  Size: 8192      	Blocks: 16         IO Block: 8192   directory
Device: 814h/2068d	Inode: 1           Links: 13
Access: (0755/drwxr-xr-x)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 1970-01-01 08:00:00.000000000 +0800
Modify: 1970-01-01 08:00:00.000000000 +0800
Change: 1970-01-01 08:00:00.000000000 +0800
 Birth: -
  File: /media/yunh/Backup Plus/
  Size: 131072    	Blocks: 256        IO Block: 131072 directory
Device: 821h/2081d	Inode: 1           Links: 14
Access: (0755/drwxr-xr-x)  Uid: ( 1000/    yunh)   Gid: ( 1000/    yunh)
Access: 2022-03-20 18:08:58.000000000 +0800
Modify: 2022-03-20 18:08:58.250000000 +0800
Change: 2022-03-20 18:08:58.250000000 +0800
 Birth: -

它们的设备号分别为 0x0814 和 0x0821,展开为十进制后分别为 (8,20) 与 (8,33),和 lsblk 的输出也能对得上。这提供了一种快速查看文件所在设备的方法,你学会了吗?

不过这里测试的几个文件的主设备号都是 8,但他们明显不在同一块存储设备上,因此书上的说法是有问题的。

结语

本文尝试通过解释 api 接口底层做了什么来阐释 linux 文件系统在设计层面的一些考虑,配合通俗易懂的日常命令和简单程序来进行验证,践行“纸上得来终觉浅,绝知此事要躬行”的理念,目的是做一个 linux 文件系统的引入,后面有机会可以出一篇文章,专门阅读 linux 源码来证实本文的一些结论,想想就让人激动~~

参考

[1]. Linux文件系统详解

[2]. 硬盘基本知识(磁头、磁道、扇区、柱面)

[3]. 磁盘分区也是隐含了技术技巧的

[4]. Ext2文件系统简单剖析(一)

[5]. Linux下对inode和块的理解

[6]. inode 、数据块、磁盘容量

[7]. linux文件系统—inode及相关概念 inode大小的最佳设置

[8]. APUE—UNIX文件系统

[9]. 文件atime未变问题的研究

[10]. Linux下查看和修改文件时间

[11]. Linux中8个有用的touch命令

[12]. 准确获取linux文件的创建时间

[13]. Inode vs Vnode

[14]. Linux调试分析诊断利器——strace

[15]. Linux tar命令解压时提示时间戳异常的处理办法

[16]. Why atime is not preserved in tar?

页面下部广告