☰
Linux文件处理:彻底搞懂struct dirent与struct stat
2026/10/13 7:43:24 网站建设 项目流程

写Linux下的文件处理程序,绕不开两个结构体:struct dirent和struct stat。前者是目录项,代表目录里的“名单”;后者是文件元数据,代表文件本身的“档案”。很多朋友在刚接触时会被这俩搞混,因为名字像、头文件不同、用法也完全不同。更常见的是,网上很多示例代码只教你调函数,不解释字段含义,出了问题根本不知道从哪查。

这篇文章就围绕这两个结构体做一次彻底拆解,从字段定义、底层语义讲到实际项目里的用法,最后用一个类ls -l的Demo把整个流程串起来。内容尽量偏实操,所有代码我都按“能直接抄走改”的标准写,涉及坑点也会单独列出来。

1. 先分清两个结构体的定位:目录项与文件档案

1.1 一句话辨别:一个管“目录里有什么”,一个管“文件本身长什么样”

struct dirent是你遍历目录时返回的“条目”,它描述的是目录里的一个子项,可能是一个普通文件、一个子目录,也可能是符号链接。每个目录项里最关键的信息就是文件名d_name,以及少量提示信息,比如文件类型d_type和 inode 编号d_ino。

struct stat则是内核针对一个文件(或目录、链接、设备节点)给出的详细状态信息,包括文件类型、权限位、大小、占用块数、链接数、所有者、各种时间戳。获取它的函数是stat、fstat、lstat,传入路径或文件描述符,内核把对应文件的元资料填进结构体。

两者经常同时出现,是因为一个典型操作路径是这样的:先用opendir打开目录,再用readdir循环拿到struct dirent,此时你只拿到了目录项的名字和类型提示,如果还想知道这个文件多大、什么时候改的、权限是什么,就必须用lstat或stat去获取struct stat。所以这两个结构体不是竞争关系,而是上下游关系。

1.2 典型场景:你用它们能做哪些事

这两个结构体是几乎所有文件管理类工具的基础设施。写ls,你需要dirent列出名字,再用stat补全权限和时间;写find,你需要递归遍历目录,靠dirent发现子目录,靠stat判断时间与大小条件;写备份工具或同步工具,更得依赖stat里的st_mtime和st_size来判断文件是否变化;哪怕是做终端文件管理器、FTP服务端本地文件列表,底层也躲不过这俩结构体。

所以我一直认为,搞懂它们比背一堆函数名更重要。函数名查手册就行,结构体字段背后的语义才是真正要花时间理解的。

2. struct dirent 拆解:目录里的原始登记表

2.1 头文件、字段结构与平台差异

struct dirent定义在<dirent.h>中。Linux glibc 下的典型定义大约长这样:

struct dirent { ino_t d_ino; /* inode 编号 */ off_t d_off; /* 目录项偏移量 */ unsigned short d_reclen; /* 本目录项长度 */ unsigned char d_type; /* 文件类型提示 */ char d_name[256]; /* 文件名,以 \0 结尾 */ };

不同平台字段略有差异。比如 BSD 系会有d_namlen,有些系统d_name是弹性数组成员。跨平台代码千万不要直接认为d_name固定 256 字节,最好用strlen处理边界,而不是依赖固定大小。

d_ino是磁盘 inode 编号,同一个文件不管有几个硬链接,d_ino都一样。你可以用这个字段判断两个目录项是否指向同一个文件,比比较路径更靠谱。

d_type是文件类型提示,取值来自这么几个宏:

DT_UNKNOWN 0 未知,需要 stat 判断 DT_REG 8 普通文件 DT_DIR 4 目录 DT_LNK 10 符号链接 DT_CHR 2 字符设备 DT_BLK 6 块设备 DT_FIFO 1 命名管道 DT_SOCK 12 套接字

