Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0.3 文件系统导航与文件操作

本章回答

Linux 目录树为什么这样组织?ls -l 的每一列是什么?硬链接与软链接在 inode 层有什么差别?cp -a、mv、rm -rf 的边界在哪?find 与 xargs -0 怎样配合才不会被带空格的文件名坑到?

读完你会

用 inode 解释链接、重命名与删除;读懂 stat 的三个时间;写出不会误删的批量操作;用 find 按类型、时间、大小筛文件,并用 -print0 安全传递。

Shell 把路径字符串交出去之后,真正理解它的是内核的 VFS。同一棵树上的每个名字,最终都落在某个 inode 上;目录只是一个「名字 → inode 号」的映射表。把这一层想清楚,链接、删除、重命名、磁盘占用这些看似无关的现象就会连成一条线。

这一章从目录树开始,先建立「文件系统是一棵挂载树」的模型,再逐列拆 ls -l,然后进入 inode 与链接。后半章讲操作:复制、移动、删除、查找、批量处理。每个操作都给出边界与失败模式,因为文件操作的错误往往不可逆。

命令以 Ubuntu 24.04 的 GNU coreutils 9.4、findutils 4.9 为准。BSD/macOS 的 find、stat、xargs 参数不同,脚本跨平台时要特别小心。

0.3.1 目录树:FHS 与 usrmerge

Linux 只有一棵以 / 为根的目录树。磁盘、分区、网络存储都通过挂载(mount)接到树的某个节点上,而不是各自拥有一个盘符。这是理解 df、du、find -xdev 的前提。

/
├── bin -> usr/bin          # usrmerge 后是指向 /usr/bin 的符号链接
├── sbin -> usr/sbin
├── lib -> usr/lib
├── etc/                    # 主机配置:/etc/passwd、/etc/fstab、/etc/ssh
├── home/                   # 普通用户主目录
├── root/                   # root 的主目录(不在 /home 下)
├── usr/                    # 只读的系统资源:bin、lib、share、include
│   ├── bin/                # 用户命令
│   ├── sbin/               # 管理命令
│   └── local/              # 本地编译安装,优先级高于发行版包
├── var/                    # 可变数据:log、spool、lib、cache
│   ├── log/
│   └── lib/
├── tmp/                    # 临时文件,可能被清理,不要放持久数据
├── run/                    # 本次启动的运行时状态(tmpfs)
├── dev/                    # 设备节点:sda、null、tty、pts
├── proc/                   # 内核投影:进程与参数
├── sys/                    # 内核投影:设备与驱动模型
├── boot/                   # 内核与 initramfs
├── opt/                    # 第三方整包软件
├── srv/                    # 服务数据(约定,非强制)
├── mnt/  media/            # 临时挂载点 / 可移动介质
└── lost+found/             # ext4 修复时找回的孤儿文件

这张树的读法是:配置在 /etc,数据在 /var,程序在 /usr,内核接口在 /proc 与 /sys。一个服务出问题,先按这四类找证据:配置改错看 /etc,写不进数据看 /var 的权限与空间,程序版本看 /usr,内核或设备看 /proc、/sys。

FHS(Filesystem Hierarchy Standard)规定了这些目录的用途,但不强制实现。Ubuntu 24.04 的 /bin 已经是指向 /usr/bin 的符号链接,这叫 usrmerge:把根文件系统上的可执行文件合并进 /usr,让根分区可以更小、容器镜像更容易做只读层。

# 确认 usrmerge 是否生效
ls -ld /bin /sbin /lib
# 输出应类似:/bin -> usr/bin

/usr/local 是本地安装的默认前缀。./configure --prefix=/usr/local 装出来的程序在 /usr/local/bin,通常排在 $PATH 中发行版目录之前。这也是为什么「源码装了一个新版本,系统命令却还是旧的」——先 type -a 看实际执行的是哪个路径。

0.3.2 路径、cwd 与解析

路径分绝对与相对。绝对路径从 / 开始;相对路径从进程的当前工作目录(current working directory, cwd)开始。cwd 是进程的属性,不是 Shell 的全局变量;每个进程各有一份,fork 后继承,chdir 后只影响自己。

pwd            # 逻辑路径:可能包含符号链接
pwd -P         # 物理路径:解析掉所有符号链接
cd /tmp && pwd # 切换 cwd
cd -           # 回到上一个目录,并打印它

pwd 默认输出 $PWD,它可能是带符号链接的逻辑路径;pwd -P 让内核重新解析,给出真实路径。写脚本时如果要比较两个目录是否是同一个 inode,用 pwd -P 或 stat -c %i,不要比较字符串。

路径解析由内核逐段完成。遇到符号链接就替换成目标,遇到 . 就留在当前目录,遇到 .. 就回到父目录。有两个容易忽略的规则:

  • .. 是按目录项解析的。如果 /a/link 是指向 /x/y 的符号链接,cd /a/link 后再 cd ..,回到的是 /x,不是 /a。因为 Shell 的 cwd 已经是 /x/y。
  • 末尾斜杠要求目标是目录。cp file dir/ 要求 dir 是目录;cp file dir 在 dir 不存在时是「复制成名为 dir 的文件」。这个差别在脚本里经常导致意外。

cd 是 Shell 内建,因为它要改变当前 Shell 的 cwd。/bin/cd 不存在。子进程里的 cd 不影响父 Shell:

# 子 Shell 里 cd,父 Shell 不动
( cd /tmp && pwd )   # 输出 /tmp
pwd                  # 仍在原目录

0.3.3 ls -l 逐列读与 inode

$ mkdir -p /tmp/fs-lab && cd /tmp/fs-lab
$ printf 'hello\n' > a.txt
$ ln a.txt hard.txt
$ ln -s a.txt soft.txt
$ ls -li
total 8
131073 -rw-r--r-- 2 wang wang 6 Oct 17 10:31 a.txt
131073 -rw-r--r-- 2 wang wang 6 Oct 17 10:31 hard.txt
131074 lrwxrwxrwx 1 wang wang 5 Oct 17 10:31 soft.txt -> a.txt
$ stat -c '%n inode=%i links=%h size=%s' a.txt hard.txt soft.txt
a.txt inode=131073 links=2 size=6
hard.txt inode=131073 links=2 size=6
soft.txt inode=131074 links=1 size=5
$ rm a.txt
$ cat hard.txt
hello
$ cat soft.txt
cat: soft.txt: No such file or directory

ls -l 的每一列:

列例子含义
权限-rw-r--r--类型 + 属主/属组/其他人的 rwx
链接数2指向同一 inode 的硬链接数(目录还含 . 与子目录的 ..)
属主wang文件所有者
属组wang文件所属组
大小6普通文件是字节数;目录是目录项块的大小;符号链接是目标路径长度
时间Oct 17 10:31默认显示 mtime;超过六个月显示年份
名字a.txt目录项的名字;符号链接追加 -> target

ls -i 在第一列显示 inode 号。a.txt 与 hard.txt 的 inode 号相同、链接数都是 2,说明它们是同一个文件的两个名字。soft.txt 是独立 inode,内容是字符串 a.txt。

stat 给出三个时间,初学最容易混:

时间字段何时更新常被误认为
atime%x读取文件内容时(受 relatime 限制)创建时间
mtime%y修改文件内容时修改权限的时间
ctime%z修改 inode 元数据(权限、属主、链接数、mtime)时创建时间

Linux 没有「创建时间」的通用接口。ext4 有 crtime,但 stat 默认不显示;Btrfs 有 btime。mtime 只反映内容变化,改权限只动 ctime。备份工具用 mtime 判断是否需要复制,用 ctime 判断元数据是否变化。

Ubuntu 默认挂载选项含 relatime:只有 atime 比 mtime/ctime 旧,或超过 24 小时,才更新 atime。这是为了减少每次读文件都写元数据的开销。要更精确的 atime,用 strictatime,但会明显增加写放大。

0.3.4 硬链接与软链接

定义 0.3.1 · 硬链接与符号链接

硬链接(hard link)是目录中一个「名字 → inode」的目录项。多个硬链接指向同一个 inode,共享同一份数据与元数据;删除一个名字只是把 inode 的链接数减一,减到 0 且没有进程打开时才释放数据。符号链接(symbolic link,软链接)是一个独立文件,内容是目标路径字符串;访问时内核按该字符串重新解析。

硬链接:
  /home/wang/a.txt ─┐
                    ├──→ inode 131073(links=2)──→ 数据块 "hello\n"
  /home/wang/hard.txt ┘
​
符号链接:
  /home/wang/soft.txt ──→ inode 131074(内容是 "a.txt")──→ 解析后到达 a.txt

两者的边界:

维度硬链接符号链接
跨文件系统不允许,EXDEV允许
指向目录普通用户不允许(防止环)允许
目标删除后数据仍在,另一个名字可用悬空(dangling),访问报 ENOENT
大小与目标相同目标路径字符串的长度
权限与目标共享自身 lrwxrwxrwx,权限位被忽略
rm 行为链接数减一删除链接本身,不动目标
备份/打包需保留链接关系(tar 默认保留)保存目标字符串

创建与检查:

ln target hardlink          # 硬链接;target 必须存在
ln -s target symlink        # 符号链接;target 可以不存在
ln -sfn /new/target link    # -f 覆盖,-n 把链接当文件而不是目录
readlink link               # 打印链接内容
readlink -f link            # 解析到最终绝对路径;悬空则失败
ls -l link                  # 看箭头
find . -maxdepth 1 -type l  # 找符号链接

ln -s target link 中的 target 若是相对路径,相对于 link 所在目录解析,不是相对于当前目录。这是脚本里最常见的悬空链接来源:

# 错误:在 /tmp 下执行,链接指向 /tmp/a.txt,而不是 /etc/a.txt
ln -s /etc/a.txt /tmp/link        # 绝对路径,没问题
ln -s a.txt /tmp/rel-link         # 指向 /tmp/a.txt
# 正确写法之一:先 cd 到目标目录,或用绝对路径

硬链接还有一个常被忽略的用途:节省空间。同一份大文件在同一个文件系统内做多个硬链接,只占一份数据;rsync -H、tar 的硬链接保留、Borg 的去重都依赖这个概念。反过来,du 默认对同一 inode 只计一次,du --count-links 才会重复计。

0.3.5 cp、mv、rm 的边界

三个命令看起来最简单,出事也最多。

cp:复制的是内容与元数据

