Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_inoinode 号
d_off下一个目录项的偏移(telldir/seekdir 用)
d_reclen这一项的长度(变长)
d_type类型:DT_REG/DT_DIR/DT_LNK/…,可能是 DT_UNKNOWN
d_name名字,以 \0 结尾,长度不定

四条纪律:

  1. readdir 返回 NULL 时,必须区分 EOF 与错误。它不设 errno 为 0,所以调用前手动 errno = 0,循环结束后检查 errno。
  2. d_name 的内存在下一次 readdir 后可能被覆盖。要保留就 strdup。
  3. 顺序不定。readdir 返回的是文件系统内部的哈希/树顺序,不是字母序;要排序自己收集后 qsort。
  4. 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
mkdirEEXIST、ENOENT(父目录不存在)、EACCES、EROFS
rmdirENOTEMPTY、EBUSY(挂载点)、EACCES
renameEXDEV、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_TOrename 的两端
IN_MODIFY文件内容被写
IN_ATTRIB元数据变化(权限、时间、xattr)
IN_CLOSE_WRITE可写打开的 fd 被关闭(最可靠的内容写完信号)
IN_DELETE_SELF / IN_MOVE_SELF被监视对象本身被删/被移走
IN_IGNOREDwatch 被内核移除(删除或 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 相同)

三个必须知道的限制

  1. 事件会合并。在两次 read 之间,同一文件的多次 IN_MODIFY 可能只报一次;连续相同的 IN_CREATE/IN_DELETE 也可能合并。inotify 不是逐条日志,不能用来精确计数。
  2. 队列会溢出。默认 max_queued_events=16384;溢出时收到 IN_Q_OVERFLOW,之前的事件丢失。正确反应是全量重扫,而不是继续增量处理。
  3. 不递归。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 就够。

关于目录与事件五个误解
  1. 「readdir 按字母序」——不保证。ls 自己排序,readdir 不排。
  2. 「readdir 返回的 d_name 可以直接存进数组」——下次 readdir 可能覆盖,必须拷贝。
  3. 「unlink 之后空间立刻释放」——有进程持有 fd 时不释放;lsof +L1 能看到 (deleted)。
  4. 「inotify 会为每个写操作报一次」——会合并,且可能溢出;要可靠,得配合定期全量扫描。
  5. 「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_inoinode 号与 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。

