硬链接为已有文件增加另一个目录项。两个名字都指向同一文件系统里的同一个 inode,其中没有哪个天然具有“原件”地位。内容、所有者、权限和链接数属于这个共享对象,文件名只是到达它的入口。因此,需要把目录项的变化与文件对象本身的变化分开理解,才能判断修改、删除和替换到底影响了哪些访问路径。

坏掉的样子

把硬链接当成独立备份,会产生错误的恢复边界。通过任意名字写入或截断共享 inode,另一名字读到的内容也会改变;修改模式位,同样改变的是一个对象的权限。第二个路径可以在第一个路径被删除后继续存在,却不能抵御针对共享文件的普通原地修改,也不会自动保留先前历史版本。

许多编辑器和部署工具采用另一种更新方式:先创建替代文件,再通过 rename 覆盖某个路径。该目录项此时指向新 inode,其他硬链接仍然到达旧对象。两个名字是否一起显示新内容,取决于实际更新方法。因此不能仅凭它们昨天建立过硬链接,就断言今天仍共享身份;应检查当前对象以及写入工具的真实行为。

硬链接还受文件系统挂载边界约束。即使两个目录在统一路径树里看起来相邻,link 也不能随意把不同挂载中的对象连接起来。Linux 还禁止普通目录硬链接。这些限制意味着,硬链接不能作为跨目录层次、跨挂载符号链接的通用替代品;使用前必须先确认目标对象和边界,而不是只看路径字符串。

快速验证

把脚本保存为 hardlinks.py,在有权创建临时目录的 Linux 环境运行 python3 hardlinks.py。它要求 /tmp 与 /run 可写且属于不同文件系统;需要时可将 CROSS_ROOT 设为另一文件系统上的可写目录。实验不要求 root,也不创建挂载,并会明确检查跨文件系统前提。实测使用两个独立 tmpfs 挂载、Linux 6.12.76 和 Python 3.11.2,所有新建条目都位于临时目录中:

from pathlib import Path
from tempfile import TemporaryDirectory
import errno,os

with TemporaryDirectory(prefix='xbcatm-hardlinks-',dir='/tmp') as directory, TemporaryDirectory(prefix='xbcatm-crosslink-',dir=os.environ.get('CROSS_ROOT','/run')) as other:
    root=Path(directory);source=root/'source';alias=root/'alias'
    source.write_bytes(b'original');os.link(source,alias)
    before=source.stat();linked=alias.stat()
    assert (before.st_dev,before.st_ino)==(linked.st_dev,linked.st_ino)
    assert before.st_nlink==linked.st_nlink==2 and before.st_size==linked.st_size==8
    alias.write_bytes(b'changed')
    assert source.read_bytes()==b'changed'
    print('two names share device/inode, size, and link count; in-place write changes both views')
    replacement=root/'replacement';replacement.write_bytes(b'new file');replacement.replace(source)
    assert source.stat().st_ino!=alias.stat().st_ino
    assert source.read_bytes()==b'new file' and alias.read_bytes()==b'changed'
    assert source.stat().st_nlink==alias.stat().st_nlink==1
    print('atomic replacement changes one name to a new inode; the other name retains the old file')
    with alias.open('rb') as opened:
        alias.unlink()
        assert os.fstat(opened.fileno()).st_nlink==0 and opened.read()==b'changed'
    print('last unlink removes the final name but an existing open descriptor still reads the old data')
    assert root.stat().st_dev!=Path(other).stat().st_dev,'exercise requires two filesystem mounts'
    try:os.link(source,Path(other)/'cross-filesystem')
    except OSError as error:assert error.errno==errno.EXDEV,error
    else:raise AssertionError('cross-filesystem hard link unexpectedly succeeded')
    child=root/'directory';child.mkdir()
    try:os.link(child,root/'directory-link')
    except OSError as error:assert error.errno==errno.EPERM,error
    else:raise AssertionError('directory hard link unexpectedly succeeded')
    print('cross-filesystem link fails with EXDEV; directory hard link fails with EPERM on Linux')

样本先创建 source,写入八字节内容,再通过 os.link 添加 alias。两个名字报告相同的设备与 inode 组合、八字节大小,以及值为二的链接数。比较时必须包含设备身份,因为 inode 编号并不跨文件系统全局唯一;仅仅比较内容相同,也无法证明两个入口共享对象,独立副本同样可能有相同字节。

通过 alias 写入 changed,会修改现有 inode,因此读取 source 得到相同新内容。这次操作是普通截断写入,没有替换路径。结果直接解释了为什么第二个名字不能作为保护旧内容的独立副本:只要写者能够修改共享对象,两条访问路径就一起看到变化,即使另一条路径从来没有被该写者直接打开。

随后,包含 new file 的独立文件通过 rename 替换 source。两个名字此时报告不同 inode,链接数分别变成一。source 读到替代文件,alias 则保留 changed。替换只改变选中的目录项,不会搜索旧 inode 的其他名字,更不会把所有硬链接统一改指向新对象。更新行为由对象身份决定,而不是由哪个名字被称作原件决定。

样本打开 alias,再删除它最后的目录项。通过已打开描述符读取状态,链接数为零,但仍能读到 changed。打开引用在关闭前继续保留访问能力。链接数为零,表示没有硬链接名字留下,并不表示每个进程都立即失去现有引用;因此路径消失与所有使用者结束,是需要分别观察的状态。

最后,向第二个文件系统建立硬链接得到 EXDEV,尝试硬链接目录得到 EPERM。脚本检查具体错误。如果两个临时根目录实际位于同一文件系统,前提检查会明确失败,不会假装已经验证跨文件系统限制。这样得到的失败证据对应真实边界,而不是依据目录名字推测挂载关系。

检查身份和实际更新方式

依赖共享存储之前,应检查目标路径当前的设备与 inode 组合及链接数。链接数说明有多少硬链接目录项指向对象,但不会列出这些名字位于哪里。遍历目录寻找匹配 inode 是另一个操作,会受访问权限和扫描期间变化影响。不能把一个计数当成完整路径清单,也不能忽略检查之后对象仍可能被替换。

如果使用硬链接构建类似快照的目录树,应确保更新采用替换文件方式,并控制所有能够触及共享 inode 的写者。文件系统快照与独立复制具有不同契约。节省空间的链接树,应按照真实更新方式和保留能力描述,而不能仅凭多个目录看起来各有一份文件,就声称已经建立独立备份。

unlink 和 rename 主要影响命名空间可见性。本实验验证身份与打开引用行为,没有验证断电后的持久性。如果发布结果必须可靠落盘,还应另外核对文件系统同步要求与应用恢复流程。多保留一个名字,或者观察到原子替换成功,都不足以单独证明预期数据能够经历崩溃并完整保留。

来源

  • link(2),共享 inode 身份、跨文件系统限制与目录链接错误,已核验 2026-09-12。
  • unlink(2),链接数减少与已取消链接文件的打开寿命,已核验 2026-09-12。
  • rename(2),替换一个路径而不改变其他硬链接,已核验 2026-09-12。