写法行为
cp src dst复制内容;dst 存在则覆盖(默认不提示)
cp -i src dst覆盖前询问;别名常设为 cp -i
cp -r dir dst递归复制,不保留权限、时间、属主
cp -a dir dstarchive:递归 + 保留权限/时间/链接/属主(属主需 root)
cp -p src dst保留 mode、ownership、timestamps
cp --reflink=auto src dst支持 CoW 的文件系统(Btrfs/XFS)上做轻量复制

cp -a 是备份与迁移的默认选择。它保留符号链接本身而不是跟随,保留硬链接关系,保留权限与时间。cp -r 不保留这些,恢复出来的服务可能因为权限不对而起不来。

mv:同一文件系统内是 rename

在同一文件系统内,mv 只是修改目录项,原子且几乎不耗时;跨文件系统时,mv 变成「复制 + 删除」,中途失败可能留下半个目标文件。判断是否同一文件系统:

stat -c '%d %n' /src/file /dst/
# 第一列是设备号;不同则是跨文件系统
findmnt -T /dst

跨文件系统移动大文件时,先 cp -a 到目标、校验、再删除源;不要直接 mv 后假设成功。mv 覆盖目标时也是原子的 rename:目标要么是旧文件,要么是新文件,不会出现半个。

rm:没有回收站

rm 删除的是目录项,inode 的链接数减一;数据块在链接数为 0 且没有进程打开时释放。如果还有进程持有 fd,磁盘空间不会立即回收——这就是「rm 了日志,df 还是满」的原因。

安全习惯:

# 1. 先列出将被删除的内容,确认路径
find /var/tmp/cache -mindepth 1 -maxdepth 1 -print

# 2. 变量必须加引号并检查非空
: "${CACHE_DIR:?CACHE_DIR is not set}"
[[ $CACHE_DIR == /var/tmp/cache ]] || { echo "unexpected dir"; exit 1; }

