本文要点
stat一次给出四个时间戳:Access(atime)、Modify(mtime)、Change(ctime)、Birth(创建时间 btime)。实测新建文件时四者完全相同,但它们的用途和可信度完全不同touch -d能把 mtime 改成2020-01-01,ctime 却留在当下:ctime 是「inode 最后变更时间」,改权限、改属主、改名都会刷新它,而且无法被伪造——这正是判断文件有没有被动过的关键证据- 默认的
relatime并不是「每次读都更新」:实测首次读会更新 atime,24 小时内第二次读不更新,文件被修改后下一次读又会更新;strictatime才每次读都更新,noatime则永不更新 find -mtime -1会漏掉 25 小时前的文件——它按整数天截断,25 小时算 1 天,而-1要求「不足 1 天」;要精确到小时得用-mmin -1560或-newermt '30 hours ago'cp默认把 mtime 重置为当下,只有cp -p、touch -r、tar解包才保留原值;ls -l/-lu/-lc分别显示 mtime / atime / ctime 三列
Linux 文件时间戳:atime、mtime、ctime 与 find 时间筛选
「这个文件到底是什么时候被改的?」——备份脚本要不要重跑、日志能不能删、find 该筛哪一批、上线包有没有被替换过,最后都会落到这个问题上。而回答它的第一道坎,是很多人把 ls -l 里那一列时间当成了「修改时间」,却不知道那其实是 mtime,也不知道系统其实记了四个时间。
第二个坎是 find 的时间筛选。-mtime -1 看起来是「最近一天」,跑出来的结果却总少几个文件;换 -mtime +1 又多了不该有的。这不是 find 有 bug,而是它的时间单位被截断成整数天了。
本文在一台 Ubuntu 22.04 云服务器(KVM,1 核、1.9G 内存,内核 5.15.0-30-generic,机器时区为 UTC)上把这两件事跑了一遍:先用 stat 看清四个时间戳,再用 touch 伪造一次 mtime,看 ctime 为什么骗不了人;接着用 loop 挂载的 ext4 镜像分别以 relatime / strictatime / noatime 实测 atime 的更新时机;最后用三个不同年龄的文件验证 find 的整数天陷阱与三种精确写法。文中命令与输出均为真实执行结果。
第一步:stat 一次看全四个时间戳
先造一个文件,然后用 stat 看它:
cd /mnt/tsdemo && stat demo.txt
四个时间戳的含义差别很大,值得逐一对上号:
| 字段 | 名称 | 什么时候变 | ls 里怎么看 |
|---|---|---|---|
Access | atime | 文件内容被读取时(受挂载选项限制,见下节) | ls -lu |
Modify | mtime | 文件内容被写入时(echo >>、编辑器保存) | ls -l(默认这一列) |
Change | ctime | inode 变更时:内容、权限、属主、文件名、链接数 | ls -lc |
Birth | btime | 文件创建时间,之后不再变(ext4 的 crtime) | 无(ls 不显示) |
注意截图里四项时间一模一样——因为这是一个刚 echo 出来的新文件,创建、写入、inode 变更、首次读取挤在了同一个瞬间。这恰恰说明:想区分它们,必须让文件经历「先创建,再被改,再被读」的不同阶段。
另外注意 Birth 这一行:它来自内核的 statx 接口,需要文件系统支持(ext4 从内核 4.11 起提供),stat 版本较老时不会显示这一行。它的价值在于它是唯一一个「只能被创建、无法被修改」的时间戳——crtime 在 inode 里落定后就再没有系统调用能改它(debugfs 之类的离线工具除外),所以判断「这个文件是不是那个时间点出生的」只能看它。
第二步:伪造一次 mtime,看 ctime 怎么拆穿
touch -d 可以随意指定时间,改的是 atime 和 mtime。那改完之后,能不能看出文件被动过手脚?把 mtime 直接改成 2020 年试试:
date -u '+现在 : %F %T'; cd /mnt/tsdemo && chmod 640 demo.txt && touch -d '2020-01-01 08:00:00' demo.txt && stat --printf='mtime : %y\nctime : %z\nbirth : %w\n' demo.txt
结果很直白:
mtime成功伪装成2020-01-01 08:00:00;ctime是当下(2026-09-11 21:09:54)——因为touch写元数据这件事本身就变更了 inode;birth停在文件真正被创建的那一刻(21:09:40),一动不动。
所以「文件时间对不对」要有两个层次的理解:mtime 是可以被任何程序随手改的,备份软件、解压工具、rsync -t、甚至 git checkout 都会按自己的需要设置 mtime,拿它当审计证据并不可靠;而 ctime 是内核自动维护的、应用层改不了的,只要 inode 有任何变更它就会前进。想确认一个文件「最近有没有被动过」,看 ctime 比看 mtime 靠谱得多。
⚠️ 顺带说清一个常见误解:ctime 不是「创建时间」(Create time 的错觉)。它是 Change time,文件 20 年前创建、今天改一次权限,ctime 就是今天。真正的创建时间是 Birth。
第三步:atime 到底什么时候更新?三种挂载策略实测
atime 最容易让人误判,因为它不是每次读都更新——具体行为由文件系统的挂载选项决定,而大多数发行版默认给的是 relatime。
要干净地对比三种策略,用一个 loop 挂载的 ext4 镜像最方便,互不干扰:
dd if=/dev/zero of=/opt/ts.img bs=1M count=30 status=none
mkfs.ext4 -q -F /opt/ts.img
mkdir -p /mnt/tsdemo
mount -o loop,relatime /opt/ts.img /mnt/tsdemo然后在每种策略下做同一套动作:创建文件 → 读一次 → 隔 1 秒再读一次 → 追加内容(改 mtime)→ 再读一次,每次都记录 atime 的 epoch:
run_case() {
opt=$1
mount -o loop,$opt /opt/ts.img /mnt/tsdemo
cd /mnt/tsdemo
echo hello > f.txt
t0=$(stat -c %X f.txt)
sleep 1.1; cat f.txt >/dev/null; t1=$(stat -c %X f.txt)
sleep 1.1; cat f.txt >/dev/null; t2=$(stat -c %X f.txt)
sleep 1.1; echo hi >> f.txt; cat f.txt >/dev/null; t3=$(stat -c %X f.txt)
echo "[$opt] 创建后=$t0 读1=$t1 读2=$t2 改mtime后读=$t3"
cd /; umount /mnt/tsdemo
}
run_case relatime
run_case strictatime
run_case noatime[relatime] 创建后=1789161063 读1=1789161064 读2=1789161064 改mtime后读=1789161066
[strictatime] 创建后=1789161066 读1=1789161067 读2=1789161068 改mtime后读=1789161069
[noatime] 创建后=1789161069 读1=1789161069 读2=1789161069 改mtime后读=1789161069整理成表格更清楚:
| 挂载策略 | 第一次读 | 24 小时内再读 | 文件被修改后再读 | 开销 |
|---|---|---|---|---|
relatime(默认) | ✅ 更新 | ❌ 不更新 | ✅ 更新 | 低,只在必要时写一次 |
strictatime | ✅ 更新 | ✅ 更新 | ✅ 更新 | 每次读都写 inode |
noatime | ❌ 不更新 | ❌ 不更新 | ❌ 不更新 | 无 |
relatime 的规则可以概括成一句话:当 atime 比 mtime 或 ctime 旧,或者已经超过 24 小时没更新时,才写一次。新创建的文件三者相等,所以第一次读就会更新 atime;此后 24 小时内再读,条件不满足,就不再写——这正是上面「读1 变了、读2 没变」的原因。而追加内容让 mtime 前进了,atime 立刻又「比 mtime 旧」,于是下一次读又更新。
两个实测踩到的坑:
- 同一时钟滴答内的读取看不到变化。ext4 的时间戳精度约 4ms,两次读挨得太近时 atime 的秒数根本不变,会让人误以为「没更新」。所以上面每次读之间都
sleep 1.1隔开——排查 atime 问题时,先把时间拉开再比较。 mount -o remount,relatime清不掉noatime。测试时我从noatime切回relatime用过 remount,结果 atime 依旧不动:remount 是叠加新选项,不会移除已经生效的noatime。要换策略只能先cd /(shell 的当前目录在挂载点里时umount会报target is busy),umount之后再带显式选项重新mount。
另外,截图里的 strictatime 那行 mount 选项只显示 rw 而没有 strictatime 字样——因为它是内核的默认值,/proc/mounts 不会把默认值单独列出来,别以为没生效。
第四步:find 的整数天陷阱
先造三个年龄不同的文件(用 touch -d 直接指定 mtime),然后做最直觉的那个查询:
cd /mnt/tsdemo/find-demo; ls -l --time-style=long-iso; echo '== find -mtime -1 =='; find . -name '*.txt' -mtime -1 -printf '%f\n' | sort
三个文件的年龄分别是 1 小时、10 天、25 小时。-mtime -1 的直觉含义是「最近一天」,结果却只吐出了 new-1h.txt——那个 25 小时前的 trap-25h.txt 被悄悄漏掉了。
原因是 -mtime 的时间单位是整数天,且向零截断:25 小时 ÷ 24 = 1.04,截断后是 1 天;而 -n 的语义是「不足 n 天」,-1 要求截断值 < 1,也就是必须为 0。所以 -mtime -1 实际筛的是「不足 24 小时」,25 小时正好落在门外。
把几种写法放在一起对比(同一个目录、同一批文件):
| 表达式 | 命中 | 实际语义 |
|---|---|---|
-mtime -1 | new-1h.txt | 截断后不足 1 天,即 < 24 小时 |
-mtime 0 | new-1h.txt | 截断后等于 0 天,同样是 < 24 小时 |
-mtime +1 | old-10d.txt | 截断后大于 1 天,10 天的命中,25 小时的不命中 |
-mmin -1560 | new-1h.txt、trap-25h.txt | 按分钟算,26 小时内 |
-newermt '30 hours ago' | new-1h.txt、trap-25h.txt | 直接和「30 小时前」这个时间点比 |
-newer old-10d.txt | new-1h.txt、trap-25h.txt | 比参照文件 old-10d.txt 新 |
结论很实用:-mtime 只适合「天」级别的粗筛,一旦边界要求精确(清理 24 小时前的日志、捞最近 6 小时的备份),就换成 -mmin(按分钟)或 -newermt(按时间点,可读性最好)。-newer 则适合「比某个基准文件新」的场景,比如只同步比上次快照更新的文件。
还有两个细节:
- 目录本身也会被匹配。
find . -mtime -1会把当前目录.一起列出来(目录的 mtime 是它最近一次增删文件的时刻),所以要么加-type f,要么像截图里那样用-name '*.txt'限定。 -newermt的绝对时间按本地时区解释。这台演示机是 UTC(date带+0000),所以-newermt '30 hours ago'这种相对时间怎么写都安全;但写成-newermt '2026-09-10 20:00'这种绝对时间时,find会按机器的本地时区去理解它——跨时区排查(比如日志是 UTC、机器是 CST)时,这里差 8 小时就是差一批文件。
第五步:复制、打包之后 mtime 还在吗
把同一个文件用四种方式复制/打包一遍,看 mtime 谁守住了:
echo data > src.txt
touch -d '2020-01-01 08:00:00' src.txt
cp src.txt plain.txt
cp -p src.txt pres.txt
touch -r src.txt reffile.txt
tar cf t.tar src.txt; mkdir out; tar xf t.tar -C out
stat -c '%n mtime=%y' src.txt plain.txt pres.txt reffile.txt out/src.txtsrc.txt mtime=2020-01-01 08:00:00.000000000 +0000
plain.txt mtime=2026-09-11 21:11:13.341318045 +0000
pres.txt mtime=2020-01-01 08:00:00.000000000 +0000
reffile.txt mtime=2020-01-01 08:00:00.000000000 +0000
out/src.txt mtime=2020-01-01 08:00:00.000000000 +0000| 动作 | mtime | 说明 |
|---|---|---|
cp | ❌ 重置为当下 | 复制是「新建文件」,mtime 就是写入时刻 |
cp -p | ✅ 保留 | -p = --preserve=mode,ownership,timestamps |
touch -r 参照文件 | ✅ 保留 | 把参照文件的时间戳整套盖过来(含 atime) |
tar 打包再解包 | ✅ 保留 | tar 默认记录并还原 mtime(这正是增量备份的基础) |
这解释了为什么「迁移完数据,备份脚本却把全量文件又传了一遍」——中间用了不带 -p 的 cp,所有 mtime 都变成了迁移那一刻,增量判断自然全部失效。同理,rsync 要保住时间戳得带 -t(-a 已含),scp 想保住得用 -p。归档类工具的细节可参考 Linux tar 打包与压缩 里增量备份那部分。
第六步:ls -l、-lu、-lc 分别看的是哪个时间
同一个文件,三条命令三个时间——因为默认那一列只是 mtime:
ls -l --time-style=long-iso src.txt
ls -lu --time-style=long-iso src.txt
ls -lc --time-style=long-iso src.txt-rw-r--r-- 1 root root 5 2020-01-01 08:00 src.txt
-rw-r--r-- 1 root root 5 2026-09-11 21:11 src.txt
-rw-r--r-- 1 root root 5 2026-09-11 21:11 src.txt三行分别是 -l(mtime,2020 年那个伪造值)、-lu(atime,刚被 cp 读过所以是当下)、-lc(ctime,touch -d 改元数据时刷新成当下)。atime 和 ctime 在这台机器上撞成了同一分钟,属正常现象——它俩恰好都被同一批操作触发了。
习惯上 ls -l 用得最多,但要提醒一句:ls -l 那一列不是「文件创建时间」。很多人拿它当创建时间用,看到老文件的 mtime 是最近几天就以为文件是新的,其实可能只是被人 cp 过、或者解压时重新落了盘。真要创建时间,用 stat 的 Birth;要「有没有被动过」,看 ctime。
另外 ls 还有两个冷门但好用的开关:-lu 看 atime 能帮你找出「被读过但没被改过」的文件(比如排查某个配置到底有没有被加载),--time-style=long-iso 则让时间变成 2026-09-11 21:11 这种好读的固定格式,比默认的「Sep 11 21:11」更适合贴进排查记录。
时间戳排查清单
把上面这些压成一张可以照着敲的表:
| 想做什么 | 命令 | 要点 |
|---|---|---|
| 看全四个时间戳 | stat 文件 | Access/Modify/Change/Birth 四行 |
| 判断文件有没有被动过 | stat -c '%z' 文件 | 看 ctime,它无法被应用层伪造 |
| 看真正的创建时间 | stat -c '%w' 文件 | 即 Birth,需要 ext4 + 较新 coreutils |
| 看某文件最近有没有被读 | ls -lu 文件 | 注意 relatime 下 24 小时内可能不更新 |
| 精确筛最近 N 小时 | find . -type f -mmin -360 | 别用 -mtime,它按整数天截断 |
| 按时间点筛 | find . -type f -newermt '30 hours ago' | 可读性最好;绝对时间按本地时区解释 |
| 只同步比基准新的文件 | find . -type f -newer 基准文件 | 增量备份常用 |
| 复制时保住时间戳 | cp -p / touch -r / tar | 不带 -p 的 cp 会把 mtime 重置 |
| 换 atime 策略 | cd / 后 umount + mount -o loop,<策略> | remount 清不掉 noatime |
时间戳的坑说到底集中在两点:把哪个时间当成哪个时间,以及把天当成小时。前者让你误判文件的历史(拿 mtime 当创建时间、拿 ctime 当创建时间),后者让你漏掉本该处理的文件(-mtime -1 漏掉 25 小时前的日志)。想再往深处走,atime/mtime 都存在 inode 里,理解 inode 的结构可以看 Linux 软链接与硬链接;挂载选项与文件系统选型的完整清单见 Linux 文件系统:从 ext4 到 XFS;如果问题是「时间根本不对」而不是「时间戳含义不清」,那就是时钟同步的事了,见 Linux 时间同步;文件查找的其它用法(按名字、大小、权限组合筛选)见 Linux 文件查找:find 与 locate。
清理演示产物
演示只产生一个 30MB 的 loop 镜像和几个空文件,确认后删掉即可:
cd / && umount /mnt/tsdemo
rm -f /opt/ts.img
rm -rf /mnt/tsdemo收个尾:四个时间戳里,mtime 是给工具用的(备份、同步、构建系统都靠它判断新旧),atime 是给内核做缓存决策用的(所以默认的 relatime 才要省着写),ctime 是给排查用的(应用层改不了),Birth 是给归档用的(记录了文件真正的出生时刻)。分清楚「谁维护、谁可信、什么时候变」,stat 和 find 这两个命令就不会再给出让你意外的答案。
评论 (0)
暂无评论,快来抢沙发吧!