2.6 目录、链接与文件事件
目录也是文件吗?readdir 的顺序为什么不定、返回的 d_name 为什么下次调用就失效?硬链接与符号链接差在哪?inotify 会丢事件吗,递归监听为什么要自己加?
正确遍历目录并处理 d_type=DT_UNKNOWN;用 link/symlink/rename 构造目录树;用 nftw 统计目录;用 inotify 监听变化并处理队列溢出。
2.5 把文件当成一个 inode 看。但 inode 没有名字:名字在目录里,目录把「名字 → inode 号」的映射存成目录项(dentry)。硬链接是多个名字指向同一个 inode;符号链接是一个独立 inode,内容是另一个路径。理解了这层,你才能解释 rm 之后磁盘空间为什么没释放、mv 为什么有时是瞬时的、inotify 为什么收到的事件比你预期的少。
这一章的最后一部分讲文件事件。轮询 stat 既慢又漏,inotify 把「文件变了」变成「fd 可读」,这是现代服务重载配置、同步工具、构建系统的基础。但它有合并与溢出,不是可靠的消息队列。
2.6.1 目录也是文件:dirent 与 readdir
目录在磁盘上是一串「目录项」,每项至少包含 (名字, inode 号)。st_size 是这些项占的字节数,不是「目录里文件的总大小」。目录的 x 位允许穿过,r 位允许读目录项,w 位允许增删项。
#include <dirent.h>
DIR *opendir(const char *path);
struct dirent *readdir(DIR *dir);
int closedir(DIR *dir);
struct dirent 的关键字段:
| 字段 | 含义 |
|---|---|
d_ino | inode 号 |
d_off | 下一个目录项的偏移(telldir/seekdir 用) |
d_reclen | 这一项的长度(变长) |
d_type | 类型:DT_REG/DT_DIR/DT_LNK/…,可能是 DT_UNKNOWN |
d_name | 名字,以 \0 结尾,长度不定 |
四条纪律:
readdir返回 NULL 时,必须区分 EOF 与错误。它不设errno为 0,所以调用前手动errno = 0,循环结束后检查errno。d_name的内存在下一次readdir后可能被覆盖。要保留就strdup。- 顺序不定。
readdir返回的是文件系统内部的哈希/树顺序,不是字母序;要排序自己收集后qsort。 d_type可能是DT_UNKNOWN(某些文件系统不记录类型)。此时要fstatat(dirfd(dir), name, ..., AT_SYMLINK_NOFOLLOW)补齐。
readdir_r 已被标记为废弃(glibc 2.24 起):它要求调用者提供缓冲区,容易算错长度,而且并不比 readdir 更安全——readdir 对同一个 DIR * 是线程安全的(内部加锁),不同 DIR * 互不影响。
/* lsdir.c — 遍历目录,打印名字与类型;处理 DT_UNKNOWN */
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
static char type_of(const struct dirent *de, int dfd)
{
if (de->d_type != DT_UNKNOWN)
return de->d_type == DT_DIR ? 'd' : de->d_type == DT_LNK ? 'l' :
de->d_type == DT_REG ? '-' : '?';
struct stat st;
if (fstatat(dfd, de->d_name, &st, AT_SYMLINK_NOFOLLOW) == -1)
return '?';
return S_ISDIR(st.st_mode) ? 'd' : S_ISLNK(st.st_mode) ? 'l' :
S_ISREG(st.st_mode) ? '-' : '?';
}
int main(int argc, char *argv[])
{
const char *path = argc > 1 ? argv[1] : ".";
DIR *d = opendir(path);
if (!d) { perror("opendir"); return 1; }
int dfd = dirfd(d);
struct dirent *de;
errno = 0; /* 关键:区分 EOF 与错误 */
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
continue;
printf("%c %s\n", type_of(de, dfd), de->d_name);
errno = 0;
}
if (errno != 0)
perror("readdir");
if (closedir(d) == -1)
perror("closedir");
return 0;
}
dirfd(d) 拿到目录流底层的 fd,配合 fstatat 可以在不拼路径的情况下取属性——这也是避免 chdir 竞态的标准手法。
目录流的位置:telldir/seekdir
long telldir(DIR *dir);
void seekdir(DIR *dir, long loc);
void rewinddir(DIR *dir);
telldir 返回一个不透明的位置值,只能交给同一个 DIR * 的 seekdir 使用;它不对应字节偏移,也不保证跨进程/跨文件系统可移植。目录在遍历期间被修改时,seekdir 的行为未定义(可能跳过或重复项)。需要「可恢复的遍历」时,自己记录已处理的文件名,而不是保存 telldir 的值。
2.6.2 硬链接与符号链接
目录项(名字 → inode) inode 数据
/tmp/a.txt ────────────────────► #12345 (S_IFREG, nlink=2) ──► "hello\n"
/tmp/b.txt ────────────────────► #12345
/tmp/s.link ──► #12346 (S_IFLNK, nlink=1) ──► 内容 "/tmp/a.txt"
│
└── 解析时重新查 /tmp/a.txt,可能指向别的 inode,也可能不存在
读图:硬链接是同一个 inode 的第二个名字,nlink 加一,删掉一个名字只是减一,数据要到 nlink==0 且没有进程打开才释放。符号链接是独立 inode,内容是目标路径字符串;它不增加目标的 nlink,目标可以不存在(悬空链接),也可以被替换。
#include <unistd.h>
int link(const char *oldpath, const char *newpath);
int linkat(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, int flags);
int symlink(const char *target, const char *linkpath);
int symlinkat(const char *target, int newdirfd, const char *linkpath);
ssize_t readlink(const char *path, char *buf, size_t bufsiz);
ssize_t readlinkat(int dirfd, const char *path, char *buf, size_t bufsiz);
int unlink(const char *path);
int unlinkat(int dirfd, const char *path, int flags); /* AT_REMOVEDIR 删目录 */
失败语义:
link:EXDEV(跨文件系统)、EPERM(目标是非空目录或受保护文件)、EMLINK(链接数上限)、EEXIST;symlink:EEXIST、ENAMETOOLONG、EPERM;不检查 target 是否存在;readlink:返回写入buf的字节数,不加\0;buf太小会截断并返回bufsiz(不报错),所以要判断n == bufsiz;unlink:ENOENT、EACCES(目录无写权限)、EISDIR(用unlink删目录)、EBUSY(挂载点)。
link() 对符号链接的行为在 Linux 上是「给链接本身建硬链接」,POSIX 说实现定义;要链接目标,用 linkat(..., AT_SYMLINK_FOLLOW)。删除符号链接用 unlink,删的是链接本身。
完整示例:三种名字
/* linkdemo.c — 硬链接、符号链接、unlink 后的 nlink */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static void show(const char *path)
{
struct stat st;
if (lstat(path, &st) == -1) { perror(path); return; }
printf("%-12s type=%c nlink=%lu size=%lld\n", path,
S_ISLNK(st.st_mode) ? 'l' : S_ISREG(st.st_mode) ? '-' : '?',
(unsigned long)st.st_nlink, (long long)st.st_size);
}
int main(void)
{
const char *file = "link_demo.txt";
const char *hard = "hard.link";
const char *soft = "soft.link";
int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) { perror("open"); return 1; }
if (write(fd, "hello\n", 6) != 6) { perror("write"); return 1; }
close(fd);
unlink(hard); unlink(soft); /* 允许重复运行 */
if (link(file, hard) == -1) { perror("link"); return 1; }
if (symlink(file, soft) == -1) { perror("symlink"); return 1; }
show(file);
show(hard);
show(soft);
char buf[256];
ssize_t n = readlink(soft, buf, sizeof buf - 1);
if (n == -1) { perror("readlink"); return 1; }
if (n == (ssize_t)sizeof buf - 1) fprintf(stderr, "readlink 可能被截断\n");
buf[n] = '\0';
printf("soft.link -> %s\n", buf);
if (unlink(hard) == -1) { perror("unlink hard"); return 1; }
show(file); /* nlink 从 2 变 1 */
unlink(soft);
unlink(file);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 linkdemo.c -o linkdemo
./linkdemo
# link_demo.txt type=- nlink=2 size=6
# hard.link type=- nlink=2 size=6
# soft.link type=l nlink=1 size=10
# soft.link -> link_demo.txt
# link_demo.txt type=- nlink=1 size=6
注意 soft.link 的 size=10 是目标字符串 link_demo.txt 的长度,不是目标文件大小。
realpath:把路径解析成绝对路径
#include <stdlib.h>
char *realpath(const char *path, char *resolved);
传 resolved=NULL 时由 libc malloc,调用者 free。它解析所有符号链接、..、.,返回绝对路径;失败返回 NULL,errno:ENOENT(路径不存在)、ELOOP(循环链接)、EACCES、ENAMETOOLONG。不要自己用 PATH_MAX 栈缓冲区(见 2.1)。
realpath 也不能用来做安全判断:它解析完到 open 之间,路径仍可能被换掉。要锚定目录,用 open(dir) + openat(dirfd, name, O_NOFOLLOW)。
dirname/basename:路径拆分的可重入版本
#include <libgen.h>
char *d = dirname("/a/b/c.txt"); /* 返回 "/a/b";可能修改入参 */
char *b = basename("/a/b/c.txt"); /* 返回 "c.txt";可能修改入参 */
这两个函数可能修改传入的字符串,且返回静态/入参内部指针;多线程要用 dirname_r/basename_r(GNU 扩展)或自己复制:
char path[] = "/a/b/c.txt";
char *tmp1 = strdup(path), *tmp2 = strdup(path);
printf("%s / %s\n", dirname(tmp1), basename(tmp2));
free(tmp1); free(tmp2);
规则:dirname("/a/b/") 得 /a,basename("/a/b/") 得 b;dirname("foo") 得 .,basename("/") 得 /。尾斜杠与根路径是测试重点。
2.6.3 mkdir、rmdir、rename 与 unlink
int mkdir(const char *path, mode_t mode); /* 实际权限 mode & ~umask */
int mkdirat(int dirfd, const char *path, mode_t mode);
int rmdir(const char *path); /* 只能删空目录 */
int rename(const char *oldpath, const char *newpath);
int renameat2(int olddirfd, const char *oldpath,
int newdirfd, const char *newpath, unsigned int flags);
失败语义:
| 调用 | 常见 errno |
|---|---|
mkdir | EEXIST、ENOENT(父目录不存在)、EACCES、EROFS |
rmdir | ENOTEMPTY、EBUSY(挂载点)、EACCES |
rename | EXDEV、EISDIR/ENOTDIR、ENOTEMPTY(目录非空)、EBUSY |
renameat2 | 上面的 + EINVAL(flags 不支持)、EEXIST(RENAME_NOREPLACE) |
rename 在同一文件系统内是原子的:目标要么旧、要么新。它还能把文件从一个目录移到另一个目录(同一文件系统),这是 mv 瞬时完成的原因。跨文件系统返回 EXDEV,mv 会退化成「复制 + 删除」。
renameat2 的三个有用 flags:
RENAME_NOREPLACE:目标存在则EEXIST,不覆盖;RENAME_EXCHANGE:原子交换两个路径;RENAME_WHITEOUT:overlayfs 专用。
unlink 删除目录项后,inode 的 nlink 减一;如果还有进程持有打开的 fd,数据不会释放,直到最后一个 fd 关闭。这就是「rm 了文件但 df 没降」的原因:
lsof +L1 | grep deleted # 找出持有已删除文件的进程
remove(path) 是便利函数:能删文件就 unlink,能删空目录就 rmdir。它的错误码混了两套,排障时不如显式调用清晰。
chdir 与 fchdir:进程级状态
int chdir(const char *path);
int fchdir(int fd);
成功返回 0,失败 -1;errno:ENOENT、ENOTDIR、EACCES。cwd 是进程级状态,同进程的所有线程共享;一个线程 chdir 会影响其他线程的相对路径解析。服务端程序不应靠 chdir 切换工作目录,而应把目录 fd 存下来,用 openat:
int dirfd = open("/srv/data", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dirfd == -1) errExit("open dir");
int fd = openat(dirfd, "conf/app.json", O_RDONLY | O_NOFOLLOW);
if (fd == -1) errExit("openat");
这样即使别的线程改了 cwd,路径仍锚定在 dirfd 上;O_NOFOLLOW 防止最后一段是符号链接(配合 openat2 的 RESOLVE_BENEATH 可以禁止 .. 逃逸)。fchdir 用于临时切换后恢复,但在多线程程序里同样要加锁。
2.6.4 遍历目录树:nftw
手写递归要处理符号链接、挂载点、权限不足、深度限制,容易漏。nftw 把这些封装好:
#include <ftw.h>
int nftw(const char *path,
int (*fn)(const char *path, const struct stat *st, int type, struct FTW *ftw),
int nopenfd, int flags);
fn 的返回值为 0 继续,非 0 则停止遍历并成为 nftw 的返回值。type 是 FTW_F(文件)、FTW_D(目录)、FTW_DNR(不可读目录)、FTW_NS(stat 失败)、FTW_SL(符号链接,仅在 FTW_PHYS 下)。ftw->level 是深度,ftw->base 是路径中文件名的偏移。
flags:
| flag | 含义 |
|---|---|
FTW_PHYS | 不跟随符号链接(推荐) |
FTW_MOUNT | 不跨挂载点 |
FTW_DEPTH | 后序遍历(先子后父,适合删除) |
FTW_CHDIR | 遍历时 chdir 到每个目录(会影响多线程) |
nftw 的回调没有 void *userdata,要累加就用文件级静态变量;这也意味着它不适合重入。
/* du2.c — 用 nftw 统计目录占用(st_blocks * 512),不跟随符号链接 */
#include <ftw.h>
#include <stdio.h>
#include <stdlib.h>
static unsigned long long total;
static int add_blocks(const char *path, const struct stat *st, int type, struct FTW *ftw)
{
(void)path;
(void)ftw;
if (type == FTW_F || type == FTW_D)
total += (unsigned long long)st->st_blocks * 512;
return 0;
}
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s DIR\n", argv[0]); return 1; }
if (nftw(argv[1], add_blocks, 16, FTW_PHYS) == -1) {
perror("nftw");
return 1;
}
printf("%llu bytes (%llu KiB)\n", total, total / 1024);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 du2.c -o du2
./du2 /usr/include | tail -1
nopenfd=16 是同时打开的文件描述符上限;遍历很深的树时,nftw 会提前关闭一些目录 fd。它不会因为深度耗尽而失败,但会变慢。
2.6.5 inotify:把文件变化变成事件
#include <sys/inotify.h>
int inotify_init1(int flags); /* IN_NONBLOCK | IN_CLOEXEC */
int inotify_add_watch(int fd, const char *path, uint32_t mask);
int inotify_rm_watch(int fd, int wd);
inotify_add_watch 返回一个 watch descriptor(wd),同一个路径重复添加会返回同一个 wd 并更新 mask。失败返回 -1,errno:ENOENT、EACCES、ENOSPC(watch 数量超限)、EINVAL(mask 为空)。
常用事件掩码:
| 掩码 | 触发时机 |
|---|---|
IN_CREATE / IN_DELETE | 目录内创建/删除条目 |
IN_MOVED_FROM / IN_MOVED_TO | rename 的两端 |
IN_MODIFY | 文件内容被写 |
IN_ATTRIB | 元数据变化(权限、时间、xattr) |
IN_CLOSE_WRITE | 可写打开的 fd 被关闭(最可靠的内容写完信号) |
IN_DELETE_SELF / IN_MOVE_SELF | 被监视对象本身被删/被移走 |
IN_IGNORED | watch 被内核移除(删除或 rm_watch) |
IN_Q_OVERFLOW | 事件队列溢出(wd == -1) |
IN_ISDIR | 事件针对目录(与其他位组合) |
读取事件:
struct inotify_event {
int wd;
uint32_t mask;
uint32_t cookie; /* rename 配对用 */
uint32_t len; /* name 的长度,含 \0 与填充 */
char name[]; /* 变长,可能为空 */
};
read 返回的缓冲区里是若干个完整事件,每个事件占 sizeof(struct inotify_event) + ev->len 字节。缓冲区至少 sizeof(struct inotify_event) + NAME_MAX + 1;太小会 EINVAL。遍历时要按事件长度推进,不能按字节。
/* watchdir.c — 监听一个目录,打印事件;处理溢出与 EINTR */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/inotify.h>
#include <unistd.h>
#define MASK (IN_CREATE | IN_DELETE | IN_MOVED_FROM | IN_MOVED_TO | \
IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | IN_DELETE_SELF | IN_MOVE_SELF)
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s DIR\n", argv[0]); return 1; }
int fd = inotify_init1(IN_CLOEXEC);
if (fd == -1) { perror("inotify_init1"); return 1; }
int wd = inotify_add_watch(fd, argv[1], MASK);
if (wd == -1) { perror("inotify_add_watch"); return 1; }
char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
printf("watching %s (wd=%d)\n", argv[1], wd);
fflush(stdout);
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n == -1) {
if (errno == EINTR) continue;
perror("read");
break;
}
for (char *p = buf; p < buf + n; ) {
const struct inotify_event *ev = (const struct inotify_event *)p;
if (ev->mask & IN_Q_OVERFLOW) {
fprintf(stderr, "queue overflow: events lost, rescan needed\n");
} else {
printf("wd=%d mask=%#x%s%s%s\n", ev->wd, ev->mask,
(ev->mask & IN_ISDIR) ? " DIR" : "",
ev->len ? " name=" : "",
ev->len ? ev->name : "");
fflush(stdout);
}
p += sizeof(struct inotify_event) + ev->len;
}
}
close(fd);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 watchdir.c -o watchdir
mkdir -p /tmp/w && ./watchdir /tmp/w &
echo hi > /tmp/w/a.txt # IN_CREATE, IN_MODIFY, IN_CLOSE_WRITE
mv /tmp/w/a.txt /tmp/w/b.txt # IN_MOVED_FROM + IN_MOVED_TO(cookie 相同)
三个必须知道的限制
- 事件会合并。在两次
read之间,同一文件的多次IN_MODIFY可能只报一次;连续相同的IN_CREATE/IN_DELETE也可能合并。inotify不是逐条日志,不能用来精确计数。 - 队列会溢出。默认
max_queued_events=16384;溢出时收到IN_Q_OVERFLOW,之前的事件丢失。正确反应是全量重扫,而不是继续增量处理。 - 不递归。
inotify_add_watch只监视指定目录本身;子目录要自己readdir后逐个添加,而且「先 readdir 再 add_watch」之间有新目录创建的竞态。常见做法是加完 watch 后再扫一遍,或接受可能漏掉新目录的第一个事件。
相关上限在 /proc/sys/fs/inotify/ 下:
cat /proc/sys/fs/inotify/max_queued_events # 16384
cat /proc/sys/fs/inotify/max_user_watches # 通常 65536 或更高
cat /proc/sys/fs/inotify/max_user_instances # 128
fanotify 是另一套接口,能挂在整个挂载点上并拿到文件 fd,适合杀毒/审计;它需要 CAP_SYS_ADMIN,日常文件同步用 inotify 就够。
- 「
readdir按字母序」——不保证。ls自己排序,readdir不排。 - 「
readdir返回的d_name可以直接存进数组」——下次readdir可能覆盖,必须拷贝。 - 「
unlink之后空间立刻释放」——有进程持有 fd 时不释放;lsof +L1能看到(deleted)。 - 「
inotify会为每个写操作报一次」——会合并,且可能溢出;要可靠,得配合定期全量扫描。 - 「
inotify_add_watch会递归监视子目录」——不会,只监视你指定的那一个目录。
2.6.6 getdents64 与 struct dirent 的真实布局
这一节回答:readdir 返回的 d_name 为什么下一次调用就失效?d_off 到底是什么?目录项在磁盘上长什么样?
readdir(3) 是 libc 对 getdents64(2) 的封装。内核返回的不是「一个 dirent 数组」,而是一段变长记录:
struct linux_dirent64 {
ino64_t d_ino; /* inode 号 */
off64_t d_off; /* 下一个目录项的偏移(不透明) */
unsigned short d_reclen; /* 本条记录的总长度 */
unsigned char d_type; /* 类型,可能是 DT_UNKNOWN */
char d_name[]; /* 名字,NUL 结尾,后面可能有填充 */
};
getdents64(fd, buf, count) 返回写入 buf 的字节数,0 表示目录读完;调用者必须按 d_reclen 逐条推进,不能按 sizeof(struct dirent) 跳。d_name 的长度是 strlen(d_name)+1,但记录会被对齐到 8 字节,所以 d_reclen 通常大于「头部 + 名字」。
struct dirent(libc 版本)多了一个 d_type 字段,d_name 通常是 256 字节的数组。libc 在 readdir 内部复用同一个 struct dirent,每次调用覆盖它;这就是「d_name 下次调用就失效」的原因。要保留名字必须 strdup。
| 字段 | 含义 | 注意 |
|---|---|---|
d_ino | inode 号 | 与 stat 的 st_ino 对应;./.. 也有 |
d_off | 下一项偏移 | 不透明,只给 seekdir/telldir;不是字节偏移 |
d_reclen | 记录长度 | 遍历时按它推进 |
d_type | 类型 | DT_UNKNOWN 时需要 fstatat 补齐 |
d_name | 名字 | NUL 结尾;不保证是合法 UTF-8 |
d_type 是「优化」而不是「保证」:ext4/XFS 通常填,部分文件系统(如某些 NFS、FUSE)返回 DT_UNKNOWN。用 d_type 判断类型前先处理 DT_UNKNOWN,否则会把普通文件误判为未知。
目录项不是「文件内容」
目录的 st_size 是目录项占用的字节数(ext4 上通常是 4096 的倍数),不是「目录里所有文件大小之和」。目录项里只有 (名字, inode 号) 和类型;文件大小、时间、权限都在 inode 里。删除一个目录项只改目录文件,不动 inode(除非链接数降为 0)。
getdents64 不保证顺序,也不保证「遍历期间目录不变时一定看到所有新增项」;SUSv3 只要求「调用 opendir/rewinddir 时已存在的项都能返回」。需要稳定快照就自己排序/复制。
/* dirwalk.c — 用 openat + fdopendir 递归遍历,避免拼路径与 cwd 竞态 */
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static int walk(int dfd, const char *label, int depth)
{
if (depth > 8) {
fprintf(stderr, "depth limit at %s\n", label);
return 0;
}
int fd = openat(dfd, label, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (fd == -1) {
fprintf(stderr, "openat %s: %s\n", label, strerror(errno));
return -1;
}
DIR *d = fdopendir(fd); /* 成功后 fd 归 DIR 所有 */
if (!d) { perror("fdopendir"); close(fd); return -1; }
struct dirent *de;
errno = 0;
while ((de = readdir(d)) != NULL) {
if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue;
char type = '?';
if (de->d_type == DT_DIR) type = 'd';
else if (de->d_type == DT_REG) type = '-';
else if (de->d_type == DT_LNK) type = 'l';
else if (de->d_type == DT_UNKNOWN) {
struct stat st;
if (fstatat(dirfd(d), de->d_name, &st, AT_SYMLINK_NOFOLLOW) == 0)
type = S_ISDIR(st.st_mode) ? 'd' :
S_ISREG(st.st_mode) ? '-' :
S_ISLNK(st.st_mode) ? 'l' : '?';
}
printf("%*s%c %s\n", depth * 2, "", type, de->d_name);
if (type == 'd') {
/* 以当前目录 fd 为锚点递归;label 只在本层有效 */
walk(dirfd(d), de->d_name, depth + 1);
}
errno = 0;
}
if (errno != 0) perror("readdir");
closedir(d); /* 同时关闭 fd */
return 0;
}
int main(int argc, char *argv[])
{
const char *root = argc > 1 ? argv[1] : ".";
int fd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd == -1) { perror("open root"); return 1; }
printf("%s\n", root);
walk(fd, ".", 0); /* 打开 root 自身 */
close(fd);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 dirwalk.c -o dirwalk
mkdir -p /tmp/tree/a/b && touch /tmp/tree/a/b/c /tmp/tree/x
./dirwalk /tmp/tree
# /tmp/tree
# d a
# d b
# - c
# - x
fdopendir(fd) 成功后,fd 由 DIR * 管理,不能再单独 close;closedir 会关它。walk() 里用 dirfd(d) 作为下一层的锚点,避免「先拼路径再 opendir」的竞态:即使目录被改名,dirfd 仍指向同一个 inode。
- 「
d_name可以存进数组」——下一次readdir会覆盖;必须拷贝。 - 「
d_type一定准确」——可能是DT_UNKNOWN;用fstatat补。 - 「
d_off是字节偏移」——不透明值,只给seekdir;不同文件系统含义不同。 - 「
readdir按字母序」——不排序;ls自己排。 - 「目录的
st_size是内容总大小」——是目录项占用;文件大小在 inode 里。
2.6.7 scandir、fts 与 nftw 的选择
这一节回答:遍历一个目录/一棵树,什么时候用 scandir、fts、nftw,什么时候自己写递归?
| 接口 | 层级 | 排序 | 主要用途 | 限制 |
|---|---|---|---|---|
readdir | 单目录 | 无 | 最小、最可控 | 自己处理 d_type/错误 |
scandir | 单目录 | 有(alphasort/versionsort/自定义) | 需要排序的列表 | 一次性分配整个数组 |
fts | 整棵树 | 可配置 | 需要跳过子树、读取子项、处理符号链接 | BSD 系,非 POSIX |
nftw | 整棵树 | 前序/后序 | 统计、删除、权限修复 | 回调无 userdata,不可重入 |
| 自己递归 | 整棵树 | 任意 | 需要精细控制/并发/取消 | 要自己处理 fd 上限与竞态 |
scandir:
int scandir(const char *dirp, struct dirent ***namelist,
int (*filter)(const struct dirent *),
int (*compar)(const struct dirent **, const struct dirent **));
成功返回项数,失败 -1;namelist 与每个元素都要 free。alphasort 用 strcoll(受 locale 影响),versionsort 做版本号排序(file2 < file10)。过滤函数返回非 0 表示保留。
nftw 的关键是 flags 与回调返回值(Kerrisk 18.9,p326–330):
| flag/返回值 | 作用 |
|---|---|
FTW_PHYS | 不跟随符号链接;type=FTW_SL |
FTW_MOUNT | 不跨挂载点 |
FTW_DEPTH | 后序遍历(先子后父,适合删除) |
FTW_CHDIR | 遍历时 chdir 到每个目录(多线程危险) |
FTW_ACTIONRETVAL | 让回调返回 FTW_SKIP_SUBTREE/FTW_SKIP_SIBLINGS/FTW_STOP |
| 返回 0 | 继续 |
| 返回非 0 | 停止,nftw 返回同一个值 |
nftw 的回调没有 void *userdata,只能靠文件级静态变量传状态;这使它不适合重入/多线程。需要「跳过某个子树」时,FTW_ACTIONRETVAL + FTW_SKIP_SUBTREE 比在回调里判断路径更清晰。nopenfd 是同时打开的目录 fd 上限(不是深度上限);超过时 nftw 会关闭并重开,变慢但不会失败。
nftw 内部动态分配遍历状态(目录 fd、路径栈)。从回调 longjmp 出去会跳过它的清理逻辑,至少泄漏 fd/内存。要提前终止,让回调返回非 0;要跳过子树,用 FTW_SKIP_SUBTREE。这也是「库函数回调里不要随便 longjmp」的通用规则。
2.6.8 *at 家族与目录 fd 编程
这一节回答:openat/unlinkat/renameat 这些带 at 的调用,除了多一个参数,还解决了什么问题?
*at 系列把「相对路径的锚点」从进程 cwd 改成目录 fd。这在多线程、沙箱、需要防 TOCTOU 的场景里是必需的:
| 调用 | 作用 | 关键 flags |
|---|---|---|
openat(dirfd, path, flags, mode) | 打开 | O_NOFOLLOW、O_PATH、O_DIRECTORY |
fstatat(dirfd, path, &st, flags) | 取属性 | AT_SYMLINK_NOFOLLOW、AT_EMPTY_PATH |
unlinkat(dirfd, path, flags) | 删除 | AT_REMOVEDIR 删目录 |
renameat(olddirfd, old, newdirfd, new) | 改名 | 跨目录 |
renameat2(..., flags) | 带语义的改名 | RENAME_NOREPLACE/EXCHANGE/WHITEOUT |
linkat(olddirfd, old, newdirfd, new, flags) | 硬链接 | AT_SYMLINK_FOLLOW、AT_EMPTY_PATH |
symlinkat(target, newdirfd, new) | 符号链接 | target 不解析 |
readlinkat(dirfd, path, buf, size) | 读链接 | 不跟随最后一段 |
mkdirat/mknodat | 创建目录/设备 | mode 受 umask |
fchmodat/fchownat/utimensat | 改元数据 | AT_SYMLINK_NOFOLLOW |
execveat(dirfd, path, argv, envp, flags) | 执行 | AT_EMPTY_PATH、AT_SYMLINK_NOFOLLOW |
dirfd 可以是 AT_FDCWD(用 cwd),也可以是 open(dir, O_PATH|O_DIRECTORY) 得到的 fd。用目录 fd 的好处:
- 不受其他线程
chdir影响; - 目录被 rename 后仍锚定同一个 inode;
- 配合
O_NOFOLLOW/openat2可以阻止符号链接逃逸; - 可以用
openat(dirfd, "..", ...)但仍要小心..逃逸(用openat2的RESOLVE_BENEATH)。
AT_EMPTY_PATH 让「空路径」表示 dirfd 本身(fstatat/linkat/execveat),常与 O_PATH/O_TMPFILE 配合;linkat 的 AT_EMPTY_PATH 通常需要 CAP_DAC_READ_SEARCH,普通用户用 /proc/self/fd/N 替代。
- 「
openat的dirfd必须是目录 fd」——是的;传普通文件 fd 返回ENOTDIR。 - 「
AT_FDCWD是常量 0」——它是特殊负值(-100),不要当 fd 用。 - 「
unlinkat默认能删目录」——必须带AT_REMOVEDIR,否则EISDIR。 - 「
linkat默认跟随符号链接」——Linux 上linkat默认不跟随;要跟随加AT_SYMLINK_FOLLOW。 - 「
*at自动阻止..逃逸」——不会;openat(dirfd, "../x")仍可逃出,需openat2的RESOLVE_BENEATH。
mount(2) 与 umount2(2):命令背后的系统调用
mount /dev/sdb1 /mnt 最终调用:
#include <sys/mount.h>
int mount(const char *source, const char *target,
const char *fstype, unsigned long flags, const void *data);
int umount2(const char *target, int flags);
容器/命名空间里最常用的 flags:
| flag | 作用 | 典型用法 |
|---|---|---|
MS_BIND | 绑定挂载(把一棵子树挂到另一个点) | mount("/src", "/dst", NULL, MS_BIND, NULL) |
MS_REC | 递归(配合 bind/rprivate) | `MS_BIND |
MS_PRIVATE | 挂载事件不传播 | 容器启动时把 / 设为 private |
MS_SLAVE | 单向接收传播 | 需要接收宿主挂载 |
MS_NOSUID/MS_NODEV/MS_NOEXEC | 安全选项 | 容器 rootfs |
MS_REMOUNT | 改已有挂载的 flags | 重新挂载 / 为 ro |
/* 让挂载事件不再传播到宿主;容器 pivot_root 前的标准动作 */
mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL);
/* 绑定挂载一个目录,并递归 */
mount("/host/data", "/container/data", NULL, MS_BIND | MS_REC, NULL);
/* 卸载但即使 busy 也 detach(懒卸载) */
umount2("/container/data", MNT_DETACH);
MS_BIND忽略fstype/data:绑定挂载不是「重新格式化」,只是引用同一棵子树。- 传播是默认开的:systemd 把
/设为 shared;容器里不先MS_PRIVATE,挂载会漏到宿主。 umount2(MNT_DETACH)不是「立即释放」:还有进程持有该挂载时,它只是从命名空间摘掉,资源等引用归零才释放。- 普通用户不能
mount:需要CAP_SYS_ADMIN;用户命名空间里可以有受限的 mount,但受MS_NOSUID等限制。
2.6.9 renameat2 的四种语义
这一节回答:rename 已经原子了,renameat2 的 RENAME_NOREPLACE/EXCHANGE 又解决什么?
int renameat2(int olddirfd, const char *oldpath,
int newdirfd, const char *newpath, unsigned int flags);
| flags | 语义 | 目标存在时 |
|---|---|---|
0 | 普通 rename | 原子替换 |
RENAME_NOREPLACE | 不覆盖 | EEXIST |
RENAME_EXCHANGE | 原子交换两个路径 | 两边都必须存在,否则 ENOENT |
RENAME_WHITEOUT | overlayfs 白化 | 仅 overlayfs 支持 |
RENAME_NOREPLACE 是「原子地创建目标名」的另一种方式:先写临时文件,再 renameat2(..., RENAME_NOREPLACE),如果目标已存在就失败,不会覆盖别人的文件。RENAME_EXCHANGE 适合「原子切换两个版本」:current 与 next 互换,读者永远看到完整的一个。
边界(与 rename 相同):
- 同一文件系统,否则
EXDEV; old是目录、new是非空目录 →ENOTEMPTY;old是目录、new是文件 →ENOTDIR;反之EISDIR;new是挂载点 →EBUSY;RENAME_EXCHANGE下两个路径可以是文件/目录混合,但都必须存在;- NFS/overlayfs 对 flags 支持不一,可能
EINVAL/EOPNOTSUPP。
/* renamex.c — RENAME_NOREPLACE 与 RENAME_EXCHANGE 的行为 */
#include <errno.h>
#include <fcntl.h>
#include <linux/fs.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static int write_file(const char *path, const char *s)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd == -1) return -1;
ssize_t n = write(fd, s, strlen(s));
close(fd);
return n == (ssize_t)strlen(s) ? 0 : -1;
}
static void show(const char *path)
{
char buf[64] = {0};
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd == -1) { printf("%s: %s\n", path, strerror(errno)); return; }
ssize_t n = read(fd, buf, sizeof buf - 1);
close(fd);
printf("%s = %.*s\n", path, n > 0 ? (int)n : 0, buf);
}
int main(void)
{
unlink("rx_a"); unlink("rx_b");
if (write_file("rx_a", "AAA") == -1 || write_file("rx_b", "BBB") == -1) {
perror("write_file"); return 1;
}
errno = 0;
int rc = renameat2(AT_FDCWD, "rx_a", AT_FDCWD, "rx_b", RENAME_NOREPLACE);
printf("NOREPLACE onto existing: rc=%d errno=%d (%s)\n",
rc, errno, strerror(errno));
errno = 0;
rc = renameat2(AT_FDCWD, "rx_a", AT_FDCWD, "rx_c", RENAME_NOREPLACE);
printf("NOREPLACE onto free name: rc=%d errno=%d (%s)\n",
rc, errno, strerror(errno));
errno = 0;
rc = renameat2(AT_FDCWD, "rx_c", AT_FDCWD, "rx_b", RENAME_EXCHANGE);
printf("EXCHANGE: rc=%d errno=%d (%s)\n", rc, errno, strerror(errno));
show("rx_b");
show("rx_c");
unlink("rx_a"); unlink("rx_b"); unlink("rx_c");
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 renamex.c -o renamex
./renamex
# NOREPLACE onto existing: rc=-1 errno=17 (File exists)
# NOREPLACE onto free name: rc=0 errno=0 (Success)
# EXCHANGE: rc=0 errno=0 (Success)
# rx_b = AAA
# rx_c = BBB
NOREPLACE 的目标存在时返回 EEXIST,不是 ENOTEMPTY/EACCES。EXCHANGE 后 rx_b 与 rx_c 的内容互换,但两个名字都还在;这正是「原子切换 symlink/current 版本」的底层原语。
- 「
RENAME_NOREPLACE等于先access再rename」——不等;前者原子,后者有 TOCTOU。 - 「
RENAME_EXCHANGE可以有一边不存在」——不行,两边都必须存在,否则ENOENT。 - 「所有文件系统都支持 flags」——老内核
ENOSYS,NFS/overlayfs 可能EINVAL/EOPNOTSUPP。 - 「
renameat2能跨文件系统」——不能,跨设备EXDEV;mv会退化为复制+删除。
2.6.10 链接保护:fs.protected_*
这一节回答:为什么普通用户不能给 /etc/passwd 建硬链接?/tmp 里的符号链接为什么有时打不开?
Linux 用一组 sysctl 限制「跟随/创建链接」的行为,默认值因发行版而异:
| sysctl | 默认(常见) | 作用 |
|---|---|---|
fs.protected_hardlinks | 1 | 只有属主或对源文件有读写权限时才能建硬链接 |
fs.protected_symlinks | 1 | sticky 目录中的符号链接:只有链接属主或目标属主能跟随 |
fs.protected_fifos | 2 | 禁止在全局可写 sticky 目录创建 FIFO(除非属主) |
fs.protected_regular | 2 | 禁止在 sticky 目录打开不属于自己的普通文件(防 O_CREAT 攻击) |
protected_hardlinks=1 阻止「把 root 的 /etc/shadow 硬链接到我的目录,再通过我的文件读它」这类攻击;protected_symlinks 阻止「在 /tmp 放一个指向 /etc/shadow 的链接,骗 root 程序跟随」。这些检查在 may_linkat/may_follow_link 里完成,失败返回 EPERM/EACCES。
这也是「为什么 open("/tmp/link") 有时返回 EACCES 而不是 ENOENT」的原因之一。排障时先看 sysctl fs.protected_*,再看文件属主与 sticky 位;不要把所有 EACCES 都归因于 chmod。
容器通常继承宿主机的 sysctl;docker run --privileged 或 --security-opt 可能改变它们。Kubernetes 的 securityContext 也可能设置 readOnlyRootFilesystem/allowPrivilegeEscalation。跨环境行为不一致时,先 sysctl -a | grep fs.protected 对齐。
2.6.11 inotify 的完整事件语义
这一节回答:inotify 会丢事件吗?IN_IGNORED 与 IN_ONESHOT 有什么区别?wd 为什么会复用?
inotify 的核心是一个内核事件队列,read 从队列取 struct inotify_event:
struct inotify_event {
int wd; /* watch descriptor;溢出时为 -1 */
uint32_t mask; /* 事件位 */
uint32_t cookie; /* rename 配对;其他事件为 0 */
uint32_t len; /* name 的长度,含 NUL 与填充 */
char name[]; /* 变长;监视对象自身事件时为空 */
};
一条事件的长度是 sizeof(struct inotify_event) + len;缓冲区至少 sizeof(struct inotify_event) + NAME_MAX + 1,否则 read 返回 EINVAL。len 包含 NUL 与对齐填充,不能假设 len == strlen(name)+1。
掩码与行为位
| 位 | 类型 | 作用 |
|---|---|---|
IN_ACCESS | 事件 | 读 |
IN_MODIFY | 事件 | 写 |
IN_ATTRIB | 事件 | 元数据(权限、时间、xattr、链接数) |
IN_CLOSE_WRITE/IN_CLOSE_NOWRITE | 事件 | 关闭可写/只读 fd |
IN_OPEN | 事件 | 打开 |
IN_CREATE/IN_DELETE | 事件 | 目录内创建/删除 |
IN_DELETE_SELF/IN_MOVE_SELF | 事件 | 监视对象自身被删/被移走 |
IN_MOVED_FROM/IN_MOVED_TO | 事件 | rename 的两端,cookie 相同 |
IN_UNMOUNT | 事件 | 文件系统被卸载(随后 IN_IGNORED) |
IN_Q_OVERFLOW | 事件 | 队列溢出,wd=-1,事件丢失 |
IN_IGNORED | 事件 | watch 被移除(rm_watch、删除、卸载) |
IN_ISDIR | 修饰 | 事件对象是目录 |
IN_ONLYDIR | 行为 | 路径不是目录则 ENOTDIR |
IN_DONT_FOLLOW | 行为 | 不跟随符号链接 |
IN_EXCL_UNLINK | 行为 | 不报告已 unlink 但仍打开的文件事件 |
IN_MASK_ADD | 行为 | 把新掩码 OR 进现有 watch |
IN_ONESHOT | 行为 | 只报告一次,然后自动移除 |
合并、顺序与丢失
- 合并:新事件与队列尾部事件的
wd/mask/cookie/name完全相同时,内核丢弃新事件。所以「同一文件连续写 100 次」可能只看到 1 次IN_MODIFY;不能用来计数。 - 顺序:队列有序;rename 的
IN_MOVED_FROM一定在IN_MOVED_TO之前。 - 溢出:
max_queued_events(默认 16384)满后,内核丢弃事件并放一个IN_Q_OVERFLOW;收到它必须全量重扫,不能继续增量。 IN_IGNORED:rm_watch、对象被删、文件系统卸载都会产生;IN_ONESHOT触发后不会产生IN_IGNORED。wd复用:watch 被移除后,wd可能被下一次inotify_add_watch复用;收到IN_IGNORED后要把wd → path映射删掉,否则新 watch 的事件会被错认成旧路径。- 非递归:只监视指定目录本身;子目录要自己加。
/proc/sys/fs/inotify/ 下三个上限:max_queued_events、max_user_instances、max_user_watches。ioctl(fd, FIONREAD, &n) 可以看当前排队字节数。
- 「每个
write都会报一次IN_MODIFY」——会合并;要精确计数用 fanotify/审计或应用层日志。 - 「
IN_IGNORED一定会有」——IN_ONESHOT触发后没有;不要等它来清理映射。 - 「溢出后继续增量处理」——事件已丢;必须全量重扫。
- 「
wd永久对应一个路径」——会被复用;收到IN_IGNORED立即删映射。 - 「
inotify递归监视」——不递归;IN_CREATE|IN_ISDIR后要自己加。 - 「
read缓冲区小一点没关系」——小于一个事件会EINVAL;至少sizeof(event)+NAME_MAX+1。
2.6.12 递归监听与溢出恢复
这一节回答:怎样监听整棵目录树,并在「新建子目录」与「队列溢出」两种情况下都不漏事件?
递归监听的正确结构是「先加根,再遍历加子目录;事件驱动补加;溢出全量重扫」:
inotify_init1(IN_CLOEXEC|IN_NONBLOCK);nftw(root, add_watch, ..., FTW_PHYS)给每个目录加 watch,wd → path存进表;- 循环
read;对每个事件:IN_Q_OVERFLOW:重新nftw全量扫描(已存在的 watch 会返回同一wd,更新映射即可);IN_CREATE|IN_ISDIR或IN_MOVED_TO|IN_ISDIR:对新目录nftw加 watch;IN_IGNORED/IN_DELETE_SELF/IN_MOVE_SELF:删除wd映射;
- 退出时关闭 inotify fd,内核自动清理所有 watch。
「先 readdir 再加 watch」之间有窗口:新目录可能在遍历后、加 watch 前创建。解决办法是加完 watch 后再扫一遍,或接受「可能漏掉新目录的第一个事件」,在下一次事件/定期扫描时补上。生产级实现通常两者都做。
/* watchrec.c — 递归 inotify;处理新建目录、IN_IGNORED 与溢出重扫 */
#include <errno.h>
#include <ftw.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/inotify.h>
#include <unistd.h>
#define MAX_WD 65536
#define MASK (IN_CREATE | IN_DELETE | IN_MOVED_FROM | IN_MOVED_TO | \
IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | \
IN_DELETE_SELF | IN_MOVE_SELF | IN_ONLYDIR)
static int gfd = -1;
static char *gpath[MAX_WD];
static volatile sig_atomic_t gstop;
static void on_sigint(int sig) { (void)sig; gstop = 1; }
static int add_one(const char *path)
{
int wd = inotify_add_watch(gfd, path, MASK);
if (wd == -1) {
if (errno != ENOENT && errno != ENOTDIR)
fprintf(stderr, "add_watch %s: %s\n", path, strerror(errno));
return -1;
}
if (wd < MAX_WD) {
free(gpath[wd]);
gpath[wd] = strdup(path);
}
return wd;
}
static int add_tree_cb(const char *path, const struct stat *st, int type,
struct FTW *ftw)
{
(void)st; (void)ftw;
if (type == FTW_D || type == FTW_DP) add_one(path);
return 0;
}
static void add_tree(const char *root)
{
if (nftw(root, add_tree_cb, 16, FTW_PHYS) == -1)
fprintf(stderr, "nftw %s: %s\n", root, strerror(errno));
}
static void handle(const struct inotify_event *ev)
{
const char *dir = (ev->wd >= 0 && ev->wd < MAX_WD) ? gpath[ev->wd] : NULL;
const char *name = ev->len ? ev->name : "";
if (ev->mask & IN_Q_OVERFLOW) {
fprintf(stderr, "queue overflow: full rescan\n");
add_tree("."); /* 示例:以当前目录为根;生产代码用保存的 root */
return;
}
printf("mask=%#x dir=%s%s%s\n", ev->mask,
dir ? dir : "?", name[0] ? "/" : "", name);
if (ev->mask & IN_IGNORED) {
if (ev->wd >= 0 && ev->wd < MAX_WD) {
free(gpath[ev->wd]);
gpath[ev->wd] = NULL;
}
return;
}
if ((ev->mask & IN_ISDIR) &&
(ev->mask & (IN_CREATE | IN_MOVED_TO)) && dir && name[0]) {
char path[4096];
snprintf(path, sizeof path, "%s/%s", dir, name);
add_tree(path); /* 新目录可能带一整棵子树 */
}
}
int main(int argc, char *argv[])
{
const char *root = argc > 1 ? argv[1] : ".";
if (chdir(root) == -1) { perror("chdir"); return 1; }
gfd = inotify_init1(IN_CLOEXEC | IN_NONBLOCK);
if (gfd == -1) { perror("inotify_init1"); return 1; }
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGINT, &sa, NULL) == -1) { perror("sigaction"); return 1; }
add_tree(".");
char buf[65536] __attribute__((aligned(__alignof__(struct inotify_event))));
while (!gstop) {
ssize_t n = read(gfd, buf, sizeof buf);
if (n == -1) {
if (errno == EINTR) continue;
if (errno == EAGAIN) { usleep(100000); continue; }
perror("read");
break;
}
for (char *p = buf; p < buf + n; ) {
const struct inotify_event *ev = (const struct inotify_event *)p;
handle(ev);
p += sizeof(struct inotify_event) + ev->len;
}
}
close(gfd);
for (int i = 0; i < MAX_WD; i++) free(gpath[i]);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 watchrec.c -o watchrec
mkdir -p /tmp/wr && ./watchrec /tmp/wr &
mkdir -p /tmp/wr/a/b
touch /tmp/wr/a/b/c
mv /tmp/wr/a /tmp/wr/a2
# 应看到 CREATE|ISDIR、MOVED_FROM/MOVED_TO、对 a2/b 的补加
IN_ONLYDIR 保证 add_watch 只接受目录,避免把普通文件当成目录监视。chdir(root) 是为了让 nftw(".") 与新目录路径一致;生产代码应保存 root 的目录 fd,用 openat 拼路径,避免进程 cwd 被其他线程改掉。
- 初始化是否带
IN_CLOEXEC/IN_NONBLOCK? - 是否给每个子目录加了 watch,而不只是根?
- 新目录事件是否在
IN_CREATE|IN_ISDIR/IN_MOVED_TO|IN_ISDIR后补加? IN_IGNORED后是否删除了wd → path映射?IN_Q_OVERFLOW后是否全量重扫,而不是继续增量?- 缓冲区是否 ≥
sizeof(event)+NAME_MAX+1,并按len推进? - 退出时是否关闭 fd(内核会自动清理 watch)?
2.6.13 内核路径:readdir 与 inotify
这一节回答:readdir 在内核里是谁在填目录项?inotify_add_watch 之后事件怎么排队?
readdir/getdents64
| 层次 | 位置 | 动作 |
|---|---|---|
| 系统调用 | fs/readdir.c | __do_sys_getdents64 → iterate_dir |
| 公共层 | fs/readdir.c | iterate_dir 检查 FMODE_PATH、调用 file->f_op->iterate_shared |
| 文件系统 | fs/ext4/dir.c | ext4_readdir 遍历目录页/htree |
| 输出 | fs/readdir.c | dir_emit → filldir64 把 (ino, off, type, name) 写进用户缓冲 |
| 返回 | fs/readdir.c | 返回字节数;缓冲满时返回已填部分 |
dir_emit 的 type 来自 d_type;DT_UNKNOWN 表示文件系统没提供。ext4_readdir 对 htree 目录会按哈希顺序返回,所以 readdir 的顺序不是字母序,也不是 inode 序。
inotify
| 层次 | 位置 | 动作 |
|---|---|---|
| 系统调用 | fs/notify/inotify/inotify_user.c | __do_sys_inotify_add_watch → inotify_add_watch |
| 查找/创建 | 同上 | inotify_find_inode、inotify_new_watch |
| 注册 | fs/notify/mark.c | fsnotify_add_inode_mark 挂到 inode 的 mark 列表 |
| 事件 | fs/notify/inotify/inotify_user.c | inotify_handle_inode_event → inotify_queue_event |
| 队列 | 同上 | 追加到 inotify_inode_mark/group 的 event list;合并尾部相同事件 |
| 读取 | 同上 | inotify_read 把事件拷到用户缓冲;无事件时阻塞/EAGAIN |
inotify 的 watch 挂在 inode 上(不是路径),所以 rename 后 watch 仍有效;IN_MOVE_SELF 通知你对象被移走。IN_IGNORED 在 mark 被移除时产生;IN_Q_OVERFLOW 在 group 队列超限时产生,wd=-1。
fanotify 挂在 mount/vfsmount 上,能拿到文件 fd、支持权限事件(FAN_OPEN_PERM),适合杀毒/审计;需要 CAP_SYS_ADMIN。inotify 轻量、无需特权,适合配置重载、文件同步。两者都不保证「不丢事件」;需要强一致就用日志/审计子系统。
2.6.14 两个实验:strace 看 getdents64 与 /proc 看 inotify 上限
这一节回答:怎样确认程序用的是 getdents64 而不是 read?怎样知道 inotify 为什么加不上 watch?
实验一:strace 跟踪目录读取
strace -yy -e trace=openat,getdents64,close ./dirwalk /tmp/tree 2>&1 | head -15
典型输出:
$ strace -e trace=openat,getdents64 ./dirwalk /tmp/tree 2>&1 | head -8
openat(AT_FDCWD, "/tmp/tree", O_RDONLY|O_DIRECTORY|O_CLOEXEC) = 3</tmp/tree>
openat(3</tmp/tree>, ".", O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC) = 4</tmp/tree>
getdents64(4</tmp/tree>, 0x..., 32768) = 80
getdents64(4</tmp/tree>, 0x..., 32768) = 0
close(4</tmp/tree>) = 0
读法:
getdents64返回 80 字节,是一次读到的多条变长记录;返回 0 表示目录读完。- 缓冲区大小(32768)由 glibc 的
DIR决定,不是固定的sizeof(struct dirent)。 openat(3, ".", O_DIRECTORY|O_NOFOLLOW)是fdopendir内部打开目录流;-yy把 fd 映射到路径。- 如果看到
read(3, ...)而不是getdents64,说明程序在读普通文件,不是目录。
实验二:inotify 上限与 watch 数
cat /proc/sys/fs/inotify/max_queued_events
cat /proc/sys/fs/inotify/max_user_instances
cat /proc/sys/fs/inotify/max_user_watches
# 统计当前用户的 inotify 实例(需要 root 或同用户)
find /proc/*/fd -lname 'anon_inode:inotify' 2>/dev/null | wc -l
# 观察 watch 数(需要 bpftrace/内核支持)
sudo bpftrace -e 'kprobe:inotify_add_watch { @ = count(); }'
max_user_watches 是每个真实 UID 的 watch 总数;达到上限时 inotify_add_watch 返回 ENOSPC。max_user_instances 限制 inotify_init 的次数;max_queued_events 限制每个实例的队列长度。ENOSPC 不一定是磁盘满,inotify 的 ENOSPC 表示 watch 配额耗尽。
inotify_add_watch失败:ENOENT(路径消失)、ENOTDIR(IN_ONLYDIR)、EACCES(无读权限)、ENOSPC(watch 配额)。- 收不到事件:确认监视的是目录还是文件;子目录是否单独加了 watch。
- 事件太少:可能是尾部合并;用
IN_MASK_ADD确认掩码没被覆盖。 - 事件太多/丢失:看
max_queued_events与IN_Q_OVERFLOW;溢出后全量重扫。 wd错乱:IN_IGNORED后没删映射,或wd被复用。
2.6.15 本章陷阱清单
- 现象:
readdir返回的d_name存进数组后乱码。原因:内部缓冲区被下一次调用覆盖。修复:strdup。 - 现象:遍历目录时
d_type是DT_UNKNOWN。原因:文件系统不提供类型。修复:fstatat(..., AT_SYMLINK_NOFOLLOW)。 - 现象:
unlink删目录失败。原因:unlink不能删目录。修复:rmdir/unlinkat(..., AT_REMOVEDIR)。 - 现象:
link跨文件系统失败。原因:硬链接必须同 FS。修复:用符号链接或复制。 - 现象:符号链接循环导致
ELOOP。原因:超过 40 次解引用。修复:readlink检查链路,避免自指。 - 现象:
rename返回EXDEV。原因:跨文件系统。修复:复制到目标 FS +fsync+rename。 - 现象:
renameat2(RENAME_NOREPLACE)返回EEXIST。原因:目标已存在。修复:这是预期语义;需要覆盖就用普通rename。 - 现象:
inotify只收到一次IN_MODIFY。原因:尾部事件合并。修复:不要用 inotify 计数;需要精确日志用应用层。 - 现象:
inotify完全收不到子目录事件。原因:不递归。修复:对子目录逐个add_watch,并在新建目录时补加。 - 现象:
inotify_add_watch返回ENOSPC。原因:max_user_watches用尽。修复:减少 watch/提高 sysctl/复用 watch。 - 现象:
IN_Q_OVERFLOW后状态错乱。原因:事件已丢,继续增量处理。修复:全量重扫。 - 现象:
open在/tmp的链接上返回EACCES。原因:fs.protected_symlinks。修复:检查 sysctl 与链接/目标属主。
2.6.16 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 正确遍历目录 | 处理 errno、d_name 生命周期、DT_UNKNOWN | 2.6.1 |
| 区分硬链接与符号链接 | 能画出 inode 与 nlink 的关系 | 2.6.2 |
说出 rename 的原子性与 EXDEV | 能联系到 mv 的行为 | 2.6.3 |
用 nftw 统计目录 | 知道 FTW_PHYS 的作用 | 2.6.4 |
处理 inotify 溢出 | 说出 IN_Q_OVERFLOW 后要重扫 | 2.6.5、2.6.11 |
解释 getdents64/d_type/d_name 生命周期 | 能说出变长记录与覆盖 | 2.6.6 |
用 renameat2 的 NOREPLACE/EXCHANGE | 能说出目标存在时的错误码 | 2.6.9 |
| 写一个递归 inotify 监听 | 处理新建目录、IN_IGNORED、溢出 | 2.6.12 |
Q1 readdir 循环结束后返回 NULL,怎么判断是正常读完还是出错?
Q2 一个文件有两个硬链接,nlink=2。unlink 其中一个后,另一个还能打开吗?数据什么时候释放?
Q3 readlink(buf, sizeof buf) 返回的值等于 sizeof buf,说明什么?
Q4 rename("/a/x", "/b/x") 返回 EXDEV,为什么?mv 会怎么处理?
Q5 inotify 监听了 /data,在 /data/sub 里创建文件,会收到事件吗?收到 IN_Q_OVERFLOW 后该怎么办?
答案
A1 readdir 不设置 errno 来区分;调用前 errno=0,循环结束后若 errno != 0 则是错误,否则是正常 EOF。注意循环体内每次调用前也要重置,避免上一次的残留。
A2 能打开。unlink 只删除一个目录项并把 nlink 减到 1;inode 和数据仍在。数据在 nlink==0 且没有进程持有打开 fd 时释放;lsof +L1 可以找到仍持有它的进程。
A3 缓冲区可能太小,readlink 截断了目标路径(它返回放入缓冲区的字节数,不报 ERANGE,也不加 \0)。应该用更大的缓冲区重试,并自己补 \0。
A4 源和目标不在同一个文件系统(挂载点不同),rename 不能跨设备。mv 会退化为「复制到目标临时文件 + fsync + rename + 删除源」,并保留权限/时间。
A5 不会,inotify 不递归;只监听 /data 本身。收到 IN_Q_OVERFLOW 说明队列溢出、事件已丢失,必须做一次全量重扫(readdir/stat),再从当前状态继续增量监听。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 18 章(目录、硬/软链接、readdir、nftw、目录 fd)、第 19 章(inotify)、第 14 章(VFS) | 2.6.1–2.6.15 | 完整(getdents64、*at、递归 inotify 为重点) |
| Linux 3.15+/5.x | renameat2(2)、IN_EXCL_UNLINK、fs.protected_*(母本成书后新增) | 2.6.9–2.6.11 | 按 man 页与内核 6.8 源码补充 |
| C | 第 10 章 10.7(目录与链接) | 2.6.1、2.6.2 | 对照(目录项与 inode) |
| O | 第 39–40 章(文件与目录、文件系统实现) | 2.6.1–2.6.3 | 背景(目录实现、链接计数) |
| G | 第 5 章(权限)、第 6 章(文件与目录管理)、第 7 章(磁盘与文件系统) | 2.6.2、2.6.3 | 背景(ln/readlink/find) |
| N | — | — | 未用:本章不涉及网络栈 |
下一站:2.7 进程创建与终止 从「文件树」转向「进程」:
fork复制了什么、exit与_exit差在哪、僵尸怎么回收,以及多线程程序里fork为什么危险。