# 3. 用 -- 终止选项解析,防止文件名以 - 开头
rm -rf -- "$CACHE_DIR"/*

# 4. 更稳的做法:先移到隔离目录,确认后再删
mkdir -p /var/tmp/trash && mv -- "$CACHE_DIR"/* /var/tmp/trash/

rm -rf 的三个高危模式:

  • 变量为空:rm -rf "$DIR"/ 在 DIR 为空时变成 rm -rf /。用 ${DIR:?} 强制非空。
  • 路径拼错:rm -rf /var/log/app/ 与 rm -rf /var/log/app 只差一个斜杠,但前者删目录内容,后者删目录本身;在符号链接上差别更大。
  • 通配符没匹配:rm -rf $DIR/* 在 $DIR 为空时变成 rm -rf *,删除当前目录。用 find ... -delete 或先检查。

rm 默认不进入回收站。需要可恢复删除时,用 trash-cli(trash-put)或桌面环境的回收站;服务器上更常见的做法是「先移动到带时间戳的隔离目录,定期清理」。

0.3.6 find:按条件筛文件

find 的模型是「从起点开始遍历,对每个路径求表达式,为真则执行动作」。默认动作是 -print。

条件例子说明
名字-name '*.log'、-iname '*.LOG'通配符由 find 解释,必须加引号
类型-type f、-type d、-type l普通文件/目录/符号链接
时间-mtime -7、-mmin -30、-newer ref按天/分钟;-7 表示 7 天内
大小-size +100M、-size -1k+ 大于,- 小于;单位 c/k/M/G
权限-perm 4755、-perm -4000精确/至少包含
属主-user alice、-group dev按名字或 UID
深度-maxdepth 2、-mindepth 1限制遍历深度
组合\( -name '*.log' -o -name '*.tmp' \)括号需转义,-a 是默认

三个高频用法:

# 1. 找大文件(不跨文件系统,避免扫到挂载的网络盘)
find /var -xdev -type f -size +100M -printf '%s\t%p\n' 2>/dev/null | sort -nr | head -20

# 2. 找最近修改过的配置
find /etc -type f -mmin -60 -print

# 3. 找 setuid 程序(权限审计,见 0.4)
find / -xdev -type f -perm -4000 -print 2>/dev/null

-printf 是 GNU find 的扩展,比 -exec stat 快得多。%s 是字节数,%p 是路径,%T@ 是 mtime 的 epoch 秒。BSD/macOS 的 find 没有 -printf,跨平台脚本要用 -exec stat 或 -print0 交给其他工具。

-exec 有两种结尾:\; 每个文件执行一次,+ 尽量批量执行。批量更快,但命令必须能接受多个参数:

# 每个文件一次;{} 必须与 \; 分开
find . -name '*.tmp' -exec rm -- {} \;

# 批量传参;等价于 xargs,但没有空格问题
find . -name '*.tmp' -exec rm -- {} +

-delete 是 find 自带的删除动作,比 -exec rm 少一次 fork,但必须放在所有条件之后,且默认不会跟随符号链接。先用 -print 试跑,再把 -print 换成 -delete:

# 先看
find /var/tmp/cache -type f -mtime +30 -print
# 确认后删
find /var/tmp/cache -type f -mtime +30 -delete

-prune 用来排除目录:find . -path ./node_modules -prune -o -type f -print。逻辑是「匹配 node_modules 就剪枝,否则打印普通文件」;-o 的优先级和括号需要小心。

0.3.7 xargs -0 与批量操作

xargs 把标准输入的「词」变成命令行参数。默认按空白分词,遇到带空格的文件名就断错;文件名里还有换行、引号、反斜杠时,几乎必错。

正确做法是让上游用 NUL 分隔,下游用 -0:

# 上游 -print0,下游 -0;-r 在空输入时不执行命令
find /srv/app/conf -type f -name '*.conf' -print0 \
    | xargs -0 -r sha256sum > /var/tmp/conf.sha256

# 每次只传一个文件;-I{} 会把 {} 替换成参数
find . -name '*.png' -print0 \
    | xargs -0 -r -I{} sh -c 'pngquant --force --output "$1" "$1"' _ {}

# 并行 4 路;适合压缩、校验等 CPU 密集任务
find . -type f -name '*.log' -print0 \
    | xargs -0 -r -n1 -P4 gzip

-I{} 隐含 -n1,且会把参数中的引号处理得更保守;它比 -n1 慢,但可读。sh -c '...' _ {} 里的 _ 是 $0 占位,{} 变成 $1,这样文件名不会被当成 shell 代码执行——直接 sh -c 'cmd {}' 在文件名含 ; 时会注入命令。

xargs 的退出码有坑:只要有一次调用失败,它可能仍返回 0(取决于版本与 -r/--exit)。批量任务要检查每个子命令的退出码,或改用 find -exec ... + 并在脚本里检查 $?。

关于文件与链接的五个误解
  1. 「rm 会立刻释放磁盘空间」——只有 inode 链接数为 0 且没有进程打开时才释放。日志被删但 df 仍满,先用 lsof +L1 找持有 fd 的进程。
  2. 「硬链接可以跨分区」——硬链接是同一文件系统内的目录项,跨文件系统返回 EXDEV。跨分区要用符号链接或复制。
  3. 「符号链接的权限决定能不能访问」——内核按目标文件的权限判定;符号链接自身的 lrwxrwxrwx 只是占位。
  4. 「mtime 就是创建时间」——mtime 是内容修改时间,ctime 是 inode 变更时间,Linux 没有通用的创建时间字段。
  5. 「du 的总和等于 df 的已用」——du 只统计可见文件,且默认对硬链接去重;df 统计文件系统块,包含保留块、已删除但被打开的文件、元数据。两者对不上是常态。

0.3.8 看清占用:du、tree 与 ncdu

du 统计目录下文件占用的磁盘块,不是表观大小。稀疏文件、压缩文件系统、CoW 快照都会让两者不同。

命令用途注意
du -sh dir目录总占用-s 只给总和
du -h --max-depth=1 dir | sort -h逐个子目录排序sort -h 认 K/M/G
du -x -sh /不跨文件系统避免把 /proc、网络盘算进来
du --apparent-size -sh表观大小稀疏文件会显得很大
tree -L 2 -d看目录结构只看两层,避免刷屏
ncdu /var交互式浏览需安装;按 d 删除前会确认

一个实用的排查顺序:

# 1. 哪个分区满了
df -h
# 2. 该分区下哪个一级目录最大
sudo du -x -h --max-depth=1 /var | sort -h | tail -10
# 3. 逐层下钻,直到定位到大文件/大目录
sudo du -x -h --max-depth=1 /var/log | sort -h | tail -10
# 4. 找已删除但仍被打开的文件
sudo lsof +L1 2>/dev/null | head

du 在大目录上会慢,因为它要遍历所有 inode。ncdu 把结果缓存到内存,交互式下钻更快;duc、gt5 也做同样的事。服务器上没装这些工具时,du --max-depth=1 | sort -h 足够。

文件操作的最后一组是打包与压缩。tar 只负责把多个路径串成一个归档,压缩由 gzip/xz/zstd 完成;-z、-j、-J 只是让 tar 顺手调用对应压缩器。

目的命令说明
创建 gzip 归档tar -czf app.tar.gz app/-c 创建,-z gzip,-f 指定文件
查看内容tar -tzf app.tar.gz解压前先看清单,确认没有绝对路径或 ..
解压到目录tar -xzf app.tar.gz -C /srv/-x 解压,-C 先切到目标目录
去掉顶层目录tar -xzf src.tar.gz --strip-components=1 -C src/源码包常见:解压后不要多一层 pkg-1.2.3/
排除文件tar -czf logs.tar.gz --exclude='*.log' var/模式要加引号,交给 tar 而不是 Shell
压缩单文件gzip -k app.log / zcat app.log.gzgzip 默认删除原文件,-k 保留
跨平台交换zip -r site.zip site/ / unzip -l site.zipzip 方便 Windows 解压,但不保留属主与硬链接

tar 的 -f 后面必须紧跟归档文件名,-czf 的顺序不能乱。解压不可信归档前先 tar -tzf 看清单,并在空目录或专用目录里解,不要直接解到 / 或当前工作目录;以 root 解压时,归档里的绝对路径和 .. 可能覆盖系统文件。tar 会保留目录结构与时间;权限在 root 解压时按归档恢复,普通用户受 umask 影响。需要 ACL、xattr、SELinux 标签时加 --acls --xattrs --selinux(见 0.8 的备份脚本)。

0.3.9 FHS 的边界:同一棵树,不同的写入规则

上一节只画了树的形状。真正决定「东西该放哪」的是每个目录的生命周期与写入者:/usr 随发行版升级整体替换,/etc 由管理员改,/var 由服务自己写,/run 每次启动重建。把文件放错层,症状往往不是「找不到」,而是升级后被覆盖、容器里丢失、只读根分区上写失败。

目录谁写生命周期典型误用
/usr包管理器随发行版版本替换手工放配置或运行数据,升级后消失
/usr/local管理员跨发行版升级保留与包管理器抢同一命令名
/opt第三方整包随软件自身升级把零散脚本塞进 /opt,没有包边界
/etc管理员/配置管理跨升级保留,需合并存二进制、缓存、大文件
/var/lib服务进程持久状态,必须备份放可重建的缓存,备份膨胀
/var/cache服务进程可随时删除重建放数据库文件,清缓存后数据丢失
/var/tmp任意用户重启保留,清理周期更长当永久存储;仍可能被 tmpfiles 清
/run服务/系统本次启动,tmpfs放需要跨重启的 PID、socket
/tmp任意用户可能每次启动清空放长任务中间结果,跑到一半被清
/srv管理员站点数据,约定而非强制发行版默认不建,脚本假设它存在
/boot包管理器内核与引导器放业务数据;小分区容易被内核塞满

这张表的用法是:看到一个文件,先问「它能不能从别处重建」。能重建的放 /var/cache 或 /tmp;不能重建且属于服务状态的放 /var/lib;属于主机配置的放 /etc;属于程序本体的放 /usr。备份策略直接由这一列决定。

/usr 只读化是近十年的趋势。Fedora Silverblue、Flatpak 容器镜像、Android 都把 /usr 做成只读,/etc 用 overlay 合并,/var 单独可写。Ubuntu 24.04 的根分区仍可写,但 usrmerge 已经把 /bin、/sbin、/lib 收进 /usr,目的是让根分区只保留引导所需的最小内容。

# 确认哪些顶层目录是独立挂载
findmnt -t ext4,xfs,btrfs,overlay,tmpfs -o TARGET,SOURCE,FSTYPE,OPTIONS
# 确认某个路径落在哪个文件系统
findmnt -T /var/lib/docker
# 看挂载点与设备号
stat -c '%d %n' / /var /tmp

/run 与 /var/run 的关系要特别记住:Ubuntu 上 /var/run 是指向 /run 的符号链接,PID 文件、socket、锁文件按 FHS 应放 /run。把 PID 文件写到 /var/run 在旧脚本里很常见,能工作只是因为符号链接;在只读 /var 或容器里就会失败。

systemd-tmpfiles 决定 /tmp 与 /var/tmp 的清理周期。Ubuntu 的 /usr/lib/tmpfiles.d/tmp.conf 默认清理 /tmp 中 10 天未访问的文件,/var/tmp 是 30 天。「临时」不等于「本次进程结束就删」,也不等于「安全」。长任务把中间结果放 /tmp,跑到第 11 天可能被清掉;正确位置是任务自己的工作目录或 /var/tmp 下的专用子目录。

`/usr/local` 与 `/opt` 不是同义词
  1. /usr/local 是前缀,遵循 FHS 的子目录结构(bin、lib、share、include),适合「用源码装一个和发行版并行的版本」。
  2. /opt 是整包目录,每个软件一个子目录(/opt/google/chrome),适合不遵循 FHS 布局的商业软件或自带全部依赖的包。
  3. 把源码包装到 /opt/app/bin 后,$PATH 不会自动包含它;需要显式加,或用 update-alternatives 管理多版本。
  4. /usr/local/bin 通常排在 $PATH 的发行版目录之前,这就是「装了新版本却执行旧版本」的常见原因;用 type -a cmd 看全部候选。

0.3.10 路径解析:从字符串到 dentry

open("/srv/data/report.csv") 传进去的是一串字节,内核要把它变成 struct path { vfsmount *, dentry * }。这个过程叫路径解析(path resolution),由 fs/namei.c 的 link_path_walk()、walk_component()、step_into() 完成。理解它,才能解释 ..、末尾斜杠、符号链接上限和 openat2 的安全选项。

每解析一段,内核先在目录项缓存(dentry cache)里找「父目录 + 名字」对应的 dentry。命中就不读磁盘;未命中才调用具体文件系统的 lookup,从目录数据块里读出「名字 → inode 号」,再取 inode。dentry 是纯内存结构,带引用计数,dcache 满时按 LRU 回收。/proc/sys/vm/vfs_cache_pressure 影响回收倾向。

用户态字符串 "/srv/data/report.csv"
        │
        ▼
link_path_walk()
  ├─ 取根或 cwd 的 struct path
  ├─ 逐段 walk_component()
  │    ├─ dcache 命中 → dentry
  │    ├─ 未命中 → 文件系统 lookup() → inode
  │    ├─ 是挂载点?→ follow_mount() 换 vfsmount
  │    └─ 是符号链接?→ pick_link() 取目标,重新解析(最多 40 次)
  └─ 最后一段按 flags 决定是否跟随(O_NOFOLLOW / LOOKUP_FOLLOW)
        │
        ▼
struct path { vfsmount, dentry } → inode → address_space → 页缓存

读图的重点是:符号链接在解析过程中被替换,不是打开后的重定向。open("link") 得到的是目标文件的 inode;lstat("link") 因为 AT_SYMLINK_NOFOLLOW 停在链接本身。O_NOFOLLOW 只对最后一段生效,中间段的符号链接仍会被跟随;要禁止所有段,用 openat2() 的 RESOLVE_NO_SYMLINKS。

几个硬边界:

边界值/行为失败
整个路径长度PATH_MAX 4096 字节(含结尾 NUL)ENAMETOOLONG
单个名字长度NAME_MAX 255 字节ENAMETOOLONG
符号链接嵌套40 次(MAXSYMLINKS)ELOOP
中间段不是目录例如 /etc/passwd/xENOTDIR
末尾斜杠指向非目录open("file/")ENOTDIR
悬空符号链接open("dangling")ENOENT
无权限穿过目录缺少目录 xEACCES
.. 到达挂载根停在挂载根,不跨回父文件系统正常

.. 的语义容易被误解。内核解析 .. 时,如果当前 dentry 是某个挂载点的根,它会回到挂载点下面的父目录,而不是父文件系统的同名目录;如果已经在全局根 /,.. 仍是 /。这与「cd .. 一定上一层」的直觉不同,尤其在 chroot、mount namespace、容器里。

cwd 是 struct fs_struct 里的一个 struct path,持有 dentry 与 vfsmount 的引用。chdir() 改变它,fchdir() 从 fd 恢复。因为它持有引用,删掉当前目录后进程仍然停在那个已删除的 inode 上:

mkdir /tmp/gone && cd /tmp/gone
rmdir /tmp/gone
pwd                  # /tmp/gone(逻辑路径仍记得)
pwd -P               # 可能报错或显示 /tmp/gone (deleted)
ls                   # 仍能列出已删除目录的内容(如果有)

pwd 默认打印 $PWD,它由 Shell 维护,可能包含符号链接;pwd -P 调 getcwd(),由内核从 cwd 逐级向上拼出真实路径。脚本里判断「两个路径是否同一目录」,不要比较字符串,比较 stat -c %d:%i 或 [[ a -ef b ]]。

cd 是 Shell 内建,除了 chdir() 还维护 $PWD、$OLDPWD 和目录栈:

cd /srv/app
cd -                 # 回到上一个目录并打印
pushd /var/log       # 压栈
dirs -v              # 看栈
popd                 # 弹栈
CDPATH=/srv:/var/www cd app   # 按 CDPATH 搜索,脚本里不要用

脚本里更稳的写法是显式绝对路径,并检查 cd 是否成功:

cd -- "$workdir" || { printf 'cannot cd to %s\n' "$workdir" >&2; exit 1; }

cd 失败后继续执行,后续相对路径会落在错误目录,这是「脚本删错目录」的常见起点。

PATH 的查找不在内核里。execvp() 按 PATH 逐项拼路径,execve() 只接受一个确定的路径。PATH 中的空项表示当前目录(等价于 .),这是安全风险;type -a cmd 能看到所有候选,command -v cmd 只看第一个。修改 PATH 后 hash -r 清掉 Shell 的命令哈希缓存。

末尾斜杠、//、空段、. 段在不同系统上有细微差异。POSIX 允许实现把 // 解释为实现定义的特殊路径;Linux 把连续斜杠当单个斜杠。a//b、a/./b 都等价于 a/b。脚本里不要依赖这些形式做「路径规范化」,用 realpath -m(不要求存在)或 readlink -f。

定义 0.3.2 · `openat2` 的解析约束

openat2(dirfd, path, how, size) 在 openat 基础上增加 struct open_how.resolve,用位掩码限制路径解析:RESOLVE_NO_XDEV 禁止跨挂载点,RESOLVE_NO_MAGICLINKS 禁止 /proc/self/fd 这类魔法链接,RESOLVE_NO_SYMLINKS 禁止所有符号链接,RESOLVE_BENEATH 禁止 .. 逃出 dirfd,RESOLVE_IN_ROOT 把 dirfd 当根。它是「安全打开用户提供的路径」的现代接口。

TOCTOU(time-of-check to time-of-use)是路径操作的经典漏洞:先用 stat 检查,再 open,两步之间攻击者可以把路径换成符号链接。修复不是「检查得更快」,而是把检查与使用合并到同一个 fd 上:

# 不安全:检查与使用之间路径可能被替换
[[ -f $path ]] && cat "$path"

# 较安全:打开后对 fd 做 fstat,后续只用 fd
exec {fd}<"$path" || exit 1
[[ -f /proc/self/fd/$fd ]] || exit 1
cat <&"$fd"
exec {fd}<&-

C 里对应 open(O_NOFOLLOW) + fstat + fchmod/fchown,需要完整路径控制时用 openat2 的 RESOLVE_BENEATH。这一节只需要记住:路径字符串在解析完成前都可能变;fd 一旦打开就稳定。

0.3.11 ls -l 与 stat:字段级拆解

ls -l 的七列只是 stat 结果的摘要。真正排障时要看原始字段,尤其是 st_blocks、st_dev、st_ino、st_nlink 和四个时间。

列ls 选项stat 字段易错点
类型+权限默认st_mode特殊位显示为 s/S/t/T
链接数默认st_nlink目录的链接数含 . 与子目录的 ..
属主-n 显示 UIDst_uid名字来自 NSS,可能查不到
属组-n 显示 GIDst_gid同上
大小-h 人性化st_size目录是目录块大小;设备是主次设备号
时间--time-stylest_mtim 等默认 mtime,超过六个月显示年份
名字-Q 加引号—符号链接追加 -> target
块数-sst_blocks单位 512 字节,稀疏文件远小于 st_size
inode-ist_ino同一文件系统内唯一

stat 的格式化输出比 ls 精确,适合脚本:

格式含义例
%A人类可读权限-rw-r--r--
%a八进制权限644
%f原始 mode 十六进制81a4
%F文件类型描述regular file
%s表观大小(字节)6
%b分配的 512 字节块数8
%B%b 的单位(512)512
%o优选 I/O 块大小4096
%iinode 号131073
%h硬链接数2
%d设备号(十进制)66305
%D设备号(十六进制)10301
%u/%UUID/用户名1000/wang
%g/%GGID/组名1000/wang
%t/%T设备主/次号(十六进制)1/3
%x/%y/%zatime/mtime/ctime2024-10-17 10:31:00
%w/%W出生时间/epoch- 表示不支持
%n文件名a.txt
stat -c 'path=%n type=%F mode=%A(%a) inode=%i links=%h size=%s blocks=%b*%B dev=%d uid=%u gid=%g' /etc/passwd
# path=/etc/passwd type=regular file mode=-rw-r--r--(644) inode=... links=1 size=... blocks=8*512 dev=... uid=0 gid=0

struct stat 的字段顺序与类型(Linux x86-64,man 2 stat):

字段类型含义
st_devdev_t所在文件系统设备号
st_inoino_tinode 号
st_nlinknlink_t硬链接数
st_modemode_t类型 + 12 位权限
st_uid/st_giduid_t/gid_t属主/属组
st_rdevdev_t设备节点的主次号(普通文件无意义)
st_sizeoff_t表观大小
st_blksizeblksize_t优选 I/O 块大小
st_blocksblkcnt_t分配的 512 字节块数
st_atim/st_mtim/st_ctimtimespec访问/修改/元数据变更

st_blocks 是「实际分配」而不是「表观大小」。普通文件写 1 字节通常也会分配一个 4 KiB 块,st_blocks 是 8;稀疏文件写 1 字节再 truncate 到 1 GiB,st_size 是 1 GiB,st_blocks 仍可能只有 8。ls -l 显示前者,du 与 ls -s 显示后者。

四个时间要分开:

时间何时更新谁会用
atime读取文件内容(受挂载策略限制)备份、缓存淘汰、取证
mtime写入文件内容make、rsync、增量备份
ctimeinode 元数据变化(权限、属主、链接、mtime 本身)审计、入侵检测
btime创建时间(ext4 crtime、XFS、Btrfs)取证;stat 需 %w/statx

ctime 不是「创建时间」,也不能被用户直接设置。chmod、chown、ln、mv(同一文件系统)都会更新 ctime;touch 改 mtime 也会顺带更新 ctime。备份工具用 mtime 判断内容是否变,用 ctime 判断元数据是否变;只比 mtime 会漏掉「内容没变但权限被改」的情况。

atime 的更新策略由挂载选项决定:

选项行为代价
strictatime每次读都更新读放大,元数据写多
relatime(默认)atime 旧于 mtime/ctime,或超过 24 小时才更新兼顾正确性与性能
noatime完全不更新依赖 atime 的工具会失真
lazytime只在内存标记,定期/卸载时写回崩溃可能丢 atime 更新
findmnt -T /var -o TARGET,OPTIONS
# 临时改回严格 atime(需要 root;生产慎用)
sudo mount -o remount,strictatime /var

statx() 是 stat 的现代替代,能一次取回 btime、挂载 ID、属性位、DIO 对齐等信息:

struct statx stx;
if (statx(AT_FDCWD, path, AT_STATX_SYNC_AS_STAT, STATX_BASIC_STATS | STATX_BTIME, &stx) < 0) {
    perror("statx");
    return 1;
}
printf("ino=%llu size=%llu btime=%lld.%09u attrs=0x%llx\n",
       (unsigned long long)stx.stx_ino,
       (unsigned long long)stx.stx_size,
       (long long)stx.stx_btime.tv_sec, stx.stx_btime.tv_nsec,
       (unsigned long long)stx.stx_attributes);

stx_mask 告诉你哪些字段有效:文件系统不支持 btime 时,STATX_BTIME 位不会置位,stx_btime 全 0。不要用「btime 为 0」判断文件创建于 1970 年。

0.3.12 文件类型、设备节点与虚拟文件

ls -l 第一个字符是文件类型,stat -c %F 给文字描述。Linux 有七种:

字符类型创建方式读写的语义
-普通文件open(O_CREAT)字节流;可 mmap
d目录mkdir目录项表;getdents 读
l符号链接symlink内容是目标路径;readlink 读
c字符设备mknod(需 CAP_MKNOD)走驱动 read/write/ioctl
b块设备mknod走块层;通常挂载后访问
pFIFOmkfifo管道语义;open 可能阻塞
sUnix socketbind(AF_UNIX)socket API;ss -xl 查看

/dev 下的设备节点由 devtmpfs 或 systemd-udevd 维护。ls -l /dev/null 显示 1, 3,这是主设备号 1、次设备号 3;stat -c %t:%T 给十六进制(1:3)。mknod 手工建设备节点需要 root 与 CAP_MKNOD,容器默认没有这个 capability,所以容器里不能随意 mknod。

几个特殊设备的边界值得记住:

ls -l /dev/null /dev/zero /dev/full /dev/random /dev/urandom
# crw-rw-rw- 1 root root 1, 3 ... /dev/null
# crw-rw-rw- 1 root root 1, 5 ... /dev/zero
# crw-rw-rw- 1 root root 1, 7 ... /dev/full
设备读写用途
/dev/null立即 EOF丢弃,返回成功丢弃输出
/dev/zero无限零字节丢弃初始化、dd
/dev/full无限零字节返回 ENOSPC测试写失败路径
/dev/random熵不足时阻塞(现代内核用 CRNG 初始化后不阻塞)混合熵池旧脚本的随机源
/dev/urandom不阻塞混合熵池现代推荐的随机源
/dev/tty当前控制终端同上脚本向用户提问
/dev/fd/N复制 fd N同上进程替换、/dev/stdin

/dev/stdin、/dev/stdout、/dev/stderr 在 Linux 上是指向 /proc/self/fd/0、1、2 的符号链接。cmd 2>/dev/stdout 与 cmd 2>&1 在多数情况下等价,但不是同一个操作:2>&1 复制当前 fd 1 的打开文件描述;2>/dev/stdout 重新打开 /proc/self/fd/1 指向的目标。如果 fd 1 是一个带 O_APPEND 的文件,重新打开会继承 flags?/proc/self/fd/N 的 open 会复制原 fd 的 flags(除 O_CLOEXEC 等),但偏移是否共享取决于实现。需要精确复制时用 2>&1,不要用 /dev/stdout。

FIFO 的 open 语义:

  • 以读打开、没有写者:阻塞,直到有写者;
  • 以写打开、没有读者:阻塞,直到有读者;
  • O_NONBLOCK 读:立即成功,读不到返回 EAGAIN;
  • O_NONBLOCK 写、没有读者:返回 ENXIO;
  • 读写都结束后,FIFO 里的数据不持久,下次打开是空的。
mkfifo /tmp/demo.fifo
# 终端 A:阻塞等待
cat /tmp/demo.fifo
# 终端 B:
printf 'hello\n' > /tmp/demo.fifo

Unix socket 文件在 ls -l 里是 s,不能用 cat 读,要用 socket()/connect()。ss -xl 列出现有 socket,ss -xlp 带进程。删除 socket 文件不会关闭已建立的连接,只会让新连接找不到路径。

/proc 与 /sys 是虚拟文件系统,stat 显示的大小通常是 0,但 cat 能读出内容。它们的读写不是普通文件语义:/proc/sys/... 写入会触发内核处理函数,/sys/... 通常一个文件一个值,写入格式由驱动定义。不要对它们做 cp -a、tar、rsync 的常规备份;备份工具应排除 /proc、/sys、/dev、/run。

file 命令按魔数判断类型,不只看扩展名:

file /bin/ls /etc/passwd /dev/null /tmp/demo.fifo
# /bin/ls: ELF 64-bit LSB pie executable, x86-64, ...
# /etc/passwd: ASCII text
# /dev/null: character special (1/3)
# /tmp/demo.fifo: fifo (named pipe)

脚本里判断类型用 test 而不是 file:-f 普通文件、-d 目录、-L 符号链接、-p FIFO、-S socket、-b 块设备、-c 字符设备、-s 非空。test 会跟随符号链接(除 -L/-h),要判断链接本身用 -L。

0.3.13 inode、稀疏文件与磁盘占用

inode 是文件系统里「除名字和内容之外的一切」:类型、权限、属主、大小、块指针、时间、链接数。目录项只存「名字 → inode 号」。同一个 inode 可以有多个名字(硬链接),一个名字删除后 inode 仍可能被进程持有。

ext4 的 inode 是固定大小的表项(默认 256 字节),格式化时确定总量。df -i 看 inode 使用率;大量小文件会把 inode 用完,此时即使还有空间也不能创建新文件,报 No space left on device。mke2fs -N 可以在格式化时调整 inode 数量,事后扩容困难。

df -i /var
# Filesystem      Inodes  IUsed   IFree IUse% Mounted on
# /dev/nvme0n1p2 31227904 812345 30415559    3% /

st_nlink 对普通文件是硬链接数;对目录是 2 + 子目录数(. 和父目录里的 .. 各算一个,每个子目录的 .. 再算一个)。所以一个空目录的链接数是 2,有一个子目录就变 3。find . -type d -links 2 能找出「没有子目录的目录」。

稀疏文件是「表观大小很大、实际分配很小」的文件。创建方式:

# 1. truncate:只改 inode 的 size,不分配块
truncate -s 1G /tmp/sparse.img

# 2. dd seek:跳过前面的块
dd if=/dev/zero of=/tmp/sparse.img bs=1 count=1 seek=1073741823

# 3. fallocate:分配但不写(ext4 的 unwritten extent)
fallocate -l 1G /tmp/allocated.img

对比:

ls -l  /tmp/sparse.img /tmp/allocated.img
ls -ls /tmp/sparse.img /tmp/allocated.img
du -h  /tmp/sparse.img /tmp/allocated.img
du -h --apparent-size /tmp/sparse.img
stat -c '%n size=%s blocks=%b' /tmp/sparse.img /tmp/allocated.img

预期:sparse.img 的 st_size 是 1 GiB,st_blocks 很小(可能 0 或 8),du 显示几 KiB;allocated.img 的 st_blocks 接近 1 GiB/512。fallocate 分配的是 unwritten extent,读出来是零,但块已经预留,不会在写入时因空间不足失败。truncate 不预留,写入时可能 ENOSPC。

SEEK_HOLE 与 SEEK_DATA 让程序跳过空洞,只读实际数据:

off_t data = lseek(fd, 0, SEEK_DATA);
off_t hole = lseek(fd, data, SEEK_HOLE);

cp --sparse=always、tar --sparse、rsync -S 会利用这些接口保留稀疏性。普通 cp 默认 --sparse=auto,源是稀疏且目标文件系统支持时才保留;cp --sparse=never 会把空洞写成真实的零块,文件可能从几 KiB 膨胀到 1 GiB。备份大镜像前先确认工具是否保留稀疏。

du 与 df 的口径不同,对不上是常态:

工具统计对象包含不包含
du目录下可见文件的分配块硬链接去重(默认)已删除但打开的文件、保留块、快照
df文件系统块元数据、保留块、已删除打开文件未被任何目录引用的 orphan(通常已回收)
ls -l表观大小空洞实际分配
ls -s分配块(1 KiB 单位)间接块空洞

排查「df 满但 du 小」的顺序:

# 1. 确认分区与保留块
df -h /var; sudo tune2fs -l /dev/nvme0n1p2 | grep -i 'reserved block'
# 2. 找已删除但仍被打开的文件
sudo lsof +L1 2>/dev/null | sort -k7 -nr | head
# 3. 找隐藏的挂载(du -x 不会跨)
findmnt -R /var
# 4. 找大目录,逐层下钻
sudo du -x -h --max-depth=1 /var | sort -h | tail
# 5. 文件系统快照/CoW 占用(btrfs/zfs/lvm)
sudo btrfs filesystem du -s /var 2>/dev/null

lsof +L1 的输出里 NLINK 是 0,表示文件已被删除但仍有进程持有 fd。/proc/PID/fd 能看到符号链接指向 ... (deleted)。解决方式是重启持有者、让它重新打开日志,或 : > /proc/PID/fd/N 截断(对追加写日志有效,对普通文件要小心偏移)。

CoW 文件系统(Btrfs、XFS reflink、ZFS、overlayfs)还有一层:cp --reflink=always 创建的是共享 extents,修改任一份才复制对应块。du 会把共享块算给每个文件,btrfs filesystem du 才会给出「独占/共享」的拆分。容器镜像的层也是同理:docker ps -s 的 SIZE 是读写层,镜像层共享。

关于 inode 与占用的六个误解
  1. 「删除文件一定释放空间」——链接数为 0 且没有打开的 fd 才释放;lsof +L1 找残留。
  2. 「ls -l 的大小就是磁盘占用」——它是表观大小;ls -s/stat %b 才是分配块,稀疏文件差别巨大。
  3. 「du -sh 等于 df 已用」——du 只统计可见文件,且默认对硬链接去重;保留块、快照、已删除打开文件都不在 du 里。
  4. 「inode 用完只影响大文件」——恰恰相反,大量小文件先耗尽 inode;df -i 要看。
  5. 「fallocate 与 truncate 等价」——前者分配 unwritten extent(预留空间),后者只改 size(不预留)。
  6. 「目录的链接数没有意义」——它是 2 + 子目录数,可以用来快速判断目录是否有子目录,也是 find -links 的基础。

0.3.14 find 的表达式全解:优先级、动作与边界

find 不是「按名字找文件」的命令,而是一个表达式求值器:从起点遍历目录树,对每个路径求一个布尔表达式,为真就执行动作。绝大多数误删、漏删、扫描缓慢,都源于没把「优先级」和「动作的副作用」算清楚。

定义 0.3.3 · find 表达式的语法

find 的表达式由测试(test,如 -name、-type)、动作(action,如 -print、-delete、-exec)、全局选项(option,如 -maxdepth、-xdev)和运算符组成。运算优先级从高到低:!(否定)、-a(与,可省略)、-o(或)。括号用 \(` `\)。表达式从左到右求值,-a/-o 短路;没有动作时默认 -print。

优先级用一张表记住:

写法结合方式例子实际含义
! expr最高! -name '*.log'不匹配 .log
expr1 expr2-a,左结合-type f -name '*.log'普通文件且名字匹配
expr1 -o expr2低于 -a-name '*.log' -o -name '*.tmp'两种名字之一
\( ... \)显式分组\( -name '*.log' -o -name '*.tmp' \) -type f先分组再与类型

没有括号时,-type f -name '*.log' -o -name '*.tmp' 等价于 (-type f -a -name '*.log') -o (-name '*.tmp'),会匹配所有 .tmp 文件,包括目录。这是 find 最常见的逻辑错误。

find 的两类元素必须分清:

类别例子副作用放在表达式中的位置
测试-name、-type、-mtime、-size、-perm、-user无任意
动作-print、-print0、-printf、-exec、-delete、-ls、-quit有通常放最后
全局选项-maxdepth、-mindepth、-xdev、-follow、-L影响遍历放在所有测试之前

-delete 既是动作又隐含 -depth(后序遍历,先处理子项)。如果写成 find . -delete -name '*.tmp',删除动作先执行,-name 根本来不及过滤;实际会删掉整棵树。动作放在条件之后,先用 -print 试跑,是唯一可靠的习惯。

按名字、路径与类型

测试含义边界
-name '*.log'匹配 basename,glob 语法通配符由 find 解释,必须加引号
-iname忽略大小写受 locale 影响
-path './src/*'匹配完整路径* 能跨 /
-ipath忽略大小写同上
-regex整路径正则默认 Emacs 方言,-regextype posix-extended 可换
-type f普通文件可写 -type f,d 匹配多类
-xtype l链接指向的类型(对悬空链接特殊)与 -type 的区别在是否跟随
-L / -H / -P跟随所有/命令行/不跟随符号链接默认 -P
-empty空文件或空目录对普通文件是 size 0
-readable/-writable/-executable当前用户可读/写/执行按 access(2) 语义,受 ACL 影响

-name 只匹配路径最后一段,-path 匹配整条路径。要排除 .git:

find . -path ./.git -prune -o -type f -print

读法是:如果路径是 ./.git,-prune 返回真且不下降,-o 短路,不执行后面的 -print;否则 -prune 为假,继续求值 -type f -print。-prune 只对目录有效,且不能与 -delete 同用(-delete 的 -depth 会让 prune 失效)。

按时间

测试单位语义常见误用
-mtime n24 小时文件数据在 n×24h 前被修改-mtime -1 是「不到 24 小时」,不是「今天」
-mmin n分钟同上,精度更高备份脚本用它找「刚变的」
-atime n24 小时访问时间受 relatime 影响,不等于「最近读过」
-ctime n24 小时inode 变更时间不是创建时间
-newer ref—mtime 晚于 refref 必须是存在的文件
-newermt '2024-10-01'—mtime 晚于给定时间GNU 扩展,BSD 不支持
-newerXY ref—X=文件时间,Y=参照时间-newerat、-newercm 等组合
-used n天atime 比 ctime 晚 n 天少用
-daystart—从今天 00:00 起算必须放在时间测试前

-mtime n 的取整规则是「(now - mtime) / 86400 向下取整后等于 n」,所以 -mtime 0 是过去 24 小时内,-mtime 1 是 24–48 小时前。要「今天」用 -newermt "$(date +%F)" 或 -daystart -mtime 0。删除 30 天前的日志用 -mtime +30,它表示「大于 30 个完整 24 小时」,边界比直觉晚一天。

# 最近 2 小时改过的配置
find /etc -xdev -type f -mmin -120 -print
# 2024-10-01 之后修改的文件
find /srv -xdev -type f -newermt '2024-10-01 00:00:00' -print
# 比参考文件新的文件
find . -type f -newer ./VERSION -print

按大小

-size n[cwbkMG] 的单位与取整是另一个坑:

单位含义取整
c字节精确
w2 字节字向上取整
b512 字节块向上取整
k1024 字节向上取整
M1024² 字节向上取整
G1024³ 字节向上取整

「向上取整」意味着 -size 1k 匹配的是 1 到 1024 字节的文件(空文件是 0 个单位,不匹配),-size -1k 只匹配空文件。找「大于 100 MiB」用 -size +100M;找「恰好 0 字节」用 -empty 或 -size 0,不要用 -size -1k 猜。

# 大于 100 MiB 的普通文件,按大小降序
find /var -xdev -type f -size +100M -printf '%s\t%p\n' 2>/dev/null | sort -nr | head -20
# 0 字节文件
find /srv -xdev -type f -empty -print

按权限、属主与 inode

测试含义注意
-perm 644权限恰好等于 644特殊位也必须一致
-perm -644至少包含 644 的所有位常用审计写法
-perm /222任意一个写位被设置GNU 扩展;+222 已废弃
-perm -4000至少包含 setuid找 setuid 程序
-user alice / -uid 1000属主-nouser 找无主文件
-group dev / -gid 1000属组-nogroup 找无组文件
-links n硬链接数-links +1 找多名字文件
-inum ninode 号配合 ls -i 定位
-samefile ref与 ref 同一 inode比比较 inode 号更方便

-perm 的三种前缀必须记牢:无前缀是精确匹配,- 是「全部这些位都要有」,/ 是「至少有一位」。find / -perm -4000 找 setuid;find / -perm /6000 找 setuid 或 setgid;find / -perm 4755 只找精确 4755。

动作:打印、执行、删除

动作作用边界
-print每行一个路径文件名含换行时不可解析
-print0NUL 分隔配 xargs -0 或 while read -d ''
-printf自定义格式GNU 扩展;字段见下表
-ls类似 ls -l 的输出格式不稳定,脚本不要解析
-exec cmd {} \;每个文件执行一次慢,但命令只收一个文件
-exec cmd {} +批量传参{} 必须在 + 前且只能一次
-execdir cmd {} \;在文件所在目录执行更安全,{} 带 ./ 前缀
-delete删除隐含 -depth;先 -print 试跑
-quit找到一个就退出配合 -print 做存在性检查

-printf 的常用字段:

字段含义字段含义
%p完整路径%fbasename
%h目录部分%P去掉起点后的路径
%s字节大小%b512 字节块数
%k1 KiB 块数%iinode 号
%n硬链接数%m八进制权限
%M符号权限%u/%g用户名/组名
%y类型字符%l符号链接目标
%d深度%D设备号
%T@mtime epoch%TY-%Tm-%Tdmtime 日期
%A@atime epoch%C@ctime epoch
%B出生时间(GNU 4.3.3+)%F文件系统类型

-printf 支持 \n、\t、\0、\\,宽度用 %10s。它比 -exec stat 少 fork 无数次,是大规模扫描的首选;但 BSD/macOS 的 find 没有 -printf。

-exec ... + 的批量语义:find 会把尽可能多的路径追加到命令末尾,直到接近 ARG_MAX;命令只执行少数几次。它比 \; 快一个数量级,但要求命令能接受多个参数,且 {} 必须出现在最后:

# 正确:{} 在最后,+ 结尾
find . -type f -name '*.tmp' -exec rm -f -- {} +

# 错误:{} 不在最后,find 会报 "missing argument to -exec"
find . -type f -exec cp {} /tmp/backup +

-execdir 在文件所在目录执行,{} 会展开成 ./name,避免路径被解释成选项;它还会把当前目录加入 PATH 的前面(安全风险),所以命令要用绝对路径或 command。在不可信目录里,优先 -execdir /bin/rm -f -- {} +。

遍历控制与性能

选项作用注意
-maxdepth n最多下降 n 层全局选项,放最前
-mindepth n至少 n 层才求值常配 -maxdepth 1
-xdev / -mount不跨文件系统防止扫到 NFS、/proc
-depth后序遍历-delete 隐含
-prune不下降当前目录与 -delete 冲突
-noleaf关闭目录叶子优化旧文件系统才需要
-ignore_readdir_race忽略遍历中文件消失高并发目录
-warn/-nowarn是否警告默认对某些写法警告

find / 默认会跨挂载点,扫到 /proc、/sys、NFS 时会很慢甚至卡住。生产脚本一律加 -xdev,并显式排除虚拟文件系统:

find / -xdev \( -path /proc -o -path /sys -o -path /dev -o -path /run \) -prune \
     -o -type f -name '*.conf' -print

-xdev 已经能挡住独立挂载的 /proc、/sys,但如果它们和根在同一个挂载命名空间里以 bind mount 出现,仍要 -prune。find 的退出码只有 0(全部成功)和 1(有错误),权限错误会让它返回 1;脚本里 2>/dev/null 会连「找不到起点」一起吞掉,至少先检查起点存在。

写一条 find 命令的顺序
  1. 定起点:绝对路径,确认存在,避免 . 受 cwd 影响。
  2. 加遍历约束:-xdev、-maxdepth、-mindepth、-prune。
  3. 写测试:-type、-name、-mtime、-size、-perm,复杂条件加括号。
  4. 先 -print 试跑,把输出重定向到文件,抽样检查。
  5. 换成动作:-print0 交给下游,或 -delete/-exec;-delete 放最后。
  6. 验证退出码:find ...; echo $?,1 表示有错误被吞掉。

0.3.15 xargs:把 NUL 分隔的流变成参数

xargs 的职责只有一个:从 stdin 读「项」,拼成命令行,执行命令。它的默认行为是按空白分词并解释引号,这与 Unix 文件名允许空格、换行、引号的事实直接冲突。正确用法只有一条主线:上游 -print0,下游 -0。

选项作用边界
-0 / --null输入按 NUL 分隔,不做引号处理唯一对任意文件名都正确的模式
-d DELIM自定义单字节分隔符-d '\n' 适合「一行一项」
-r / --no-run-if-empty空输入不执行命令GNU 默认空输入也会跑一次
-n N每次最多 N 个参数与 -I 冲突
-L N每次最多 N 行行内有空白时仍算一行
-P N最多 N 个并发进程输出可能交错;0 表示不限
-I STR用 STR 替换参数中的占位符隐含 -L1,且按行读
-a FILE从文件读项不与管道共用
-t执行前打印命令行到 stderr调试用
-p执行前询问交互式
-E STR / -e遇到 STR 停止已废弃,优先 -d
-s N命令行最大字节数默认约 128 KiB
-x超过 -s 时报错而不是截断防止参数被悄悄截断
-o子进程把 stdin 接到 /dev/tty需要交互输入时
--process-slot-var=VAR给每个并发槽位一个编号配 -P 做分片输出

xargs 的退出码不是「子命令的退出码」,而是一组约定:

码含义
0所有调用成功
1xargs 自身出错(参数、读入、内存)
123有子命令以 1–125 退出
124有子命令以 255 退出(xargs 立即停止)
125有子命令被信号杀死
126命令找到但不可执行
127命令找不到

所以 find ... | xargs rm 返回 0 不代表每个 rm 都成功;脚本里要检查 123,或改用 find -exec ... + 并检查 $?。-P 并发时,任何一个子命令失败都会让最终状态变成 123,但你不知道是哪一个;需要逐项状态时,让每个子命令把结果写到独立文件,或改用能汇报结果的并行工具。

# 正确:NUL 分隔,空输入不执行,单参数,4 路并发
find /srv/data -xdev -type f -name '*.log' -print0 |
    xargs -0 -r -n1 -P4 gzip -9

# 追加校验和;-r 防止空目录时执行一次 sha256sum
find /srv/app/conf -type f -name '*.conf' -print0 |
    xargs -0 -r sha256sum > /var/tmp/conf.sha256

# 每项单独起一个 shell;$1 是文件名,不会被当成代码
find . -name '*.png' -print0 |
    xargs -0 -r -I{} sh -c 'pngquant --force --output "$1" "$1"' _ {}

最后一条的 _ 是 $0 占位,{} 变成 $1。如果写成 sh -c 'pngquant {}',文件名里的 ;、$()、反引号会被 shell 解释,等于把文件名当代码执行。-I{} 还会把输入按行处理,文件名含换行时仍会断;要绝对安全,用 -0 加 sh -c '... "$1"' _,不要让 {} 进入脚本正文。

xargs 与 find -exec ... + 的选择:

维度xargs -0find -exec ... +
文件名安全-print0 + -0 时安全{} 直接传参,安全
并发-P不支持
参数分批-n、-s 可控find 自动分批
错误汇报123,不区分哪项命令退出码原样返回
适用需要并发、需要复用其他命令的输出简单的批量删除/复制/校验

0.3.16 扩展属性、chattr 与 access 竞态

传统 mode 只有 12 位,ACL 解决「谁能访问」,扩展属性(extended attributes, xattr)解决「文件还带什么元数据」。SELinux 标签、capability、文件校验和、桌面环境的「下载来源」都存成 xattr。

xattr 分命名空间:

命名空间例子谁可写备份
user.*user.comment属主(普通文件/目录)tar --xattrs、rsync -X
trusted.*trusted.overlay.*需 CAP_SYS_ADMIN通常不备份
security.*security.selinux、security.capability需 LSM/CAP_SETFCAPtar --xattrs --selinux
system.*system.posix_acl_access内核/ACL 工具tar --acls、rsync -A
# 读、写、列、删
getfattr -d -m - /srv/app/config.yml       # -m - 列出所有命名空间
setfattr -n user.comment -v 'deploy=2024-10' /srv/app/config.yml
getfattr -n user.comment --only-values /srv/app/config.yml
setfattr -x user.comment /srv/app/config.yml

# 看 capability 与 SELinux 标签
getfattr -n security.capability -e hex /usr/bin/ping 2>/dev/null
getfattr -n security.selinux /etc/passwd 2>/dev/null

cp -a、tar --xattrs、rsync -X 才保留 xattr;普通 cp 不保留。user.* 在 ext4/XFS/Btrfs 上默认启用;NFS 需要 nfs4 ACL 与 xattr 支持;tmpfs 支持 user.* 与 trusted.*,但重启丢失。getfattr -d 不跟符号链接,要看目标加 -h(读链接本身)或 -L。

文件属性(chattr/lsattr)是另一层,存在 inode 的标志位里,不是 xattr:

属性含义效果
iimmutable不能写、改名、删除、加链接;root 也受限
aappend-only只能以 O_APPEND 追加,不能覆盖/截断
dno dumpdump 跳过
Ano atime不更新 atime
Ssynchronous写入立即同步
ccompressed透明压缩(需文件系统支持)
eextentext4 extent 格式,通常只读
jjournaling数据先写日志(ext4)
sudo chattr +i /etc/resolv.conf     # 防 DNS 被改
lsattr /etc/resolv.conf             # ----i---------e-------
sudo chattr -i /etc/resolv.conf     # 改之前必须先去掉

chattr +i 是「防误改」而不是「防 root」:root 可以去掉属性再改,且有 CAP_LINUX_IMMUTABLE 的进程可以改。它常被用于保护 /etc/resolv.conf、引导文件、审计日志;容器里默认没有这个 capability,chattr 会失败。chattr +a 适合日志文件,但轮转工具需要先去掉属性,否则 rename 失败。

access(2) 与 faccessat2(2) 用来「以真实 UID/GID 检查权限」,而不是按已打开 fd 的 euid。它们的问题是 TOCTOU:检查和随后的 open 之间,路径可能被替换。access 还受 AT_EACCESS 影响:默认用 real UID,faccessat(AT_EACCESS) 用 effective UID。setuid 程序里用 access 检查用户权限,再用 root 权限打开,是经典的提权面。

/* 不安全:检查与打开是两次解析 */
if (access(path, R_OK) == 0) {
    fd = open(path, O_RDONLY);   /* 这中间 path 可能变成符号链接 */
}

