15、获取文件属性:stat()、fstat()、lstat() 函数详解,以及 struct stat 结构体

文件操作里,除了读写数据,还有一个很常见的需求——获取文件本身的“身世信息”。

比如这个文件多大?什么时候修改的?是普通文件还是目录?权限是什么?

这些信息,在 Linux 系统里都藏在文件的 inode 里。而我们要做的,就是通过三个系统调用把它们挖出来:stat()fstat()lstat()

说实话,这三个函数我几乎每天都会用到。不管是写日志系统、做文件监控,还是实现一个简单的 ls -l,都离不开它们。

15.1 struct stat:文件信息的“容器”

在调用 stat 系列函数之前,你得先准备一个“收纳盒”——也就是 struct stat 结构体。所有获取到的文件属性,都会填到这个结构体里。

#include <sys/stat.h>

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_mode:这个字段最复杂,它同时编码了文件类型和权限。判断文件类型要用宏,比如 S_ISREG(m) 判断是否普通文件,S_ISDIR(m) 判断是否目录。
  • st_size:文件大小。注意,对目录来说这个值通常是 0 或很小的数,别拿它当目录里文件的总大小。
  • st_mtim:最后修改时间。我写增量备份工具时,就是靠这个字段判断文件是否被改过。
  • st_blocks:文件占用的磁盘块数。你想想看,一个空洞文件(sparse file)的 st_size 可能很大,但 st_blocks 很小,这就是它的“真实占用”。
小提示st_atimst_mtimst_ctim 在 POSIX.1-2008 标准里是 struct timespec 类型,精度到纳秒。老代码里可能看到 st_atimest_mtimest_ctime,那是秒级精度,注意区分。

15.2 stat():通过路径名获取属性

stat() 是最常用的一个。你给它一个文件路径,它就把属性填到结构体里。

#include <sys/stat.h>

int stat(const char *pathname, struct stat *statbuf);

成功返回 0,失败返回 -1 并设置 errno。

来看一个实际例子:

#include <stdio.h>
#include <sys/stat.h>
#include <time.h>

int main() {
    struct stat sb;

    if (stat("/etc/passwd", &sb) == -1) {
        perror("stat");
        return 1;
    }

    printf("文件大小: %ld 字节\n", sb.st_size);
    printf("占用块数: %ld\n", sb.st_blocks);
    printf("最后修改时间: %s", ctime(&sb.st_mtime));

    if (S_ISREG(sb.st_mode)) {
        printf("这是一个普通文件\n");
    } else if (S_ISDIR(sb.st_mode)) {
        printf("这是一个目录\n");
    }

    return 0;
}

这段代码我经常用来做快速诊断。比如某个日志文件突然变大,我先 stat 一下看看大小和时间,心里就有数了。

15.3 fstat():通过文件描述符获取属性

如果你已经打开了一个文件,手里有文件描述符 fd,那就用 fstat()。它不需要路径名,直接从内核里拿信息。

#include <sys/stat.h>

int fstat(int fd, struct stat *statbuf);

我个人的习惯是:如果文件已经打开了,就别再用 stat() 传路径了。为什么?因为路径可能被篡改(TOCTOU 竞态条件),而 fd 是确定的。

举个例子:

#include <stdio.h>
#include <fcntl.h>
#include <sys/stat.h>