注意,这里有个重要问题:d_type并不可靠。某些文件系统(早期的一些网络文件系统、某些 FUSE 实现)拿不到类型信息,会返回DT_UNKNOWN。所以靠它做判断只适合快速过滤,真正需要准确类型时,还是要走lstat。

2.2 通过 opendir/readdir/closedir 读取目录项

readdir的使用套路非常固定:

#include <stdio.h> #include <errno.h> #include <dirent.h> int main(void) { DIR *dp = opendir("."); if (!dp) { perror("opendir"); return 1; } struct dirent *entry; errno = 0; while ((entry = readdir(dp)) != NULL) { printf("%s\n", entry->d_name); } if (errno != 0) { perror("readdir"); closedir(dp); return 1; } closedir(dp); return 0; }

这里有一个非常经典的坑:readdir返回NULL可能是到达目录末尾,也可能是出错了。如果不把errno在循环前清零,就无法区分这两种情况。很多老代码会忽略这个细节,导致出错时静默退出,排查起来非常痛苦。我的习惯是循环前errno = 0,循环后检查errno,这个习惯帮你省下大量定位时间。

另外,readdir返回的指针指向DIR结构内部维护的静态缓冲区,下次调用readdir时内容会被覆盖。如果你把目录项保存下来供后续使用,必须把d_name复制出来,千万不要直接保存指针,否则循环还没结束,前面的数据就已经全被覆盖了。

2.3 过滤掉 . 和 .. 以及隐藏文件的历史包袱

目录里的.和..是系统自动生成的目录项,作用是让目录结构可以向上回溯。在ls这类工具里,除非显式列出,否则要跳过它们。判断方式很简单:

if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { continue; }

隐藏文件在 Linux 下的判断逻辑就是一个约定:文件名以.开头,就默认隐藏。但注意,ls -a用a表示 all,不是说.开头的文件是“属性”文件,这点和 Windows 不同。如果你在实现一个目录遍历程序,需要决定是否处理隐藏文件:递归搜索时通常都要处理,否则会漏掉配置目录里的东西;但展示时默认过滤,更贴近用户习惯。

2.4 如果还想排序,scandir 是更省心的选择

手写readdir循环拿到条目后,排序得自己用qsort或手动插入。其实标准库提供了一个封装函数scandir,一步到位:

#include <dirent.h> #include <stdio.h> #include <stdlib.h> static int name_filter(const struct dirent *ep) { if (strcmp(ep->d_name, ".") == 0 || strcmp(ep->d_name, "..") == 0) return 0; return 1; } int main(void) { struct dirent **namelist; int n = scandir(".", &namelist, name_filter, alphasort); if (n < 0) { perror("scandir"); return 1; } for (int i = 0; i < n; i++) { printf("%s\n", namelist[i]->d_name); free(namelist[i]); } free(namelist); return 0; }

scandir返回的结构体数组是动态分配的,每个struct dirent也是单独malloc出来的,用完必须逐一释放,最后还要释放外层数组,否则就内存泄漏了。它的好处是排序直接给alphasort或versionsort,并且可以通过filter函数把.、..提前过滤掉,代码干净很多。

不过scandir拿到的还是struct dirent,想获取文件大小、权限,依然需要调用stat族函数。所以接下来必须啃struct stat。

3. struct stat 拆解:内核怎么描述一个文件

3.1 头文件与核心字段

struct stat定义在<sys/stat.h>中。不同平台字段名有差异,Linux glibc 下的典型定义:

struct stat { dev_t st_dev; /* 设备 ID */ ino_t st_ino; /* inode 编号 */ mode_t st_mode; /* 文件类型 + 权限位 */ nlink_t st_nlink; /* 硬链接数 */ uid_t st_uid; /* 所有者用户 ID */ gid_t st_gid; /* 所有者组 ID */ dev_t st_rdev; /* 设备节点的主次设备号 */ off_t st_size; /* 文件字节数 */ blksize_t st_blksize; /* I/O 缓冲区大小 */ blkcnt_t st_blocks; /* 占用的 512 字节块数 */ struct timespec st_atim; /* 最后访问时间 */ struct timespec st_mtim; /* 最后修改时间 */ struct timespec st_ctim; /* 状态变更时间 */ };