/* 较安全:打开后对 fd 检查,后续只用 fd */
fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) { /* ELOOP 表示最后一段是符号链接 */ }
struct stat st;
if (fstat(fd, &st) < 0) { ... }
/* 需要按 real uid 判权时,用 faccessat2(fd, "", mode, AT_EMPTY_PATH) */

faccessat2 支持 AT_EMPTY_PATH,可对已打开的 fd 做权限检查,把「检查对象」固定成 fd;openat2 的 RESOLVE_BENEATH/RESOLVE_NO_SYMLINKS 则把「解析范围」固定住。两者组合,才能消掉路径竞态。

0.3.17 复制、同步与打包:cp、rsync、tar

cp 的选项决定「复制什么」与「怎么复制」:

选项作用边界
-a-dR --preserve=all保留链接、权限、时间、xattr、ACL、SELinux
-d不跟随符号链接,保留硬链接等价 -P --preserve=links
-P不跟随符号链接复制链接本身
-L跟随所有符号链接可能复制到树外
-p保留 mode/ownership/timestamps不含 ACL/xattr
--preserve=all再加 context/links/xattr属主需 root
--reflink=autoCoW 文件系统上共享 extentsXFS/Btrfs;跨文件系统退化为普通复制
--sparse=always检测零块并打洞普通复制可能把稀疏文件写实
-u仅当源更新时复制比较 mtime,不比较内容
-n不覆盖已存在目标与 -i 不同,不询问
-l建硬链接而不是复制同一文件系统
-s建符号链接目标路径按源路径
-T把目标当普通文件/目录禁止「复制进目录」的推断
--remove-destination先删目标再复制断开目标已有的硬链接
-x不跨文件系统备份挂载树时用
-Z设置 SELinux 上下文需 SELinux

