Home LKL的增量构建
Post
Cancel

LKL的增量构建

本文基于 LKL(Linux Kernel Library)项目的 PR #635,解决lkl增量编译构建系统 bug,以及它的修复思路。

一、背景:LKL 与它的构建流水线

LKL(Linux Kernel Library)把 Linux 内核编译成一个用户态库(liblkl),让应用程序能直接链接内核、在进程内运行文件系统、网络栈等子系统。

lkl的构建入口是:

1
make -C tools/lkl

这条命令背后,涉及到对linux内核和对lkl新增的头文件的构建:

1
2
3
4
5
6
7
8
kernel 源码
   └─(顶层 kbuild)─> vmlinux            (PHONY 目标)
        └─ objcopy ─> lib/lkl.o
             └─ install ─> tools/lkl/lib/lkl.o
                  └─ headers_install.py ─> ~200 个 LKL 头文件(lkl_ 前缀)
                       └─ 每个宿主 .o
                            └─ liblkl.a
                                 └─ 各个 .so

这条链路有个对增量构建不友好的特性:vmlinux 在顶层 Makefile 里是 PHONY 的。 在Make构建系统中,一条正常的规则大概是:

1
2
foo.o: foo.c
	$(CC) -c foo.c -o foo.o

Make根据文件foo.o和foo.c的修改时间(mtime)判断是否应该执行这条命令重新生成foo.o,如果foo.c比foo.o更新,说明发生了源代码的修改,需要重新构建。 而PHONY声明的目标是假目标,不对应磁盘文件,make每次执行时都会无条件执行,而不考虑磁盘上文件的状态。

二、问题定位:两个并存的 bug

在这个PR之前,lkl的构建系统有两个问题:

1. 正确性问题:改内核源码不触发重编

tools/lkl/Makefilelib/lkl.o 的规则原本只依赖 bin/stat$(DOT_CONFIG)

1
2
3
4
# 修复前
$(OUTPUT)lib/lkl.o: bin/stat $(DOT_CONFIG)
	$(Q)export PATH="$(srctree)/tools/lkl/bin/:${PATH}" ;\
	$(MAKE) -C ../.. ARCH=lkl $(KOPT)

lib/lkl.o 的依赖列表里根本没有内核源码。于是一旦 .config 已存在,单纯修改某个内核 .c 文件,make 会认为 lib/lkl.o 已经是最新,递归的 kernel make 永远不会被唤起。

更阴险的是——宿主侧库(liblkl.a / .so)仍然会在”陈旧的 lkl.o“之上重新链接。链接本身成功了,于是这个问题被静默掩盖:你以为改了代码,其实跑的还是旧的。

这对于需要侵入式修改lkl内核代码的情况非常不友好,当认为修了bug重新跑了一下发现没有修好百思不得其解时发现没有自动重新编译,非常annoying,这就是为什么我要修掉这个bug。

2. 性能问题:无改动也要全量重编

反过来,即使什么都没改,每次 make 也几乎把整条链路跑一遍。原因是流水线上多个阶段无条件刷新了输出的 mtime

  • lkl.o 每次都用 objcopy 重新生成(覆盖写,mtime 必然更新);
  • syscall_defs.h 同理被重新抽取、sed 重写;
  • headers 安装阶段每次都把头文件重写一遍;
  • install 的 cp 不带 -p,目标 mtime 被刷新。

mtime 一变,下游(~200 个头文件 → 每个宿主 .o → liblkl.a → 每个 .so)的依赖链就全部判为”过期”,整条链在没有任何源码变化时也会重新触发。对一个大型 C 项目,这意味着每次 make 都要编译几百个文件。

三、核心原理:增量构建到底靠什么

要理解修复,先要厘清 Make / kbuild 的增量判断机制:

  • Make 的增量判断:目标 mtime 比任一依赖,就认为是最新;否则重跑编译构建。一切都建立在”依赖关系正确 + 输出 mtime 只在该重编时才变”这两个前提上。
  • kbuild 的 if_changed:内部用 newer-prereqs 比较”前置依赖里比目标新的文件”。但它有一个关键限制——会过滤掉 PHONY 先决条件。也就是说,如果一个规则的前置是 PHONY 目标,if_changed 会直接忽略它,不会因为这个前置”变了”而去重编。

vmlinux 恰恰是 PHONY。这就构成了本 PR 的核心矛盾:

下游产物(lkl.osyscall_defs.h)的真实输入是 vmlinux 的内容,但 vmlinux 是 PHONY,既不能用普通 mtime 依赖(永远”新”),也不能用 if_changed(会把 PHONY 过滤掉、静默跳过真实的内容变化)。

