1. 报错现场和第一反应
如果你也跟我一样,在编译 LLVM 的时候撞上这一行——collect2: fatal error: ld terminated with signal 9 [Killed]——先别急着怀疑源码或者编译器坏了。我最近在一台内存只有 4G 的小机器上交叉编译 LLVM 15.0.7,make -j4 跑了二十多分钟,所有编译目标差不多都结束了,最后链接 clang 可执行文件的时候,屏幕突然蹦出这条,终端好像被什么东西掐了一下,构建直接中断。
这个报错我踩过不止一次。第一次遇到时还以为是 ld 被某个脚本杀了,翻了不少资料,最后在 dmesg 里看到真正的元凶:内核的 OOM killer 把 ld 进程 Killed 了。简单说,不是 LLVM 源码有 bug,也不是 ld 本身坏了,而是物理内存加交换分区加在一起也不够用了。围绕 LLVM、编译报错、collect2、ld、signal 9 这几个关键词,网上能找到很多零散回答,但能一次性讲清原理、排查链路和解决方法的并不多,所以我把自己的实战过程整理出来。
1.1 这行报错到底是谁在报
很多刚接触 LLVM 源码编译的人会误以为 collect2 是某个编译器插件,其实它是 GCC 内部的一个链接辅助程序。GCC 在编译完目标文件之后,并不会直接调用 ld,而是先启动 collect2,由 collect2 去收集构造函数、析构函数等全局信息,再在背后调用真正的系统链接器 ld。
所以当你看到 collect2: fatal error: ld terminated with signal 9 [Killed],实际发生的是:collect2 启动了子进程 ld,但 ld 在链接过程中被外部信号杀掉了。collect2 从子进程退出状态里发现 ld 是被信号 9 终止的,于是把这条错误打出来。
为什么不是 ld: fatal error 直接报?因为 ld 根本没机会自己写错误消息,它不是正常退出,而是被强制杀死。这也是这个报错最迷惑人的地方:别人看到的错误信息包含具体文件、符号、内存不足等提示,而你的日志里只有孤零零一行 [Killed]。
1.2 signal 9 是“杀”,不是“崩”
signal 9 就是 SIGKILL,这条信号的特点是:进程不能拦截、不能忽略、不能优雅清理。无论是 kill -9 12345,还是内核 OOM killer 选择某个进程作为牺牲品,最终表现都一样——进程瞬间终止,不留任何 handler 执行机会。
如果链接器是自身逻辑错误或者遇到畸形输入,大概率会收到 signal 11(段错误)或者 signal 6(中止),而 signal 9 意味着有人在外部强制终止了它。在绝大多数编译 LLVM 的场景里,这个“外部”就是 Linux 内核的内存保护机制,也就是大家常说的 OOM killer。
OOM killer 筛选“牺牲品”时,一般会优先挑占用内存大、oom_score 高的进程。在链接阶段,ld 的 RSS 能轻松跑到 1GB 到 3GB 以上,一旦系统内存耗尽,它几乎每次都是第一个被盯上的目标。
1.3 先看一眼这几个最直接的证据
遇到 [Killed] 时,我建议先做三个动作,而不是马上换参数重编:
bash复制sudo dmesg -T | grep -Ei 'oom|killed process'
bash复制free -h
bash复制journalctl -k --since "10 minutes ago" | grep -Ei 'oom|killed'
第一段命令能直接看到内核有没有记录 OOM 事件。第二段命令看当前内存和 swap 剩余量。第三段命令是某些没有 dmesg 权限的发行版上的替代方案。
一般只要输出里有 Killed process 1234 (ld) ...,就可以把问题定性为内存不足。这时再往下查,比反复重编要高效得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内存压力从哪来:LLVM 链接阶段的内存模型
很多人第一次编 LLVM 时会很困惑:我的机器有 8 核 CPU,make -j8 编译其他项目都很正常,为什么一到 LLVM 就崩?原因在于 LLVM 的链接阶段对内存的消耗是“脉冲式”的,而不是均匀的。
2.1 为什么 LLVM 的链接比普通 C 项目更耗内存
普通 C 项目编译出来是一堆很小的 .o 文件,链接成一个可执行文件时,符号量有限,ld 的工作量不大。但 LLVM 不一样,clang 可执行文件本质上要链接十几个大型静态库,像 libLLVMCore.a、libLLVMCodeGen.a、libLLVMTarget.a、libclangAST.a 等,每一个库都有成百上千个对象文件。
系统链接器 ld 要读取这些 .a 库中的符号索引和重定位信息,构建全局符号表、节区布局、重定位表,最后生成可执行文件。这些表在内存中的大小并不等于二进制文件大小,而是会在链接过程中被放大很多倍。以 LLVM 15 为例,Release 版 clang 的二进制可能只有几百 MB,但链接这个二进制时,ld 的峰值内存经常超过 2GB,如果开了 LTO,直接奔着 4GB 以上去。
2.2 并行任务会放大峰值,不是平均值
make -jN 里的 N 并不限制并发任务的类型,编译器和链接器都会按队列启动。编译阶段每个 cc1plus 进程大约吃 200MB 到 500MB,如果 N=8,光编译器这块可能就有 3GB 到 4GB。链接阶段更夸张,单个 ld 就可能吃掉 1GB 到 3GB,如果同时出现两个链接任务,内存峰值会立刻翻倍。
这就是为什么你用 top 看时,CPU 使用率不高,内存却一直在涨,然后某刻突然崩掉。Ninja 比 Make 的调度更贪心,它会在资源允许时尽量并行启动任务,所以只要内存不足,Ninja 比 Make 更容易触发 OOM。
2.3 相关热搜词里容易混淆的几个相邻问题
我顺手看了下跟这个报错一起出现的几个热词,有不少其实是另一类问题,容易被搜索引擎带到一起。
llvmpipe (llvm 15.0.7, 256 bits) 是 Mesa 里软件渲染器运行时打出来的描述,它用的是 LLVM 的 JIT 引擎,跟编译 LLVM 时的链接错误没有直接关系,只是名字里都有 LLVM。qt空工程编译一堆报错 可能是因为 Qt Creator 调用了并行构建,子任务太多触发了 OOM,也可能是工具链本身有问题。ld: unrecognised emulation mode: aarch64linux 是交叉编译时链接器不认目标架构,通常需要安装对应的交叉 binutils,或者改用 ld.lld。ld does not support --fix-cortex-a53-843419 则是旧版 GNU ld 不认某个 ARM 修复参数,常见于 GCC 和 binutils 版本不配套的交叉工具链。
这些报错看起来都带 ld,但成因完全不同,排查时要先核对自己的完整错误文本,不要往上套。
2.4 用 dmesg 把“凶手”钉死
有一次我在容器里编 LLVM,报错形式和本机一模一样,但宿主机上 dmesg 能看到,容器里却看不到完整信息。后来我在容器里查了 cgroup 的内存事件才确认。
bash复制cat /sys/fs/cgroup/memory.events
输出里 oom_kill 那一项如果大于 0,说明这个 cgroup 内确实发生过 OOM 终止。再看:
bash复制cat /sys/fs/cgroup/memory.max
这个文件写着容器或服务能用的最大内存,如果只有 4G,而宿主机内存是 32G,问题就很清楚了。你以为是“机器内存不够”,其实是“容器限额不够”。
3. 排查链路:从 Killed 到确认根因
报错只有一行,排查却要按链路走完。我把自己的排查套路写成一个可复用的过程,方便下次直接照着做。
3.1 确认 OOM killer 是否真的介入
最优先的动作永远是查内核日志。在桌面 Linux 上直接:
bash复制sudo dmesg -T | grep -Ei 'out of memory|oom|killed process'
如果看到类似这样的记录:
code复制[Mon Jan 23 10:25:11 2025] Killed process 31415 (ld) total-vm:5123456kB, anon-rss:2145678kB, file-rss:3456kB, shmem-rss:0kB
那基本实锤。注意 total-vm 是虚拟内存,anon-rss 是真正的匿名物理内存,链接器死前占了约 2GB 物理内存。
如果 dmesg 没有输出,试试:
bash复制journalctl -k --since today | grep -Ei 'oom|killed'
还有一种是 systemd 服务或容器环境,OOM 事件可能没进内核 ring buffer,而是被记录在 cgroup 的 memory.events 里,或者直接被上层编排系统吞了。这种情况下,看 memory.events 是第二可靠的路径。
3.2 看内存和交换分区:free、CommitLimit
确认 OOM 后,再看当前系统还剩多少内存:
bash复制free -h
重点关注 available 这一列,它不是 free + buff/cache 那么简单的换算。如果 swap 是 0,或者只剩几百 MB,那么编译进入链接阶段后大概率出问题。
还可以看内存提交量:
bash复制grep -E 'CommitLimit|Committed_AS' /proc/meminfo
Committed_AS 表示所有进程承诺要使用的内存总量,CommitLimit 是系统允许的提交上限。如果 Committed_AS 已经接近或超过 CommitLimit,即使物理内存看起来还有剩余,大块内存申请也可能会失败或被 OOM 盯上。
3.3 容器里容易被忽略的 cgroup 限制
云服务器和 CI 环境里最常见的情况是,free -h 看到的是宿主机内存,而你的进程真正能用的只有 cgroup 限制值。
先看当前进程所在 cgroup 的内存上限:
bash复制cat /proc/self/cgroup
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.events
如果 memory.max 是 4294967296,换算下来就是 4G,而你构建配置又是 make -j8,那 OOM 几乎是必然。有些 CI 平台把编译任务封装在 Docker 容器里,容器内存限制独立于宿主机,外层 free -h 再大也救不了你。
3.4 再看链接临时文件和 ld 的行为
少部分情况下,ld 被杀不是因为内存峰值,而是因为 TMPDIR 指向的临时目录写满了,导致进程在某种内部错误之后被外部监控杀掉。这种情况少见,但值得排查。
bash复制df -h "$TMPDIR"
du -sh /tmp/cc* 2>/dev/null | tail
collect2 在链接过程中会生成并删除临时文件,如果 /tmp 很小而且同时有多个链接任务,临时文件可能堆积到上 GB。磁盘满通常直接报 No space left on device,但某些封装脚本会在进程异常时直接 kill -9,最终也会表现为 signal 9。
不过从我遇到的情况看,99% 还是内存问题。这一步可以放在最后查。
3.5 一份可直接复用的排查模板
这里整理成一张表,按顺序执行:
| 目的 | 命令 | 判断依据 |
|---|---|---|
| 查内核 OOM 记录 | sudo dmesg -T | grep -Ei 'oom|killed process' |
出现 Killed process ... (ld) |
| 查系统内存 | free -h |
available 低于 1G 就很危险 |
| 查交换分区 | swapon --show |
无 swap 时,峰值容易撞墙 |
| 查内存提交量 | grep -E 'CommitLimit|Committed_AS' /proc/meminfo |
Committed_AS 接近 CommitLimit |
| 查容器限制 | cat /sys/fs/cgroup/memory.max |
上限小,则外部 free 无参考价值 |
| 查 cgroup OOM 事件 | cat /sys/fs/cgroup/memory.events |
oom_kill 记录大于 0 |
| 查临时目录 | df -h "$TMPDIR" |
剩余空间少于 1G 需注意 |
这套查完,你基本能确定问题究竟出在物理内存、swap、容器 cgroup 还是临时目录。
4. 真正管用的解决办法
定位到 OOM 之后,解法并不玄乎。我按优先级从高到低排列,你可以根据自己的机器配置选组合。
4.1 最优先:降低并行度
不要盲目用 make -j$(nproc)。对于内存有限的机器,降低并发是成本最低、见效最快的办法。
bash复制make -j1
如果觉得 -j1 太慢,可以先试 -j2:
bash复制make -j2
使用 Ninja 时同理:
bash复制ninja -j2
有人会觉得 8 核机器只开 2 个任务太浪费,但你要知道,任务一旦触发 OOM,前面编译的成果虽然不会丢,但链接阶段反复失败,浪费的时间更多。-j1 不是最终目标,只是保底手段。我一般会在 4G 内存机器上用 -j2,8G 内存上用 -j4,但一旦出现 [Killed],先降到 -j1 把整个构建跑通,再慢慢往上加。
4.2 从源头减负:关掉 LTO、减少调试符号
LLVM 的内存峰值很大一部分来自附加的构建选项,尤其是 LTO。LTO 会把所有中间表示合并到一起去优化,链接阶段内存消耗会暴涨。如果你不是专门测试 LTO 构建,建议明确关闭:
bash复制cmake -S llvm-project/llvm -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_ASSERTIONS=OFF \
-DLLVM_ENABLE_LTO=OFF \
-DLLVM_ENABLE_PROJECTS="clang;lld" \
-DLLVM_TARGETS_TO_BUILD="X86"
-DCMAKE_BUILD_TYPE=Release 会去掉大量调试信息,省下的内存很可观。Debug 构建里 DWARF 调试信息会占用大量内存和磁盘,链接 clang 时尤其明显。如果你不是要调试 LLVM 本身,千万别用 Debug 模式硬编。
还有一个容易被忽略的参数是这个:
bash复制-DLLVM_PARALLEL_LINK_JOBS=1
它专门限制链接任务数,即使编译任务可以多路并行,同一时间也只跑一个链接。这个参数在 LLVM 的 CMake 构建系统中很实用,比单纯降 -j 更精细。可以配合:
bash复制-DLLVM_PARALLEL_COMPILE_JOBS=2
分开控制编译和链接并发。
4.3 换用 lld,少了一半链接内存
如果你的系统里已经有 lld,让它替掉 GNU ld 是最爽的方案。lld 的内存占用通常比 GNU ld 低不少,链接速度也更快,这就是 LLVM 项目自己为什么要开发 lld 的原因之一。
先安装:
bash复制# Debian/Ubuntu
sudo apt install lld
# RHEL/Fedora/CentOS
sudo dnf install lld
然后在 CMake 配置里加上:
bash复制cmake -S llvm-project/llvm -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" \
-DCMAKE_SHARED_LINKER_FLAGS="-fuse-ld=lld" \
-DLLVM_ENABLE_PROJECTS="clang;lld" \
-DLLVM_PARALLEL_LINK_JOBS=1
这里有个小坑:如果你还没有可用的 ld.lld,使用这个方案之前必须先装好 lld。如果你正在从源码编译 LLVM,并且 LLVM_ENABLE_PROJECTS 里带了 lld,但系统里没有现成的 ld.lld,在第一次链接 host 工具时就会因为找不到 ld.lld 而失败。稳妥的做法是先装系统包里的 lld,再让它去编源码里的 lld。
4.4 加 swap 可以救急
如果不想改构建参数,加 swap 是最快速的临时缓解手段。虽然 swap 慢,但能让进程熬过瞬时峰值。
bash复制sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
注意不是所有文件系统都支持 fallocate,不支持时可以改用 dd:
bash复制sudo dd if=/dev/zero of=/swapfile bs=1M count=8192
加完 swap 后,再搭配 -j2 或 LLVM_PARALLEL_LINK_JOBS=1,大部分 4G 内存机器都能把 LLVM 编完。不要依赖 swap 解决所有问题,它只是在内存不够时给 OOM killer 一个缓冲,如果 swap 本身也被填满,该崩还是会崩。
4.5 其他“相关报错”的解决思路
如果你搜过来是因为类似但不一样的报错,这里顺手给结论。
遇到 ld: unrecognised emulation mode: aarch64linux,通常是交叉编译时调用了宿主机 ld,而宿主机 ld 不支持目标架构。可以安装 binutils-aarch64-linux-gnu,或者用 clang --target=aarch64-linux-gnu -fuse-ld=lld 绕开 GNU ld 的 emulation 限制。
遇到 ld does not support --fix-cortex-a53-843419,多半是 binutils 版本太旧,升级 binutils 即可;也可以使用新版 lld,它能识别这个参数。
遇到 qt空工程编译一堆报错,先看是不是 Qt Creator 里构建并行度设置太高,再检查编译器和链接器是不是被系统限内存的工具包装了一层。这类问题只要把 jobs 调低,通常能排除一大部分。
5. 为什么 LLVM 项目特别容易撞上这个错
同样的内存容量,编译普通项目可能没事,到 LLVM 就崩,不是偶然,这跟 LLVM 的架构和链接模型有关。
5.1 静态库符号解析的峰值开销
LLVM 的源码树里,lib/ 下面有大量静态库,像 libLLVM*、libclang*。链接 clang 时,ld 要把这些静态库中所有需要的目标文件解出来,然后构建一张巨大的全局符号表。符号表、字符串表、动态符号表、重定位表叠加在一起,内存占用会明显超过最终二进制文件本身。
我做过一次对比:同一份 LLVM 15.0.7 源码,用 GNU ld 链接 clang,峰值 RSS 大约 2.4GB;在相同配置下改用 lld,峰值降到 1.2GB 左右。这就是换链接器能立竿见影的原因。
5.2 主机核数和内存经常不成正比
云服务器特别容易出现“核多内存少”的尴尬组合,比如 8 核 4G,或者 16 核 8G。CPU 核数多会诱导你写 -j8、-j16,内存却承受不住。
如果你的机器是 8 核 4G,我建议按下面的表来设置,这是基于我个人经验给出的保守值,不是严格标准:
| 物理内存 | 安全并行度 | 链接器建议 | LTO 建议 |
|---|---|---|---|
| 4G | -j1 或 -j2 |
lld | 关闭 |
| 8G | -j2 到 -j4 |
lld | 关闭 |
| 16G | -j4 到 -j8 |
可选 | 可尝试 |
| 32G 及以上 | -j8 以上 |
任意 | 可按需 |
这里判断的核心原则是:编译任务内存可以线性叠加,链接任务内存是倍增的。链接阶段的进程数量一定要严格限制。
5.3 容器、CI 对内存的隐藏约束
还有一个LLVM项目容易踩的坑是“宿主机看着内存很多,容器里莫名被 OOM”。很多 CI 跑在容器里,CPU 限制和内存限制是分开配置的,可能给了 8 核,但内存只给了 4G。你在容器里执行 free -h 看到的是宿主机数据,看不到 cgroup 的 memory.max,于是怎么调参都找不到原因。
因此只要编译环境是容器,第一件事就是看:
bash复制cat /sys/fs/cgroup/memory.max
如果这个值很小,后续所有构建并行度都应以它为准,而不是以宿主机内存为准。
6. 踩过几次坑后的编译习惯
既然这个错不是第一次遇到,我已经把 LLVM 编译时的内存管理变成了一套固定习惯。这里分享几个我觉得最实用的点。
6.1 先估算再动手
源码下载完不要直接 cmake --build,先看机器内存和是否容器有限制。我通常会这样估算:
- 计算可用内存,记为
MEM_GB。 - 编译并发建议不超过
MEM_GB / 2。 - 链接并发强制为 1。
- LTO 一律先关掉。
- 能用 lld 就先用 lld。
这套估算不是精确计算,但能避免 90% 的 [Killed]。如果你用 CMake,可以这样约束:
bash复制cmake -S llvm-project/llvm -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" \
-DCMAKE_SHARED_LINKER_FLAGS="-fuse-ld=lld" \
-DLLVM_ENABLE_PROJECTS="clang;lld" \
-DLLVM_TARGETS_TO_BUILD="X86;ARM" \
-DLLVM_PARALLEL_LINK_JOBS=1
然后在 Ninja 运行时再控制总并发:
bash复制ninja -j2
这样从构建系统层面就管住了链接压力,即使你手滑把 ninja 的 -j 写大,也不至于让两个 ld 同时跑。
6.2 给 Ninja 设置合理并行度
Ninja 的默认行为是尽可能多地并行,所以直接执行 ninja 很容易打爆内存。我建议在编译期间开一个监控窗口:
bash复制watch -n 2 'free -h; pgrep -a ld | head'
这样能看到内存变化,也能清楚看到有没有多个 ld 同时存在。如果发现 ld 超过两个,就说明并行度设得太高,立刻停下来重设参数。
6.3 保留现场,比盲目重试重要
报错之后不要马上执行 make clean 然后从头再来,这个动作最浪费生命。[Killed] 一般发生在链接阶段,此前编译出的 .o 文件都还在,只要降低并行度重新执行构建,Ninja 或 Make 会自动跳过已经完成的编译产物,继续后面的链接。重试前把 dmesg 输出存一份,对比前后两次是不是同样在链接阶段被杀,这有助于判断降并发是否有效。
如果每次都死在同一个链接目标,还可以单独链接那一个目标,比如:
bash复制ninja clang -j1
这比全量重编快得多。只要这一个目标能过,再回到 ninja -j2 继续完整编译。
最后说个真实感受:遇到 collect2: fatal error: ld terminated with signal 9 [Killed],大多数时候不是技术问题,而是“并发开太大,内存没管够”。把链接并发限制住,把 LTO 关掉,有条件就换 lld,这个报错基本就不会再出现。如果你正在被 LLVM 编译折腾,先从降 -j 开始,大概率能直接解决。
