stat 那十几个字段分别在说什么

难度 ★★☆☆☆ · 前置:文件描述符:一个整数背后的三层跳转 | 一句话结论:stat 的十几个字段里,st_mode 的高位是类型、低位是权限,st_blocks 的单位永远是 512 字节,st_nlink 才是 ls -l 里第二个数字。三个时间戳中只有 ctime 不可改。

ls -l 大概是我最早会用的命令之一,但很长一段时间里我只会看它的最后两列,文件名和时间,中间那一排东西全靠猜,-rw-r--r-- 里哪个是类型哪个是权限,那个 1 是什么的数量,Nov 13 20:41 是创建还是修改,我从来没认真想过,反正文件能打开就行,猜错了也没人扣我工资,真正让我难受的是有一回我要判断一个配置文件是不是被同事改过,我对着 ls -l 看了十几秒,发现自己根本不知道那个时间戳在什么情况下会跳,也就是说我准备拿它当证据,却说不清这证据是怎么来的。

直到有次排查一个配置问题,我 ls -l 看了半天,跟同事说这文件昨天改过。

同事看了一眼,问,你看的是访问时间还是修改时间。

我当场答不上来。

后来才明白,ls -l 默认显示的是 mtime,而 stat 一次会把三个时间全给你,它们各自的含义差别很大,混着看会得出完全错误的结论,更要命的是我 cat 一下那个文件 atime 就变了,也就是说我用来判断什么时候改过的那个数字,可能是我自己刚才看它的时候写进去的,这个念头让我不太舒服。

这篇从一次 ls -l 出发,把 stat 那十几个字段全过一遍。

一次 ls -l,四个答不准的问题

1
2
printf 'hello' > /tmp/tiny.txt   # 先造个文件,后面几段都拿它当样本
ls -l /tmp/tiny.txt
1
-rw-r--r-- 1 alice alice 5 Nov 13 20:41 /tmp/tiny.txt

七个部分,看着都能猜,但真问起来,第一个字符 - 是什么,换成 l、d、s、p、c、b 呢,那个 1 是什么的数量,Nov 13 20:41 是哪个时间,创建、修改还是访问,大小 5 之外这文件究竟占了磁盘多少空间,四个问题里三个光看 ls -l 是答不准的,第二个尤其反直觉,那个数字通常是 1,既不是文件大小也不是大小单位,而是硬链接数,我在很长一段时间里把它理解成了这文件有几份拷贝,还因此以为拷贝越多占的空间越多,于是有次清理日志时我特意确认了一遍只有 1 份、可以放心删,删完 df 一点没降,我坐在那儿盯着屏幕想了很久,还怀疑过是文件系统没回收,最后才发现那 1 说的是别的事。(这一条是写这篇文章查资料的时候才彻底对上的,之前几年一直理解错,好在没造成过实际后果)

stat 能一次把答案全给你。

三个调用,区别只在问谁

1
2
3
int stat(const char *path, struct stat *buf);
int fstat(int fd, struct stat *buf);
int lstat(const char *path, struct stat *buf);

三个函数的名字只差一个字母,行为差在遇到符号链接时跟不跟。

调用 参数是 遇到符号链接时
stat 路径 跟着链接走,报目标的信息
lstat 路径 不跟,报链接本身的信息
fstat 已打开的 fd 无所谓,已经打开了

ls -l 用的是 lstat,所以它才有能力把 l 显示在第一个字符上,你想想看,如果 ls 用 stat,那它列一个指向 /etc/passwd 的链接时,会在第一个字符显示 -,大小显示三千多,名字还是那条链接的名字,三种信息混在一起自相矛盾,所以这个选择不是随手定的,是必须的,同理,如果你只想知道这个文件多大,stat 就够,如果问这条链接指向哪,必须 lstat,我当初以为这两个函数只是名字差一个字母,随便用哪个都行,直到写了个脚本去统计目录占用,stat 把所有软链接都算成了目标文件的大小,加起来比 du 多出一个数量级,才明白差的那个 l 是 load 的意思,不是 little。