解决办法不是让 make 去”猜” vmlinux 变没变,而是在 recipe 内部做内容比较:重跑 objcopy 生成新内容到临时文件,和已有产物 cmp,只有真正不同才替换,否则保留原 mtime。

四、解决方案逐处拆解

PR 一共改了 4 个文件。

改动 1:tools/lkl/Makefile —— 加 FORCE 保证进入递归 make

1
2
3
4
# 修复后
$(OUTPUT)lib/lkl.o: bin/stat $(DOT_CONFIG) FORCE
	$(Q)export PATH="$(srctree)/tools/lkl/bin/:${PATH}" ;\
	$(MAKE) -C ../.. ARCH=lkl $(KOPT)

FORCE 是一个永远”过期”的伪目标,加进依赖后,lib/lkl.o 每次都会被认为需要更新,于是递归的 kernel make 一定会被唤起。至于 kernel 内部到底要重编哪些文件,交给 kbuild 自己按源码时间戳判断——这一步是正确且高效的,不用我们手动维护一长串内核源码依赖。

通过这一点,解决了增量编译的正确性问题:改任何内核源码,递归 make 都会跑,增量编译由 kbuild 完成。

改动 2:arch/lkl/include/uapi/asm/Kbuild —— 声明 generated-y

1
2
generated-y += config.h
generated-y += syscall_defs.h

syscall_defs.h 是 objcopy 从 vmlinux 里抽取出来生成的,不是手写头文件。如果不声明为 generated-yscripts/Makefile.asm-generic 会把它当成一个过期的 wrapper(包壳)头文件,在每次构建时 REMOVE 掉,使得 arch/lkl/Makefile 的 objcopy 规则每次都重新抽取它。

加上 generated-y 后,kbuild 知道这是”生成物”,不会再误删,于是这个抽取步骤在无变更时可以稳定跳过。

改动 3:arch/lkl/Makefile —— objcopy 到临时文件再 cmp

这里和改动4解决的都是性能问题,把 lkl.osyscall_defs.h 的规则从”直接覆盖生成”改成”生成到 .new 临时文件,cmp 后再决定替换还是丢弃”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
quiet_cmd_link_lkl = OBJCOPY $@
      cmd_link_lkl = $(OBJCOPY) $(if $(KEEP_EH_FRAMES),,-R .eh_frame) \
                     -R .syscall_defs \
                     $(foreach sym,$(LKL_ENTRY_POINTS),-G$(prefix)$(sym)) \
                     --prefix-symbols=$(prefix) $< $@.new && \
                     { if cmp -s $@.new $@; then rm -f $@.new; \
                       else mv -f $@.new $@; fi; }

lkl.o: vmlinux
	$(call cmd,link_lkl)

quiet_cmd_gen_syscall_defs = OBJCOPY $@
      cmd_gen_syscall_defs = $(OBJCOPY) -j .syscall_defs -O binary \
                             --set-section-flags .syscall_defs=alloc $< $@.raw && \
                             sed 's/\x0//g' $@.raw > $@.new && rm -f $@.raw && \
                             { if cmp -s $@.new $@; then rm -f $@.new; \
                               else mv -f $@.new $@; fi; }

arch/lkl/include/generated/uapi/asm/syscall_defs.h: vmlinux
	$(call cmd,gen_syscall_defs)

几个要点:

  • 为什么不用 if_changed:前面说过,vmlinux 是 PHONY,newer-prereqs 会把它过滤掉,导致真实的内容变化被静默忽略。所以这里手写 cmp 在 recipe 内部判断。
  • cmp -s $@.new $@ 的作用:内容相同就删掉临时文件、保留旧产物(mtime 不变);内容不同才 mv 覆盖。这样 vmlinux 内容没变时,lkl.o/syscall_defs.h 的 mtime 保持稳定,下游不会被无端重编。
  • install 的 cp 加 -p

    1
    
    @cp -p lkl.o $(INSTALL_PATH)/lib/
    

    -p 保留源文件的 mtime,让”保住 mtime”这个效果一路传播到 tools/lkl/lib/lkl.o,从而整条宿主侧链路都受益。

改动 4:arch/lkl/scripts/headers_install.py —— 两遍式安装 + 内容比较

头文件安装阶段也做了同样的”内容比较 + 保 mtime”处理,但用 Python 实现,并且分两遍:

第一遍 install_headers:先用 headers_install.sh 把头文件装到 .raw 临时文件,而不是直接装到最终目标。

1
2
3
4
5
6
raw = out_dir + "/" + os.path.basename(h) + ".raw"
ret = os.system(self.srctree+"/scripts/headers_install.sh %s %s" %
                (self.relpath2abspath(h), raw))
if ret != 0:
    sys.exit(1)
new_headers.add(raw)