在旧代码里你可能会看到st_atime、st_mtime、st_ctime这种写法,那是 glibc 为了兼容老代码提供的宏。新写的代码建议直接使用st_mtim.tv_sec和st_mtim.tv_nsec,表示更精确,也不会踩到宏兼容层的坑。

3.2 stat、fstat、lstat:三个函数,三种语义

这三者的区别非常关键:

  • stat(path, &buf):传入一个路径,内核会沿路径访问文件,如果路径的最后一项是符号链接,它会被解引用,得到的是链接目标的信息。
  • lstat(path, &buf):与stat几乎一样,但如果路径最后一项是符号链接,它不解引用,得到的是链接本身的信息。
  • fstat(fd, &buf):传入一个已经打开的文件描述符,得到该描述符对应文件的信息。

什么时候用哪个?默认遍历文件系统内容时用lstat,因为你拿到的dirent可能就是一条符号链接,你希望知道“这个链接本身”的信息,而不是它背后指向的东西。否则你遍历到一个指向目录的符号链接时,如果不加控制,很容易出现递归循环或误判。

举个例子:一个符号链接/tmp/link -> /etc/some_dir,用stat看它得到的是目标目录的信息,用它判断S_ISDIR会返回真;用lstat看它得到的是链接本身,S_ISLNK才返回真。在递归遍历目录时,如果用stat判断目录类型,就会自动钻进符号链接指向的目录,这在带环链路里会无限递归。所以遍历逻辑里判断“是否继续向下走”一定要用lstat的结果。

3.3 st_mode 是位掩码:类型位、权限位、特殊位混在一个整数里

st_mode是一个mode_t,实际上是unsigned int。它把文件类型、权限以及 setuid/setgid/sticky 位全部打包在一起。理解它的方式就是位掩码:

  • 高 4 位(或按宏划分)表示文件类型,对应S_IFMT掩码。
  • 低 9 位是 rwx 权限,对应S_IRUSR、S_IWUSR、S_IXUSR等。
  • 还有 3 个特殊位:S_ISUID(setuid)、S_ISGID(setgid)、S_ISVTX(sticky)。

判断文件类型的标准做法不是直接比较st_mode与S_IFREG,而是用宏:

if (S_ISREG(st.st_mode)) { /* 普通文件 */ } if (S_ISDIR(st.st_mode)) { /* 目录 */ } if (S_ISLNK(st.st_mode)) { /* 符号链接 */ } if (S_ISCHR(st.st_mode)) { /* 字符设备 */ } if (S_ISBLK(st.st_mode)) { /* 块设备 */ } if (S_ISFIFO(st.st_mode)) { /* 命名管道 */ } if (S_ISSOCK(st.st_mode)) { /* 套接字 */ }

这些宏其实就是((st_mode & S_IFMT) == S_IFXXX)的封装。

权限位判断也类似。要判断“用户是否有读权限”,就判断(st_mode & S_IRUSR)是否为 0。如果为 0,说明该用户没有读权限。注意,这里判断的只是权限位,不是“当前进程是否真的能访问”,因为还要考虑 ACL、能力位、文件系统挂载选项等,但对常规展示已经足够。

setuid、setgid、sticky 位在ls -l显示时非常微妙。它们会复用 x 位的位置:

特殊位x 位可执行显示含义
setuid是s执行时以文件所有者身份运行
setuid否Ssetuid 已设置但无执行权限
setgid是s执行时以文件组身份运行
setgid否Ssetgid 已设置但无执行权限
sticky是t目录内仅所有者/root 可删除文件
sticky否Tsticky 已设置但无执行权限