十三个字段,按用途分四组

struct stat 字段不算多,分组看会清楚很多。

组 字段 说明
身份 st_dev 文件在哪个设备上(主设备号 + 次设备号)
st_ino inode 号,同一文件系统内唯一
st_rdev 只有设备文件才有意义:这个设备本身的设备号
类型与权限 st_mode 一个整数,位域里编码了文件类型 + 权限 + 特殊位
st_nlink 硬链接数
属主 st_uid 属主 uid
st_gid 属组 gid
大小与占用 st_size 文件逻辑长度,字节
st_blksize 推荐的 IO 块大小,通常 4096
st_blocks 实际占用的 512 字节块数
时间 st_atime 最后访问时间
st_mtime 最后内容修改时间
st_ctime 最后 inode 变更时间

st_blocks 和 st_size 的关系是第一个反直觉点,st_blocks 的单位永远是 512 字节,不是 st_blksize,所以 st_blocks * 512 才是实际占的字节数,这个乘数是 BSD 时代留下来的硬编码约定,跟 st_blksize 一点关系都没有,st_blksize 是文件系统建议你做 IO 时的块大小,通常是 4096,两个数字一个是按什么粒度过账,一个是按什么粒度读写,名字长得像,作用完全是两回事,我第一次把它们当成一个东西用,算出来的占用空间小了 8 倍。(那个错误没造成事故,因为当时我只是想在监控里报个数字)

st_mode:一个整数装了四样东西

这是 stat 里最值得花时间的地方。

st_mode 是 32 位整数,实际只用低 16 位,像下面这样一位一位排开就清楚了,低 9 位是权限、中间 3 位是特殊位、高 4 位是类型,这个顺序没有为什么,就是历史这么定的。

1
2
15 14 13 12 | 11 10 9 | 8 7 6 | 5 4 3 | 2 1 0
[文件类型] | [特殊位] | [属主] | [属组] | [其他]

低 9 位是权限,三位一组,属主的 rwx、属组的 rwx、其他的 rwx,中间 3 位是特殊位,SUID、SGID、sticky,高 4 位是文件类型。

常见的类型取值一共就七个。

八进制 类型 ls -l 首字符
0100000 普通文件 -
0040000 目录 d
0120000 符号链接 l
0010000 命名管道 FIFO p
0140000 socket s
0020000 字符设备 c
0060000 块设备 b

判断类型的正确写法是掩码后比较。

1
if ((st.st_mode & S_IFMT) == S_IFREG) { /* 普通文件 */ }

S_IFMT 是 0170000 这个掩码,S_IFREG 是 0100000,不要直接写 st.st_mode == S_IFREG,那样权限位也参与比较,带执行权限的文件就判不出来了,这个错误 ls 自己不会犯,手写代码的人经常犯,因为你的测试文件刚好是 0644 的时候它能过,等你拿一个 0755 的可执行文件一测,就发现条件永远不成立。(我第一次写文件类型判断就是这么写的,测了一下午以为内核骗我)

stat 命令输出的那个十六进制 mode 就是 st_mode 原值。

1
stat -c '%f %a %A' /tmp/tiny.txt
1
81a4 644 -rw-r--r--

81a4 是十六进制的 st_mode,也就是八进制的 0100644,前三位 010 是普通文件,后面 644 是权限,%a 给八进制权限,%A 给人类可读的那一串。

三个时间戳,为什么会搞混

字段 名字 什么时候变
st_atime access time 读文件内容时
st_mtime modification time 文件内容改变时
st_ctime change time inode 改变时(权限、属主、链接数)

最容易记错的是 ctime,很多人以为它是 create time,不是,老的 struct stat 里根本没有创建时间这个字段,ctime 是 change time,改的是 inode,也就是说你 chmod 一下、chown 一下、多建一个硬链接,ctime 都会变,而文件的字节一个都没动,这个字段在排查时极其有用,ctime 变了而 mtime 没变,基本可以断定有人动了权限或者属主。