第二遍 update_header:读取 .raw,生成 lkl_ 前缀化的最终内容,但只有内容真的变化时才写目标文件

1
2
3
4
5
6
7
8
9
def update_header(self, h):
    dst = h[:-len(".raw")]
    content = open(h).read()
    # ... 对 includes / defines / structs / unions 做 lkl_ 前缀替换 ...
    existing = open(dst).read() if os.path.exists(dst) else None
    if content != existing:
        print("  INSTALL\t%s" % dst)
        open(dst, 'w').write(content)
    os.unlink(h)

于是:无变更时头文件内容相同,目标文件不被重写、mtime 不变,依赖这些头文件的宿主 .o 也就不会重编。

后续小优化(commit 8ee6a43:先用 st_size 比较,大小不同直接跳过读取已有文件,只在大小相等时才做逐字节比较,避免无谓的 IO:

1
2
3
4
5
6
content = content.encode()
existing = None
if os.path.exists(dst) and os.stat(dst).st_size == len(content):
    existing = open(dst, 'rb').read()
if content != existing:
    open(dst, 'wb').write(content)

五、为什么用”内容比较”而不是”mtime 比较”

@tavip 建议:能不能把处理后头文件的 mtime 设成源头文件的 mtime,仅当源 mtime 更新时才重处理?

不行,因为 headers_install.py 处理一个头文件的输出,不只取决于该头文件本身

它的工作机制是:先扫描所有被选中的头文件,构建一个全局includes / defines / structs / unions 前缀集合,再统一给每个头文件里的这些符号加 lkl_ 前缀。

考虑这样的场景:

  • 头文件 A 里本来就有 struct foo * 这种”不完整/不透明”的引用,但当时没有任何头文件定义过 struct foo
  • 后来另一个头文件 B 新增了 struct foo 的真正定义;
  • 于是全局 struct 集合变了,A 里那个原本不需要前缀的 struct foo * 现在也该被加前缀

注意:A 的源文件一行没动,但它的处理输出应该变。这里实际上比较的不是源代码的内容,而是在比较当前最新的头文件重新生成的文件输出和已安装文件的内容: 看 update_header() 的真实流程(第 169–202 行):

1
2
3
4
5
6
def update_header(self, h):
    content = open(h).read()          # ① 重新读取 A 的源(.raw),永远执行
    ... # 用全局 self.structs 等给 A 套前缀,永远执行
    existing = open(dst).read() if os.path.exists(dst) else None
    if content != existing:           # ② 内容比较:只在这里做判断
        open(dst, 'w').write(content) # ③ 不同才写盘、更新 mtime

而如果使用A的mtime和已安装文件的mtime进行比较,就会出现漏报的情况,A的头文件没有重新输出。

六、修复效果

修复后:

  1. 正确性:编辑任何内核源码,都会经 FORCE 进入递归 make,由 kbuild 增量编译,正确传播到最后生成的库。
  2. 性能:无改动时 make 不再做宿主侧工作,只剩两次对 PHONY vmlinux 不可避免的 OBJCOPY。

PR 里给出的无操作(no-op)make 输出,正好用来对照:

1
2
3
4
5
6
7
8
make -C tools/lkl MMU=1 -j128
make: Entering directory '/path/to/linux/tools/lkl'
  CALL    scripts/checksyscalls.sh
  OBJCOPY lkl.o
  OBJCOPY arch/lkl/include/generated/uapi/asm/syscall_defs.h
make -C ../.. ARCH=lkl  install INSTALL_PATH=/path/to/linux/tools/lkl/
  INSTALL       linux/tools/lkl//lib/lkl.o
make: Leaving directory '/path/to/linux/tools/lkl'

注意:这里 OBJCOPYINSTALL 仍会打印,但因为用了 cmp / 内容比较,lkl.o 和头文件的 mtime 并未改变,于是下游几百个宿主 .oliblkl.a、各 .so 都不会被重新触发。

写在最后

上次更新博客还是两年以前,过去一段时间被项目和研究占满导致没有精力维护和更新博客. 然而,过去两年大模型出现了天翻地覆的发展,对我的工作流也产生了很大的影响。现在,原本需要自己动手完成的编码和debug工作,很多都交给了大模型来完成,我只需要给出一些思考和和方向;进度变快的同时,也导致很多时候其实并不了解底层的实现原理,是时候放慢一下脚步,整理一下思路和知识了。后续应该会更新更多的lkl和llvm相关的内容,或许还会有一些ai infra.

By the way, 这篇文章的初稿是混元3根据PR中讨论的内容整理出来的,看上去还不错,我只做了一些小的调整。腾讯似乎又要赶上第一梯队了。

This post is licensed under CC BY 4.0 by the author.