news 2026/9/24 18:57:50

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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、closedir

2.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返回NULL,errno是EACCES。这时候第一反应不应该是加sudo,而是先检查你要遍历的路径上的每一级目录权限。注意,路径上任何一个目录缺执行权限,最终都会表现为EACCES,哪怕目标目录本身权限没问题。排查的时候用namei -l /path/to/dir一条命令就能看清每一级的权限。

另一个权限相关的坑是:目录有读权限但没有执行权限时,readdir能正常返回文件名,但如果你想对每个条目再调stat,stat会失败。所以如果你在写遍历工具,一定要想清楚目标目录的执行权限是否足够。遍历工具加个--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返回NULL,errno=EACCES路径上某一级目录缺少执行权限namei -l逐级检查权限
readdir遍历中途返回NULL,errno异常底层文件系统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的理解加深不止一个档次。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 18:56:11

专业级PPT制作AI工具选型指南:从大纲生成到排版优化的完整实操流程

1. 为什么“专业级PPT”这件事&#xff0c;AI工具的选择比努力更重要做PPT这件事&#xff0c;几乎每个职场人都绕不开。不管你是做技术方案汇报、产品路演、年终总结&#xff0c;还是给学生上课、参加比赛答辩&#xff0c;PPT都是那个“最后一道关卡”。我见过太多人&#xff0…

作者头像 李华
网站建设 2026/9/24 18:55:45

HTTP与HTTPS区别详解:加密原理、证书信任与实战排障

刚入行的朋友最常问我的一个问题&#xff0c;多半是“HTTP 和 HTTPS 到底有什么区别&#xff1f;”要是放到两三年前&#xff0c;我可能会甩一句“HTTPS 就是加密版的 HTTP”&#xff0c;然后让对方自己去看文档。但现在在网络安全这个行当里混久了&#xff0c;我越发现这个“加…

作者头像 李华
网站建设 2026/9/24 18:55:45

SpringBoot图形验证码从生成到校验的完整实战指南

做一个图形验证码&#xff0c;是每个 Web 开发者迟早都要面对的需求。登录、注册、发帖、秒杀、支付确认&#xff0c;几乎只要有用户输入和接口调用的地方&#xff0c;就能看到它的影子。SpringBoot 因为起步快、生态好&#xff0c;成了很多人实现这个功能的首选框架&#xff0…

作者头像 李华
网站建设 2026/9/24 18:55:43

Bun实测:一个运行时搞定全栈开发,内置打包测试SQLite与Redis

每年都会冒出一个号称"重新定义开发体验"的新工具&#xff0c;但大部分更新日志翻两页就乏了。直到这轮前端全栈工具链卷到 Bundler、测试框架、数据库驱动全部要重新选型的时候&#xff0c;Bun 的重磅发布确实让我停下手上的活&#xff0c;实打实跑了几个样例。这个…

作者头像 李华