cp -a src dst 与 cp -r src dst 的差别不只是权限:-a 保留符号链接本身、硬链接关系、xattr、ACL、SELinux 标签;-r 跟随命令行给出的链接、把硬链接复制成多份、丢掉 xattr。恢复一个服务目录时,-r 可能让配置文件的 SELinux 标签错掉,服务起不来。

cp 覆盖一个已存在的硬链接目标时,会写进同一个 inode,而不是替换目录项:

printf 'old\n' > a; ln a b
printf 'new\n' > c
cp c a
cat b          # new:b 与 a 仍指向同一 inode

要让 a 变成独立的新文件,用 cp --remove-destination c a 或 mv。备份脚本里这很关键:直接 cp 到正在使用的文件,读者可能看到半截内容;正确做法是写临时文件 + rename。

rsync 是「增量同步」工具,不是复制命令的别名:

选项作用注意
-aarchive:递归 + 权限 + 时间 + 链接 + 属主不含 ACL/xattr
-A / -X保留 ACL / xattr需要目标文件系统支持
-H保留硬链接大目录内存开销高
-S处理稀疏文件传输时压缩空洞
--delete删除目标端多余文件先用 --dry-run
--delete-excluded连排除的也删危险
-n / --dry-run只显示计划必做
-i / --itemize-changes逐项说明变化排障用
--partial保留半截文件以便续传与 --inplace 取舍
--inplace直接改目标文件非原子,读者可能看到半截
-e ssh指定远程 shell默认就是 ssh
--bwlimit限速生产迁移必备