看个例子就明白。

1
2
3
4
touch /tmp/tiny.txt
stat -c '%y | %z' /tmp/tiny.txt # mtime | ctime
chmod 600 /tmp/tiny.txt
stat -c '%y | %z' /tmp/tiny.txt

touch 改的是 mtime(内容没变,但时间被设成现在),chmod 改的是 inode 里的权限位,所以只有 ctime 变了,mtime 一动不动。

atime 则是被严重调优过的一个字段,每次读文件都写一次 atime,等于每读一次多一次磁盘写,对性能是灾难,所以现代系统默认挂载参数是 relatime,只有当 atime 比 mtime/ctime 老,或者距上次更新超过 24 小时,才更新,noatime 更狠,完全不更新,这就是为什么你 cat 一个文件之后再 stat 它,atime 可能一点变化都没有,不是系统坏了,是设计如此,也正是这一条让我开头那个看一眼就知道什么时候改过的判断彻底站不住,我当时还怀疑过是 touch 把它盖掉了,翻出历史命令看了一遍,确认自己什么都没敲,才肯承认这个字段本来就是这样的。(relatime 从 2.6.30 起就是多数发行版的默认值,你在 findmnt 里看不到它显式出现,是因为它就是默认)

想确认自己的挂载参数。

1
findmnt -no OPTIONS /

那个数字是硬链接数

回到开头的问题二,ls -l 第二位是 st_nlink。

1
ls -l /tmp/tiny.txt | awk '{print $2}'
1
1

普通文件默认 1,ln 一个硬链接两边都变成 2,inode 与硬链接那篇讲了为什么这个数字能撑起删了文件空间不降的整条链路。

目录的 st_nlink 至少要 2,. 是一个指向自己的硬链接,父目录里的那条引用是另一个,所以空目录的 st_nlink 是 2,每加一个子目录就 +1。

1
mkdir -p /tmp/t/sub && ls -ld /tmp/t
1
drwxr-xr-x 3 alice alice 4096 Nov 13 20:45 /tmp/t

那个 3 等于 . 加父目录引用加 sub。

也正因为这样,你在根目录上跑 ls -ld / 会看到一个很大的数字,那不是文件数,是子目录数加 2,我第一次注意到的时候还以为是系统在骗我,明明只有十几个目录,怎么显示二十几。

st_size 和 du 报的不是一回事

这是第二个反直觉点。

1
2
stat -c '%s' /tmp/tiny.txt   # 逻辑大小 5
du -h /tmp/tiny.txt # 实际占用 4.0K

st_size 是 5 字节,文件内容的长度,实际占磁盘是 4096 字节,因为文件系统按块分配,最小块就是 4KB,5 字节也占一整块,du 报的是后者,也就是 st_blocks × 512,ls -s 也是。

稀疏文件是两个数字差得最离谱的地方。

1
2
3
truncate -s 1G /tmp/sparse.img
ls -l /tmp/sparse.img # 1073741824
du -h /tmp/sparse.img # 0

truncate 只改了文件长度这个数字,没有真的分配任何块,读出来全是零,这些零是内核现生成的,不占磁盘,所以你会看到一个 1G 的文件 du 报 0,这个特性不是漏洞,是设计,虚拟机镜像、数据库文件、容器镜像层都靠它省空间,只是第一次看到的时候很难相信,我当初以为 du 坏了,还专门 dd 读了一遍确认里面真的是零。

cp 这边容易误会,它默认是 --sparse=auto,会用一套粗启发式判断源文件稀不稀疏,是的话目标文件也照样写成稀疏的,不会凭空撑大,真会把它展开成实心的是 --sparse=never,所以复制稀疏镜像不需要额外参数,想要绝对保险(比如源文件的空洞分布在 auto 的启发式盲区里),再显式写 --sparse=always,或者用 rsync -S。(这个我踩过一次,一个镜像复制完把构建机的盘撑满了,后来加 --sparse=always 才正常,至于 auto 为什么没认出来,我也没深究)

