Linux目录操作进阶:从opendir、readdir到scandir与nftw实战

📅 发布时间:2026/9/24 18:57:53
Linux目录操作进阶:从opendir、readdir到scandir与nftw实战
处理过几百G日志目录见过那种一个子系统一个目录、里面再按日期套时间戳的典型目录布局就会明白Linux系统编程里的目录操作从来不是表面看起来“打开目录读一遍”那么简单。前阵子我为了给一套跨NFS的日志系统写目录清理脚本把opendir、readdir、scandir、nftw这一整套API从头到尾重新过了一遍踩了几个挺隐蔽的坑。趁着记忆还热乎把目录和目录流这部分整理成一篇能直接照着用的操作笔记从底层原理到实际代码一次性讲透。这篇内容主要面向已经开始写Linux C/C程序、但还没系统梳理过目录操作的朋友。无论你是要写个批量重命名工具还是给服务端做日志轮转又或是想自己实现一个简化版find目录流这套接口都是基础中的基础。我尽量把每个函数背后的设计意图说清楚再给可运行的代码和踩坑记录读完你至少能少走一半弯路。1. 目录结构基础目录到底是个什么东西1.1 目录项与inode的映射关系很多人把目录理解成“装文件的盒子”这个类比在用户层面没问题但在系统编程层面会误导人。目录本质上也是一个文件只不过它里面存的数据不是普通内容而是一张“登记表”每条记录由一个文件名和一个inode编号组成这条记录就叫目录项也就是dirent。你听到的“inode”才是文件真正的主人体。文件的数据块、权限、时间戳都存在inode里。而文件名只是登记表上的一个门牌号它告诉你“这个文件叫foo.txt它的inode是13572468”。所以“重命名文件”这个操作根本不需要动文件数据只需要改登记表上的一条记录“硬链接”也是在另外一张登记表上增加一条指向同一个inode的条目。理解了这张表后面读readdir返回的struct dirent就会顺理成章。底层存储上不同文件系统的目录格式不一样。ext4的目录文件内部用ext4_dir_entry_2组织条目变多之后还会引入Htree索引保证在大目录里查找文件名不用线性扫描xfs、btrfs也各有各的组织方式。但这些对应用层来说都无所谓因为目录流接口已经把差异全部屏蔽掉了。你通过readdir拿到的就是一个个逻辑上的目录项不需要关心磁盘上到底怎么排列。1.2 目录权限位与三类常见操作目录的权限位很容易被搞混。普通文件的读、写、执行权限含义比较直观目录则完全不同。读权限决定你能不能列出目录里有哪些条目写权限决定你能不能在这个目录里创建、删除或重命名文件执行权限决定你能不能“进入”这个目录也就是能不能按名字访问里面的条目。这里有一个很经典的坑如果一个目录只有读权限没有执行权限比如444你确实能用readdir列出文件名但没法stat其中任何一个文件因为每次名字解析都需要执行权限来穿过目录。反过来只有执行权限没有读权限比如111你能cd进去但如果不知道具体文件名ls -l会直接报错因为它没法读取目录项名称。实际排查权限问题时先想清楚你到底要做的是“列表、增删、还是访问”再对照权限判断别一股脑怪到文件本身。现实里还有一个常见误解删除一个文件需要文件本身有写权限吗不需要删除操作改的是文件所在目录的登记表所以只需要目录的写权限。这也解释了为什么只读文件放在可写目录里照样能被删掉很多新手在这里栽过跟头。2. 目录流三件套opendir、readdir、closedir2.1 目录流DIR对象与文件描述符的关系打开一个普通文件你得到的是FILE *或者文件描述符fd。打开一个目录你得到的是一个DIR *也就是“目录流”句柄。目录流内部持有一个文件描述符但它比裸fd多做了一层标准化它屏蔽了不同文件系统的目录条目格式还带了缓冲区批量读取目录项避免每个entry都触发一次系统调用。你可能会好奇既然目录也是文件能不能直接用open和read去读目录数据早期Unix确实允许这么做但后来POSIX把这个口子堵上了因为不同文件系统的目录内部结构差异太大直接读裸数据等于自找麻烦。而且readdir返回的是逻辑条目不是磁盘上的原始字节这种抽象对应用层来说太重要了。DIR流还支持一个操作用fdopendir把一个已经打开的文件描述符转成DIR *。这在需要先open目录fd、再统一用目录流接口处理的场景里很好用。但要注意关闭顺序先用closedir回收DIR流它会自动关闭自己持有的fd不要再手动close去重复关闭否则可能出现fd被复用后的错误close。2.2 struct dirent关键成员与d_name生命周期readdir每次返回一个指向struct dirent的指针这个结构里最关键的是两个字段d_name和d_type。d_name是文件名d_type是文件类型。不同的系统struct dirent定义略有差别glibc下还有一些扩展字段比如d_ino、d_off、d_reclen但日常用到最多的就是前两个。d_name的生命周期是最容易踩的坑readdir返回的指针指向目录流内部的静态缓冲区下一次调用readdir时这个缓冲区会被覆盖。也就是说你不能把返回的d_name指针存下来留到循环之后再用必须立刻strdup或者memcpy出来。如果只存了个指针循环结束你会发现所有名字都变成了最后一次readdir的结果。判断遍历有没有出错也有讲究。readdir在读完最后一条之后返回NULL但这并不代表一切正常。你需要把errno先清零然后再判断只有errno没变的情况下NULL才表示正常结束。否则可能是底层读取出错最常见的错误是EACCES或者EIO。别小看这个细节在遍历大量网络目录时底层I/O错误是真实会出现的。2.3 遍历顺序与d_type的类型判断readdir究竟按什么顺序返回目录项这个问题答案很不浪漫由文件系统决定没有规定。ext4可能按hash索引顺序返回FAT格式的目录可能按创建顺序返回反正不是字典序。而且同一目录连续两次遍历顺序也不保证一致。所以任何依赖遍历顺序的逻辑都是危险的需要排序就用scandir或者自己收集后排序。d_type是Linux系统的扩展它保证了在大多数情况下你不需要额外调用lstat就能知道条目是普通文件、目录还是符号链接。但别高兴太早有些文件系统比如某些网络文件系统或者部分场景下的XFS会在d_type里填DT_UNKNOWN意思是“我没法告诉你你自己去查”。所以严谨的代码应该对DT_UNKNOWN做兜底调用lstat来确定类型。处理几十万文件时d_type能省掉大量stat调用性能提升非常明显值得为它多写一个分支。3. 目录流的高级操作与高效遍历方案3.1 rewinddir、seekdir、telldir的适用场景除了基础的三件套目录流还有三个配套函数rewinddir回到目录开头telldir返回当前位置seekdir定位到某个位置。它们的语义和文件流里的lseek类似但记住一个关键区别这个“位置”不是文件偏移值而是目录流的内部游标只是给telldir和seekdir配合使用的逻辑标记。什么场景需要seekdir我遇到的典型是扫描一批文件做批量处理处理到某个条目时发现需要依赖的配置还没就绪可以先记住这个位置等到条件满足再seek回来继续。另一个场景是做断点重试避免每次失败都从头扫描整个大目录。但要注意glibc的man page里明确提醒过在调用seekdir之后之前readdir返回的那些条目指针都会失效千万别再引用。而且telldir的返回值不要试图自己去加减运算它就是个黑盒cookie只能原样传给seekdir跨目录流比较也没有任何意义。rewinddir则更常用。比如你要做两轮扫描第一轮统计数量第二轮处理文件中间直接一个rewinddir就能回到开头不用重新open一次目录。注意rewinddir不返回错误码但调用后errno可能有变化如果你对错误敏感记得先清零errno。3.2 scandir过滤、排序一步到位如果你只是想列出目录里符合条件的文件并希望结果有序其实不需要自己循环readdir加排序。POSIX提供了scandir一个函数把“打开目录、遍历、按过滤条件筛选、按排序规则排序、返回结果数组”全干完了。scandir的原型是int scandir(const char *dirp, struct dirent ***namelist, int (*filter)(const struct dirent *), int (*compar)(const struct dirent **, const struct dirent **));filter回调决定哪些条目保留返回非零表示保留compar回调决定排序方式。结果存在namelist里使用完成后需要自己释放先释放数组里每个指针再释放数组本身。不想写比较函数可以直接用现成的alphasort按文件名做字典序排序versionsort则按版本号排序处理带数字后缀的文件时更自然。下面是一个列出当前目录所有.c文件并按名字排序的例子#define _GNU_SOURCE #include stdio.h #include stdlib.h #include dirent.h #include string.h static int select_c(const struct dirent *d) { size_t len strlen(d-d_name); return len 2 strcmp(d-d_name len - 2, .c) 0; } int main(void) { struct dirent **list; int n scandir(., list, select_c, alphasort); if (n 0) { perror(scandir); return 1; } for (int i 0; i n; i) { printf(%s\n, list[i]-d_name); free(list[i]); } free(list); return 0; }scandir的缺点是会把目录里所有条目一次性读进内存。如果目录里有几百万个文件内存开销会很可观。这种场景就别用scandir了老老实实readdir手写循环一边读一边处理。3.3 nftw递归遍历目录的正确姿势手动写递归遍历目录很容易但也很容易写出死循环。POSIX提供的nftw函数把递归的事包办了你只需要提供一个回调函数它会帮你遍历整棵目录树对每个路径都调用一次回调。nftw原型是int nftw(const char *dirpath, int (*fn)(const char *fpath, const struct stat *sb, int typeflag, struct FTW *ftwbuf), int nopenfd, int flags);typeflag告诉你当前路径是什么类型FTW_F是普通文件FTW_D是先序遍历时的目录FTW_DP是后序遍历时的目录FTW_DNR是无法读取的目录FTW_SL是符号链接FTW_NS是stat失败。ftwbuf里有base和level两个常用字段base代表文件名部分在fpath里的偏移位置level代表当前深度。flags里有几个关键选项。FTW_PHYS表示不跟随符号链接这个选项几乎是必加的否则遇到指回父目录的符号链接遍历会在死循环里出不来。FTW_MOUNT表示不跨越文件系统边界对应du的-x选项。FTW_DEPTH表示后序遍历先处理子目录再处理目录本身这个模式在删除目录树时特别管用。FTW_CHDIR则会在进入每个目录前先切换工作目录对深层路径可以避免PATH_MAX限制但代价是回调里拿到的fpath可能不再是从原始根路径出发的完整路径需要踩过坑才知道。下面这段代码可以统计一个目录树下普通文件数量和总大小#define _XOPEN_SOURCE 500 #include stdio.h #include stdlib.h #include ftw.h static int file_count 0; static unsigned long long total_size 0; static int visit(const char *path, const struct stat *sb, int typeflag, struct FTW *ftwbuf) { (void)path; (void)ftwbuf; if (typeflag FTW_F) { file_count; total_size sb-st_size; } return 0; } int main(int argc, char *argv[]) { const char *path (argc 1) ? argv[1] : .; if (nftw(path, visit, 64, FTW_PHYS) ! 0) { perror(nftw); return 1; } printf(files: %d, total size: %llu bytes\n, file_count, total_size); return 0; }nftw的性能通常优于手写递归因为它内部对目录fd做了复用不需要每个目录都重复open/close。但它的回调式设计也有缺点遍历状态只能放在全局变量或者通过上下文结构体维护扩展成多线程并发比较麻烦。4. 实战手写一个目录大小统计工具4.1 需求分析与方案选型我一直喜欢用“写个du”来学习目录接口。这周隔壁组让我帮忙统计一批项目目录各子目录的大小标准du工具其实也能干但我想排除每个项目里的cache和build目录还希望对特殊情况做额外处理干脆自己写一个。需求很简单给定一个目录分别算出下一级每个子目录的总大小并排除指定名称的目录。方案有三条路第一条是opendir加递归readdir第二种是scandir递归第三种是nftw加回调。我最后选的是递归readdir因为需求是按一级子目录各自汇总nftw那种统一回调反而不容易切分。如果只是统计整棵树的总大小nftw会更简洁。选型背后的逻辑是所有工具函数都只是工具先想清楚自己需要的数据聚合方式再决定用哪个接口不要因为nftw看起来高级就无脑用。递归readdir在需要按目录维度分别汇总的场景里其实最直观。4.2 用递归readdir实现核心逻辑核心函数思路很直接传入一个路径lstat判断类型。普通文件直接返回大小目录则打开目录流遍历每一个条目过滤掉.和..排除掉需要跳过的目录名然后拼接子路径递归调用。注意这里必须用lstat而不是stat否则遇到指向目录的符号链接会顺着链接继续往下走轻则重复统计重则死循环。下面是我实际用的简化版本#define _XOPEN_SOURCE 500 #include stdio.h #include stdlib.h #include string.h #include sys/stat.h #include dirent.h static int is_excluded(const char *name) { return strcmp(name, cache) 0 || strcmp(name, build) 0; } static long long dir_size(const char *path) { struct stat st; if (lstat(path, st) ! 0) return 0; if (S_ISREG(st.st_mode)) return (long long)st.st_size; if (!S_ISDIR(st.st_mode)) return 0; DIR *dp opendir(path); if (dp NULL) { perror(path); return 0; } long long size 0; struct dirent *entry; while ((entry readdir(dp)) ! NULL) { if (strcmp(entry-d_name, .) 0 || strcmp(entry-d_name, ..) 0) continue; if (is_excluded(entry-d_name)) continue; char child[4096]; snprintf(child, sizeof(child), %s/%s, path, entry-d_name); size dir_size(child); } closedir(dp); return size; }细节上要特别留意的有三点。第一子路径拼接用snprintf并检查返回值防止缓冲区不够。路径长度在极端场景下确实可能超过4096更严谨的做法是动态扩容但在大多数管理场景下固定缓冲区够用。第二如果opendir失败必须perror打出来否则遇到EACCES你会看到统计结果莫名其妙变小还不知道哪些目录没进去。第三对条目的跳过判断放在递归之前尽量减少无效的路径拼接和系统调用。写完这个函数后再写一个main函数遍历目标目录的一级子目录对这些子目录分别调用dir_size并打印结果。整个工具大约七八十行代码比直接用du再写各种过滤逻辑灵活得多。4.3 用nftw实现同一功能的对比如果换成nftw来实现整个根目录的总大小统计代码会短很多。回调函数里只需要判断typeflag是否为FTW_F然后累加sb-st_size。这个我在前面已经给过示例。但要做“分别统计每个一级子目录”这种需求nftw的回调里需要用ftwbuf-level判断深度level等于1的就是一级子目录然后自己维护一个按目录名累加的结构体反而绕了一圈。所以我的结论是nftw适合“整棵树统一处理”的场景比如统计总量、查找所有符号链接、删除整个目录树而“按目录维度分别统计”的场景递归readdir更自然。没有绝对优劣只有匹配不匹配。日常开发里这两种方案都掌握遇到问题再快速选型。4.4 两种实现路径的对照维度递归readdirnftw回调代码量中等自己写循环和递归少回调函数聚焦单点逻辑按子目录汇总自然每个目录独立递归需要自己用level和数据结构切分符号链接处理必须自己lstat判断FTW_PHYS一行搞定跨文件系统限制需要自己比较st_devFTW_MOUNT直接支持深层路径支持需要自己优化否则PATH_MAX受限FTW_CHDIR可以规避并发改造相对容易可以分目录拆任务回调式结构不便于并发拆分实际项目里如果你想快速得到结果nftw是首选如果你在写一个需要长期维护的复杂工具递归readdir的可读性和可控性反而更好。我最后两个版本都写了跑同一批数据结果一致最终保留了递归readdir版本原因只是后续还要加“排除某些子目录”的复杂规则手写循环更灵活。5. 目录流实战中的常见坑与排查技巧5.1 权限与路径维度的问题排查最常见的现象是opendir返回NULLerrno是EACCES。这时候第一反应不应该是加sudo而是先检查你要遍历的路径上的每一级目录权限。注意路径上任何一个目录缺执行权限最终都会表现为EACCES哪怕目标目录本身权限没问题。排查的时候用namei -l /path/to/dir一条命令就能看清每一级的权限。另一个权限相关的坑是目录有读权限但没有执行权限时readdir能正常返回文件名但如果你想对每个条目再调statstat会失败。所以如果你在写遍历工具一定要想清楚目标目录的执行权限是否足够。遍历工具加个--strict模式遇到stat失败就打印警告这个设计在排障时极有帮助。路径维度还有一个经验不要相信路径里不会出现特殊字符。文件名里可以包含空格、换行、中文、甚至以-开头的名字。所有拼接出来的路径要么用snprintf的绝对路径方式要么在传给exec类接口时加--标记避免被当成选项解析。我处理过太多文件名带换行导致纯文本日志打出来的内容错乱的情况能用json或者NUL分隔尽量别用换行。5.2 遍历过程中目录被并发修改怎么办生产环境里你扫描目录的同时很可能有别的进程在创建或删除文件。这时readdir的行为取决于文件系统实现和目录fd的状态。一般情况下目录流持有的fd打开后即使目录被重命名甚至删除只要fd没关闭你仍然可以继续遍历那个已经不存在的目录。这有点像unlink之后的普通文件fd照样可读。但如果你遍历的目录是“打开后别人又往里写新文件”新文件会不会出现在这次遍历里完全取决于文件系统的目录游标实现没有统一保证。这意味着目录遍历本质上是近似快照不是强一致读。一个典型现象是先统计文件总大小统计完之后立刻再统计一次两次结果不一样。排除掉程序自身bug很可能就是并发写入导致的。解决思路是降低期望要么接受近似值要么通过业务层保证扫描期间目录不变化要么做两遍扫描取交集。千万别在设计阶段就假设readdir能看到“某一时刻”的稳定状态。另外注意虽然打开目录fd后遍历不受目录被删除影响但如果你用路径方式递归遍历情况就不一样了。比如你从/var/log开始递归已经记录了下级目录名/var/log/nginx但还没打开它时nginx目录被rename走了下一步opendir就失败并返回ENOENT。所以遍历工具的opendir失败分支一定要处理不要直接abort记录后继续遍历其他部分。5.3 符号链接死循环与跨文件系统边界手写递归时最经典的翻车现场项目目录下有个符号链接指向项目根目录然后你的程序就在链接和真实目录之间反复横跳最终栈溢出崩溃。避免方法只有一个基本原则递归遍历时不使用stat使用lstat并单独判断S_ISLNK遇到符号链接就跳过不让它进入递归。用nftw时直接把FTW_PHYS加上就安全了。第二个边界问题是跨文件系统。比如你在扫描一个挂载了NFS的目录NFS目录里又挂着一个大的本地盘如果不预留跨文件系统遍历可能会把不需要统计的挂载点内容也包含进去性能还特别差。readdir治标不治本的做法是在递归进入每个子目录前调用stat获取st_dev与父目录的st_dev比较不一致就跳过。用nftw则更简单加FTW_MOUNT即可。我在实际统计中曾遇到过符号链接指向一个几十T的存储目录当时如果没有FTW_PHYS一次遍历就能把NFS带宽打爆。这个教训让我在新手写遍历工具时都会多嘴问一句你的目录环境里有没有符号链接有没有跨挂载点这两个问题答不上来别急着写代码。5.4 常见错误速查与每次必做的检查项现象常见原因处理方式opendir返回NULLerrnoEACCES路径上某一级目录缺少执行权限namei -l逐级检查权限readdir遍历中途返回NULLerrno异常底层文件系统I/O错误记录当前路径停止该目录遍历继续其他目录文件名全部变成最后一个只存了d_name指针没有拷贝立刻用strdup保存遍历深度过深导致栈溢出递归写法没有层数上限或遇到符号链接循环加深度限制用lstat跳过符号链接scandir返回的数组释放崩溃只free了数组没free每个元素先循环free元素最后free数组目录被删除后打开失败递归遍历依赖路径字符串路径已失效opendir失败非致命记录后继续du结果和实际差异巨大用了stat没有用lstat符号链接被跟随全局检查stat/lstat使用位置每次写目录遍历代码我会强制自己列一个检查清单是否过滤了.和..是否拷贝了d_name是否用lstat而不是stat是否处理了opendir失败分支是否考虑符号链接循环是否有路径长度保护是否释放了scandir数组。这几个问题过一遍代码一般不会出大差错。最后说点个人的体会。目录流这套接口看起来简单但它是进程和文件系统交互最频繁的路径之一。写完这次的工具之后再回过头看find、du、tree的实现思路明显清楚了很多。建议有时间的同学也试着用nftw写个小工具或者把scandir的过滤回调改成正则匹配跑一跑真实目录你会对这些API的理解加深不止一个档次。