rsync 的尾部斜杠决定「复制目录本身还是目录内容」:rsync -a src/ dst/ 同步 src 的内容到 dst;rsync -a src dst/ 在 dst 下建 src。--delete 只在「源是目录内容」时符合直觉,写错会把目标端多删一层。生产脚本一律先 -n -i 看清单,再执行。

tar 的安全边界在解压而不是创建。恶意归档可以包含绝对路径、..、指向外部的符号链接,以 root 解压会覆盖系统文件。GNU tar 默认会拒绝绝对路径和 ..(--absolute-names 才允许),但仍建议:

# 1. 先看清单,不落盘
tar -tzf untrusted.tar.gz | sed -n '1,50p'
# 2. 拒绝绝对路径与 ..(清单里出现就停止)
tar -tzf untrusted.tar.gz | grep -E '(^/|(^|/)\.\.(/|$))' && exit 1
# 3. 解到空目录,不用 root,必要时去掉 suid/sgid
mkdir -p /tmp/unpack && tar -xzf untrusted.tar.gz -C /tmp/unpack \
    --no-same-owner --no-same-permissions
# 4. 需要保留 ACL/xattr 时显式加,并确认目标文件系统支持
tar -xzf backup.tar.gz -C /srv/restore --acls --xattrs --selinux

