摘要:本文系统梳理 Linux 目录操作的核心 API,围绕「目录本质也是文件」这一大前提,依次讲解 opendir、readdir、closedir 三大目录函数,再延伸到 chdir、getcwd、mkdir、rmdir、stat 等辅助函数,并结合 Unix 时间戳与 errno 错误处理做串联。文中补充了 dirent 缓冲区陷阱、d_type 兜底方案、递归路径拼接、umask 对 mkdir 的影响、localtime 线程安全等高频易错点,最后给出可直接运行的极简 ls 模板与递归遍历扩展示例。
一、核心大前提:目录本质也是文件
在 Linux 中,目录是一种特殊文件,通常称为「目录文件」。普通文件里存放的是字节流,而目录文件里存放的是该目录下所有文件条目的信息,每条信息至少包含文件名和对应的 inode 编号,这就是 ls 能列出目录内容的基础。
正因为目录也是文件,所以操作目录同样遵循一套固定流程,和普通文件的 fopen → fread 循环读取 → fclose 非常相似:
目录操作固定流程:opendir → readdir 循环读取 → closedir 关闭。
这个对应关系值得记牢:
| fopen | opendir | 打开并拿到一个流句柄 |
| fread / fgets | readdir | 循环读取内容 |
| fclose | closedir | 关闭并释放资源 |
二、目录三大核心函数
这组函数是遍历目录、实现 ls 的基础,声明在 <dirent.h> 头文件中。
2.1 opendir:打开目录流
DIR *opendir(const char *name);
- 功能:打开一个目录,拿到目录流指针 DIR *。
- 参数:name 是目录路径,相对路径和绝对路径都可以,例如 "." 表示当前目录,"../" 表示上级目录。
- 返回:成功返回 DIR * 目录流指针;失败返回 NULL。
延伸理解:DIR 可以看作目录的「句柄」,它内部维护了一个读取偏移,就像普通文件的文件位置光标。每次 readdir 都会让这个偏移往后移动一条目录项,所以一次打开后可以顺序读完整个目录。注意 DIR 的具体结构由系统实现,我们一般不需要也不应该手动解析它的内部字段。
2.2 readdir:读取一条目录项
struct dirent *readdir(DIR *dirp);
- 功能:从目录流里读取一条目录项,即一个文件或文件夹的信息,返回 struct dirent *。
- 参数:opendir 返回的目录流指针。
- 返回:读到条目时返回 dirent 指针;读到目录末尾或出错时返回 NULL。
重点成员与使用要点:
- 返回值不需要 free:readdir 返回的 struct dirent * 指向库内部维护的缓冲区,不能手动释放,closedir 时会自动回收。
- 内部缓冲区会被覆盖:因为返回的是同一块内部缓冲区,连续调用 readdir 时,上一次拿到的 d_name 内容可能已经被下一次读取覆盖。如果之后还要使用文件名,必须立刻用 strcpy 或 strdup 复制出去,不能只保存指针。
- d_type 表示文件类型:常见值有 DT_REG 普通文件、DT_DIR 目录、DT_LNK 软链接等,正好可以用在 switch 里做类型判断。
- d_name 只有名字,不是完整路径:d_name 只保存文件名本身,不包含父目录路径。递归遍历目录时最容易在这里踩坑,必须手动拼接完整路径。
- d_type 可能不可用:部分文件系统不支持 d_type,会返回 DT_UNKNOWN,此时需要用 stat / lstat 进一步判断文件类型。
延伸理解:读取 "." 目录时,返回的前两项通常就是 "." 和 ".."。如果做递归遍历,一定要把这两个条目过滤掉,否则会陷入无限递归,这两个名字分别代表当前目录和上级目录,它们本身不是“新目录”。
2.3 closedir:关闭目录流
int closedir(DIR *dirp);
- 功能:关闭目录流,释放 opendir 分配的内部资源。
- 参数:opendir 返回的 DIR *。
- 返回:成功返回 0,失败返回 -1。
它和普通 IO 的 close 对应。打开目录后无论读取过程是否发生错误,都要记得调用 closedir,否则会造成资源泄漏。
三、目录辅助函数
这些函数分别解决「切换目录、获取当前目录、新建目录、删除目录」四类问题,声明在 <unistd.h> 和 <sys/stat.h> 中。
3.1 chdir:切换工作目录
int chdir(const char *path);
功能:切换当前进程的工作目录,等价于命令行里的 cd。
重要区别:它只改变当前程序自己的进程状态,不会影响终端,因为终端是另一个独立进程。切换之后,程序里后续所有的相对路径 open、fopen 都会以新目录为基准。
例如执行 chdir("../") 切换到上级目录后,再调用 fopen("aaa","w"),就会在上级目录创建文件 aaa,而不是在原目录创建。
3.2 getcwd:获取当前工作目录
char *getcwd(char *buf, size_t size);
功能:获取程序当前工作目录的绝对路径,等价于命令行里的 pwd。
- buf:用于存放路径字符串的缓冲区。
- size:缓冲区大小,防止溢出。
- 返回:成功返回 buf,失败返回 NULL。
典型用法是 chdir 之后再调用 getcwd 验证是否切换成功,相当于「cd 之后 pwd」的组合。
3.3 mkdir:新建目录
int mkdir(const char *pathname, mode_t mode);
功能:新建目录。mode 是权限,如 0775、0755 这类八进制权限。
延伸理解:实际创建出来的目录权限会受到进程 umask 的影响,最终权限通常是 mode & ~umask。也就是说,即使传了 0777,如果 umask 是 0002,最终得到的往往还是 0775。想要精确控制权限,需要在创建后显式调用 chmod,或者先临时修改 umask。
3.4 rmdir:删除目录
int rmdir(const char *pathname);
功能:删除目录。
硬性限制:只能删除空目录,目录里存在文件或子目录时会直接报错。
rm -r 命令的底层并不是单纯调用 rmdir,而是先递归删除目录里的所有内容,最后再对空目录调用 rmdir。理解这一点,就能明白为什么不能指望 rmdir 一句命令清空整个目录树。
四、stat:高频文件属性接口
stat 用于读取文件或目录的底层属性,也就是元信息,包含 inode、大小、权限、时间、链接数等。声明在 <sys/stat.h> 中。
4.1 函数原型与参数
int stat(const char *path, struct stat *buf);
- path:文件路径。
- buf:struct stat 结构体,用来存放读取到的属性信息。
- 返回:成功返回 0,失败返回 -1。
4.2 重点成员
| st_ino | inode 号,对应 ls -i | %lu |
| st_mode | 权限和文件类型,配合 S_ISREG / S_ISDIR / S_ISLNK 判断类型 | |
| st_nlink | 硬链接计数 | |
| st_uid / st_gid | 所有者用户 ID、组 ID | %d |
| st_size | 文件大小,单位字节,和 lseek 拿到的大小一致 | %lu |
| st_mtim.tv_sec | mtime,内容修改时间戳,Unix 秒数 |
延伸理解:st_mode 里同时保存了文件类型和权限位。低 9 位是权限,例如可以用 st.st_mode & 0777 取出 rwxrwxrwx 对应的权限值。类型判断则推荐统一使用宏:
if (S_ISREG(st.st_mode)) {
/* 普通文件 */
} else if (S_ISDIR(st.st_mode)) {
/* 目录 */
} else if (S_ISLNK(st.st_mode)) {
/* 符号链接 */
}
这样比手动比较 st_mode & S_IFMT 更清晰,也更不容易出错。
4.3 stat 与 lstat 的区别
stat 会跟进软链接,拿到的是链接目标文件的信息;lstat 不跟进,拿到的是软链接本身的信息。因此在需要判断「这个路径本身是不是软链接」时,必须使用 lstat,否则会被 stat 带偏,把软链接误判成目标文件的类型。
4.4 stat 完整示例
#include <stdio.h>
#include <sys/stat.h>
int main(int argc, char *argv[])
{
const char *path = argc > 1 ? argv[1] : ".";
struct stat st;
if (stat(path, &st) != 0) {
perror("stat");
return 1;
}
printf("inode: %lu\\n", (unsigned long)st.st_ino);
printf("硬链接数: %lu\\n", (unsigned long)st.st_nlink);
printf("大小: %lu 字节\\n", (unsigned long)st.st_size);
if (S_ISDIR(st.st_mode)) {
printf("类型: 目录\\n");
} else if (S_ISREG(st.st_mode)) {
printf("类型: 普通文件\\n");
} else if (S_ISLNK(st.st_mode)) {
printf("类型: 符号链接\\n");
}
return 0;
}
五、Unix 时间戳
Linux 中的时间统一使用 Unix 时间戳表示:从 1970-01-01 00:00:00 UTC 起,到现在经过的总秒数。相关函数声明在 <time.h> 中。
5.1 time:获取当前时间戳
time_t time(time_t *t);
- 功能:获取当前时间戳,单位是秒。
- 参数:可以传 NULL;如果传入变量地址,秒数也会同时存入 *t。
- 返回:时间戳。
5.2 localtime:秒数转结构体
struct tm *localtime(const time_t *timep);
功能:把秒数时间戳转换成包含年月日时分秒的 struct tm。
struct tm 常用成员有 tm_year、tm_mon、tm_mday、tm_hour、tm_min、tm_sec。
注意:tm_year 保存的是「年份减去 1900」后的值,tm_mon 的范围是 0 到 11。因此打印年份要写 tm_year + 1900,打印月份要写 tm_mon + 1,这是新手最容易写错的两个偏移量。
5.3 ctime:秒数直接转字符串
char *ctime(const time_t *timep);
功能:把秒数时间戳直接转换成人类可读的时间字符串,效果类似 date 命令输出,例如:
Wed Oct 22 20:30:00 2026
延伸理解:
- localtime 返回的是内部静态缓冲区,和 readdir 类似,内容可能被下一次调用覆盖;多线程环境下更应使用线程安全版本 localtime_r。
- localtime 得到本地时间,gmtime 得到 UTC 时间。服务器日志经常统一存 UTC,打印时再转本地时间。
- ctime 本质上等价于 asctime(localtime(…)),所以它输出的也是本地时间。
5.4 时间函数综合示例
#include <stdio.h>
#include <time.h>
int main(void)
{
time_t now = time(NULL);
printf("时间戳: %ld\\n", (long)now);
char *s = ctime(&now);
printf("ctime: %s", s);
struct tm *t = localtime(&now);
printf("%d-%02d-%02d %02d:%02d:%02d\\n",
t->tm_year + 1900,
t->tm_mon + 1,
t->tm_mday,
t->tm_hour,
t->tm_min,
t->tm_sec);
return 0;
}
六、perror 与 errno
void perror(const char *s);
perror 的底层依赖全局变量 errno。当系统调用出错时,内核会把错误编号写入 errno,我们可以根据它定位原因。声明在 <stdio.h> 中,errno 定义在 <errno.h> 中。
- 作用:先打印你传入的字符串 s,再自动拼接上 errno 对应的文字错误信息。
- 输出目标:标准错误 stderr,和 fprintf(stderr, …) 是同一类。
- 对比:strerror(errno) 只返回错误字符串但不打印,使用上更灵活;perror 则适合直接报错。
示例:
if (-1 == ret) {
perror("chdir");
/* 可能输出:chdir: No such file or directory */
}
延伸理解:errno 只在系统调用或部分库函数失败时才有意义,成功时它的值可能残留旧错误。严谨做法是先判断返回值是否表示失败,再读取 errno,或者在调用前手动把 errno 清零,避免误读旧值。
七、整体串联与易错考点汇总
以下是可以直接背诵的简答版重点:
八、极简 ls 遍历模板
下面是最基础的目录遍历模板,只打印 "." 目录下的所有条目名:
#include <stdio.h>
#include <dirent.h>
#include <unistd.h>
int main(void)
{
DIR *dir = opendir(".");
if (NULL == dir) {
perror("opendir");
return 1;
}
struct dirent *info;
while ((info = readdir(dir)) != NULL) {
printf("%s\\n", info->d_name);
}
closedir(dir);
return 0;
}
8.1 延伸一:递归遍历目录并拼接路径
下面的示例演示了最容易踩坑的两点:过滤 "." 和 "..",以及用完整路径递归进入子目录。因为 d_name 只有名字,进入子目录前必须拼上父目录路径。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <unistd.h>
void list_dir(const char *path, int depth)
{
DIR *dir = opendir(path);
if (NULL == dir) {
perror("opendir");
return;
}
struct dirent *info;
while ((info = readdir(dir)) != NULL) {
if (strcmp(info->d_name, ".") == 0 ||
strcmp(info->d_name, "..") == 0) {
continue;
}
for (int i = 0; i < depth; i++) {
printf(" ");
}
char full_path[1024];
snprintf(full_path, sizeof(full_path), "%s/%s",
path, info->d_name);
if (info->d_type == DT_DIR) {
printf("[DIR] %s\\n", info->d_name);
list_dir(full_path, depth + 1);
} else {
printf("[FILE] %s\\n", info->d_name);
}
}
closedir(dir);
}
int main(int argc, char *argv[])
{
const char *path = argc > 1 ? argv[1] : ".";
list_dir(path, 0);
return 0;
}
8.2 延伸二:d_type 不可用时的 stat 兜底
部分文件系统不支持 d_type,会返回 DT_UNKNOWN。此时应拼接完整路径后调用 lstat,再通过 S_ISREG / S_ISDIR / S_ISLNK 宏判断类型。这里刻意使用 lstat,确保能正确识别软链接本身,而不是它指向的目标。
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
int safe_type(const char *dir_path, struct dirent *info)
{
if (info->d_type != DT_UNKNOWN) {
return info->d_type;
}
char full_path[1024];
snprintf(full_path, sizeof(full_path), "%s/%s",
dir_path, info->d_name);
struct stat st;
if (lstat(full_path, &st) == 0) {
if (S_ISDIR(st.st_mode)) return DT_DIR;
if (S_ISLNK(st.st_mode)) return DT_LNK;
if (S_ISREG(st.st_mode)) return DT_REG;
}
return DT_UNKNOWN;
}
九、总结
目录操作的学习可以抓住两条主线:
- 遍历这条线:把目录当作一种特殊文件,用 opendir、readdir、closedir 完成顺序读取,同时警惕 d_name 不含路径、返回值缓冲区会被覆盖、d_type 可能失效这三个坑。
- 属性这条线:用 stat / lstat 获取 inode、类型、大小、硬链接数、时间等元信息,配合 chdir / getcwd / mkdir / rmdir 管理目录本身,再用 time / localtime / ctime 把时间戳翻译成人能看懂的形式。
把这两条线串起来,再记住 errno / perror 负责错误定位,就能完整覆盖 Linux 目录操作的基础面。后续遇到 nftw 这类递归遍历工具,本质上也只是对 opendir / readdir / closedir 流程的进一步封装。
网硕互联帮助中心




评论前必须登录!
注册