make 是如何实现监控源文件不变就不编译这样一个策略的?

靠的是文件时间戳 + 依赖图,不是“监控”。

make 并不会常驻盯着文件变化。它每次启动时,读取 Makefile,比较目标文件和依赖文件的修改时间,然后决定要不要执行规则。

最核心的逻辑是:

app: main.o util.o
	cc -o app main.o util.o
 
main.o: main.c
	cc -c main.c
 
util.o: util.c
	cc -c util.c

当你执行 make app 时,它会这么判断:

  1. 先看目标 app 是否存在 如果不存在,就构建。

  2. 如果 app 存在,就看它依赖的 main.outil.o 只要有任意一个依赖比 app 更新,app 就要重建。

  3. main.o 也是一样 如果 main.cmain.o 新,就重新编译 main.o

所以本质规则就是:

如果某个依赖文件的 mtime 比目标文件新,则目标过期,需要重建。

可以理解成伪代码:

build(target):
  for dep in dependencies(target):
    if dep needs rebuild:
      build(dep)
 
  if target does not exist:
    rebuild target
  else if any dep.mtime > target.mtime:
    rebuild target
  else:
    skip

这就是为什么它能做到“源文件没变就不编译”: 因为 .c 没变,.o 的时间戳就仍然比 .c 新,make 判断目标还是最新的,于是跳过。

有几个关键点。

第一,.PHONY 会禁用这套机制。 比如:

.PHONY: clean
clean:
	rm -f *.o app

clean 不是一个真实文件,所以每次都会执行。 如果你把正常构建目标也写成 .PHONY,那 make 就没法用时间戳优化了。

第二,依赖必须声明完整。 如果 main.o 实际还依赖 main.h,但你没写:

main.o: main.c main.h

那你改了 main.hmake 不知道,自然不会重编。 所以 C/C++ 项目常用 -MMD -MP 自动生成头文件依赖。

第三,这不是内容比较,是时间比较。 make 通常不看文件内容是否真的变了,只看修改时间。哪怕你把文件内容改回去,只要时间更新了,通常也会触发重建。

第四,时钟和拷贝会影响结果。 比如:

  • cp 重新拷贝文件可能更新时间
  • Git checkout 可能改变 mtime
  • 不同机器时钟不一致会导致误判

所以它是个简单高效的策略,不是绝对精确的内容感知策略。

你问的“监控”如果更准确地说,应该是:

make 通过声明依赖关系,在执行时基于文件修改时间做增量构建判断。

不是:

  • 文件系统 watcher
  • 常驻 daemon
  • 内容 hash 检测

如果你愿意,我可以接着给你画一张 make -> 递归检查依赖 -> 比较时间戳 -> 决定是否重编 的流程图。