我自己实现权限字符串时,会先填 9 个 rwx 字符,再对特殊位进行覆盖改写,否则很容易把s和S搞混。

3.4 st_blocks、st_size 与 ls 的 total 值

st_size是文件逻辑大小,单位是字节。对于普通文件就是长度,对于符号链接,st_size是目标路径字符串的长度,这个特性有时候很有用。对于目录,st_size通常是 4096 或更大,它表示目录文件本身占用的存储,不代表里面所有文件的总和。

st_blocks是实际占用的磁盘块数,注意单位是 512 字节,不是换算后的 1KB 或 4KB。ls -l输出的第一行total 12里的 12,其实就是该目录下所有条目st_blocks之和,这个数字的单位就是 512 字节块。所以total显示12,意味着磁盘上大约占用了 12 * 512 = 6144 字节,而不是 12KB。

为什么会有st_size和st_blocks两个值?因为稀疏文件的存在。一个文件逻辑上可能很大,但实际分配到的磁盘块远少于逻辑大小。st_size告诉你“读起来有多长”,st_blocks告诉你“占磁盘多少空间”。在写磁盘占用统计工具时,务必用st_blocks而不是st_size,否则报告会虚高。

3.5 时间戳处理:把秒数变成可读时间

st_mtim.tv_sec是从 1970-01-01 00:00:00 UTC 起的秒数。打印时要用localtime转成本地时间,再用strftime格式化:

struct stat st; if (lstat("file.txt", &st) == 0) { struct tm *tm = localtime(&st.st_mtim.tv_sec); char timebuf[64]; strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", tm); printf("%s\n", timebuf); }

ls -l默认显示逻辑更聪明:6 个月以内的文件显示“月 日 时:分”,更早的显示“月 日 年”。你的工具如果追求一致体验,可以根据当前时间减去st_mtim.tv_sec判断是否超过 180 天,决定格式。这个逻辑不难,但很影响观感。

4. 实战:手写一个类 ls -l 的目录遍历工具

4.1 方案设计:用 readdir 获取条目,用 lstat 获取状态

现在把前面所有知识点整合起来,写一个简化版ls -l。

实现目标:接收一个目录路径作为参数,输出该目录下所有非隐藏条目的名称、类型、权限、硬链接数、所有者和组、大小、修改时间。输出格式尽量贴近原版ls -l。

整体流程:

