1. 报错是怎么来的:先读懂 collect2 和 signal 9 在说什么
作为一个常年跟 LLVM 打交道的人,我太熟悉这种报错出现在终端里的感觉了。你辛辛苦苦写好了代码,configure 过了,cmake 也过了,编译器吭哧吭哧编译了大半天,眼看着编译进度条走到 90% 以上,突然“啪”一下给你甩出这么一行:
bash复制collect2: fatal error: ld terminated with signal 9 [Killed]
然后整个编译进程直接退出,留下一脸懵的你。更让人崩溃的是,这往往不是代码逻辑的问题,因为前面编译阶段一切正常,你甚至还没来得及怀疑自己的代码。我第一次碰到这个错误的时候,第一反应是去检查代码里是不是有内存越界,结果查了半天一无所获,后来才明白问题出在系统资源上,跟代码本身没有半毛钱关系。
要理解这个报错,得先搞清楚这里面的角色关系。collect2 是 GCC/LLVM 编译器套件里的一个辅助程序,它负责收集编译产生的目标文件,然后调用真正的链接器 ld 来完成最终的可执行文件生成。你可以把 collect2 理解成一个“包工头”,它自己不干活,但是负责把活派给真正的“工人” ld。
而 signal 9 就是 Linux 系统里的 SIGKILL 信号,这是系统最暴力的终止信号,进程连清理现场的机会都没有,直接被内核杀掉。在编译链接的语境下,出现 signal 9 [Killed],绝大多数情况下是操作系统的 OOM Killer(内存不足杀手)机制在起作用——也就是系统物理内存不够用了,内核选择牺牲掉某个“吃内存大户”来保住整个系统不被拖垮。
链接器 ld 恰恰就是那个“吃内存大户”。链接阶段需要把一堆目标文件、静态库、动态库全部加载进内存,构建符号表、重定位信息、节区合并,这些操作对内存的消耗是极其恐怖的。特别是 LLVM 这种以 C++ 模板和复杂抽象著称的工具链,编译出来的目标文件本身就庞大,加上 LLVM 链接时的符号解析工作量,内存峰值很容易把系统资源耗尽。
再说个更扎心的现实:这个问题在 LLVM 项目本身、Clang 编译 C++ 大型项目、以及基于 Qt 的工程中尤其常见。为什么?因为 C++ 模板实例化会成倍膨胀代码体积,Qt 的元对象编译器(moc)又会生成大量额外的符号信息,这些叠加起来让链接器的工作量雪上加霜。根据我的实测经验,一个包含大量模板代码的 Qt 工程在链接阶段的内存峰值,可以达到最终可执行文件大小的 20 到 50 倍。
所以每次看到有人发帖问“为什么我的 Qt 空工程编译一堆报错,最后卡在 signal 9”的时候,我基本可以断定:不是你的代码写错了,是你的机器内存扛不住了。接下来的内容,我会从排查思路到具体解决手段,把这个问题的来龙去脉彻底讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位问题:确认是不是 OOM,以及“凶手”是谁
在动手解决之前,先要确认问题到底出在哪个环节。ld terminated with signal 9 虽然绝大多数情况是 OOM,但也不能排除其他可能,比如系统对进程数量的限制、cgroup 内存限制、甚至是磁盘空间不足导致链接器写临时文件失败。所以排查要系统化,别一上来就瞎改参数。
2.1 三步确认:dmesg 日志、退出码、内存监控
第一步,也是最直接的一步,查看内核日志。OOM Killer 每次动手杀人之前都会在系统日志里留下记录:
bash复制dmesg | grep -i oom
dmesg | grep -i killed
journalctl -k | grep -i oom
如果你能看到类似这样的输出:
code复制Out of memory: Killed process 12345 (ld) total-vm:20485760kB, anon-rss:18385100kB, ...
那问题就实锤了,确实是 OOM Killer 把链接器干掉了。注意看 total-vm 和 anon-rss 这两项,它们告诉你链接器当时到底申请了多少虚拟内存、实际驻留了多少物理内存。如果 anon-rss 都已经到了 18GB 甚至更高,说明你的链接器在疯狂吃内存,而你的物理内存显然不够它挥霍。
第二步,查看编译进程的退出状态。如果没有任何 dmesg 记录,那可能是 shell 环境本身的限制,用 ulimit -a 看一下:
bash复制ulimit -a
重点看 max memory size 和 virtual memory 这两项。如果被设置了限制,链接器撞到上限也会被终止。这个问题在 CI/CD 环境或者某些加固过的服务器上比较常见,本地开发机上一般不会遇到。
第三步,如果是通过 Docker 或 Kubernetes 运行的编译任务,还要检查容器的内存限制。docker stats 可以实时查看容器内存使用情况,cat /sys/fs/cgroup/memory.max 可以查看 cgroup 的内存上限。很多容器环境的默认内存限制只有几百 MB,跑 LLVM 这种重量级编译简直是痴人说梦。
2.2 为什么链接器这么吃内存:从链接原理说起
说实话,很多人不理解,不就是把几个 .o 文件拼在一起吗,怎么会吃掉几十 GB 内存?这就要从链接器的工作机制说起了。
链接器的工作流程大致分三步:符号解析、重定位、输出写入。
符号解析阶段,链接器要把所有输入目标文件的符号表全部加载进内存,建立一个全局的符号哈希表。一个大型 C++ 项目的符号数量动辄数百万个,每个符号都关联着名字字符串、类型信息、所属节区、引用关系等。光是这些符号表的开销,就能轻松吃掉几个 GB 内存。
重定位阶段更夸张。链接器需要把所有需要重定位的位置全部记录下来,计算每个符号的最终地址,然后回填到对应位置。LLVM 编译出来的目标文件,重定位条目的数量比 GCC 编译同规格项目要多不少,这也是 LLVM 工具链特性带来的额外负担。
输出写入阶段,链接器要把所有输入文件的节区合并、对齐、排序,然后一次性写入输出文件。这个过程需要在内存里维护完整的节区数据,又是一块不小的开销。
更倒霉的是,静态库的链接还会引发“级联膨胀”。链接器按需提取静态库中的目标文件,但被提取的每个目标文件又可能引用其他目标文件中的符号,于是继续提取,像滚雪球一样越滚越大。如果项目用了 Boost、ICU、Qt 这类厚重的静态库,链接器内存消耗会呈指数级爆炸。
所以你看,链接器吃内存不是“浪费”,而是这个工作天然就是内存密集型任务。理解了这一点,你就能明白为什么内存扩容或者优化链接参数才是根本出路,而不是去改代码。
3. 解决方案:从“救急”到“治本”的完整实战
接下来进入正题,我按照“见效速度从快到慢、操作难度从低到高”的顺序,把解决 signal 9 的完整方案列出来。每一个方案我都会给出具体命令和参数,你直接照着操作就行。
3.1 最快见效:降低链接并行度
如果你用的编译系统支持并行链接,比如 CMake 的并行构建、或者链接器开启了多线程模式,那么多个链接任务并发执行会瞬间拉高内存峰值。最简单的做法就是减少并行任务数。
比如使用 make 构建,可以限制编译和链接的并行度:
bash复制make -j1
这条命令强制所有任务串行执行,内存压力会大幅下降,代价是编译时间变长。如果机器配置尚可,可以尝试 -j2 或 -j4,找到一个平衡点。对于 CMake 构建的 LLVM 项目,你还可以在配置阶段指定并行的编译单元数量:
bash复制cmake --build . --parallel 2
这个方案适合那种“内存差一点点就够”的情况。比如链接一个大型项目需要 12GB 内存,你的机器只有 8GB,然后你同时跑了两个链接任务,每个要 6GB,结果直接爆掉。这时候把并行度降下来,让两个任务串行执行,问题立刻解决。
3.2 战略性调整:给系统增加交换空间
如果说降低并行度是“省着吃”,那增加交换空间就是“借地方吃”。当物理内存不够时,Linux 会把不常用的内存页换到磁盘上的 swap 分区,腾出物理内存给链接器用。
创建交换文件的完整操作流程:
bash复制# 创建一个 16GB 的交换文件(大小按需调整)
sudo fallocate -l 16G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# 查看是否生效
free -h
看到 Swap 行有可用空间就说明生效了。要永久生效,还需要在 /etc/fstab 里加一行:
code复制/swapfile none swap sw 0 0
这里我要提醒一个关键点:如果增加 swap 后依然报 signal 9,那问题可能比想象的更严重。因为 OOM Killer 在物理内存耗尽后、swap 也成为瓶颈时,会优先杀掉“内存占用最大”的进程。如果你加了 swap 还是被杀,说明链接器的内存需求确实超出了系统的承受上限,这时候得换更根本的手段。
3.3 换工具:用 lld 替代系统默认的 ld
这是我最推荐的一个方案,没有之一。LLVM 工具链自带一个名为 lld 的链接器,它最大的优势就是更快、更省内存。我实测过,lld 在链接大型 C++ 项目时的内存峰值,可以比 GNU ld 降低 30% 到 50%,链接速度还能快好几倍。
用 lld 有两种方式。第一种,直接在编译命令里指定链接器:
bash复制clang++ -fuse-ld=lld your_source.cpp -o your_program
第二种,在 CMake 中全局指定:
cmake复制set(CMAKE_EXE_LINKER_FLAGS "-fuse-ld=lld")
set(CMAKE_SHARED_LINKER_FLAGS "-fuse-ld=lld")
对于 LLVM 项目自身的构建,如果你用 CMake 配置,可以在生成构建系统时加参数:
bash复制cmake -G Ninja -DLLVM_ENABLE_LLD=ON ../llvm
LLVM_ENABLE_LLD 这个选项作用很直接——它会让你构建出来的 LLVM 工具链默认使用 lld 作为链接器。而且 lld 是并行加载输入文件,内存占用曲线比 GNU ld 平缓得多,对 signal 9 的防御效果好得明显。用了 lld 之后,我基本没再因为链接器 OOM 犯过愁。
3.4 精打细算:调整链接器的内存使用策略
如果暂时没法用 lld,GNU ld 本身也提供了一些内存相关的参数,可以缓解内存压力。
最经典的是 --no-keep-memory 选项。默认情况下,GNU ld 会把输入文件的内容缓存在内存里以加快符号解析速度,加上这个参数可以让 ld 在不需要的时候及时释放内存,代价是链接速度变慢。在 CMake 里可以这样加:
cmake复制set(CMAKE_EXE_LINKER_FLAGS "-Wl,--no-keep-memory")
set(CMAKE_SHARED_LINKER_FLAGS "-Wl,--no-keep-memory")
另一个有用的参数是 --reduce-memory-overheads,这是 GNU ld 的折中方案,在某些场景下能降低内存占用同时尽量保持链接性能。我实测下来,这个参数对包含大量调试信息的二进制尤其有效。
还有一点容易被忽略:-g 选项会将调试信息写入目标文件,如果调试信息规模化膨胀,链接器需要处理的数据量也成倍增加。如果你只是要编译链接一个可以运行的二进制,暂时不需要完整调试,可以考虑去掉 -g 选项,或者用 -g1 这种最小调试信息级别。这个操作对内存峰值的削减比想象中明显。
3.5 釜底抽薪:从编译阶段就为链接减负
前面几个方案都在“链接阶段”做文章,其实编译阶段的参数选择也会影响链接阶段的内存占用。比如,使用 -flto(链接时优化)虽然能提升最终程序的性能,但代价是链接器需要加载完整的中间表示(IR),内存消耗会显著上升。
如果你在用 LTO 且遇到了 signal 9,可以试试 -fno-lto 关闭 LTO,或者使用 -flto=thin 代替全量 LTO。ThinLTO 是 LLVM 的轻量级 LTO 方案,它只对必要的跨模块信息做优化,内存占用控制在普通链接的 1.5 到 2 倍左右,远低于全量 LTO。
bash复制# 全量 LTO 改为 ThinLTO
clang++ -flto=thin -fuse-ld=lld your_source.cpp -o your_program
另外,如果项目使用了静态库,可以考虑把静态库改为动态库。静态库在链接时要全部加载进内存做符号分析,动态库只需要处理符号表,内存占用差距很大。当然这是大工程,一般到不了这一步就能解决问题。
3.6 终极方案:扩容内存或调整 OOM Killer 策略
当你试完以上所有方案还不够用时,问题就很明确了:你的机器物理内存确实不够,或者你的链接需求确实太暴力。这时候有两个方向可以考虑。
方向一:增加物理内存。如果你是云服务器,直接在控制台扩容;如果是本地工作站,加内存条是最直接的方案。我在实际工作中就遇到过一台 8GB 内存的机器编译某个大型项目始终过不去,加了 8GB 内存后一次通过,毫无波澜。内存是这个问题的“第一性原理”,其他所有方案都是在有限的物理内存下做妥协。
方向二:临时调整 OOM Killer 的偏向性。Linux 内核允许你调整 OOM 评分的阈值,让特定进程不容易被杀。比如给编译进程设置一个较低的 oom_score_adj:
bash复制echo -500 > /proc/self/oom_score_adj
# 或者在脚本式编译时,前置执行上述命令
不过我基本不建议在生产环境这么搞。因为 OOM Killer 是保护系统安全的最后一道防线,你把某个进程的分数调低了,它可能会转而杀掉其他进程,比如数据库或者 SSH 服务,等于拆东墙补西墙。这个操作只适合你在本地调试且确定不会影响其他服务的时候使用。
4. 进阶排查:llvmpipe、Qt 工程等场景的连带问题
前面讲的都是通用解决方案,但实际上 LLVM 编译报错的问题往往不止 signal 9 这一个表象。结合最近不少人在社区里反馈的相关问题,我再补充两个高频场景的排查经验。
4.1 llvmpipe 相关:软渲染带来的编译连锁反应
最近很多人在网上搜“llvmpipe (llvm 15.0.7, 256 bits)”相关的问题。llvmpipe 是 Mesa 中的一个软件渲染实现,它使用 LLVM 做即时编译来生成 CPU 上的渲染代码。很多人编译 Mesa 或者编译带 Mesa 的图形栈时,会遇到 llvmpipe 触发的编译问题,而且经常和 signal 9 绑定出现。
这种情况下的排查思路要拓宽成两条线:一方面,llvmpipe 在构建时依赖 LLVM 的环境,如果 LLVM 编译时就出现了内存问题,那后面 llvmpipe 的构建自然跟着遭殃;另一方面,llvmpipe 的编译单元数量大、模板复杂度高,对链接器内存的冲击比普通项目更猛烈。
如果你不是必须要用 llvmpipe,可以在配置时关掉它。比如编译 GLibc 图形栈时,-Dgallium-drivers=... 参数里去掉包含 llvmpipe 的软件渲染选项。但如果你的使用场景必须依赖软渲染,那就乖乖回到第 3 章,把 lld 换上去、内存加够,这是最稳妥的路线。
从实际经验看,llvmpipe 构建中遇到 signal 9 的概率比 Qt 工程还高,主要原因是它大量依赖 LLVM 的生成代码,符号和重定位的爆炸规模远超过普通应用。有时候你把 LLVM_ENABLE_LLD=ON 打开,问题就迎刃而解了。
4.2 Qt 空工程也报错一堆?别怀疑人生
有用户反馈“Qt 空工程编译一堆报错,ld block”,这个话题在 CSDN、Stack Overflow 上都挺热。说实话,“空工程”这个概念很容易误导人——你以为的空工程,实际上包含了 Qt 的头文件解析、moc 元对象编译、以及 Qt 提供的各种隐式链接库(如 QtCore、QtGui 等)。
一个最小化的 Qt Widgets 应用,生成的 Makefile 里链接的库就有好几十个,每个库的符号表都要加载进内存。如果 Qt 库是 Debug 版本,那符号表膨胀程度至少翻一倍。在这种前提下,链接器吃几个 GB 内存完全正常。
如果你在 Qt 工程里遇到 signal 9,除了套用前面所有方案之外,还有一个 Qt 特有的优化点:确认你链接的是 Release 版本的 Qt 库而不是 Debug 版本。Debug 库的符号信息远多于 Release,内存占用差距巨大。
另外,Qt 6.0 之后的版本默认使用 Ninja 作为构建系统,Ninja 的并行编译策略比较激进,默认情况下会启动与 CPU 核心数相等的链接任务。如果你是多核机器,比如 8 核 16 线程,Ninja 可能会同时启动十几个链接任务,内存瞬间爆炸。
解决方法很简单,构建时限制并行数:
bash复制ninja -j2
或者配置 CMake 时限制:
bash复制cmake --build . --parallel 2
注意 --parallel 参数控制的是同时编译的源文件数量,链接任务通常不会同时启动这么多,但在内存紧张的时候,把并行度从默认值调低到 2 或 4,能显著降低峰值内存。
5. 实战记录:一次完整的 signal 9 解决过程
理论讲了这么多,我来分享一个真实案例,把前面所有方案串起来,让你看看完整的排查和解决流程是什么样的。
之前帮一位朋友调试项目,他用的是一台 8GB 内存的 Ubuntu 服务器,在编译一个基于 LLVM 的代码分析工具。第一次编译,跑到链接阶段报了 signal 9,dmesg 确认是 OOM。当时他的编译命令长这样:
bash复制make -j8
这台机器是 4 核 8 线程,-j8 意味着并行编译 8 个源文件。编译期间的 object 文件会占用磁盘和内存,到链接阶段时内存余量本身就不多,链接器一启动就撞墙了。
我的操作路径是:
第一步,先降到 make -j2 重试,居然过了。但编译时长从原来的 15 分钟拉长到 40 多分钟,体验不算好。我建议他这个方案可以作为“保底”,但还有优化空间。
第二步,安装 lld 并切换链接器,保持 -j8 重跑。lld 的表现很惊艳,内存峰值从接近系统上限压到了 4.7GB 左右,编译时间不仅没有增加,反而因为 lld 本身的并行处理能力,跑到了 13 分钟左右。
第三步,在 CMake 配置里追加了 -DLLVM_ENABLE_LLD=ON,这样整个 LLVM 项目后续所有模块的构建都默认使用 lld,从根上避免了重复踩坑。
整个过程从开始排查到完全解决不到半小时。后面我基本形成了惯性:只要编译环境内存少于 16GB、项目里又涉及 LLVM 或大型 C++ 代码库,就优先切换 lld,基本能避免 90% 的 signal 9 问题。
5.1 完整排查命令速查表
| 排查步骤 | 命令 | 预期结果 |
|---|---|---|
| 确认是否为 OOM | dmesg | grep -i oom |
出现 OOM Killer 记录 |
| 查看内存使用情况 | free -h |
确认内存总量和剩余 |
| 查看进程内存峰值 | /usr/bin/time -v make -j1 |
输出 Maximum resident set size |
| 确认 cgroup 限制 | cat /sys/fs/cgroup/memory.max |
查看容器内存上限 |
| 检查链接器版本 | ld --version |
确认是 GNU ld 还是 lld |
这个表是我每次排查编译问题时的固定动作,步骤不多但很管用。尤其是 /usr/bin/time -v 这个命令,它会如实报告进程的最大驻留内存(RSS),你可以根据这个数值精确判断链接器实际需要多少内存,从而决定该加多少 swap 或者升到多少物理内存。
5.2 不同方案适用场景对比
| 方案 | 见效速度 | 操作成本 | 适用场景 |
|---|---|---|---|
| 降低并行度 | 立即 | 极低 | 内存只是略紧张,可以牺牲速度 |
| 增加 swap | 立即 | 低 | 内存缺口不大,且磁盘空间充足 |
| 换用 lld | 很快 | 低 | 强烈推荐,几乎适用于所有场景 |
| 调整 ld 参数 | 有限 | 低 | 无法安装 lld 时的替代方案 |
| 关闭 LTO | 有限 | 低 | 明确使用 LTO 且内存不够时 |
| 增加物理内存 | 取决于采购 | 高 | 编译需求长期存在,最治本的方案 |
我的建议是,不论你最终选择哪个方案,优先把 lld 换上去试试。它省内存、速度快,而且作为 LLVM 工具链的官方链接器,和 LLVM 项目的兼容性本身就是最好的。
6. 常见问题与避坑技巧
最后整理一批我踩过的坑和经常被问到的问题,这些细节分散在网上很多帖子角落,但实际影响很大。
6.1 为什么加了 swap 还是被杀?
很多人以为加了 swap 就万事大吉,结果发现还是 OOM。原因在于:swap 与物理内存通过内核的交换策略配合工作,如果物理内存耗尽后,swap 也被大量占满,OOM Killer 依然会触发。
还有一个更隐蔽的原因:某些云服务器默认的 vm.swappiness 值很低(比如 10),意味着内核倾向于少用 swap,优先使用物理内存。这时候你加了 16GB swap 也只是挂着好看,系统实际并不怎么用它。可以通过调整 swappiness 值让系统更激进地使用 swap:
bash复制sudo sysctl vm.swappiness=60
这个值可以从 0 到 100,越高代表越积极使用 swap。但要注意,60 已经算激进,再往上可能会明显拖慢系统性能,因为 swap 的读写速度跟内存比是天壤之别。
6.2 为什么我的 32GB 内存机器还报 OOM?
这种场景往往不是物理上限问题,而是进程数量超了。如果你在编译时开启了超多并行任务,比如 32 核机器上 make -j64,同时有 64 个编译器进程和多个链接进程在跑,每个编译器本身的 RSS 可能就有几百 MB,加起来轻轻松松超过 32GB。
解决方案还是回到老办法:降低并行度。另外需要关注是不是有其他常驻服务(如数据库、IDE、多个 Docker 容器)占用了大量内存。用 free -h 看一下 available 列,剩余内存往往比你想的少得多。
6.3 为什么换 lld 之后编译还是失败?
这个问题我见了不下十次。很多人用 -fuse-ld=lld 或者 LLVM_ENABLE_LLD=ON,结果系统报错说找不到 ld.lld。原因很简单:你的系统里压根没装 lld。
安装方法因发行版而异:
bash复制# Ubuntu/Debian
sudo apt install lld
# CentOS/RHEL/Fedora
sudo dnf install lld
# Arch Linux
sudo pacman -S lld
装好之后确认一下 ld.lld 在不在 PATH 里:
bash复制which ld.lld
如果安装好了依然报错,看看是不是环境变量问题。有时候你在自定义的 shell 脚本里限制了 PATH,导致 lld 的安装路径没有被包含进去。
6.4 一个冷门的坑:磁盘空间不足也会触发 signal 9
这个坑有点反直觉,但确实存在。链接器工作过程中经常需要写临时文件,比如 /tmp 目录下的临时链接脚本。如果磁盘满了,ld 写入临时文件失败,会以非正常方式退出。某些版本的 LD 会把这映射为 signal 9 错误。
排查方法很简单,确认一下磁盘空间:
bash复制df -h /tmp
我遇到过一次这种情况,当时服务器上挂了个庞大的日志文件把 /tmp 塞满了,编译报的错和 OOM 一模一样,排查了好半天才找到真正原因。所以这个点也值得列出来帮大家省时间。
6.5 signal 9 不等于信号 9 来自代码
最后说一句心态层面的体会。很多人在编译遇到 signal 9 时,第一反应是怀疑自己代码里有什么“魔法”触碰了系统信号。其实完全不是。大多数 OOM 场景下,signal 9 单纯就是内核的自我保护机制在发挥作用。链接器不是你代码的一部分,它是独立的系统工具,被系统杀掉和你的代码逻辑没有关系。
所以遇到 signal 9 最忌讳的事就是:反复检查代码、修改代码结构、尝试各种代码层面的“优化”,结果折腾一整天毫无进展。正确思路永远是先从系统层面排查内存、资源限制、链接工具链三件事,先确认大环境没问题,再去考虑代码层面的可能性。
根据我长期的实际操作经验,90% 以上的 signal 9 问题,靠降并行度、加 swap、换 lld 这三个操作就能解决。如果你的项目构建流程还没把 lld 纳入默认链接器,我强烈建议现在就调整,这可能是你在编译这条路上做的最省心的一次改动。
