目录

Linux-03 目录结构与路径解析:FHS 的设计骨架与软链接下的路径陷阱

上一篇讲软件怎样通过包管理器进入系统,这一篇继续解决「东西该放哪、路径怎么解析」。同样先看五个问题:

  1. /usr 里放的全是系统程序,为什么叫 usr(user)?
  2. /bin/usr/bin 有什么区别?为什么现代系统里 /bin 只是个软链接?
  3. 自己编译的软件装 /usr/local 还是 /opt?Go 服务的日志、配置、pid 文件各放哪?
  4. cd /a/b/../ccd /a/c 一定等价吗?
  5. pwdpwd -P 什么时候输出不一样?

前三个的答案在 FHS 这份标准里,后两个的答案在软链接身上。

1. FHS:目录结构不是约定俗成,是一份标准

目录结构叫 FHS(Filesystem Hierarchy Standard,文件系统层次标准),现行 3.0 版。它规定了根目录下每个目录放什么,各发行版遵守它,所以你在 Ubuntu 上学的目录知识在 Rocky 上同样适用。

ls / --group-directories-first
# bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run
# sbin  srv  sys  tmp  usr  var

1.1 FHS 的设计骨架:两个维度

死记 20 个目录没有意义。FHS 真正的设计逻辑是用两个维度给数据分类,这才是「所以然」:

静态(装完就不变) 可变(运行时会改)
可共享(多台机器能用同一份) /usr/opt /home/var/mail
不可共享(每台机器必须有自己的) /etc/boot /var/log/var/run/proc

这个 2×2 的划分不是学院派分类,它直接决定了运维手段:

# ① /usr 是【静态可共享】 -> 可以只读挂载,也可以多机共享一份
mount -o remount,ro /usr
#                   ^^ 只读挂载 /usr 是嵌入式设备和高安全环境的常规做法:
#                      系统程序不可能被篡改,因为分区本身就是只读的
# 容器镜像的分层设计也是同一个思路:/usr 那层是只读的镜像层

# ② /etc 是【静态不可共享】 -> 每台机器的配置不同,但装完基本不变
#    所以 /etc 应该进版本控制(etckeeper),也应该重点备份

# ③ /var 是【可变不可共享】 -> 唯一会持续增长的地方
#    所以生产机的标准做法是给 /var 单独分区,防止日志打满根分区导致系统不可用
df -h /var
# /dev/vdb1  100G  12G  88G  12% /var
#  ^^^^^^^^ 独立分区。就算日志写爆了,/ 还有空间让你 ssh 进来处理

# ④ /home 是【可变可共享】 -> 所以企业里常用 NFS 挂载家目录,
#    用户在任意一台机器登录都能看到自己的文件

记住这条:能只读挂载的(/usr)、必须本机独有的(/etc)、会不断长大的(/var)——分区规划和备份策略都是按这三类来的。

1.2 根目录下每个目录的职责