int main() {
    int fd = open("data.bin", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    struct stat sb;
    if (fstat(fd, &sb) == -1) {
        perror("fstat");
        close(fd);
        return 1;
    }

    printf("文件大小: %ld 字节\n", sb.st_size);
    printf("文件所在设备: major=%d minor=%d\n",
           major(sb.st_dev), minor(sb.st_dev));

    close(fd);
    return 0;
}

这里有个细节:st_dev 是文件所在设备的 ID,可以用 major()minor() 宏拆出主次设备号。我在做磁盘空间统计时,经常用这个字段来区分文件在哪个分区上。

15.4 lstat():绕过符号链接

这个函数跟 stat() 几乎一样,唯一的区别是:如果路径是一个符号链接,lstat() 返回的是 符号链接本身 的属性,而不是它指向的目标文件。

#include <sys/stat.h>

int lstat(const char *pathname, struct stat *statbuf);

为什么要单独搞一个 lstat?

你想想看,如果你用 stat() 去查一个符号链接,它自动解引用,你永远拿不到链接文件自己的信息。而 lstat() 就是专门干这个的。

我曾经在写一个文件同步工具时踩过这个坑。当时我想判断一个文件是不是符号链接,用 stat() 怎么都判断不出来,后来才发现要用 lstat() 配合 S_ISLNK() 宏。

#include <stdio.h>
#include <sys/stat.h>

int main() {
    struct stat sb;

    if (lstat("mylink", &sb) == -1) {
        perror("lstat");
        return 1;
    }

    if (S_ISLNK(sb.st_mode)) {
        printf("这是一个符号链接\n");
        printf("链接文件大小: %ld 字节\n", sb.st_size);
        // 注意:符号链接的 st_size 是目标路径的字符串长度
    } else {
        printf("这不是符号链接\n");
    }

    return 0;
}

嗯,这里要注意:符号链接的 st_size 不是目标文件的大小,而是目标路径字符串的字节数。比如链接指向 /usr/bin/python3,那 st_size 就是 16。

15.5 三个函数的对比

函数 参数 符号链接处理 典型场景
stat() 路径名 解引用 获取任意路径的文件属性
fstat() 文件描述符 不适用(已打开) 已打开文件的属性查询
lstat() 路径名 不解引用 检查符号链接本身

简单总结一下:

  • 有路径,想看目标文件 → 用 stat()
  • 有 fd,不想走路径 → 用 fstat()
  • 有路径,想看链接本身 → 用 lstat()

15.6 实战:实现一个简易的 ls -l

光说不练假把式。我们来写一个简化版的 ls -l,把文件类型、权限、大小、修改时间都打印出来。

#include <stdio.h>
#include <sys/stat.h>
#include <time.h>
#include <pwd.h>
#include <grp.h>

void print_file_type(mode_t mode) {
    if (S_ISREG(mode))  putchar('-');
    else if (S_ISDIR(mode)) putchar('d');
    else if (S_ISLNK(mode)) putchar('l');
    else if (S_ISCHR(mode)) putchar('c');
    else if (S_ISBLK(mode)) putchar('b');
    else if (S_ISFIFO(mode)) putchar('p');
    else if (S_ISSOCK(mode)) putchar('s');
    else putchar('?');
}

void print_perms(mode_t mode) {
    char perms[10] = "---------";
    if (mode & S_IRUSR) perms[0] = 'r';
    if (mode & S_IWUSR) perms[1] = 'w';
    if (mode & S_IXUSR) perms[2] = 'x';
    if (mode & S_IRGRP) perms[3] = 'r';
    if (mode & S_IWGRP) perms[4] = 'w';
    if (mode & S_IXGRP) perms[5] = 'x';
    if (mode & S_IROTH) perms[6] = 'r';
    if (mode & S_IWOTH) perms[7] = 'w';
    if (mode & S_IXOTH) perms[8] = 'x';
    printf("%s", perms);
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <文件路径>\n", argv[0]);
        return 1;
    }

    struct stat sb;
    if (lstat(argv[1], &sb) == -1) {
        perror("lstat");
        return 1;
    }

    // 文件类型 + 权限
    print_file_type(sb.st_mode);
    print_perms(sb.st_mode);

    // 硬链接数
    printf(" %ld", sb.st_nlink);

    // 所有者
    struct passwd *pw = getpwuid(sb.st_uid);
    struct group  *gr = getgrgid(sb.st_gid);
    printf(" %s %s", pw ? pw->pw_name : "?", gr ? gr->gr_name : "?");

    // 大小
    printf(" %8ld", sb.st_size);

    // 修改时间
    char timebuf[64];
    strftime(timebuf, sizeof(timebuf), "%b %d %H:%M",
             localtime(&sb.st_mtime));
    printf(" %s", timebuf);

    // 文件名
    printf(" %s\n", argv[1]);

    return 0;
}

这段代码我稍微解释一下:

  • lstat() 而不是 stat(),这样能正确显示符号链接本身。
  • getpwuid()getgrgid() 把 uid/gid 转成用户名和组名。
  • strftime() 格式化时间,比 ctime() 更灵活。
注意getpwuid()getgrgid() 返回的指针指向静态缓冲区,在多线程环境下要用 getpwuid_r()getgrgid_r() 可重入版本。

15.7 知识体系图

下面这张图帮你理清 stat 系列函数的核心逻辑:

stat 系列函数核心逻辑 stat(path, &buf) 通过路径名,解引用符号链接 fstat(fd, &buf) 通过文件描述符,已打开文件 lstat(path, &buf) 通过路径名,不解引用符号链接 struct stat buf st_mode | st_size | st_mtim | st_blocks | st_uid | st_gid ... 文件类型、权限、大小、时间、所有者、块数等 判断文件类型 S_ISREG() / S_ISDIR() / S_ISLNK() 获取文件大小 st_size(字节)/ st_blocks(块) 检查修改时间 st_mtim / st_atim / st_ctim

15.8 避坑指南

最后,分享几个我踩过的坑:

  • TOCTOU 竞态:先 stat 判断文件存在,再 open 打开。这中间文件可能被替换。正确做法是 open 后用 fstat 验证。
  • 符号链接大小:lstat 得到的 st_size 是目标路径字符串长度,不是目标文件大小。别搞混了。
  • st_blocks 的单位:它是 512 字节块的数量,不是 st_blksize 的块数。很多新手在这里算错磁盘占用。
  • 时间精度:老代码用 st_mtime(秒级),新代码用 st_mtim(纳秒级)。跨平台移植时要注意。

我曾经在生产环境里遇到过一个问题:一个监控脚本用 stat 检查日志文件大小,结果日志文件被删了又重建,stat 拿到的是新文件的属性,但监控脚本还拿着旧 fd 在写。后来改成 open 后统一用 fstat,问题就解决了。

嗯,文件属性获取这块,说白了就是三个函数加一个结构体。但用好了,能解决很多实际问题。你写代码的时候,多想想「我到底要查什么?用哪个函数最合适?」就行了。

核心要点回顾

  • stat() 查路径,解引用符号链接
  • fstat() 查 fd,适合已打开文件
  • lstat() 查路径,不解引用符号链接
  • struct stat 包含文件所有元数据
  • 用 S_ISxxx 宏判断文件类型

公众号:蓝海资料掘金营,微信deep3321