tar -x 以 root 运行会按归档恢复属主与权限,包括 setuid 位;解压不可信归档时应加 --no-same-owner --no-same-permissions,或先以普通用户解压再审查。--strip-components=1 常用于源码包,但它不改变安全性,只是去掉顶层目录。

0.3.18 移动、重命名与删除:mv、renameat2、rm

mv 在同一文件系统内是 rename(2):原子地改目录项,不搬数据。跨文件系统时,GNU mv 退化成「复制 + 删除」,中途失败会留下半个目标;大目录迁移应先 rsync -aH --info=progress2,校验后再删源。

写法行为
mv src dstdst 是目录则移入,否则改名
mv -T src dst强制把 dst 当名字,不推断目录
mv -n src dst不覆盖已存在目标
mv -u src dst仅当源更新时移动
mv -b覆盖前备份为 dst~
mv -t dir src...指定目标目录,适合 xargs
mv --strip-trailing-slashes去掉源末尾斜杠

rename(2) 的原子性只保证「名字切换」这一步,不保证数据落盘。崩溃后可能看到旧名字或新名字,但不会看到半个目录项;要保证持久,rename 后还要 fsync 父目录。renameat2(2) 增加了三个有用标志:

标志语义用途
RENAME_NOREPLACE目标存在则 EEXIST不覆盖的原子创建
RENAME_EXCHANGE原子交换两个路径配置热切换、双缓冲
RENAME_WHITEOUT创建 whiteout 节点overlayfs 内部
/* 原子发布:临时文件已写好并 fsync,再 rename 成正式名 */
if (renameat2(AT_FDCWD, tmp, AT_FDCWD, final, RENAME_NOREPLACE) < 0) {
    if (errno == EEXIST) { /* 已有别的实例发布,保留旧的 */ }
    else { perror("renameat2"); }
}

RENAME_NOREPLACE 在 NFS、部分旧文件系统上返回 EINVAL/EOPNOTSUPP;可移植脚本要回退到 link(tmp, final) + unlink(tmp)(link 在目标存在时返回 EEXIST,本身原子)。

rm 删除目录项,不删除 inode;inode 链接数为 0 且没有打开的 fd 时才释放数据。rm 的选项边界:

选项作用注意
-r / -R递归不跟随符号链接(删链接本身)
-f不提示,忽略不存在与 -i 相反,后者优先
-i每个文件询问大量文件很慢
-I删除超过 3 个或递归前询问一次比 -i 实用
-d删除空目录等价 rmdir
--one-file-system不跨挂载点清理挂载树时防误删
--preserve-root拒绝删除 /默认开启
--no-preserve-root允许删 /不要用
-v打印删除的路径审计

rm -rf 的三个高危模式仍然成立:变量为空、路径拼错、通配符没匹配。再加两个:在符号链接后加斜杠(rm -rf link/ 会跟随链接删目标内容,而 rm -rf link 只删链接),以及在 /tmp 里用可预测名字(竞态)。GNU rm 拒绝删除 . 和 ..,但 rm -rf -- "$dir"/ 在 dir 为空时仍可能变成 rm -rf /;${dir:?} 是底线。

需要「不可恢复」时,shred 在 SSD、CoW 文件系统、带日志的文件系统上不保证覆盖原块;它只对传统磁盘上的未压缩文件有意义。企业环境更可靠的做法是加密盘 + 销毁密钥,而不是反复覆写。

关于复制、同步与删除的八个误解
  1. 「cp -r 与 cp -a 只差权限」——-a 还保留符号链接、硬链接、xattr、ACL、SELinux 标签。
  2. 「cp 覆盖文件会替换 inode」——默认是截断并写入同一个 inode,硬链接的另一端会看到新内容;要替换用 --remove-destination 或先 rm。
  3. 「rsync -a 会保留 ACL 与 xattr」——要显式 -A -X;-a 只含基本权限与时间。
  4. 「rsync --delete 只删源端没有的文件」——它按目标端对比,排除规则写错会删掉本应保留的文件;先 -n。
  5. 「tar -x 会自动防路径穿越」——GNU tar 默认拒绝绝对路径与 ..,但符号链接、硬链接、--absolute-names 仍能绕过;不可信归档要在空目录、非 root 解。
  6. 「mv 总是原子的」——只在同一文件系统内是 rename;跨文件系统是复制+删除。
  7. 「rename 返回成功就落盘了」——目录项持久化要 fsync 父目录,崩溃可能丢名字。
  8. 「shred 能安全擦除 SSD」——磨损均衡、CoW、压缩都会让覆写落到别的块;SSD 用全盘加密 + discard/安全擦除。

0.3.19 实验:用 strace 与 inode 取证

实验一:看清一次「打开、删除、重命名」的路径解析

mkdir -p /tmp/fs-lab && cd /tmp/fs-lab
printf 'hello\n' > a.txt
ln a.txt hard.txt
ln -s a.txt soft.txt
strace -f -yy -e trace=openat,newfstatat,statx,readlinkat,unlink,unlinkat,rename,renameat2 \
    -o /tmp/fs.trace \
    bash -c 'cat soft.txt >/dev/null; rm a.txt; mv hard.txt renamed.txt'
grep -E 'soft|a\.txt|hard|renamed' /tmp/fs.trace | tail -20

预期能看到类似的行(fd 号与 inode 因环境而异):

openat(AT_FDCWD, "soft.txt", O_RDONLY|O_CLOEXEC) = 3
newfstatat(AT_FDCWD, "soft.txt", ..., 0) = 0
unlink("a.txt") = 0
rename("hard.txt", "renamed.txt") = 0

读法:openat(AT_FDCWD, ...) 表示从 cwd 解析;O_CLOEXEC 是 glibc 给 fopen/cat 自动加的;newfstatat 是 stat 的现代入口;unlink 只改目录项,不写数据块。如果 soft.txt 指向不存在的目标,openat 会返回 -1 ENOENT,而 newfstatat(..., AT_SYMLINK_NOFOLLOW) 仍能看到链接本身。

把 -e trace= 换成 %file 可以看所有路径类系统调用;加 -y 把 fd 显示成路径,加 -yy 连 socket/pipe 也显示。strace 会显著拖慢程序,只用于单次复现,不要包在生产脚本里。

实验二:硬链接、inode 与「删了但没释放」

cd /tmp/fs-lab
stat -c 'name=%n inode=%i links=%h size=%s blocks=%b' a.txt hard.txt soft.txt
ls -li
# 制造一个被进程持有的已删除文件
dd if=/dev/zero of=big.log bs=1M count=64 status=none
exec 9<big.log
rm big.log
lsof +L1 2>/dev/null | grep big.log || sudo lsof +L1 | grep big.log
df -h /tmp | tail -1
exec 9<&-
sync; df -h /tmp | tail -1

预期:a.txt 与 hard.txt 的 inode 相同、links=2;soft.txt 的 inode 不同、size 是目标字符串长度。rm big.log 后 lsof +L1 仍列出该文件,df 的已用空间不降;关闭 fd 9 后空间释放。若没有 lsof,用 ls -l /proc/*/fd 2>/dev/null | grep deleted 找持有者。

用 debugfs 看 inode 的创建时间(需要 root 与未挂载或只读访问;生产上优先 statx):

sudo debugfs -R 'stat <'"$(stat -c %i /tmp/fs-lab/a.txt)"'>' /dev/nvme0n1p2 2>/dev/null | head -15

输出里的 crtime 是 ext4 的创建时间,ctime 是 inode 变更时间。不要在没有备份的块设备上执行写操作;debugfs 的 stat 是只读的,但仍建议先用 findmnt -T /tmp 确认设备。

0.3.20 三个可运行脚本

以下脚本面向 Ubuntu 24.04 的 GNU coreutils/findutils,放在 ~/bin/ 并 chmod +x。每个都先用 bash -n 做语法检查;涉及删除的先用 -n 试运行。

脚本一:safe-clean.sh——按天数清理,默认试运行

#!/usr/bin/env bash
# safe-clean.sh — 清理目录下超过 N 天的普通文件
# 用法: safe-clean.sh [-n] [-x] [-P N] -d DIR -m DAYS
set -euo pipefail

usage() { printf 'usage: %s [-n] [-x] [-P N] -d DIR -m DAYS\n' "${0##*/}" >&2; }
die()   { printf 'error: %s\n' "$*" >&2; exit 2; }