  1. 解析命令行参数,没有参数就默认当前目录。
  2. opendir打开目录。
  3. 循环调用readdir,跳过.、..和以.开头的条目。
  4. 拼接路径:目录路径 +/+ 文件名。
  5. 调用lstat获取条目信息。
  6. 格式化输出各个字段。
  7. 累计st_blocks之和,输出total。

这一步的关键在于路径拼接。很多人直接snprintf到栈数组,但要注意目录路径末尾是否已有/,否则拼出来的路径会变成dir/file和dir//file混在一起。统一处理方式如下:

char path[PATH_MAX]; int len = snprintf(path, sizeof(path), "%s/%s", dirpath, entry->d_name); if (len < 0 || len >= (int)sizeof(path)) { // 处理路径过长 }

PATH_MAX定义在<limits.h>中,一般取 4096。栈上开这个大小没问题。但如果你要支持任意深的路径,就不能用固定数组,需要动态扩容,我后面会讲。

4.2 权限字符串生成函数

这个函数把st_mode转换成类似-rwxr-xr-x的字符串。逻辑分三步:

  1. 首字符根据文件类型设置。
  2. 填入 9 个 rwx 位。
  3. 处理 setuid、setgid、sticky 对 x 位的覆盖。
static void mode_to_string(mode_t mode, char *buf) { // 类型位 if (S_ISDIR(mode)) buf[0] = 'd'; else if (S_ISLNK(mode)) buf[0] = 'l'; else if (S_ISCHR(mode)) buf[0] = 'c'; else if (S_ISBLK(mode)) buf[0] = 'b'; else if (S_ISFIFO(mode)) buf[0] = 'p'; else if (S_ISSOCK(mode)) buf[0] = 's'; else buf[0] = '-'; // 所有者权限 buf[1] = (mode & S_IRUSR) ? 'r' : '-'; buf[2] = (mode & S_IWUSR) ? 'w' : '-'; buf[3] = (mode & S_IXUSR) ? 'x' : '-'; // 组权限 buf[4] = (mode & S_IRGRP) ? 'r' : '-'; buf[5] = (mode & S_IWGRP) ? 'w' : '-'; buf[6] = (mode & S_IXGRP) ? 'x' : '-'; // 其他用户权限 buf[7] = (mode & S_IROTH) ? 'r' : '-'; buf[8] = (mode & S_IWOTH) ? 'w' : '-'; buf[9] = (mode & S_IXOTH) ? 'x' : '-'; if (mode & S_ISUID) buf[3] = (mode & S_IXUSR) ? 's' : 'S'; if (mode & S_ISGID) buf[6] = (mode & S_IXGRP) ? 's' : 'S'; if (mode & S_ISVTX) buf[9] = (mode & S_IXOTH) ? 't' : 'T'; buf[10] = '\0'; }

这个函数我每次写文件管理器都要重抄一遍,时间久了闭眼都能写出来。核心是最后那三行覆盖逻辑,很多人漏掉,导致特殊位完全没显示出来。

4.3 完整代码:readdir + lstat 实现类 ls -l

这里给出完整可编译运行的代码。编译命令gcc -o myls myls.c,不依赖额外库。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <dirent.h> #include <sys/stat.h> #include <sys/types.h> #include <pwd.h> #include <grp.h> #include <time.h> #include <limits.h> #include <errno.h> static long total_blocks = 0; static void mode_to_string(mode_t mode, char *buf) { if (S_ISDIR(mode)) buf[0] = 'd'; else if (S_ISLNK(mode)) buf[0] = 'l'; else if (S_ISCHR(mode)) buf[0] = 'c'; else if (S_ISBLK(mode)) buf[0] = 'b'; else if (S_ISFIFO(mode)) buf[0] = 'p'; else if (S_ISSOCK(mode)) buf[0] = 's'; else buf[0] = '-'; buf[1] = (mode & S_IRUSR) ? 'r' : '-'; buf[2] = (mode & S_IWUSR) ? 'w' : '-'; buf[3] = (mode & S_IXUSR) ? 'x' : '-'; buf[4] = (mode & S_IRGRP) ? 'r' : '-'; buf[5] = (mode & S_IWGRP) ? 'w' : '-'; buf[6] = (mode & S_IXGRP) ? 'x' : '-'; buf[7] = (mode & S_IROTH) ? 'r' : '-'; buf[8] = (mode & S_IWOTH) ? 'w' : '-'; buf[9] = (mode & S_IXOTH) ? 'x' : '-'; if (mode & S_ISUID) buf[3] = (mode & S_IXUSR) ? 's' : 'S'; if (mode & S_ISGID) buf[6] = (mode & S_IXGRP) ? 's' : 'S'; if (mode & S_ISVTX) buf[9] = (mode & S_IXOTH) ? 't' : 'T'; buf[10] = '\0'; } static const char *uid_to_name(uid_t uid) { struct passwd *pw = getpwuid(uid); return pw ? pw->pw_name : "?"; } static const char *gid_to_name(gid_t gid) { struct group *gr = getgrgid(gid); return gr ? gr->gr_name : "?"; } static void print_entry(const char *dirpath, const struct dirent *entry) { char fullpath[PATH_MAX]; if (snprintf(fullpath, sizeof(fullpath), "%s/%s", dirpath, entry->d_name) >= (int)sizeof(fullpath)) { fprintf(stderr, "path too long: %s\n", entry->d_name); return; } struct stat st; if (lstat(fullpath, &st) != 0) { perror("lstat"); return; } total_blocks += st.st_blocks; char modebuf[16]; mode_to_string(st.st_mode, modebuf); struct tm *tm = localtime(&st.st_mtim.tv_sec); char timebuf[32]; strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M", tm); printf("%s %3lu %-8s %-8s %8ld %s %s\n", modebuf, (unsigned long)st.st_nlink, uid_to_name(st.st_uid), gid_to_name(st.st_gid), (long)st.st_size, timebuf, entry->d_name); } int main(int argc, char **argv) { const char *path = "."; if (argc > 1) { path = argv[1]; } DIR *dp = opendir(path); if (!dp) { perror("opendir"); return 1; } struct dirent *entry; errno = 0; while ((entry = readdir(dp)) != NULL) { if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { continue; } if (entry->d_name[0] == '.') { continue; // 隐藏文件默认不显示 } print_entry(path, entry); } if (errno != 0) { perror("readdir"); closedir(dp); return 1; } closedir(dp); printf("total %ld\n", total_blocks); return 0; }

运行效果大致是:

-rw-r--r-- 2 alice dev 2048 2025-06-10 10:30 notes.txt drwxr-xr-x 3 alice dev 4096 2025-06-11 14:02 scripts lrwxrwxrwx 1 alice dev 9 2025-06-12 09:15 link -> notes.txt total 24

这段代码用来验证前面的理论非常合适。你可以在 tmp 目录里建几个不同类型文件试试:普通文件、子目录、符号链接、管道,观察输出区别。

4.4 进阶:支持递归遍历与隐藏文件

如果要写成ls -R,只需把print_entry修改为:当S_ISDIR(st.st_mode)为真时,以该路径为参数递归进入同一个函数。但有两个注意点。

第一,不能直接用stat判断目录,要用lstat判断后手动决定是否跟进。这样符号链接指向目录时,默认不递归,避免环。

第二,递归时路径拼接必须考虑长度。栈上的PATH_MAX数组在深层目录里会不够用,使用动态字符串是更稳健的选择。C 语言里没有现成字符串类,可以手写一个简易的扩容拼接函数,或者直接用openat系列函数配合fd避开路径拼接,这是更高级也更优雅的做法。

openat的方向是:打开一个目录拿到fd,然后每次fstatat(fd, name, &st, AT_SYMLINK_NOFOLLOW)获取状态,用fdopendir进入子目录。整个遍历都在目录文件描述符之间穿梭,不依赖拼接路径。这套思路在实现find、备份工具时特别实用,以后可以单独写一篇讲。

5. 实操中高频踩坑记录:这些问题几乎人人都遇到

5.1 readdir 与 lstat 混用时,路径拼接错了还毫无察觉

最常见的问题是这个:

lstat(entry->d_name, &st);

在刚opendir(".")时,entry->d_name恰好能工作,因为当前目录的路径前缀为空。一旦你遍历的是/tmp/data,这条代码就会去相对进程当前目录找文件,结果找不到,或者更糟,找到了另一个同名文件,让你以为数据出错了。

正确写法永远是snprintf拼接目录路径与文件名。我的习惯是写一个工具函数:

static int build_path(char *out, size_t out_size, const char *dir, const char *name) { int n = snprintf(out, out_size, "%s/%s", dir, name); return (n < 0 || n >= (int)out_size) ? -1 : 0; }

所有调用方统一用这个函数,排查路径问题时就一个地方看。

5.2 readdir 是共享缓冲区的,保存 d_name 必须 strdup

前面提过,readdir内部用静态缓冲区,返回的指针在下次调用时就失效了。如果你需要把所有文件名收集起来再做处理,一定要复制:

char *name = strdup(entry->d_name); // 保存在列表里,用完释放

有时候你只是把指针塞进数组,第一轮循环看起来好好的,第二轮用的时候发现全是最后的条目,就是这个问题。排查方式:打印所有收集到的名字,对比目录实际情况,立刻就能看出来。

5.3 readdir 不是线程安全的,同一个 DIR* 不能并发读

readdir在多线程下并不是绝对安全的。不同线程各自opendir打开不同的DIR*,各自读写自己的流,通常没有问题;但多个线程共享同一个DIR*并同时调用readdir,就属于未定义行为,程序可能在 glibc 版本升级后突然崩溃或读到乱数据。

readdir_r曾经是替代方案,但因为接口设计问题和NAME_MAX的可靠性问题,glibc 标记它弃用了,新代码不建议用。如果确实需要并发遍历,最简单的方式是给每个线程单独opendir,或者给共享目录流加锁。如果数据量很大,更合理的做法是直接上nftw,让库函数替你处理目录树遍历,你只需要写回调函数。

5.4 d_type 不可靠,别拿它当最终判断

d_type在 ext4、xfs 等本地文件系统上基本都准确,但在某些网络文件系统和 FUSE 驱动上可能返回DT_UNKNOWN。我在一个用 FUSE 实现的挂载目录里就遇到过:d_type全部是 0,如果代码里直接用它给文件分类,得到的结果全部是“未知”。

稳妥的做法是:d_type只做快速预判,简化逻辑;但真正影响行为的关键判断必须用lstat。有些同学为了让程序“快一点”省掉lstat,结果正确性受损,不值得。

5.5 stat 与 lstat 搞混,导致符号链接判断失效

判断一个条目是不是符号链接,必须用lstat。如果误用stat,符号链接会被解引用,你看到的是它的指向对象。典型的故障现象:程序输出里没有lrwxrwxrwx的行,全是普通文件;或者遍历目录时顺着符号链接进入深层目录,甚至出现循环。

我的判断原则很简单:只要在处理目录项,默认用lstat;只有在明确想“跟随链接查看最终文件”的时候,才用stat。

5.6 输出格式对齐问题

原版ls -l的列宽是根据所有条目动态算出来的,比如大小列会把所有文件中位数最大的宽度作为列宽,然后左对齐。上面我给的示例代码里用%8ld固定宽度,文件和目录混排时看起来还算整齐,但如果遇到特别大的文件错位就会很明显。

想要对齐,需要两遍扫描:第一遍遍历目录拿所有struct stat,找出每列最大宽度;第二遍再格式化输出。这也顺便说明了一个技术决策:为什么很多实用工具会先把所有条目收集到数组里,而不是边读边打。边读边打代码简单,但没法做动态对齐。

6. 围绕这两个结构体的一些经验总结

我用readdir加lstat的组合写过好几个工具,从最开始的简易列出文件,到后来支持递归统计目录大小、按修改时间过滤文件、生成文件清单,核心逻辑从来没变过:先拿dirent拿到名字,再拿stat补齐信息。理解清楚这两层,遇到任何文件操作需求,都能迅速定位到该用什么函数、什么字段。

如果你正在写自己的文件工具,建议把这篇文章里几个函数抄进你的工具箱:mode_to_string、build_path、readdir循环模板、errno清零检查。这几个函数是通用零件,任何涉及目录遍历的程序都能直接复用。后续你可能还会接触到nftw、openat、fstatat这些进阶方案,它们解决的问题和这里提到的坑是一脉相承的。

最后再分享一个调试技巧:写这类程序时,先用一个测试目录把各种文件类型摆齐全。普通文件、子目录、符号链接、管道、设备文件(有条件的话)、文件名带空格的、隐藏文件。输入尽量刁钻,跑一跑很快就能暴露问题。

我实际测试时通常会在/tmp下建一个testdir,里面放几个不同扩展名的文件、一个subdir、一个指向subdir的软链接、一个hidden.txt,然后拿这个目录反复遍历。这样验证total块数、权限字符串、时间格式,基本一次就能确认逻辑是否健壮。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询