第 18 章 目录与链接 (Directories and Links)
核心结论
-
i-node 与目录分离:i-node 不含文件名;文件名由「目录条目」(文件名 → i-node 号)建立;多个目录条目指向同一 i-node 就是「硬链接」。
-
硬链接 vs 符号链接:硬链接直接指向 i-node(同文件系统、不能指向目录);符号链接是「内容为目标路径名」的特殊文件(可跨文件系统、可指向目录、可悬空)。
-
i-node 1 保留为坏块记录;根目录固定在 i-node 2;目录条目中 i-node 字段为 0 表示「未使用条目」。
-
unlink 的非立即删除:rm 删除最后一个链接后,文件直到「所有打开的 fd 都关闭」才真正释放——这是
tmpfile()/ 临时文件不显式清理的实现基础。 -
现代 at 接口:
openat/fstatat/linkat/unlinkat等以「目录 fd」解释相对路径,避免与 CWD 相关的 TOCTOU 竞态,也支持「每线程虚拟工作目录」。 -
chroot 是 jailing 不是 security:
/是自身的父目录;特权进程可用mknod/fchroot/传递 fd 等方式逃出 chroot jail;BSD 的jail()更严格。
|
本章主旨
本章是文件系统章节的收尾,介绍目录与链接的内部机制与系统调用。读者应掌握:硬链接与符号链接的本质区别(i-node 引用 vs 路径名引用)、 |
一、核心概念
本章围绕 7 个核心概念展开:从目录与 i-node 的关系入手,到硬链接/符号链接的本质,再到核心系统调用、目录扫描、工作目录、*at 接口、最后是 chroot 与路径解析。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
目录与 i-node 关系 |
目录 = 「文件名 → i-node 号」表;i-node = 文件的元数据(含类型、权限、时间戳、数据块指针),不含文件名;多个目录条目指向同一 i-node = 多个硬链接。 |
§18.1;i-node 1 = 坏块记录,i-node 2 = 根目录;目录不能直接 |
硬链接 (Hard Link) |
在某目录的条目中加入「某文件名 → 某 i-node 号」;所有硬链接等价;删除直到「链接数 = 0 且无打开 fd」才真正释放。 |
§18.1;硬链接不能跨文件系统(i-node 号仅本文件系统唯一);硬链接不能指向目录(防环路)。 |
符号链接 (Soft Link) |
类型为 symlink 的特殊文件,内容是「目标路径名」字符串;可跨文件系统、可指向目录、可悬空;内核自动解引用(除非显式不解引用)。 |
§18.2;SUSv3 要求 |
链接操纵系统调用 |
|
§18.3-§18.5; |
目录创建/扫描 |
|
§18.6/§18.8/§18.9; |
工作目录与根目录 |
CWD 决定相对路径起点;root 决定绝对路径起点; |
§18.10/§18.12; |
at 接口家族 |
|
§18.11;用于避免 TOCTOU 竞态;也为「每线程虚拟工作目录」提供基础;SUSv4 标准化。 |
二、详细笔记
18.1 目录与硬链接
What:目录是「文件名 → i-node 号」的映射表;i-node 是不含文件名的文件元数据结构;多个目录条目指向同一 i-node = 多个硬链接。
Why:理解这一点才能解释为什么同一文件可有多个名字、为什么 rm 删最后一个链接但文件仍可能存在(fd 引用)、为什么硬链接不能跨文件系统(i-node 号仅本文件系统内唯一)。
How:i-node 表的关键事实:
-
i-node 编号从 1 开始——0 表示「未使用的目录条目」。
-
i-node 1 用于记录坏块。
-
根目录固定存于 i-node 2。
-
i-node 不含文件名——文件名是目录的属性,不是文件本身的属性。
硬链接的限制:
-
不能跨文件系统(i-node 号仅本文件系统唯一)。
-
不能指向目录(防止环路)。
-
创建:
link(oldpath, newpath);删除:unlink(pathname)。
When:需要「同一文件多个入口」(如多份程序共享同一物理文件);硬链接节省磁盘且同步更新。
Example:
$ echo -n 'It is good to collect things,' > abc
$ ln abc xyz # 创建硬链接
$ ls -li abc xyz
122232 -rw-r--r-- 2 mtk users 63 Jun 15 17:07 abc
122232 -rw-r--r-- 2 mtk users 63 Jun 15 17:07 xyz
# 同一 i-node 122232;链接计数 2
$ rm abc
$ ls -li xyz
122232 -rw-r--r-- 1 mtk users 63 Jun 15 17:07 xyz
# 文件仍存在,只是链接数降到 1
18.2 符号链接
What:符号链接是「内容为目标路径名」的特殊类型文件(S_IFLNK);内核在路径解析时自动解引用(除非显式不解引用)。
Why:弥补硬链接的两大限制——可跨文件系统、可指向目录;代价是可能「悬空」(目标被删除后链接仍存在但不可访问)。
How:关键特性:
-
符号链接的权限位固定为
rwxrwxrwx,无意义;实际权限检查看目标。 -
短符号链接(≤ 60 字节)存在 i-node 数据块指针区,省去分配磁盘块;约 97% 符号链接 ≤ 60 字节。
-
SUSv3 要求
_POSIX_SYMLOOP_MAX ≥ 8;Linux 2.6.18 前限制单链路 5 次解引用,之后 ≥ 8;整路径总解引用 ≤ 40。 -
目录部分的符号链接总是解引用——
/somedir/somesubdir/file中somedir/somesubdir总是解引用,file视系统调用而定。
When:需要跨文件系统的引用、目录引用、动态路径解析——首选符号链接。
Example:
$ ln -s a dir/sl # 相对路径符号链接
$ ln -s x dir/dsl # 悬空链接(x 不存在)
$ ./nftw_dir_tree -p -d dir # 用 FTW_PHYS 不解引用,sl 标识为 SL
18.3 link/unlink/rename
What:link() 创建硬链接、unlink() 删除链接(最后一个时删文件)、rename() 改名字或移动目录。
Why:操纵目录条目的三个基本工具;rename 只动目录条目,不动文件数据,原子且高效。
How:
// 摘自《The Linux Programming Interface》第 18 章
#include <unistd.h>
int link(const char *oldpath, const char *newpath);
int unlink(const char *pathname);
int rename(const char *oldpath, const char *newpath);
// 全部返回 0 成功,-1 错误
-
link(oldpath, newpath):Linux 不解引用符号链接(与 SUSv3 不一致);SUSv4 允许实现定义;linkat()提供AT_SYMLINK_FOLLOW标志。 -
unlink(pathname):Linux 删除目录返回EISDIR;SUSv3 要求EPERM;可移植代码应容忍两者。符号链接:unlink删除链接本身,不删除目标。 -
rename(oldpath, newpath):原子操作;若 newpath 存在则覆盖;newpath 与 oldpath 同文件时是 no-op;不解引用符号链接;不能跨文件系统(返回EXDEV)。
非立即删除:文件被 unlink 后,只要还有 fd 引用,内核保留 i-node 与数据块。tmpfile() 用此特性:创建临时文件 → unlink → 用 fd 读写 → fclose 时彻底删除。
When:
-
「覆盖式创建」——先
unlink再open(O_CREAT)是「race-free 原子创建」的传统模式。 -
「原地重命名」——
rename(src, dst)是「原子替换」的标准做法;配置文件热加载常用。
Example:第 18 章 Listing 18-1 的 t_unlink 演示「unlink + fd 仍可用」:
// 摘自《The Linux Programming Interface》第 18 章(Listing 18-1 简化)
int fd = open(argv[1], O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR);
unlink(argv[1]); /* 文件名消失,但 i-node + fd 仍有效 */
for (j = 0; j < numBlocks; j++)
write(fd, buf, BUF_SIZE); /* 继续写,磁盘占用上升 */
close(fd); /* fd 关闭后文件彻底删除 */
18.4 符号链接操作:symlink/readlink
What:symlink() 创建符号链接;readlink() 读取符号链接内容(即目标路径名,不解引用)。
Why:区别于通用文件操作——readlink 是专门读取「符号链接字符串」的系统调用。
How:
#include <unistd.h>
int symlink(const char *filepath, const char *linkpath);
ssize_t readlink(const char *pathname, char *buffer, size_t bufsiz);
-
symlink:filepath 可不存在(创建悬空链接合法);linkpath 已存在则返回EEXIST。 -
readlink:不写入终止符;返回值为复制字节数;buffer 太小返回截断字符串;建议按PATH_MAX分配。
When:realpath 解析前想知道「链接原貌」,用 readlink;判断是否为悬空链接用 readlink + stat 比较。
Example:第 18 章 Listing 18-4 view_symlink:
// 摘自《The Linux Programming Interface》第 18 章(Listing 18-4 简化)
if (lstat(argv[1], &statbuf) == -1) errExit("lstat");
if (!S_ISLNK(statbuf.st_mode)) fatal("not a symlink");
numBytes = readlink(argv[1], buf, BUF_SIZE - 1);
buf[numBytes] = '\0'; /* 手动添加终止符 */
printf("readlink: %s --> %s\n", argv[1], buf);
realpath(argv[1], buf); /* 解析到绝对路径 */
printf("realpath: %s --> %s\n", argv[1], buf);
18.5 目录创建/删除与 remove
What:mkdir() 创建新目录(含 . 与 .. 两个条目);rmdir() 删除空目录;remove() 库函数自动分派 unlink 或 rmdir。
Why:remove() 是 C 标准库函数,使「删除文件或目录」无需类型判断——跨 C 标准库的实现都可移植。
How:
-
mkdir(pathname, mode):mode 与 umask 按位与;S_ISUID永远关闭;S_ISGID若父目录有则继承;S_ISVTX来自 mode。 -
rmdir(pathname):只删空目录;若最后分量是符号链接返回ENOTDIR。 -
remove(pathname):若是文件调unlink,目录调rmdir;不解引用符号链接。
When:写跨 UNIX 平台代码时优先 remove();平台特化时 mkdir/rmdir 控制更细。
Example:
$ mkdir dir # 创建空目录
$ mkdtemp("/tmp/dir.XXXXXX") # 创建唯一名目录(glibc)
18.6 opendir/readdir/nftw 目录扫描
What:opendir() 打开目录流;readdir() 返回下一条目;nftw() 递归遍历目录树调用用户函数。
Why:read 不能读目录——必须用 readdir;nftw 提供「递归访问整棵子树」的标准范式。
How:
// 摘自《The Linux Programming Interface》第 18 章
#include <dirent.h>
DIR *opendir(const char *dirpath);
DIR *fdopendir(int fd); // 从已打开 fd 创建目录流
struct dirent *readdir(DIR *dirp); // 返回静态 dirent 结构
int readdir_r(DIR *dirp, struct dirent *entry, struct dirent **result);
void rewinddir(DIR *dirp);
int closedir(DIR *dirp);
int dirfd(DIR *dirp);
dirent 关键字段:d_ino(i-node 号)、d_name(文件名);Linux 还支持 d_type(文件类型),但 Btrfs/ext2/3/4 之外不一定支持。
nftw() 标志:
-
FTW_DEPTH:后序遍历(先访问文件再访问目录)。 -
FTW_MOUNT:不跨越文件系统挂载点。 -
FTW_PHYS:不解引用符号链接。 -
FTW_CHDIR:访问每个文件前先chdir进所在目录。
nftw 的回调返回 0 继续;非 0 立即终止遍历并把该值返回给 nftw 的调用者。
When:
-
「列出单层目录内容」——
readdir循环。 -
「递归处理整棵树」(如备份、查找)——
nftw。 -
「需要避免单次
opendir的 TOCTOU」——fdopendir把目录 fd 转成目录流。
Example:第 18 章 Listing 18-2 list_files:
// 摘自《The Linux Programming Interface》第 18 章(Listing 18-2 简化)
DIR *dirp = opendir(dirpath);
for (;;) {
errno = 0;
struct dirent *dp = readdir(dirp);
if (dp == NULL) break;
if (strcmp(dp->d_name, ".") == 0 || strcmp(dp->d_name, "..") == 0)
continue; /* 跳过 . 和 .. */
printf("%s/%s\n", dirpath, dp->d_name);
}
if (errno != 0) errExit("readdir");
closedir(dirp);
注意 errno = 0 在 readdir 之前——readdir 返回 NULL 既可能是「结束」也可能是「错误」,区分靠 errno。
18.7 工作目录与 chroot
What:进程的「根目录」决定绝对路径起点;「CWD」决定相对路径起点。chdir/fchdir 改 CWD;chroot 改根目录(特权)。
Why:每个进程有独立的 CWD(线程共享 CWD);根目录用于 chroot jail(FTP、容器雏形)。
How:
-
getcwd(buf, size):返回 CWD 的绝对路径;Linux/x86-32 上限 4096 字节(PATH_MAX)。 -
chdir(pathname):解引用符号链接。 -
fchdir(fd):从已打开 fd 改 CWD——可用于「保存-恢复」场景。
int fd = open(".", O_RDONLY); /* 保存当前 CWD */
chdir(somepath);
fchdir(fd); /* 恢复 */
close(fd);
chroot(pathname) 陷阱(§18.12):
-
chroot 不改 CWD——chroot 后必须
chdir("/"),否则相对路径可逃出。 -
已打开的「jail 外 fd」可通过
fchdir+chroot(".")逃出;必须关闭所有 jail 外的 fd。 -
特权进程可用
mknod创建内存设备(/dev/mem)逃出。 -
接收 UNIX domain socket 传来的「jail 外 fd」也可逃出。
-
BSD 的
jail()提供更严格的安全隔离。
When:
-
服务需要「返回原 CWD」——
fchdir+open(".", O_RDONLY)比getcwd+chdir更高效(无需复制路径)。 -
容器/沙箱——chroot 是基础,但需配合 namespace、cgroup 等才是现代容器方案。
Example:经典 FTP 匿名登录 chroot jail。
18.8 at 接口家族
What:openat/fstatat/linkat/unlinkat/renameat/mkdirat/fchmodat/fchownat/readlinkat/symlinkat/utimensat/mknodat/mkfifoat/faccessat——用「目录 fd」解释相对路径。
Why:避免与 CWD 相关的 TOCTOU 竞态;支持「每线程虚拟工作目录」(CWD 是进程共享、线程共享的属性)。
How:
-
dirfd是「目录 fd」;相对路径相对该 fd 解析。 -
AT_FDCWD表示「按进程的 CWD」——与传统调用行为一致。 -
绝对路径忽略
dirfd。 -
部分调用支持额外标志:
AT_SYMLINK_NOFOLLOW(不解引用符号链接)、AT_REMOVEDIR(unlinkat删除目录而非文件)、AT_EACCESS(按 effective UID 检查访问)、AT_SYMLINK_FOLLOW(linkat解引用符号链接)。
// 摘自《The Linux Programming Interface》第 18 章
#define _XOPEN_SOURCE 700
#include <fcntl.h>
int openat(int dirfd, const char *pathname, int flags, ... /* mode_t mode */);
SUSv3 未标准化;SUSv4 标准化;编译需 _XOPEN_SOURCE >= 700 或 _POSIX_C_SOURCE >= 200809。
When:
-
多线程程序希望避免 CWD 的影响——用
openat等。 -
路径前缀可能被并发修改(如
unlink+open之间的 race)——用openat+ 预打开的目录 fd。
Example:避免 CWD race 的 openat 模式:
int dirfd = open("/var/lock", O_RDONLY | O_DIRECTORY);
int fd = openat(dirfd, "my.lock", O_WRONLY | O_CREAT, 0644);
/* 此时即使 /var/lock 被替换为其他目录,dirfd 仍指向原目录 */
18.9 路径解析:realpath/dirname/basename
What:realpath() 解析所有符号链接与 ./.. 为绝对路径;dirname()/basename() 拆分路径为目录部分与文件名部分。
Why:拿到「用户给的相对路径」后常常需要「真实的绝对路径」(用于日志、对账);dirname/basename 是 shell 脚本的标准工具的 C 版本。
How:
-
realpath(path, resolved_path):写入 resolved_path 缓冲区;返回与参数相同的指针;glibc 允许 resolved_path=NULL(自动 malloc)。 -
dirname(path)/basename(path):会修改入参字符串——若要保留原字符串必须strdup后传入。
dirname/basename 语义关键点:
-
末尾斜杠忽略:
/etc/passwd/→ dirname=/etc,basename=passwd。 -
无斜杠:
passwd→ dirname=.,basename=passwd。 -
全斜杠:
/→ dirname=/,basename=/。 -
NULL 或空串:返回
.。
When:日志中需要「绝对路径」——realpath;用户输入是「相对路径」、库需要「绝对路径」——realpath。
Example:第 18 章 Listing 18-5 t_dirbasename:
// 摘自《The Linux Programming Interface》第 18 章(Listing 18-5 简化)
for (j = 1; j < argc; j++) {
char *t1 = strdup(argv[j]);
char *t2 = strdup(argv[j]);
printf("%s ==> %s + %s\n", argv[j], dirname(t1), basename(t2));
free(t1); free(t2);
}
三、关键图表
(本章无独立编号图表)
|
核心系统调用对照表
|
|
硬链接 vs 符号链接
|
四、思维导图
mindmap
root((第 18 章 目录与链接))
目录与 i-node
文件名到 i-node 表
i-node 1 坏块
i-node 2 根目录
目录不能 read write
硬链接
同一 i-node 多个名
不能跨文件系统
不能指向目录
链接计数为 0 才释放
符号链接
内容是路径名
可跨文件系统
可指向目录
悬空链接合法
8 次解引用上限
链接系统调用
link unlink rename
symlink readlink
mkdir rmdir remove
linkat 等 at 接口
目录扫描
opendir readdir
fdopendir dirfd
nftw 递归
FTW DEPTH PHYS MOUNT
工作目录
getcwd chdir fchdir
chroot 特权
chroot jail 陷阱
fchroot 关闭 fd
at 接口
dirfd 目录描述符
AT FDCWD
AT SYMLINK NOFOLLOW
TOCTOU 避免
路径解析
realpath 绝对路径
dirname basename
末尾斜杠忽略
NULL 返回 dot
五、重点与易错点
-
i-node 不含文件名——文件名是目录条目(映射表)的属性;多个条目指向同一 i-node 即多个硬链接;这就是为什么同一文件可以有多个「名字」且删除其中一个不影响另一个。
-
硬链接的两大限制:不能跨文件系统(i-node 号仅本 FS 唯一)、不能指向目录(防环路)。bind mount 可模拟「目录硬链接」的效果(第 14.9.4 节)。
-
符号链接 ≠ 硬链接的语义:符号链接不计入目标 i-node 链接计数;目标删除后符号链接成为「悬空」但仍存在;这是与硬链接的本质区别。
-
link()在 Linux 不解引用符号链接——与 SUSv3 不一致;SUSv4 允许实现定义;可移植代码避免对符号链接使用link();用linkat(…, AT_SYMLINK_FOLLOW)显式控制。 -
unlink()不立即删除文件——只要还有 fd 引用,i-node 与数据块保留;tmpfile()用此特性实现「自动清理临时文件」。 -
unlink()删除目录返回EISDIR(Linux)/EPERM(SUSv3)——LSB 显式允许 Linux 偏离;可移植代码应容忍两种错误码。 -
rename()是原子操作且不动文件数据——若 newpath 与 oldpath 指向同一文件是 no-op(不会删除);不解引用符号链接;不能跨文件系统(EXDEV)。 -
readlink()不写终止符——返回的是字节数;buffer 必须留一个字节手动写'\0';buffer 不够返回截断字符串。 -
目录不能用
read()/write()——必须用opendir/readdir或mkdir/link/unlink等;某些 UNIX 实现支持read(directory_fd)但不可移植。 -
readdir返回 NULL 的两重含义——结束或错误;区分需提前errno = 0,循环结束检查errno。 -
nftw回调返回非零立即终止遍历——不能用longjmp退出(会泄漏动态分配的数据结构);推荐返回FTW_STOP(需_GNU_SOURCE)。 -
at 接口的
dirfd行为:相对路径按 dirfd 解析;绝对路径忽略 dirfd;AT_FDCWD表示「按 CWD」;这是避免 TOCTOU 与每线程虚拟 CWD 的关键。 -
chroot 不是安全机制:必须
chdir("/")、关闭 jail 外 fd;特权进程可用mknod创建内存设备逃出;BSD 的jail()更严格;现代容器用 namespace + cgroup。 -
realpath缓冲区大小:建议PATH_MAX;glibc 允许resolved_path = NULL自动 malloc,但可移植代码应避免。 -
dirname/basename会修改入参字符串——若要保留原字符串必须strdup后再传入。 -
d_type字段非所有 FS 支持——Btrfs/ext2/3/4 支持完整,其他 FS(如 XFS)可能只填 0;不要依赖d_type判定类型,回退用lstat。 -
跨章衔接:第 14 章 i-node 与文件系统细节;第 15 章文件权限;第 16 章扩展属性;第 19 章 inotify 监控目录事件;第 22 章符号链接与信号处理;第 29 章线程共享 CWD。