把 Birth 那行找出来

1
stat /tmp/tiny.txt
1
2
3
4
5
6
7
8
  File: /tmp/tiny.txt
Size: 5 Blocks: 8 IO Block: 4096 regular file
Device: fd01h/64769d Inode: 2490934 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ alice) Gid: ( 1000/ alice)
Access: 2026-11-13 20:41:12.283941877 +0800
Modify: 2026-11-13 20:41:12.283941877 +0800
Change: 2026-11-13 20:41:12.283941877 +0800
Birth: 2026-11-13 20:41:03.780780556 +0800

最后那行 Birth 值得说一句,Linux 确实有创建时间,但它是后加的,statx 系统调用(4.11 起)才提供,老 stat 拿不到,所以你会看到两种情况,新版 stat 命令、支持 btime 的文件系统(ext4/xfs 都支持)、内核够新,三样凑齐才看得见这行,否则它根本不出现。(我第一次在别的机器上找不到这行,还以为是自己命令敲错了)

再看时间戳的三次变化。

1
2
3
4
5
6
7
echo hello > /tmp/t.txt
stat -c 'mtime=%y ctime=%z' /tmp/t.txt
sleep 1
cat /tmp/t.txt > /dev/null
stat -c 'mtime=%y ctime=%z' /tmp/t.txt
chmod 600 /tmp/t.txt
stat -c 'mtime=%y ctime=%z' /tmp/t.txt

第一次是创建,第二次 cat 只可能改 atime,而且 relatime 下很可能压根不改,第三次 chmod 只改 ctime。

最后看设备号。

1
stat -c '%d %i %h' / /tmp /dev/shm
1
2
3
2097301 2 25
2097301 2490934 3
24 1 1

第一列 st_dev,前两个都是 2097301,说明同一个文件系统,/dev/shm 是 24,说明它在 tmpfs 上,这就是为什么 /dev/shm 里的文件和 /tmp 里的文件做不了硬链接,硬链接不能跨文件系统,顺带说明一句,stat -c %d 打印的是内核内部编码值,不是 major:minor 那种格式,想看那一套,用 stat -c '%D' 拿十六进制,至于两者怎么换算,我每次用都得现查,从来没记住过。

ctime 不是创建时间

ctime 是创建时间?它是 inode 变更时间,创建时间叫 btime/Birth,只有 statx 提供。ls -l 的大小就是占的磁盘空间?那是 st_size,占的空间看 du 或 st_blocks × 512,稀疏文件和小文件占整块,都会让两者差很远。

st_blocks 的单位和 st_blksize 一样?st_blksize 通常 4096,但 st_blocks 的单位永远是 512 字节。stat 和 lstat 没区别?遇到符号链接就天差地别,stat 报目标,lstat 报链接本身,ls -l 用 lstat。

文件类型可以直接比 st_mode?必须先跟 S_IFMT 掩码。atime 每次读都更新?现代默认 relatime,一天最多更新一次,甚至更少。

常看的三个数

stat 把一个文件的全部属性一次给你,lstat 是专门看符号链接本身的那一个。

st_mode 是位域,高 4 位类型、中 3 位特殊位、低 9 位权限。

atime、mtime、ctime 分别对应读、改内容、改 inode,三者可以互相独立。

st_size 是逻辑长度,st_blocks × 512 是实际占用,两者经常不同。

Linux 有创建时间,但不在 stat 里,在 statx 里。

下一个问题是权限,644 和 755 谁都会用,但 4755、2775、1777 这三个数字干什么的,能说清的人少一半,还有一套东西叫 ACL,很多人吃过它的亏,尤其是那个 mask,你以为给了某个人读写权限,结果他被 mask 挡住了,查半小时才发现是另一行在管事。