dry_run=1 xdev=() jobs=1 dir='' days=''
while getopts ':nxd:m:P:h' opt; do
    case $opt in
        n) dry_run=1 ;;
        x) xdev=(-xdev) ;;
        d) dir=$OPTARG ;;
        m) days=$OPTARG ;;
        P) jobs=$OPTARG ;;
        h) usage; exit 0 ;;
        :) die "option -$OPTARG requires an argument" ;;
        \?) die "unknown option -$OPTARG" ;;
    esac
done
shift $((OPTIND - 1))

[[ -n $dir && -n $days ]] || { usage; exit 2; }
[[ -d $dir ]] || die "not a directory: $dir"
[[ $days =~ ^[0-9]+$ ]] || die "DAYS must be a non-negative integer"
[[ $jobs =~ ^[1-9][0-9]?$ ]] || die "-P must be 1..99"

# 拒绝明显的危险根目录;realpath -e 要求路径存在
root=$(realpath -e -- "$dir") || die "cannot resolve: $dir"
case $root in
    /|/usr|/etc|/var|/home|/root|/boot|/dev|/proc|/sys)
        die "refusing to clean $root" ;;
esac

# 空数组在 bash 5.2 下可直接展开;旧版本用 ${xdev[@]+"${xdev[@]}"}
mapfile -d '' -t victims < <(
    find "$root" ${xdev[@]+"${xdev[@]}"} -type f -mtime +"$days" -print0 2>/dev/null
)

trap 'printf "interrupted after %d candidates\n" "${#victims[@]}" >&2; exit 130' INT
trap 'printf "terminated after %d candidates\n" "${#victims[@]}" >&2; exit 143' TERM

printf 'root=%s days=%d candidates=%d dry_run=%d jobs=%d\n' \
    "$root" "$days" "${#victims[@]}" "$dry_run" "$jobs"

if (( dry_run )); then
    for f in "${victims[@]}"; do printf 'would remove %q\n' "$f"; done
    exit 0
fi

(( ${#victims[@]} == 0 )) && exit 0
printf '%s\0' "${victims[@]}" |
    xargs -0 -r -n1 -P"$jobs" rm -f --
printf 'removed %d files\n' "${#victims[@]}"

关键点:-n 默认试运行,真实删除要显式去掉;mapfile -d '' 按 NUL 读,文件名含空格/换行都安全;${xdev[@]+...} 兼容旧 bash 的空数组;xargs -0 -r -n1 -P 把「空输入」和「并发」都处理了;trap 在中断时只报告,不假装成功。并发删除的顺序不确定,但每个 rm 只作用于一个路径,互不依赖。

#!/usr/bin/env bash
# link-audit.sh — 审计符号链接与硬链接;只读
set -euo pipefail
root=${1:-.}
[[ -d $root ]] || { printf 'not a directory: %s\n' "$root" >&2; exit 2; }

printf '== dangling symlinks ==\n'
while IFS= read -r -d '' link; do
    [[ -e $link ]] || printf '%q -> %q\n' "$link" "$(readlink -- "$link")"
done < <(find "$root" -xdev -type l -print0 2>/dev/null)

printf '== hard links (nlink > 1) ==\n'
find "$root" -xdev -type f -links +1 -printf '%i %n %p\n' 2>/dev/null | sort -n

printf '== symlink loops / unresolvable ==\n'
while IFS= read -r -d '' link; do
    if ! readlink -f -- "$link" >/dev/null 2>&1; then
        printf '%q\n' "$link"
    fi
done < <(find "$root" -xdev -type l -print0 2>/dev/null)

find 的输出用进程替换 < <(...) 喂给 while,循环在当前 shell 执行,计数/数组不会丢(见 0.5)。readlink -f 失败既可能是环,也可能是权限不足;脚本把它们归为「不可解析」,需要区分时看 readlink 的 stderr。

脚本三:disk-report.sh——按块与按表观大小排大文件

#!/usr/bin/env bash
# disk-report.sh — 只读磁盘报表;GNU coreutils/findutils
set -euo pipefail
root=${1:-/var}
top=${2:-20}
[[ -d $root ]] || { printf 'not a directory: %s\n' "$root" >&2; exit 2; }
[[ $top =~ ^[1-9][0-9]*$ ]] || { printf 'top must be positive\n' >&2; exit 2; }

printf '== filesystem ==\n'
df -hT -- "$root" 2>/dev/null || df -h -- "$root"

printf '== top %d files by apparent size ==\n' "$top"
find "$root" -xdev -type f -printf '%s %p\0' 2>/dev/null |
    sort -z -nr | head -z -n "$top" |
    while IFS= read -r -d '' rec; do
        size=${rec%% *}; path=${rec#* }
        printf '%12s  %q\n' "$(numfmt --to=iec --suffix=B -- "$size")" "$path"
    done

printf '== top-level dirs by allocated bytes ==\n'
du -x -B1 --max-depth=1 -- "$root" 2>/dev/null | sort -nr | head -n "$top" |
    while read -r bytes path; do
        printf '%12s  %s\n' "$(numfmt --to=iec --suffix=B -- "$bytes")" "$path"
    done

第一条管道用 %s %p\0 把「大小 + 路径」作为一个 NUL 记录,sort -z -nr 按记录前缀的数值排序,head -z 保留 NUL;解析时以第一个空格切分,路径里的空格/换行都不会破坏记录。du 统计的是分配块,和 find %s 的表观大小可能差很多,脚本把两者分开列,避免混用。

这三个脚本覆盖了本章的四类边界:空变量与危险路径、带空格/换行的文件名、中断信号、并发删除。真正上线前还要补日志、锁与保留策略,见 0.8 的 backup.sh。

0.3.21 本章出口检查

检查项达标线没达标去哪
读出 ls -l 的七列能解释链接数与大小0.3.3
区分 atime/mtime/ctime能说出改权限动哪个时间0.3.3
解释硬链接与软链接的差异至少说出跨文件系统与悬空两点0.3.4
安全地批量删除会先 -print 再 -delete,变量用 ${VAR:?}0.3.5、0.3.6
用 find 组合条件会 -type、-mtime、-size、-print00.3.6
用 xargs -0 处理怪文件名能解释为什么不能裸用 xargs0.3.7、0.3.15
定位磁盘占用会用 du -x 与 lsof +L10.3.8、0.3.13
读出 stat 的字段能解释 st_blocks 与三个时间0.3.11
写对 find 表达式能说出 -a/-o/! 优先级与动作顺序0.3.14
解释 xargs 退出码知道 123/124/127 各表示什么0.3.15
安全复制与解压会用 cp -a、rsync -n、tar -t 预检0.3.17
使用 xattr 与 chattr能区分 xattr 与 inode 属性0.3.16
原子发布文件知道 renameat2(RENAME_NOREPLACE) 的边界0.3.18
即时自测 · 0.3

Q1 ls -li 显示 a 与 b 的 inode 相同、链接数为 2;c 的 inode 不同、类型是 l。删除 a 后,b 和 c 还能读吗?

Q2 一条命令 find /data -type f -mtime +30 -delete 在生产机上执行后,发现误删了挂载在 /data/backup 的 NFS 上的文件。问题出在哪里?怎样避免?

Q3 du -sh /var/log 显示 2 GB,df -h /var 显示已用 20 GB,可能的原因有哪些?

Q4 为什么 find ... -print0 | xargs -0 ... 比 find ... | xargs ... 安全?举一个会出错的文件名。

答案

A1 b 能读:它与 a 是同一 inode 的两个硬链接,删除 a 只是链接数从 2 变 1,数据仍在。c 是符号链接,若它指向 a,删除 a 后变成悬空链接,读取报 ENOENT;若指向 b 或其他仍存在的路径,则正常。用 readlink c 看目标。

A2 find 默认会跨挂载点,/data/backup 是另一个文件系统,也被遍历并删除。加 -xdev(或 -mount)只在本文件系统内查找;更稳的做法是先 -print 审核、限定 -maxdepth、并在测试环境跑一遍。删除前还应确认备份与快照。

A3 ① /var 下还有其他大目录(/var/lib、/var/cache);② 有已删除但仍被进程打开的文件(lsof +L1);③ 文件系统保留块(ext4 默认 5%,tune2fs -m);④ du 因权限不足跳过了部分目录(用 sudo);⑤ 挂载了其他文件系统,df 统计的是整个 /var 所在分区;⑥ 快照/CoW 占用未计入 du。

A4 默认 xargs 按空白分词,文件名含空格会被拆成多个参数;含换行、引号、反斜杠时更糟。-print0 用 NUL 分隔,-0 按 NUL 解析,只有 NUL 不能出现在文件名中。会出错的名字:my report 2024.txt(被拆成三个参数)、a\nb.txt(被当成两个文件)。

母本对应

母本章节本书落点程度
G 鸟哥第 5.3 节 Linux 目录配置/FHS0.3.1、0.3.9完整(FHS、目录职责)
G 鸟哥第 6.2、6.5 节 文件管理、文件搜寻0.3.5、0.3.14完整(cp/mv/rm、find/-exec)
G 鸟哥第 7.1–7.3 节 文件系统、inode、df/du0.3.13、0.3.17精选(inode、稀疏、占用)
S Shotts第 3–4 章 导航、探索系统、操作文件0.3.1–0.3.5完整(ls -l、链接、cp/mv/rm)
S Shotts第 17 章 查找文件0.3.14、0.3.15完整(find、xargs)
B shell-book第 3–4 章 bash 基础命令、更多命令0.3.3–0.3.8精选(ls -li、链接、du/df)
K Kerrisk第 2.4 节 单根目录、链接与文件;第 18 章 目录与链接0.3.10、0.3.16背景(路径解析、xattr)
C CSAPP第 10.2–10.3 节 文件、打开与关闭0.3.3背景(文件是字节序列)

下一站:0.4 权限与安全模型解释 ls -l 第一列的十二个位,以及 rwx 之外还有哪些控制访问的手段。