目录 全称 / 来源 放什么 服务端是否常打交道
/bin binaries 基本命令(lscpbash)。现代系统是 /usr/bin 的软链接 间接
/sbin system binaries 系统管理命令(fdiskipiptables
/usr Unix System Resources 绝大多数程序、库、文档。系统的主体
/etc etcetera(“其他”) 所有配置文件 非常
/var variable 会变化的数据:日志、数据库、缓存、队列 非常
/run run(time) tmpfs,运行时数据:pid 文件、unix socket。重启清空
/tmp temporary 临时文件,重启清空,所有人可写(带 Sticky 位)
/home 普通用户家目录
/root root 的家目录(不是根目录,根目录是 /
/opt optional 第三方整包软件(自带完整目录结构的)
/srv service 本机对外提供的服务数据(实际很少用)
/proc process 虚拟文件系统:进程与内核信息,不占磁盘 非常
/sys sysfs 虚拟文件系统:设备与内核对象,cgroup 在这
/dev devices 设备文件(/dev/null/dev/sda
/boot 内核镜像、initramfs、引导器配置 少(第 19 篇讲)
/lib /lib64 libraries 共享库。同样是 /usr/lib 的软链接 间接
/media 可移动介质的自动挂载点(U 盘、光盘)
/mnt mount 临时手动挂载点
/lost+found fsck 修复时找回的孤儿文件(每个 ext 分区都有一个) 极少

2. 历史包袱:/bin、/usr/bin、/usr/local/bin 的三层

2.1 /usr 为什么叫 usr

开篇第一问。教科书上说 /usr = Unix System Resources,但这是后来编的解释(backronym)。真实历史是:

1970 年代初,Unix 跑在 PDP-11 上,配的 RK05 硬盘只有 1.5MB。系统装着装着,根分区满了。开发者(Ken Thompson 和 Dennis Ritchie)就把一部分内容挪到第二块盘上,而那块盘当时挂在 /usr——它原本是放用户家目录的地方(就是字面意思 user)。

于是 /bin 装不下的程序被放进了 /usr/bin/lib 放不下的库进了 /usr/lib这个纯粹因为硬盘太小而产生的分裂,被后世当成了设计,一直延续了 50 年。

分裂之后,人们需要给它编一个理由,于是有了这套说法:

  • /bin/sbin/lib启动过程必需的东西(因为它们在根分区上,开机就能用)
  • /usr 放其他的(/usr 可能是单独分区甚至网络挂载,挂载时机较晚)

这个理由在 initramfs(初始内存文件系统)出现后彻底失效了——现在系统启动时先加载 initramfs,由它负责挂载所有分区,等真正的根文件系统就位时 /usr 早就挂好了,「启动必需」的划分不再有意义。

2.2 usrmerge:把分裂合并回去

于是 2012 年起(Fedora 17 带头,Debian/Ubuntu 后来跟进)有了 usrmerge 改革:把 /bin/sbin/lib 全部变成指向 /usr/ 下对应目录的软链接

ls -ld /bin /sbin /lib /lib64
# lrwxrwxrwx 1 root root 7 Apr 22  2023 /bin -> usr/bin
# lrwxrwxrwx 1 root root 8 Apr 22  2023 /sbin -> usr/sbin
# lrwxrwxrwx 1 root root 7 Apr 22  2023 /lib -> usr/lib
# lrwxrwxrwx 1 root root 9 Apr 22  2023 /lib64 -> usr/lib64
#                                        ^^^^^^^^^^^^^^^^ 全是软链接

# 所以这两条命令跑的是【同一个文件】
ls -i /bin/ls /usr/bin/ls
# 2097347 /bin/ls    2097347 /usr/bin/ls     <- inode 号相同

这解释了一个常见困惑:为什么有的脚本写 #!/bin/bash,有的写 #!/usr/bin/bash,两个都能跑?因为它们指向同一个文件。但注意可移植性:

#!/bin/bash              # 大多数 Linux 通用(/bin 一定存在)
#!/usr/bin/bash          # 在没做 usrmerge 的老系统上可能不存在
#!/usr/bin/env bash      # ✅ 最可移植:去 PATH 里找 bash
#                           macOS 上 bash 在 /opt/homebrew/bin,这种写法才能兼容

顺带澄清 sbins:常见说法是 super user,但更准确的历史来源是 statically linked binaries(静态链接的程序,那时候为了在库还没挂载好时也能运行)。现在它的实际含义是 system binaries——系统管理类命令,非 root 通常也能执行只是干不了实事。现代发行版(Debian 13)甚至在推进把 /usr/sbin 也合并进 /usr/bin

2.3 三层的现代分工:装软件到底该装哪

这才是每天要做的决策:

位置 谁在管 什么时候用
/usr/bin/usr/lib 包管理器(apt/dnf) 你不该手动往这写东西——下次升级会被覆盖,还会让包管理器状态错乱
/usr/local/bin/usr/local/lib (系统管理员) 手动编译安装(make install 默认前缀就是 /usr/local)、下载的单个二进制
/opt/<软件名>/ 软件自己 自带完整目录结构的第三方整包(如 /opt/google/chrome
~/.local/bin (仅当前用户) 不想影响其他用户,或者没有 root 权限时
# 场景一:手动装 Go(官方 tarball 建议解压到 /usr/local)
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
#                 ^^^^^^^^^^^^^^^^^ Go 是"自带目录结构"的,所以放 /usr/local/go 整个目录

# 场景二:编译安装某个工具(GNU 三部曲)
./configure --prefix=/usr/local     # 默认就是它,会装到 /usr/local/{bin,lib,share,include}
make && sudo make install
# 卸载困难是这种方式的最大问题(没有清单),所以更推荐 checkinstall 或直接用包管理器

# 场景三:下载的单个二进制
sudo install -m 755 kubectl /usr/local/bin/kubectl
#    ^^^^^^^ 用 install 而不是 cp:一步完成复制 + 权限(见 04 篇 10.14)

# 场景四:自己写的 Go 工具,只给自己用
go install github.com/x/tool@latest       # 装到 $(go env GOPATH)/bin,即 ~/go/bin
export PATH=$PATH:$(go env GOPATH)/bin

一条铁律:永远不要手动往 /usr/bin 里塞文件。 它属于包管理器,你放进去的东西会在系统升级时被覆盖或造成文件冲突,而且 dpkg -S/rpm -qf 查不到归属,接手的人无从判断这个文件哪来的。

3. 关键目录详解

3.1 /etc:所有配置的家

etc 来自 et cetera(拉丁语「等等、其他」)——最初 Unix 就是把不属于任何分类的杂项丢这,结果它成了配置文件的标准位置。

/etc/
├── passwd, shadow, group          # 用户与组(第 10 篇)
├── hosts, resolv.conf, hostname   # 网络基础(第 15 篇)
├── fstab                          # 开机挂载表(第 11 篇)
├── crontab, cron.d/               # 定时任务(第 14 篇)
├── sudoers, sudoers.d/            # 提权规则(第 10 篇)
├── ssh/sshd_config                # SSH 服务端配置(第 16 篇)
├── systemd/system/                # systemd 单元文件(第 14 篇)
├── nginx/, mysql/                 # 各软件自己的配置目录
├── profile, profile.d/, bash.bashrc   # 全局 shell 配置
├── default/  (Debian 系)          # 服务的环境变量与启动参数
└── sysconfig/  (RHEL 系)          # 同上,RHEL 的叫法

有三个模式值得注意:

conf.d/ 目录模式。 现代软件都支持「主配置文件 + 一个目录里的碎片文件」:

grep -r 'include' /etc/nginx/nginx.conf
# include /etc/nginx/conf.d/*.conf;
# include /etc/nginx/sites-enabled/*;

# 为什么这么设计?因为它让【自动化】变得安全:
# 加一个站点 = 往目录里丢一个文件(幂等、可回滚、不用改主文件)
# 而不是用 sed 去主配置文件里插一段(容易改坏、无法回滚、并发写会冲突)

# 所以你的部署脚本应该这样做
sudo tee /etc/nginx/conf.d/myapp.conf > /dev/null <<'EOF'
server { listen 8080; location / { proxy_pass http://127.0.0.1:3000; } }
EOF
sudo nginx -t && sudo systemctl reload nginx
#         ^^ 永远先测试语法再 reload

/etc/default/(Debian)与 /etc/sysconfig/(RHEL)。 存放服务的启动参数和环境变量,与主配置分开,避免升级时冲突:

cat /etc/default/grub          # GRUB 的配置在这,改完要 update-grub

/etc 应该进版本控制。 生产机上配置漂移(drift)是事故的常见来源:

sudo apt install etckeeper     # 自动把 /etc 变成 git 仓库,每次 apt 操作自动提交
cd /etc && sudo git log --oneline | head
# 谁在什么时候改了什么配置,一目了然

3.2 /var:唯一会持续长大的地方

/var/
├── log/          # 日志 ← 磁盘爆满的头号元凶
├── lib/          # 程序的持久化数据(/var/lib/mysql、/var/lib/docker)
├── cache/        # 缓存,删了不影响正确性(只是变慢)
├── spool/        # 队列:待发邮件、打印任务、cron 任务
├── tmp/          # 临时文件,【跨重启保留】
├── run  -> /run  # 软链接,见下
└── www/          # 网站文件(有些发行版放这)

区分 libcache 很重要:/var/cache 里的东西删掉只影响性能,/var/lib 里的删掉就丢数据了。 清理磁盘时先动 cache:

sudo du -xh --max-depth=1 /var | sort -h | tail
# 12G    /var/lib          <- 数据,慎动
# 8.2G   /var/log          <- 日志,可清
# 3.1G   /var/cache        <- 缓存,随便清

sudo apt clean                       # 清 /var/cache/apt/archives 里的 deb 包
sudo journalctl --vacuum-size=500M   # 把 journal 日志压到 500MB 以内
sudo docker system prune -a          # 清未使用的镜像(/var/lib/docker 常是最大头)

3.3 /run:为什么 pid 文件从 /var/run 搬走了

/run 是一个 tmpfs(内存文件系统),重启后一定是空的:

findmnt /run
# TARGET SOURCE FSTYPE OPTIONS
# /run   tmpfs  tmpfs  rw,nosuid,nodev,noexec,relatime,size=1608468k,mode=755
#               ^^^^^ 内存文件系统,不落盘

ls /run
# containerd/  docker.sock  lock/  nginx.pid  sshd.pid  systemd/  user/  utmp

ls -ld /var/run
# lrwxrwxrwx 1 root root 4 Aug 12 22:00 /var/run -> /run     <- 历史兼容软链接

为什么要从 /var/run 搬到 /run 因为启动顺序:/var 可能是独立分区(甚至 NFS),在启动早期还没挂载;但 udev、systemd 这些早期服务就已经需要写 pid 文件和 socket 了。把它放在根分区下的 tmpfs(/run)就没有这个依赖。

顺带解决了另一个问题:tmpfs 保证重启后一定干净。旧时代 /var/run 在磁盘上,机器异常断电后会残留陈旧的 pid 文件,导致服务启动时误判「已有实例在运行」而拒绝启动。

# 你的 Go 服务如果要写 pid 或 unix socket,正确位置是
/run/myapp/myapp.pid
/run/myapp/myapp.sock

# 目录需要提前创建,systemd 提供了声明式的方式(不用自己 mkdir)
# /etc/systemd/system/myapp.service
# [Service]
# RuntimeDirectory=myapp            <- systemd 自动创建 /run/myapp,停止时自动删除
# RuntimeDirectoryMode=0750

3.4 /tmp 与 /var/tmp 的区别

/tmp /var/tmp
生命周期 重启即清空 跨重启保留
实现 很多发行版挂成 tmpfs(在内存里 一定在磁盘上
大小限制 tmpfs 时受内存限制(默认物理内存的一半) 受分区大小限制
用途 短命的临时文件 需要熬过重启的中间文件(如大型编译的中间产物)
权限 1777(人人可写 + Sticky 位 1777
findmnt /tmp
# /tmp tmpfs tmpfs rw,nosuid,nodev,size=8065536k       <- 是 tmpfs,写它等于写内存

ls -ld /tmp
# drwxrwxrwt 20 root root 4096 Aug 12 22:00 /tmp
#          ^ 这个 t 是 Sticky 位:人人可写,但【只能删自己的文件】
#            没有它,任何人都能删别人的临时文件(第 10 篇讲)

两个实战要点:

# ① /tmp 是 tmpfs 时,往它写大文件 = 吃内存,可能触发 OOM
dd if=/dev/zero of=/tmp/big bs=1M count=10000      # 💀 直接吃掉 10GB 内存
# 大文件应该写 /var/tmp 或专门的数据目录

# ② systemd 服务默认可能启用 PrivateTmp,此时服务看到的 /tmp 不是你看到的 /tmp
systemctl show nginx -p PrivateTmp
# PrivateTmp=yes
# -> nginx 的 /tmp 实际是 /tmp/systemd-private-xxx-nginx.service-yyy/tmp
# 这解释了"程序说文件写在 /tmp 但我找不到"的困惑
sudo ls /tmp/systemd-private-*/tmp/

3.5 /proc 与 /sys:不占磁盘的虚拟文件系统

这两个目录里的「文件」不存在于任何磁盘上——它们是内核在你读取的瞬间生成的。

df -h /proc /sys
# Filesystem  Size  Used Avail Use% Mounted on
# proc           0     0     0    - /proc            <- 大小是 0
# sysfs          0     0     0    - /sys

ls -l /proc/meminfo
# -r--r--r-- 1 root root 0 Aug 12 22:00 /proc/meminfo
#                        ^ 大小显示 0,但 cat 出来有几十行 —— 内容是读时才生成的

/proc 里最常用的:

# 系统级
cat /proc/cpuinfo          # CPU 详情
cat /proc/meminfo          # 内存详情(free 命令的数据来源)
cat /proc/loadavg          # 平均负载(uptime 的数据来源)
cat /proc/version          # 内核版本
cat /proc/mounts           # 当前挂载表(比 /etc/mtab 可靠)
cat /proc/net/tcp          # TCP 连接表(ss 的数据来源之一)
ls /proc/sys/net/ipv4/     # 内核参数,可读可写(sysctl 操作的就是这里)

# 进程级:/proc/<pid>/ ← 排查问题的金矿
PID=1234
ls -l /proc/$PID/exe       # 程序的真实路径(即使被删了也能看到)
ls -l /proc/$PID/cwd       # 进程的当前工作目录
ls -l /proc/$PID/fd/       # 打开的所有 fd(查 fd 泄漏、查已删除文件)
cat /proc/$PID/cmdline | tr '\0' ' '    # 完整启动命令(参数用 \0 分隔)
cat /proc/$PID/environ | tr '\0' '\n'   # 环境变量(查"配置没生效")
cat /proc/$PID/status      # 状态汇总:内存、线程数、uid、信号掩码
cat /proc/$PID/limits      # 资源限制(查 too many open files)
cat /proc/$PID/stack       # 内核栈(进程 D 状态卡死时看它卡在哪个内核函数)
wc -l < /proc/$PID/maps    # 内存映射区域数量

# 自己进程的快捷方式
ls -l /proc/self/cwd

/sys 主要是设备与内核对象,服务端最常打交道的是 cgroup:

cat /sys/fs/cgroup/cpu.max          # 容器的 CPU 限额(cgroup v2)
cat /sys/fs/cgroup/memory.max       # 内存限额
cat /sys/fs/cgroup/memory.current   # 当前用量
cat /sys/block/vda/queue/scheduler  # 磁盘 IO 调度器
cat /sys/class/net/eth0/mtu         # 网卡 MTU

一个容器里的重要陷阱/proc 默认没有被容器命名空间隔离,所以容器里 cat /proc/meminfonprocfree -h 看到的都是宿主机的数据:

# 容器里(限额 512MB)
free -h
#                total        used        free
# Mem:            62Gi        20Gi        30Gi        <- 宿主机的数据!

# 真实限额要读 cgroup
cat /sys/fs/cgroup/memory.max
# 536870912                                           <- 512MB,这才是真的

# 后果:JVM/Go 的自动内存调优会按 62GB 来算,然后被 OOMKilled
# 解法:Go 设 GOMEMLIMIT,或用 lxcfs 让 /proc 反映 cgroup 值

3.6 /dev:设备也是文件

ls -l /dev/null /dev/zero /dev/urandom /dev/sda
# crw-rw-rw- 1 root root  1, 3 Aug 12 /dev/null      <- c = 字符设备
# crw-rw-rw- 1 root root  1, 5 Aug 12 /dev/zero
# crw-rw-rw- 1 root root  1, 9 Aug 12 /dev/urandom
# brw-rw---- 1 root disk  8, 0 Aug 12 /dev/sda       <- b = 块设备
#                         ^^^^ 主设备号,次设备号 —— 内核靠这两个数字找到驱动,
#                              而不是靠文件名(文件名只是给人看的)

常用的几个「虚拟设备」:

cmd > /dev/null 2>&1        # 黑洞:丢弃所有输出
dd if=/dev/zero of=f bs=1M count=100    # 零源:产生无限个 \0
head -c 32 /dev/urandom | base64        # 随机数(生成密钥/密码)
# /dev/random vs /dev/urandom:现代内核(5.6+)两者行为已基本一致,
#   直接用 urandom 即可,别再迷信 random 更安全(它只是可能阻塞)

echo "hi" > /dev/tty        # 直接写到终端(绕过重定向,脚本里给用户提示用)
cat /dev/stdin              # 标准输入(等价 -)
ls -l /dev/std{in,out,err}  # 都是指向 /proc/self/fd/{0,1,2} 的软链接

3.7 家目录:隐藏文件的意外起源与 XDG 规范

先讲一个「所以然」:隐藏文件(点文件)是一个 bug 的产物。

早期 Unix 的 ls 需要跳过 ... 这两条特殊目录项。实现时程序员图省事,写成了「跳过所有以 . 开头的名字」。于是任何以点开头的文件都被顺带藏了起来——这个偷懒的判断被当成了特性,后来所有程序都开始把配置文件命名成 .xxx 丢进家目录。Rob Pike 在回忆里明确说过:这是个 bug,不是设计。

后果是家目录变成了垃圾场:

ls -A ~ | head -20
# .bashrc  .bash_history  .profile  .vimrc  .gitconfig  .ssh  .cache
# .config  .local  .npm  .docker  .kube  .aws  .gnupg  .python_history  ...
ls -A ~ | wc -l         # 几十上百个,配置、缓存、数据全混在一起

XDG Base Directory 规范就是来收拾这个烂摊子的,它把家目录下的东西按用途分成四类:

环境变量 默认值 放什么 要不要备份
XDG_CONFIG_HOME ~/.config 配置(用户手改的东西) ,最重要
XDG_DATA_HOME ~/.local/share 数据(程序生成、丢了会疼)
XDG_STATE_HOME ~/.local/state 状态(日志、历史记录、上次窗口位置) 可选
XDG_CACHE_HOME ~/.cache 缓存(删了只是变慢) 不用
XDG_RUNTIME_DIR /run/user/<UID> 运行时的 socket、锁(tmpfs,登出即清) 不用
ls ~/.config              # nvim/  git/  gh/  htop/  systemd/
ls ~/.local/share         # nvim/  fish/  containers/
du -sh ~/.cache           # 3.2G    <- 经常是家目录最大的部分,随时可删
rm -rf ~/.cache/*         # 安全操作(程序会自己重建)

echo $XDG_RUNTIME_DIR     # /run/user/1000
ls -ld /run/user/1000
# drwx------ 8 lhx lhx 240 Aug 12 22:00 /run/user/1000
#          ^^^^^^ 只有你自己能进,由 systemd-logind 在登录时创建、登出时销毁
# podman、pipewire、tmux 的 socket 都放这

这个划分的实际价值:备份和迁移时规则变得极简——备份 ~/.config~/.local/share,忽略 ~/.cache。而在老式的点文件模式下,你根本分不清 ~/.npm(缓存,40GB)和 ~/.ssh(密钥,必须备份)哪个该带走。

Go 程序应该遵守它(标准库直接支持):

// ✅ 用标准库,自动遵守 XDG(并在 macOS/Windows 上给出对应位置)
cfgDir, _ := os.UserConfigDir()    // Linux: ~/.config    macOS: ~/Library/Application Support
cacheDir, _ := os.UserCacheDir()   // Linux: ~/.cache     macOS: ~/Library/Caches
homeDir, _ := os.UserHomeDir()

configPath := filepath.Join(cfgDir, "myapp", "config.yaml")

// ❌ 不要硬编码
configPath := os.Getenv("HOME") + "/.myapp/config.yaml"   // 又往家目录扔垃圾了

注意 XDG_RUNTIME_DIR 有个真实的坑:它只在有登录会话时存在。用 su - user 切过去或在 cron 里执行时,这个变量是空的,依赖它的程序(如 rootless podman、systemctl --user)会报错:

sudo -u app systemctl --user status
# Failed to connect to bus: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined
# ✅ 解法:用 machinectl shell 或给该用户开启 linger
sudo loginctl enable-linger app     # 让该用户的 /run/user/<uid> 常驻,不随登出销毁

4. 一个 Go 服务的文件应该怎么摆

把 FHS 落到实处。假设服务叫 myapp

内容 路径 权限 理由
二进制 /usr/local/bin/myapp 755 root:root 管理员手动装的程序
配置 /etc/myapp/config.yaml 640 root:myapp 配置在 /etc;含密钥时 other 不可读
配置片段 /etc/myapp/conf.d/*.yaml 640 便于自动化增删
环境变量 /etc/default/myapp 640 与主配置分离,systemd 的 EnvironmentFile 读它
持久数据 /var/lib/myapp/ 750 myapp:myapp 丢了就出事的数据
缓存 /var/cache/myapp/ 750 myapp:myapp 删了只影响性能
日志 /var/log/myapp/ 750 myapp:myapp 或者直接写 stdout 交给 journald
pid / socket /run/myapp/ 750 myapp:myapp tmpfs,重启自动清
systemd 单元 /etc/systemd/system/myapp.service 644 root:root 管理员定义的单元放这(不是 /lib/systemd
大型整包安装 /opt/myapp/{bin,conf,data} 不遵循 FHS 拆分时的替代方案

对应的 systemd 单元(RuntimeDirectory 这类指令让你不用自己 mkdir 和 chown):

# /etc/systemd/system/myapp.service
[Unit]
Description=My Go Service
After=network-online.target

[Service]
Type=notify
User=myapp
Group=myapp
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yaml
EnvironmentFile=-/etc/default/myapp

# systemd 自动创建这些目录并设好属主,服务停止时按需清理
RuntimeDirectory=myapp              # -> /run/myapp        (停止时删除)
StateDirectory=myapp                # -> /var/lib/myapp    (保留)
CacheDirectory=myapp                # -> /var/cache/myapp
LogsDirectory=myapp                 # -> /var/log/myapp
ConfigurationDirectory=myapp        # -> /etc/myapp

# 顺带一提,这几个加固选项建议默认开
ProtectSystem=strict                # /usr /boot /etc 只读(呼应 1.1 的"静态可共享")
ProtectHome=yes                     # 看不到 /home
PrivateTmp=yes                      # 独立的 /tmp
NoNewPrivileges=yes                 # 禁止提权

[Install]
WantedBy=multi-user.target

为什么值得遵守这套约定? 三个实际收益:① 备份策略可以简单表达为「备份 /etc/var/lib」;② 权限和 SELinux 上下文有现成的规则可套;③ 接手的人不用问你「数据在哪、配置在哪」。容器里可以简化(配置走环境变量、日志走 stdout),但目录语义仍然沿用同一套。

5. 路径解析:内核怎么一段段走

5.1 绝对路径与相对路径

/var/log/app.log        # 绝对路径:以 / 开头,从根目录开始
var/log/app.log         # 相对路径:从【当前工作目录】开始
./app.log               # 显式相对(. 表示当前目录)
../sibling/f            # 上一级
~/project/main.go       # ~ 由 shell 展开成 $HOME,【内核不认识 ~】
~root/.ssh              # 展开成 root 用户的家目录

注意 ~shell 的展开,不是路径语法。这带来一个常见的坑:

# ✅ 能用:shell 展开了 ~
ls ~/data

# ❌ 不能用:引号阻止了展开,内核收到的是字面量 "~/data"
ls "~/data"
# ls: cannot access '~/data': No such file or directory

# ✅ 正确写法
ls "$HOME/data"
ls ~/"data with space"       # ~ 在引号外,路径其余部分在引号内

内核解析一条路径时,是**逐段(component by component)**走的,每一段都要:查目录 → 找到 inode → 检查执行权限(x)→ 继续下一段。这就是 04 篇讲的那条链:

/var/log/app.log
 │   │    └── 在 log 目录里查 "app.log" -> inode
 │   └────── 在 var 目录里查 "log"     -> inode,需要 var 有 x 权限
 └────────── 从根 inode(固定为 2)开始

由此得到两个重要推论:

① 路径中间的每一级目录都需要 x(执行/通行)权限,否则整条路径走不通——哪怕最终文件是 777。这是「权限明明给了却还是 Permission denied」的头号原因,5.4 节给排查工具。

② 路径越深,open() 的开销越大(要多走几级)。内核用 dentry cache 缓解,但在超深路径 + 冷缓存时仍可测量到差异。

5.2 路径的语法细节

# ① 多个连续斜杠等价于一个
ls /var//log///app.log        # 与 /var/log/app.log 完全相同
# 例外:开头恰好两个斜杠(//)POSIX 允许实现自定义含义,Linux 上仍等价于 /

# ② 末尾斜杠 = 断言"这必须是个目录"
ls /etc/                      # 正常
ls /etc/passwd/               # ls: cannot access: Not a directory
# 实用价值:mv src dst/ 能确保 dst 是目录,避免把 src 重命名成 dst 文件

# ③ 长度限制
getconf PATH_MAX /            # 4096   整条路径的最大字节数
getconf NAME_MAX /            # 255    单个文件名的最大字节数(注意是字节,中文占 3 字节)
# 中文文件名最多 85 个字(255/3)
python3 -c "print(len('很长的中文名'.encode()))"

# ④ 路径里什么字符都行,除了 / 和 \0(见 04 篇第 8 节)

5.3 ... 是真实存在的目录项

它们不是 shell 的语法糖,而是每个目录里真实存在的两条记录

ls -ai /tmp | head -3
# 2621441 .          <- 指向自己
#       2 ..         <- 指向父目录(这里 /tmp 的父是 /,inode 号 2)
# 2621500 somefile

stat -c '%i' /tmp /tmp/. /tmp/..
# 2621441
# 2621441        <- . 和 /tmp 是同一个 inode
# 2               <- .. 就是 / 的 inode

这解释了 04 篇那个「空目录硬链接数为 2」的现象,也解释了根目录的特殊性:

stat -c '%i' / /.. /../..
# 2
# 2          <- 根目录的 .. 指向它自己(到顶了就不再往上)
# 2

5.4 排查「Permission denied」:namei

这是本篇最实用的一个工具。当某个路径访问被拒,用 namei -l 逐层列出权限,一眼看出是哪一级挡住的:

namei -l /var/www/html/index.html
# f: /var/www/html/index.html
# dr-xr-xr-x root root /
# drwxr-xr-x root root var
# drwx------ root root www           <- 就是这一层!other 没有 x,任何非 root 都进不去
# drwxr-xr-x root root html
# -rw-r--r-- root root index.html
#                      ^^^^^^^^^^ 文件本身是 644 人人可读,但根本走不到这

# 参数
namei -l path        # l = long,显示权限、属主、属组
namei -m path        # 只显示权限
namei -x path        # 用 ? 标记不存在的部分
namei -o path        # 显示属主
namei /path/to/link  # 会把软链接的每一跳都展开显示

namei 对软链接的展示尤其有用:

namei -l /opt/app/current/bin/server
# f: /opt/app/current/bin/server
# drwxr-xr-x root root  /
# drwxr-xr-x root root  opt
# drwxr-xr-x root root  app
# lrwxrwxrwx root root  current -> releases/v1.2.3       <- 展开了软链接
#   drwxr-xr-x root root  releases
#   drwxr-xr-x root root  v1.2.3
# drwxr-xr-x root root  bin
# -rwxr-xr-x root root  server

没有 namei 时的替代方案:

# 手工逐层看
d=/var/www/html/index.html; while [ "$d" != / ]; do ls -ld "$d"; d=$(dirname "$d"); done

# 或者用 sudo -u 直接验证某用户能不能访问(最直接)
sudo -u www-data test -r /var/www/html/index.html && echo OK || echo DENIED
sudo -u www-data ls /var/www/html/

6. 软链接让路径解析变得诡异

开篇第四、五问。这一节的所有现象都源于一件事:shell 记的路径(逻辑路径)和内核认的路径(物理路径)可能不一致。

6.1 cd /a/b/../c 不一定等于 cd /a/c

先造一个场景:

mkdir -p /tmp/demo/real/sub /tmp/demo/other
ln -s /tmp/demo/real /tmp/demo/link          # link 是指向 real 的软链接

cd /tmp/demo/link
pwd
# /tmp/demo/link              <- shell 说我在 link 里

cd ..
pwd
# /tmp/demo                   <- shell 认为上一级是 /tmp/demo

看起来很正常。但换成 -P 模式,结果完全不同:

cd -P /tmp/demo/link          # -P = physical,先解析成真实路径再进去
pwd
# /tmp/demo/real              <- 直接落到真实目录

cd ..
pwd
# /tmp/demo                   <- 这次上一级是 real 的父目录,恰好也是 /tmp/demo

真正会出问题的是软链接跨目录时:

mkdir -p /tmp/x/deep/dir /tmp/y
ln -s /tmp/x/deep/dir /tmp/y/mylink

cd /tmp/y/mylink && pwd       # /tmp/y/mylink        (逻辑路径)
cd .. && pwd                  # /tmp/y               (shell 按字符串算的)

cd -P /tmp/y/mylink && pwd    # /tmp/x/deep/dir      (物理路径)
cd .. && pwd                  # /tmp/x/deep          (真实的父目录!完全不同)

为什么会这样? 因为有两套 .. 的处理逻辑:

shell 的默认行为(-L 逻辑模式) 内核 / -P 物理模式
.. 怎么算 纯字符串操作:把 $PWD 末尾一段去掉 真的去读目录里的 .. 条目,得到真实父目录
记录的路径 你走过来的路径(含软链接名) 真实路径
# 验证:内核(也就是任何外部程序)看到的永远是物理路径
cd /tmp/y/mylink
pwd                # /tmp/y/mylink        <- bash 内建的 pwd,读的是 $PWD 变量
/bin/pwd           # /tmp/x/deep/dir      <- 外部程序,调 getcwd() 问内核
pwd -P             # /tmp/x/deep/dir      <- 显式要物理路径
echo $PWD          # /tmp/y/mylink        <- $PWD 只是 shell 维护的一个字符串

ls -l /proc/$$/cwd
# lrwxrwxrwx ... /proc/1234/cwd -> /tmp/x/deep/dir       <- 内核记的也是物理路径

所以第五问的答案是pwd 是 bash 内建,默认输出 shell 维护的逻辑路径(含软链接);pwd -P/bin/pwd 输出内核记录的物理路径。当路径里有软链接时两者不同。

这个差异在脚本里是真实的 bug 来源:

# ❌ 危险:在有软链接的路径下,用 .. 拼路径可能指向意料之外的地方
cd "$APP_DIR/current"          # current 是软链接
rm -rf ../old_data             # 你以为删的是 $APP_DIR/old_data
                               # 但如果用了 cd -P 或在子进程里执行,可能删到别处

# ✅ 安全:先解析成绝对物理路径再操作
APP_ROOT=$(cd -P "$APP_DIR" && pwd)
rm -rf "$APP_ROOT/old_data"
# 全局改变 shell 的默认行为(很少用,但要知道有)
set -o physical        # 让 cd/pwd 默认走物理模式
set +o physical        # 恢复默认(逻辑模式)
shopt -s autocd        # 顺带一提:直接敲目录名就能 cd 进去

6.2 四个路径解析命令的区别

mkdir -p /tmp/p/real && ln -s /tmp/p/real /tmp/p/link
cd /tmp/p
命令 作用 /tmp/p/link 不存在的路径
readlink link 只读一层链接的内容 /tmp/p/real 报错
readlink -f X 递归解析,最后一层可以不存在 /tmp/p/real 仍输出结果
readlink -e X 递归解析,所有部分必须存在 /tmp/p/real 无输出,退出码 1
readlink -m X 递归解析,完全不检查存在性 /tmp/p/real 输出结果
realpath X 等价 readlink -e,语义更清晰,推荐 /tmp/p/real 报错
realpath -m X 允许不存在 输出结果
realpath -s X 只做词法规范化(去掉 ...),不解析软链接 /tmp/p/link
realpath /tmp/p/link                    # /tmp/p/real          解析软链接
realpath -s /tmp/p/link                 # /tmp/p/link          不解析
realpath --relative-to=/tmp /tmp/p/real # p/real               算相对路径
realpath -m /nonexistent/a/b            # /nonexistent/a/b     不检查存在

readlink -f /proc/self/exe              # 当前进程的真实程序路径
readlink -f "$(command -v python3)"     # python3 最终指向哪个版本  ← 排查多版本必用
# /usr/bin/python3.10

# 脚本里获取自身所在的真实目录(标准写法)
SCRIPT_DIR=$(cd -- "$(dirname -- "$(readlink -f "$0")")" && pwd)
#                                        ^^^^^^^^^^^ 先解析软链接,
#            否则脚本被软链接调用时会拿到链接所在目录而不是真实目录

6.3 软链接循环与 40 层限制

ln -s a b && ln -s b a          # 造一个环
cat a
# cat: a: Too many levels of symbolic links
#      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 对应错误码 ELOOP

# 内核的保护:解析一条路径时最多跟随 40 层软链接
cat /proc/sys/fs/... # (这个限制是编译期常量 MAXSYMLINKS=40,不可调)

# 检测环
realpath a
# realpath: a: Too many levels of symbolic links
find /opt -follow 2>&1 | grep -i loop      # find 会报告检测到的环

这也解释了 04 篇提到的「为什么硬链接不能指向目录」:硬链接成环时没有任何计数器能发现(因为它们是平等的目录项,不像软链接有跟随层数可数),递归工具会真的无限循环下去。软链接允许指向目录,正是因为有这个 40 层的可检测边界。

7. 目录跳转的效率技巧

cd              # 不带参数 = 回家目录(等价 cd ~ 或 cd $HOME)
cd -            # 回到【上一个】目录(在两个目录间来回切最常用)
cd -P dir       # 物理模式进入
cd -e           # 配合 -P:如果 getcwd 失败则返回非零(脚本里防御用)

# 目录栈:适合"临时去别处,办完事回来"
pushd /var/log      # 进入 /var/log,并把当前目录压栈
# ~/project /var/log       <- 打印当前栈
pushd /etc/nginx    # 再压一层
dirs -v             # 带编号查看栈
# 0  /etc/nginx
# 1  /var/log
# 2  ~/project
popd                # 弹栈并回到 /var/log
popd                # 回到 ~/project
cd ~2               # 直接跳到栈里第 2 个(配合 dirs -v 的编号)

# CDPATH:给 cd 加搜索路径(谨慎使用)
export CDPATH=".:$HOME/projects"
cd myapp            # 会在当前目录找不到时,去 ~/projects/myapp
# ⚠️ 坑:CDPATH 里【必须】把 . 放第一个,否则 cd somedir 可能跳到意想不到的地方;
#        脚本里绝对不要设 CDPATH,会让 cd 的行为不可预测

# 现代工具(需安装):按使用频率智能跳转
# zoxide:z log  -> 直接跳到最常用的含 log 的目录
# autojump / fasd 同类

8. 知识点扩展

8.1 cd — change directory

全称cd = change directory。shell 内建命令(原因见 01 篇 3.2:必须改自己进程的 cwd)。

参数 全称 作用
(无) $HOME
- 回上一个目录($OLDPWD),并打印新路径
-L logical 默认:保留路径中的软链接名
-P physical 解析软链接,进入真实目录
-e 配合 -P,若 getcwd() 失败则返回非零退出码
-@ 把文件的扩展属性当目录访问(极少用)
cd /var/log && cd - && cd -      # 在两个目录间反复横跳
cd -P /opt/app/current           # 进入软链接指向的真实目录
(cd /tmp && ls)                  # ✅ 用子 shell 临时切换,不影响当前 shell 的 cwd

最后这个技巧很有用:用括号包起来就是子 shell,里面的 cd 不影响外面。脚本里比「cd 过去再 cd 回来」安全得多(后者在中途出错时回不来)。

8.2 pwd — print working directory

参数 作用
(无)/ -L 打印逻辑路径($PWD 的值,含软链接名)——bash 内建的默认行为
-P 打印物理路径(问内核要,getcwd()
pwd; pwd -P; /bin/pwd            # 三者在有软链接时可能不同
echo $PWD                        # shell 变量,等价 pwd -L
echo $OLDPWD                     # 上一个目录,cd - 用的就是它
readlink /proc/self/cwd          # 内核视角的真实 cwd

见 6.2 的对比表。补充常用参数:

命令 参数 作用
realpath -e 所有部分必须存在(默认)
-m 允许不存在
-s / --no-symlinks 只做词法规范化
--relative-to=DIR 输出相对于 DIR 的路径
--relative-base=DIR 在 DIR 内部时输出相对路径,否则输出绝对路径
-q 静默(出错不打印)
-z 输出以 \0 结尾(配合 xargs -0
readlink -f / -e / -m 三种存在性策略(见上表)
-n 不输出末尾换行
-z \0 结尾

8.4 namei — 逐层解析路径

全称namei = name to inode(follow a pathname until a terminal point is found)

参数 全称 作用
-l --long 显示权限、属主、属组(最常用
-m --modes 只显示权限
-o --owners 只显示属主
-x --mountpoints D 标出挂载点
-v --vertical 竖排输出
-n --nosymlinks 不跟随软链接

输出首字母的含义:f: 起始路径、d 目录、l 软链接、- 普通文件、? 出错(权限不足或不存在)。

8.5 挂载点相关:findmnt / mount / df

判断「这个目录属于哪个文件系统」时用:

命令 作用
findmnt 树形列出所有挂载点(mount 好读
findmnt /path 查某个路径属于哪个挂载点
findmnt -t ext4,xfs 只看指定类型
findmnt --df 带用量的挂载表
findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS 自定义列
mount 列出挂载(旧式,输出杂乱)
cat /proc/mounts 内核视角的挂载表(最可靠,脚本用)
df -hT /path 看用量与类型
stat -f /path 看文件系统级信息(块大小、inode 总数)
lsblk -f 块设备树 + 文件系统 + UUID + 挂载点
mountpoint -q /path 判断是不是挂载点(脚本里防「往未挂载的目录写数据」)
findmnt /var/log
# TARGET SOURCE    FSTYPE OPTIONS
# /var   /dev/vdb1 ext4   rw,relatime
#  ^^^^ 说明 /var/log 属于 /var 这个挂载点,不是根分区

# 部署脚本里的防御性检查(数据盘没挂载就往上写,会把根分区写爆)
mountpoint -q /data || { echo "/data 未挂载,中止"; exit 1; }

# 判断两个路径是否在同一文件系统(决定 mv 是否原子、能否硬链接)
[[ $(stat -c '%d' /a) == $(stat -c '%d' /b) ]] && echo "同一文件系统"

8.6 目录栈与其他

pushd DIR       # 压栈并进入
pushd           # 交换栈顶两个目录
pushd +2        # 把栈里第 2 个转到栈顶并进入
popd            # 弹栈并进入新的栈顶
popd +1         # 只删除栈里第 1 个条目,不改变当前目录
dirs -v         # 带编号显示目录栈
dirs -c         # 清空栈

tree -L 2 -d /etc        # 看目录骨架(见 04 篇 10.11)
du -xh -d1 /var | sort -h    # 看哪个子目录大(-x 不跨文件系统)
find / -maxdepth 1 -type d   # 列出根下所有目录

9. 面试题

Q:FHS 是按什么逻辑组织目录的?为什么 /usr 可以只读挂载而 /var 要单独分区?

FHS 用两个维度给数据分类:静态 / 可变(装完是否还会改)和 可共享 / 不可共享(多台机器能否用同一份)。

/usr 是「静态 + 可共享」——装完就不变,所有机器的内容可以一样。所以它能只读挂载(嵌入式和高安全环境的标准做法,系统程序在物理上就不可能被篡改),也能通过 NFS 多机共享一份。容器镜像的只读层本质上是同一个思路。

/var 是「可变 + 不可共享」——它是系统里唯一会持续增长的地方(日志、数据库、缓存)。给它单独分区的理由是故障隔离:日志写爆了只会撑满 /var,根分区还有空间让你 ssh 进去处理;如果共用根分区,磁盘满了会导致连登录都做不到(登录要写 utmp、要创建临时文件)。

/etc 是「静态 + 不可共享」——每台机器配置不同但装完基本不变,所以它是备份和版本控制的重点(etckeeper)。

Q:/bin/usr/bin 有什么区别?为什么现在 /bin 是软链接?

历史上的区别是「启动必需 vs 其他」:/bin 在根分区,开机就能用;/usr 可能是独立分区甚至网络挂载,挂载时机晚。

但这个分裂的真实起因不是设计,而是硬盘太小——1970 年代 PDP-11 的硬盘只有 1.5MB,根分区装满后开发者把溢出的程序挪到了第二块盘,那块盘恰好挂在 /usr(当时是放用户家目录的地方,就是字面意思 user)。「Unix System Resources」是后来编的解释。

initramfs 出现后,启动早期由它负责挂载所有分区,「启动必需」的划分失去意义。于是 2012 年起有了 usrmerge/bin/sbin/lib 全部变成指向 /usr/ 下对应目录的软链接。可以用 ls -i /bin/ls /usr/bin/ls 验证它们 inode 相同。

实践含义:脚本的 shebang 用 #!/usr/bin/env bash 最可移植(macOS 上 bash 根本不在 /bin)。

Q:/tmp/var/tmp 有什么区别?往 /tmp 写大文件有什么风险?

/tmp 重启即清空,且多数发行版把它挂成 tmpfs(内存文件系统)/var/tmp 在磁盘上,跨重启保留,适合放需要熬过重启的中间产物。两者都是 1777 权限——人人可写,但带 Sticky 位(只能删自己的文件,否则任何人都能删别人的临时文件)。

风险是:/tmp 是 tmpfs 时写它等于吃内存,默认上限是物理内存的一半,往里 dd 一个 10GB 文件可能直接触发 OOM。大文件应该写 /var/tmp 或专门的数据目录。

还有一个容易困惑的点:systemd 服务如果启用了 PrivateTmp=yes,它看到的 /tmp/tmp/systemd-private-xxx-服务名-yyy/tmp,与你在 shell 里看到的不是同一个目录——这解释了「程序说文件写在 /tmp 但我找不到」。

Q:/run 是什么?为什么 pid 文件和 socket 要放这里而不是 /var/run

/run 是挂在根分区下的 tmpfs,存放运行时数据(pid 文件、unix socket、锁文件),重启后必定为空/var/run 现在只是指向它的兼容软链接。

搬家的原因是启动顺序/var 可能是独立分区甚至 NFS,在启动早期尚未挂载,但 udev、systemd 这些早期服务就已经需要写 pid 文件了。放在根分区的 tmpfs 上就没有这个依赖。

附带解决了另一个老问题:tmpfs 保证重启后干净,不会像磁盘上的 /var/run 那样在异常断电后残留陈旧 pid 文件,导致服务误判「已有实例在运行」而拒绝启动。

服务端实践:Go 服务的 pid/socket 放 /run/myapp/,用 systemd 的 RuntimeDirectory=myapp 声明,systemd 会自动创建并在服务停止时清理,不用自己 mkdir + chown。

Q:cd /a/b/../ccd /a/c 一定等价吗?pwdpwd -P 什么时候不同?

不一定等价——当 b 是指向别处的软链接时就不同。

根源是有两套 .. 的处理方式:shell 默认是逻辑模式(-L..纯字符串操作(把 $PWD 末尾一段去掉);内核是物理模式.. 是真的去读目录里的 .. 条目,得到真实的父目录。所以 cd /tmp/y/mylink && cd .. 在 shell 里回到 /tmp/y,而 cd -P /tmp/y/mylink && cd .. 回到的是链接目标的真实父目录。

pwd 是 bash 内建,输出 shell 维护的 $PWD(逻辑路径,含软链接名);pwd -P 和外部的 /bin/pwdgetcwd() 问内核,输出物理路径。readlink /proc/self/cwd 也是物理路径——内核只知道物理路径,逻辑路径纯粹是 shell 的记账。

脚本里的安全写法是先解析成绝对物理路径再操作:APP_ROOT=$(cd -P "$dir" && pwd),避免用 .. 拼路径时删错东西。

Q:一个文件权限是 644,属主也对,为什么读不了?怎么快速定位?

因为路径上每一级目录都需要 x(通行)权限,内核逐段解析路径时,任何一级缺 x 就直接返回 EACCES,根本走不到文件本身。

定位工具是 namei -l /完整/路径,它逐层打印每一级的权限、属主、属组,一眼就能看出是哪一级挡住了(比如 drwx------ root root www 这一层)。它还会展开路径中的软链接,显示每一跳。

另一种直接验证方式是 sudo -u www-data test -r /path && echo OK,直接以目标用户身份试一次。

Q:/proc 占磁盘空间吗?容器里 free -h 为什么显示的是宿主机内存?

/procprocfs 虚拟文件系统,内容由内核在你读取的瞬间生成,不占任何磁盘(df /proc 显示大小为 0,ls -l /proc/meminfo 显示大小也是 0 但能 cat 出内容)。

容器里看到宿主机数据,是因为 /proc 没有被容器的命名空间隔离——freenproctop 读的都是 /proc 里的宿主机全局信息,cgroup 的限额它们不认。后果是 JVM/Go 的自动调优会按宿主机的内存和核数计算,然后被 OOMKilled 或产生大量无效调度。

真实限额要读 cgroup:/sys/fs/cgroup/memory.max/sys/fs/cgroup/cpu.max(v2)。解法是 Go 里设 GOMEMLIMIT + 引入 automaxprocs,或者在宿主上部署 lxcfs 让容器的 /proc 反映 cgroup 值。

Q:自己编译的软件应该装到哪?为什么不能装 /usr/bin

/usr/bin 属于包管理器。手动往里放文件有三个问题:系统升级时可能被覆盖或产生文件冲突;dpkg -S/rpm -qf 查不到归属,接手的人不知道这文件哪来的;卸载时无从清理。

正确的位置分三种:/usr/local/——管理员手动装的东西(./configure 的默认 --prefix 就是它,下载的单个二进制也放 /usr/local/bin);/opt/<软件名>/——自带完整目录结构的第三方整包(如 Go 官方 tarball 建议解压到 /usr/local/go);~/.local/bin——只给当前用户、或者没有 root 权限时。

PATH 的默认顺序把 /usr/local/bin 排在 /usr/bin 前面,正是为了让手动装的新版本能覆盖系统自带的旧版本,这是设计意图不是巧合。

Q:软链接为什么允许指向目录,硬链接却不允许?

两者都会导致目录树成环,区别在于环是否可检测

软链接的解析是「跟随」语义,内核给了一个硬性上限 40 层MAXSYMLINKS),超过就返回 ELOOPToo many levels of symbolic links)。这是一个廉价且可靠的兜底。

硬链接是「平等的目录项」,成环之后没有任何计数器能发现——finddurm -r 递归时会真的无限循环下去;而且 .. 该指向哪个父目录变得无解,getcwd() 也无法唯一还原路径。所以内核直接禁止普通用户给目录建硬链接(... 是内核自己维护的例外)。


小结

  • FHS 的骨架是 静态/可变 × 可共享/不可共享 这个 2×2,分区规划和备份策略都从它推导:/usr 可只读、/etc 要版控、/var 要单独分区
  • /usr 的名字来自 1970 年代硬盘装不下的意外,usrmerge 之后 /bin/sbin/lib 都只是软链接
  • 装软件:包管理器管 /usr,你管 /usr/local,整包软件用 /opt永远别手动写 /usr/bin
  • /run 是 tmpfs 且在根分区,所以 pid 和 socket 放它;/tmp 可能是内存,别往里写大文件
  • /proc/sys 不占磁盘,但没有被容器隔离——容器里要读 cgroup 才知道真实限额
  • 路径逐段解析,每一级目录都要 x 权限,排查用 namei -l
  • 逻辑路径是 shell 的记账,物理路径才是内核认的pwd vs pwd -Pcd vs cd -P,有软链接时它们不同

下一篇(04 篇)讲文件与目录操作的深水区:inode 到底是什么、删除为什么叫 unlink、mv 什么时候是原子的、cp 默认丢了什么、ls 在百万文件目录里为什么会卡住。