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 硬链接与软链接
硬链接(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 dst | archive:递归 + 保留权限/时间/链接/属主(属主需 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 ... + 并在脚本里检查 $?。
- 「
rm会立刻释放磁盘空间」——只有 inode 链接数为 0 且没有进程打开时才释放。日志被删但df仍满,先用lsof +L1找持有 fd 的进程。 - 「硬链接可以跨分区」——硬链接是同一文件系统内的目录项,跨文件系统返回
EXDEV。跨分区要用符号链接或复制。 - 「符号链接的权限决定能不能访问」——内核按目标文件的权限判定;符号链接自身的
lrwxrwxrwx只是占位。 - 「
mtime就是创建时间」——mtime是内容修改时间,ctime是 inode 变更时间,Linux 没有通用的创建时间字段。 - 「
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.gz | gzip 默认删除原文件,-k 保留 |
| 跨平台交换 | zip -r site.zip site/ / unzip -l site.zip | zip 方便 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是前缀,遵循 FHS 的子目录结构(bin、lib、share、include),适合「用源码装一个和发行版并行的版本」。/opt是整包目录,每个软件一个子目录(/opt/google/chrome),适合不遵循 FHS 布局的商业软件或自带全部依赖的包。- 把源码包装到
/opt/app/bin后,$PATH不会自动包含它;需要显式加,或用update-alternatives管理多版本。 /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/x | ENOTDIR |
| 末尾斜杠指向非目录 | open("file/") | ENOTDIR |
| 悬空符号链接 | open("dangling") | ENOENT |
| 无权限穿过目录 | 缺少目录 x | EACCES |
.. 到达挂载根 | 停在挂载根,不跨回父文件系统 | 正常 |
.. 的语义容易被误解。内核解析 .. 时,如果当前 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。
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 显示 UID | st_uid | 名字来自 NSS,可能查不到 |
| 属组 | -n 显示 GID | st_gid | 同上 |
| 大小 | -h 人性化 | st_size | 目录是目录块大小;设备是主次设备号 |
| 时间 | --time-style | st_mtim 等 | 默认 mtime,超过六个月显示年份 |
| 名字 | -Q 加引号 | — | 符号链接追加 -> target |
| 块数 | -s | st_blocks | 单位 512 字节,稀疏文件远小于 st_size |
| inode | -i | st_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 |
%i | inode 号 | 131073 |
%h | 硬链接数 | 2 |
%d | 设备号(十进制) | 66305 |
%D | 设备号(十六进制) | 10301 |
%u/%U | UID/用户名 | 1000/wang |
%g/%G | GID/组名 | 1000/wang |
%t/%T | 设备主/次号(十六进制) | 1/3 |
%x/%y/%z | atime/mtime/ctime | 2024-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_dev | dev_t | 所在文件系统设备号 |
st_ino | ino_t | inode 号 |
st_nlink | nlink_t | 硬链接数 |
st_mode | mode_t | 类型 + 12 位权限 |
st_uid/st_gid | uid_t/gid_t | 属主/属组 |
st_rdev | dev_t | 设备节点的主次号(普通文件无意义) |
st_size | off_t | 表观大小 |
st_blksize | blksize_t | 优选 I/O 块大小 |
st_blocks | blkcnt_t | 分配的 512 字节块数 |
st_atim/st_mtim/st_ctim | timespec | 访问/修改/元数据变更 |
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、增量备份 |
| ctime | inode 元数据变化(权限、属主、链接、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 | 走块层;通常挂载后访问 |
p | FIFO | mkfifo | 管道语义;open 可能阻塞 |
s | Unix socket | bind(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 是读写层,镜像层共享。
- 「删除文件一定释放空间」——链接数为 0 且没有打开的 fd 才释放;
lsof +L1找残留。 - 「
ls -l的大小就是磁盘占用」——它是表观大小;ls -s/stat %b才是分配块,稀疏文件差别巨大。 - 「
du -sh等于df已用」——du只统计可见文件,且默认对硬链接去重;保留块、快照、已删除打开文件都不在du里。 - 「inode 用完只影响大文件」——恰恰相反,大量小文件先耗尽 inode;
df -i要看。 - 「
fallocate与truncate等价」——前者分配 unwritten extent(预留空间),后者只改 size(不预留)。 - 「目录的链接数没有意义」——它是
2 + 子目录数,可以用来快速判断目录是否有子目录,也是find -links的基础。
0.3.14 find 的表达式全解:优先级、动作与边界
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 n | 24 小时 | 文件数据在 n×24h 前被修改 | -mtime -1 是「不到 24 小时」,不是「今天」 |
-mmin n | 分钟 | 同上,精度更高 | 备份脚本用它找「刚变的」 |
-atime n | 24 小时 | 访问时间 | 受 relatime 影响,不等于「最近读过」 |
-ctime n | 24 小时 | inode 变更时间 | 不是创建时间 |
-newer ref | — | mtime 晚于 ref | ref 必须是存在的文件 |
-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 | 字节 | 精确 |
w | 2 字节字 | 向上取整 |
b | 512 字节块 | 向上取整 |
k | 1024 字节 | 向上取整 |
M | 1024² 字节 | 向上取整 |
G | 1024³ 字节 | 向上取整 |
「向上取整」意味着 -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 n | inode 号 | 配合 ls -i 定位 |
-samefile ref | 与 ref 同一 inode | 比比较 inode 号更方便 |
-perm 的三种前缀必须记牢:无前缀是精确匹配,- 是「全部这些位都要有」,/ 是「至少有一位」。find / -perm -4000 找 setuid;find / -perm /6000 找 setuid 或 setgid;find / -perm 4755 只找精确 4755。
动作:打印、执行、删除
| 动作 | 作用 | 边界 |
|---|---|---|
-print | 每行一个路径 | 文件名含换行时不可解析 |
-print0 | NUL 分隔 | 配 xargs -0 或 while read -d '' |
-printf | 自定义格式 | GNU 扩展;字段见下表 |
-ls | 类似 ls -l 的输出 | 格式不稳定,脚本不要解析 |
-exec cmd {} \; | 每个文件执行一次 | 慢,但命令只收一个文件 |
-exec cmd {} + | 批量传参 | {} 必须在 + 前且只能一次 |
-execdir cmd {} \; | 在文件所在目录执行 | 更安全,{} 带 ./ 前缀 |
-delete | 删除 | 隐含 -depth;先 -print 试跑 |
-quit | 找到一个就退出 | 配合 -print 做存在性检查 |
-printf 的常用字段:
| 字段 | 含义 | 字段 | 含义 |
|---|---|---|---|
%p | 完整路径 | %f | basename |
%h | 目录部分 | %P | 去掉起点后的路径 |
%s | 字节大小 | %b | 512 字节块数 |
%k | 1 KiB 块数 | %i | inode 号 |
%n | 硬链接数 | %m | 八进制权限 |
%M | 符号权限 | %u/%g | 用户名/组名 |
%y | 类型字符 | %l | 符号链接目标 |
%d | 深度 | %D | 设备号 |
%T@ | mtime epoch | %TY-%Tm-%Td | mtime 日期 |
%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 会连「找不到起点」一起吞掉,至少先检查起点存在。
- 定起点:绝对路径,确认存在,避免
.受 cwd 影响。 - 加遍历约束:
-xdev、-maxdepth、-mindepth、-prune。 - 写测试:
-type、-name、-mtime、-size、-perm,复杂条件加括号。 - 先
-print试跑,把输出重定向到文件,抽样检查。 - 换成动作:
-print0交给下游,或-delete/-exec;-delete放最后。 - 验证退出码:
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 | 所有调用成功 |
| 1 | xargs 自身出错(参数、读入、内存) |
| 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 -0 | find -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_SETFCAP | tar --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:
| 属性 | 含义 | 效果 |
|---|---|---|
i | immutable | 不能写、改名、删除、加链接;root 也受限 |
a | append-only | 只能以 O_APPEND 追加,不能覆盖/截断 |
d | no dump | dump 跳过 |
A | no atime | 不更新 atime |
S | synchronous | 写入立即同步 |
c | compressed | 透明压缩(需文件系统支持) |
e | extent | ext4 extent 格式,通常只读 |
j | journaling | 数据先写日志(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=auto | CoW 文件系统上共享 extents | XFS/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 是「增量同步」工具,不是复制命令的别名:
| 选项 | 作用 | 注意 |
|---|---|---|
-a | archive:递归 + 权限 + 时间 + 链接 + 属主 | 不含 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 dst | dst 是目录则移入,否则改名 |
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 文件系统、带日志的文件系统上不保证覆盖原块;它只对传统磁盘上的未压缩文件有意义。企业环境更可靠的做法是加密盘 + 销毁密钥,而不是反复覆写。
- 「
cp -r与cp -a只差权限」——-a还保留符号链接、硬链接、xattr、ACL、SELinux 标签。 - 「
cp覆盖文件会替换 inode」——默认是截断并写入同一个 inode,硬链接的另一端会看到新内容;要替换用--remove-destination或先rm。 - 「
rsync -a会保留 ACL 与 xattr」——要显式-A -X;-a只含基本权限与时间。 - 「
rsync --delete只删源端没有的文件」——它按目标端对比,排除规则写错会删掉本应保留的文件;先-n。 - 「
tar -x会自动防路径穿越」——GNU tar 默认拒绝绝对路径与..,但符号链接、硬链接、--absolute-names仍能绕过;不可信归档要在空目录、非 root 解。 - 「
mv总是原子的」——只在同一文件系统内是rename;跨文件系统是复制+删除。 - 「
rename返回成功就落盘了」——目录项持久化要fsync父目录,崩溃可能丢名字。 - 「
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 只作用于一个路径,互不依赖。
脚本二:link-audit.sh——悬空链接、硬链接与环
#!/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、-print0 | 0.3.6 |
用 xargs -0 处理怪文件名 | 能解释为什么不能裸用 xargs | 0.3.7、0.3.15 |
| 定位磁盘占用 | 会用 du -x 与 lsof +L1 | 0.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 |
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 目录配置/FHS | 0.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/du | 0.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之外还有哪些控制访问的手段。