目录读取的五个坑
  1. 「d_name 可以存进数组」——下一次 readdir 会覆盖;必须拷贝。
  2. 「d_type 一定准确」——可能是 DT_UNKNOWN;用 fstatat 补。
  3. 「d_off 是字节偏移」——不透明值,只给 seekdir;不同文件系统含义不同。
  4. 「readdir 按字母序」——不排序;ls 自己排。
  5. 「目录的 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` 不能 `longjmp` 跳出

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 替代。

`*at` 的五个坑
  1. 「openat 的 dirfd 必须是目录 fd」——是的;传普通文件 fd 返回 ENOTDIR。
  2. 「AT_FDCWD 是常量 0」——它是特殊负值(-100),不要当 fd 用。
  3. 「unlinkat 默认能删目录」——必须带 AT_REMOVEDIR,否则 EISDIR。
  4. 「linkat 默认跟随符号链接」——Linux 上 linkat 默认不跟随;要跟随加 AT_SYMLINK_FOLLOW。
  5. 「*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);
mount 的四个坑
  1. MS_BIND 忽略 fstype/data:绑定挂载不是「重新格式化」,只是引用同一棵子树。
  2. 传播是默认开的:systemd 把 / 设为 shared;容器里不先 MS_PRIVATE,挂载会漏到宿主。
  3. umount2(MNT_DETACH) 不是「立即释放」:还有进程持有该挂载时,它只是从命名空间摘掉,资源等引用归零才释放。
  4. 普通用户不能 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_WHITEOUToverlayfs 白化仅 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 版本」的底层原语。

`renameat2` 的四个坑
  1. 「RENAME_NOREPLACE 等于先 access 再 rename」——不等;前者原子,后者有 TOCTOU。
  2. 「RENAME_EXCHANGE 可以有一边不存在」——不行,两边都必须存在,否则 ENOENT。
  3. 「所有文件系统都支持 flags」——老内核 ENOSYS,NFS/overlayfs 可能 EINVAL/EOPNOTSUPP。
  4. 「renameat2 能跨文件系统」——不能,跨设备 EXDEV;mv 会退化为复制+删除。

2.6.10 链接保护:fs.protected_*

这一节回答:为什么普通用户不能给 /etc/passwd 建硬链接?/tmp 里的符号链接为什么有时打不开?

Linux 用一组 sysctl 限制「跟随/创建链接」的行为,默认值因发行版而异:

sysctl默认(常见)作用
fs.protected_hardlinks1只有属主或对源文件有读写权限时才能建硬链接
fs.protected_symlinks1sticky 目录中的符号链接:只有链接属主或目标属主能跟随
fs.protected_fifos2禁止在全局可写 sticky 目录创建 FIFO(除非属主)
fs.protected_regular2禁止在 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) 可以看当前排队字节数。

inotify 的六个坑
  1. 「每个 write 都会报一次 IN_MODIFY」——会合并;要精确计数用 fanotify/审计或应用层日志。
  2. 「IN_IGNORED 一定会有」——IN_ONESHOT 触发后没有;不要等它来清理映射。
  3. 「溢出后继续增量处理」——事件已丢;必须全量重扫。
  4. 「wd 永久对应一个路径」——会被复用;收到 IN_IGNORED 立即删映射。
  5. 「inotify 递归监视」——不递归;IN_CREATE|IN_ISDIR 后要自己加。
  6. 「read 缓冲区小一点没关系」——小于一个事件会 EINVAL;至少 sizeof(event)+NAME_MAX+1。

2.6.12 递归监听与溢出恢复

这一节回答:怎样监听整棵目录树,并在「新建子目录」与「队列溢出」两种情况下都不漏事件?

递归监听的正确结构是「先加根,再遍历加子目录;事件驱动补加;溢出全量重扫」:

  1. inotify_init1(IN_CLOEXEC|IN_NONBLOCK);
  2. nftw(root, add_watch, ..., FTW_PHYS) 给每个目录加 watch,wd → path 存进表;
  3. 循环 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 映射;
  4. 退出时关闭 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 被其他线程改掉。

递归 inotify 的检查清单
  1. 初始化是否带 IN_CLOEXEC/IN_NONBLOCK?
  2. 是否给每个子目录加了 watch,而不只是根?
  3. 新目录事件是否在 IN_CREATE|IN_ISDIR/IN_MOVED_TO|IN_ISDIR 后补加?
  4. IN_IGNORED 后是否删除了 wd → path 映射?
  5. IN_Q_OVERFLOW 后是否全量重扫,而不是继续增量?
  6. 缓冲区是否 ≥ sizeof(event)+NAME_MAX+1,并按 len 推进?
  7. 退出时是否关闭 fd(内核会自动清理 watch)?

2.6.13 内核路径:readdir 与 inotify

这一节回答:readdir 在内核里是谁在填目录项?inotify_add_watch 之后事件怎么排队?

readdir/getdents64

层次位置动作
系统调用fs/readdir.c__do_sys_getdents64 → iterate_dir
公共层fs/readdir.citerate_dir 检查 FMODE_PATH、调用 file->f_op->iterate_shared
文件系统fs/ext4/dir.cext4_readdir 遍历目录页/htree
输出fs/readdir.cdir_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.cfsnotify_add_inode_mark 挂到 inode 的 mark 列表
事件fs/notify/inotify/inotify_user.cinotify_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 与 inotify 的分工

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 排障顺序
  1. inotify_add_watch 失败:ENOENT(路径消失)、ENOTDIR(IN_ONLYDIR)、EACCES(无读权限)、ENOSPC(watch 配额)。
  2. 收不到事件:确认监视的是目录还是文件;子目录是否单独加了 watch。
  3. 事件太少:可能是尾部合并;用 IN_MASK_ADD 确认掩码没被覆盖。
  4. 事件太多/丢失:看 max_queued_events 与 IN_Q_OVERFLOW;溢出后全量重扫。
  5. wd 错乱:IN_IGNORED 后没删映射,或 wd 被复用。

2.6.15 本章陷阱清单

目录、链接与事件的十二个坑
  1. 现象:readdir 返回的 d_name 存进数组后乱码。原因:内部缓冲区被下一次调用覆盖。修复:strdup。
  2. 现象:遍历目录时 d_type 是 DT_UNKNOWN。原因:文件系统不提供类型。修复:fstatat(..., AT_SYMLINK_NOFOLLOW)。
  3. 现象:unlink 删目录失败。原因:unlink 不能删目录。修复:rmdir/unlinkat(..., AT_REMOVEDIR)。
  4. 现象:link 跨文件系统失败。原因:硬链接必须同 FS。修复:用符号链接或复制。
  5. 现象:符号链接循环导致 ELOOP。原因:超过 40 次解引用。修复:readlink 检查链路,避免自指。
  6. 现象:rename 返回 EXDEV。原因:跨文件系统。修复:复制到目标 FS + fsync + rename。
  7. 现象:renameat2(RENAME_NOREPLACE) 返回 EEXIST。原因:目标已存在。修复:这是预期语义;需要覆盖就用普通 rename。
  8. 现象:inotify 只收到一次 IN_MODIFY。原因:尾部事件合并。修复:不要用 inotify 计数;需要精确日志用应用层。
  9. 现象:inotify 完全收不到子目录事件。原因:不递归。修复:对子目录逐个 add_watch,并在新建目录时补加。
  10. 现象:inotify_add_watch 返回 ENOSPC。原因:max_user_watches 用尽。修复:减少 watch/提高 sysctl/复用 watch。
  11. 现象:IN_Q_OVERFLOW 后状态错乱。原因:事件已丢,继续增量处理。修复:全量重扫。
  12. 现象:open 在 /tmp 的链接上返回 EACCES。原因:fs.protected_symlinks。修复:检查 sysctl 与链接/目标属主。

2.6.16 本章出口检查

检查项达标线没达标去哪
正确遍历目录处理 errno、d_name 生命周期、DT_UNKNOWN2.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
即时自测 · 2.6

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.xrenameat2(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 为什么危险。