1. 这真的是玄学问题吗?聊聊 signal 9 背后的真相
先说结论:collect2: fatal error: ld terminated with signal 9 [Killed] 这个报错,大概率不是 LLVM 本身的问题,也不是你代码写得有问题,而是你的系统内存(或者交换分区)在链接阶段被耗尽了。signal 9 是 Linux 内核的 SIGKILL 信号,意思是进程被操作系统强制杀死。链接器 ld 正在干重活的时候,内核看它内存吃得太多,直接一枪毙了它。
我在编译 LLVM/Clang 这种巨型项目时,第一次遇到这个报错也懵了,第一反应是去查 GCC 版本、CMake 配置、依赖库缺失,折腾了半天毫无进展。后来静下心来看日志,发现每次都是链接器跑到某一个 .o 文件时报错,而且时间点非常固定——就是内存飙升到临界值的那一刻。拿 free -h 一看,物理内存还剩几百 MB,swap 也几乎为 0,一切就都清楚了。
顺便提一个热搜词里的 llvmpipe (llvm 15.0.7, 256 bits。llvmpipe 是 Mesa 里基于 LLVM 的软件渲染实现,256 bits 表示它启用了 AVX2 向量化相关的代码生成。这个细节跟咱们今天的报错有个共同点:LLVM 本身的代码规模极其庞大,哪怕只是一个空工程,只要你编译时依赖了 LLVM 库,链接阶段就可能把内存吃穿。很多人在 Qt 空工程上碰上 ld block 或 signal 9,其实就是因为链接器要处理大量调试信息和符号表,内存一下子爆了。
所以这篇文章我会从原理、排查、解决、预防四个角度,把 signal 9 这件事彻底讲透。方案覆盖从零成本的配置调整,到加 swap、换链接器、调内核参数这样的硬核操作,你可以按照自己的机器配置和项目规模对号入座。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内存杀手是如何产生的:链接阶段的内存消耗逻辑
2.1 为什么偏偏是链接阶段,而不是编译阶段
很多人有个误区,觉得编译器最吃内存,其实 GCC/Clang 在前端解析和优化阶段虽然也耗内存,但那些阶段是按“单个翻译单元”来处理的,完了就释放。真正吃内存的大户是链接器,尤其是当你需要把几千个目标文件合并成一个可执行文件或共享库的时候。
链接器要做的事情非常暴力:把所有的 .o 文件读进来,解析每个文件的符号表(symbol table)、重定位信息(relocation)、调试信息(debug info),然后在内存里构建一个全局的符号表,最后再根据这些信息生成最终的代码布局。这意味着,你的工程越大、调试信息越完整、开启的优化等级越高,链接器在内存里塞的东西就越多。
LLVM 这套代码库尤其夸张。它本身有几十万个源文件,编译出的目标文件动辄几十 GB(包含调试信息)。你用默认的 BFD ld(GNU ld)来链接时,一次性加载的文件数量巨大,内存峰值可以达到好几个 GB,甚至十几个 GB。如果你的物理内存只有 8GB 或 16GB,又开着多线程并行链接,那被 OOM killer 盯上是迟早的事。
2.2 OOM Killer 和你的系统是怎么“配合”的
Linux 内核里有一个机制叫 OOM Killer。当系统的可用内存(物理内存 + swap)低于某个阈值时,内核会根据进程的内存占用、运行时间、优先级等综合打分,挑一个“最该杀”的进程直接发 SIGKILL。这里的“最该杀”不一定是最吃内存的那个进程,它有一套自己的启发式算法,但我们碰到的场景里,通常就是当前内存占用最高、又恰好是后台任务的链接器被选中。
你可以通过下面的命令确认是不是 OOM 导致的:
bash复制dmesg | grep -i "killed process"
如果你看到类似 Killed process 12345 (ld) 这样的日志,那就八九不离十了。实际上,还能在 /var/log/kern.log 里找到 OOM Killer 的完整记分表,会详细列出每个进程的 oom_score。这个排查步骤建议放在第一步,因为很多人在错误的方向上浪费了大量时间。
2.3 为什么“Qt 空工程编译”也会触发这个问题
有人会问:我一个 Qt 空工程,怎么也会 ld terminated with signal 9?这里面有两层原因。第一层,Qt 工程的 qmake/CMake 配置里,如果开启了 debug 模式,并且链接了 Qt 的静态库或者大型第三方库(比如 LLVM、OpenCV),即使你自己的代码只有一个 main.cpp,链接器依然要处理所有依赖库的符号表,内存消耗不会因为“你的代码少”就变少。第二层,某些 Qt 版本在 Windows 或 Linux 上会调用 lld 或 ld 作为链接器,如果系统里同时存在多个链接器、而 CMake 选了默认的 BFD ld,那老链接器的内存管理方式很容易踩坑。
所以,如果在 Qt 空工程上看到 ld block 或者 signal 9,别急着怀疑 Qt 的安装有问题,先看看当时的内存和 swap 状态。很多时候,空工程只是压死骆驼的最后一根稻草,真正的内存压力来自系统的其他进程(比如浏览器、Docker、IDE)叠加在一起的结果。
3. 系统的“内存扩容术”:三种低门槛解决方案
3.1 方案一:加 swap 文件,快速续命
如果你的机器物理内存固定(比如 8GB 或 16GB),临时又没法加内存条,那第一件事就是加一个 swap 文件。注意,我推荐的是 swap 文件,不是 swap 分区,因为文件方式更灵活,随时可以调整大小,不需要动磁盘分区表。
具体操作如下:
bash复制# 创建一个 16GB 的 swap 文件
sudo fallocate -l 16G /swapfile
# 如果 fallocate 不支持,用 dd 命令也可以
# sudo dd if=/dev/zero of=/swapfile bs=1M count=16384
# 设置权限,仅 root 可读写
sudo chmod 600 /swapfile
# 格式化为 swap
sudo mkswap /swapfile
# 启用
sudo swapon /swapfile
# 查看是否生效
free -h
为了让开机自动挂载,还需要把下面这行加到 /etc/fstab 里:
code复制/swapfile none swap sw 0 0
这里有个自己的经验:swap 文件的大小,建议至少是物理内存的 1 到 2 倍。如果你要编译 LLVM,我建议 swap + 物理内存的总量最好不少于 20GB。还有,尽量不要把 swap 放在机械硬盘上,否则链接速度会明显变慢,因为链接器一边从磁盘读 .o 文件、一边往 swap 里换页,磁盘 IO 会成为新的瓶颈。有条件的话用 SSD,哪怕空间小一点也行。
3.2 方案二:关闭并行链接,降低内存峰值
很多人用 CMake 的时候,习惯性地写 make -j$(nproc) 或者 ninja -j16 来加速编译。这确实能让编译期快很多,但链接阶段的并行度也会跟着上去。假设你 16 核全开,链接器同时跑 16 个进程,每个占 2GB 内存,那就是 32GB,这谁顶得住。
我自己在实际操作时会做一件事:编译阶段用多线程,但链接阶段限制并发。CMake 生成的 Makefile 支持给链接步骤单独设置并发:
bash复制# 用 make 时,先编译目标文件,再限制链接并发
make -j$(nproc) --output-sync=recurse
make -j2 # 或者减少并发到 2
对于 Ninja,可以用 -l 参数限制负载:
bash复制ninja -j16 -l4
-l4 的意思是如果系统负载超过 4,就不再启动新的编译任务。这样当一个链接器占用了大量内存和 IO 时,系统会等它结束再继续下一个任务,内存峰值会平滑得多。
3.3 方案三:换链接器,用 lld 替代默认 ld
这个是我最推荐的做法。LLVM 项目自己就带了一个链接器叫 lld,设计目标就是快、省内存。它和 LLVM 的代码库契合度极高,链接速度可以比 GNU ld 快几倍,内存占用也低很多。
在编译 LLVM 之前,你可以先把 lld 装好(Clang 的安装包通常会附带):
- Ubuntu/Debian:
sudo apt install lld - CentOS/RHEL:
sudo yum install lld - 或者直接编译安装 LLVM 的时候,
LLVM_ENABLE_LLD=ON
然后在 CMake 里指定:
bash复制cmake -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" \
-DCMAKE_SHARED_LINKER_FLAGS="-fuse-ld=lld" \
-DLLVM_ENABLE_LLD=ON ..
-fuse-ld=lld 这个参数的意思是告诉编译器,链接时使用 lld。我实测下来,用 lld 链接 LLVM 的某个组件时,内存占用比 GNU ld 减少了大约 30% 到 40%,速度快得不是一星半点。如果你的系统里还没有 lld,或者不想装额外的包,也可以直接编译一份 lld 出来单独用。
注意:
ld terminated with signal 9里的 ld 这个进程名,如果是 lld 的进程,它的名字可能叫ld.lld。判断当前用的哪种链接器,可以在 CMake 的日志里搜fuse-ld或者直接看ps aux | grep ld。不要想当然以为报错里写了 ld 就一定是 GNU ld。
4. 内核参数优化:让 OOM Killer 不要误伤友军
4.1 调整 overcommit 策略
Linux 的虚拟内存系统有一个 overcommit 机制,分为三种模式(通过 vm.overcommit_memory 控制):
0(默认):启发式模式,内核根据内存情况自主决定是否允许进程申请过多内存。1(始终 overcommit):允许进程申请任意大小的虚拟内存,不检查实际物理内存。2(禁止 overcommit):内核不允许进程申请超过物理内存 + swap 一定比例的内存。
默认的 0 模式下,如果内核判断系统内存不足,会直接 kill 进程,这就是我们遇到的 signal 9 的根源。而把它改成 1,可以让链接器先申请到足够大的虚拟内存,然后在使用过程中发现物理内存不够时,通过 swap 慢慢换页,而不是直接被杀掉。
bash复制sudo sysctl -w vm.overcommit_memory=1
注意,这个参数是临时的,重启就失效了。想要永久生效,在 /etc/sysctl.conf 里加上:
code复制vm.overcommit_memory=1
然后执行 sudo sysctl -p。这里必须提醒一句:overcommit_memory=1 是一把双刃剑。它确实能避免 OOM killer 误杀,但如果内存真的不够,进程会长时间卡在 swap 上,系统响应变得极其缓慢,甚至可能触发系统级的假死。所以这个参数更适合有明确超大内存需求的编译场景,日常服务器不推荐长期开启。
4.2 调低 oom_score_adj
OOM Killer 在选“受害者”时,会参考每个进程的 oom_score,这个分数越高,越容易被杀。你可以给某些重要的进程设置一个很低的 oom_score_adj,告诉内核“别杀它”。
比如,在我们手动运行链接命令(或者执行 make/ninja)前,先找到当前的 shell 进程,然后:
bash复制# 查看当前 shell 的 pid
echo $$
# 把 oom_score_adj 设为负值,降低被杀的优先级
sudo echo -1000 > /proc/$$/oom_score_adj
这样做以后,即使内存压力很大,内核也会优先杀其他进程(你 IDE 的缓存进程、后台的 Web 服务之类的),而尽量保住编译进程。这个方法在低配机器上实测很有用,尤其是那些频繁在编译时打开浏览器、IDE 的开发者。
4.3 限制编译进程的 cgroup 内存
如果你用的是 systemd 系统,还可以给编译任务单独建一个 cgroup,限制它的内存上限,超过上限就自动杀掉,而不是让整个系统陷入 OOM 状态。这种做法的好处是可控——编译进程被杀,系统其他服务完全不受影响。
bash复制# 创建一个名为 build 的 scope
sudo systemd-run --scope -p MemoryLimit=8G make -j4
这样,make 及其子进程最多只能使用 8GB 内存,超了就会被系统强制终止。这个方案适合那些既想保护系统稳定、又不想手动监控内存的开发者。注意,MemoryLimit 是限制 cgroup 的总内存,包含物理内存和 swap 的使用量。
5. 实操案例:我自己在编译 LLVM 15 时踩过的坑
5.1 完整复现:从报错到解决的全过程
为了让大家心里有底,我把自己最近一次编译 LLVM 15.0.7 的完整过程复盘一下。那台机器配置是 16 核 CPU、16GB 物理内存、没有 swap。CMake 配置如下:
bash复制cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS="clang;lld" \
-DLLVM_TARGETS_TO_BUILD="X86" \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DLLVM_USE_LINKER=lld \
../llvm
然后执行 ninja -j16。编译阶段很顺利,CPU 跑得飞起,内存大概稳定在 10GB 左右。但到了某个链接步骤,系统突然卡顿了一下,接着终端就吐出了熟悉的报错:
code复制collect2: fatal error: ld terminated with signal 9 [Killed]
我第一时间运行了 dmesg | tail -20,看到了 OOM Killer 的记录。当时链接的产物是 libclang-cpp.so,这个共享库的目标文件非常多,符号表极其庞大。用 free -h 查看,物理内存已经全部耗尽,swap 为零,一切都在预料之中。
5.2 我采用的解法:swap + lld + 限制并发三管齐下
我没有直接在 16GB 物理内存上硬扛,而是做了三个改动:
第一步,创建一个 32GB 的 swap 文件(因为这台机器是 NVMe SSD,IO 速度不错,不用担心 swap 拖慢太多)。
第二步,在 CMake 里显式加上 -DLLVM_USE_LINKER=lld,确保链接器使用 lld 而不是 GNU ld。
第三步,把 ninja 的并发限制为:
bash复制ninja -j16 -l6
这样,即使 16 个任务同时跑,只要系统负载超过 6,ninja 就会暂停启动新任务。在链接阶段,内存峰值被有效控制住了。全部编译完成后,我统计了一下,链接 libclang-cpp.so 的内存峰值大约是 4.2GB,这和之前 GNU ld 动辄 8GB 的占用相比,差距非常明显。
5.3 如果你手头机器特别弱怎么办:两步增量编译法
如果你的机器内存实在太小(比如 4GB 或 8GB),连 lld 都救不了,还可以试试“两步增量编译法”。
第一步,编译源码生成目标文件:
bash复制cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS="clang;lld" \
-DLLVM_TARGETS_TO_BUILD="X86" ../llvm
ninja -j4 clang
第二步,链接时只链接你需要的工具,比如 llc、opt,而不是一上来就全量链接所有组件:
bash复制ninja -j2 llc opt
这种做法的核心思想是:把大任务拆成小任务,降低单次链接的内存需求。虽然总耗时会长一些,但至少不会被 OOM killer 打断,留得青山在不怕没柴烧。
6. 编译参数的隐藏陷阱:这些配置也在悄悄消耗内存
6.1 调试信息(-g)与 RelWithDebInfo
很多人喜欢用 -DCMAKE_BUILD_TYPE=RelWithDebInfo,既优化又带调试信息,挺方便。但你可能不知道,调试信息对内存的影响比想象中更大。LLVM 这类项目如果默认开启 -g,目标文件大小会暴涨数倍,链接器读入内存的数据量也跟着暴涨。
如果你并不需要经常调试 LLVM 内部,完全可以关掉调试信息,换成最朴素的 Release 模式。我实测,同一台机器,Release 模式链接时内存峰值比 RelWithDebInfo 低大约 40%。如果你只是用 LLVM 来做交叉编译工具链,而不是开发 LLVM 本身,建议直接把调试信息关掉。
6.2 LTO(链接时优化)的代价
还有一个常见坑是 LTO。为了一点运行性能提升,很多人会开 -flto=thin 或者 -flto=full。问题是,LTO 需要链接器把每个目标文件的中间表示(IR)全读进内存,然后重新做一轮全局优化。这意味着,即使一个很小的工程,只要开了 LTO,链接内存也会成倍增长。
我在一个实际项目中遇到过一次莫名其妙的内存暴涨,排查到最后才发现是 -DLLVM_ENABLE_LTO=Thin 引起的。当时的目标是编译一个 LLVM 的 pass 插件,代码量很少,但链接时直接吃到 12GB 内存。如果碰到这种场景,要么把 LTO 关掉,要么确保 swap 空间充足,没有第三条路可走。
6.3 并行链接线程数(-Wl,--threads)
GNU ld 和 lld 都支持多线程链接,参数分别是 -Wl,--threads=N 和 -Wl,--threads=N。这个参数能让链接器在合并符号表时用多线程,速度会快一些,但代价是额外的内存开销。默认情况下,链接器会开启和 CPU 核数相当的线程。
如果你想压榨内存,可以显式限制:
bash复制-Wl,--threads=2
这个数字建议不要超过 4,否则内存消耗的增长往往比速度提升更明显。这也是一个诸多资料里不会特别强调的细节,但实际效果很明显。
7. 常见排查命令速查表和问题对照手册
操作过程中,很多朋友会卡在“不知道从哪里查起”,这里我整理了一个速查表,方便你按图索骥:
| 排查目标 | 命令 | 关键输出 |
|---|---|---|
| 确认是否 OOM killer 触发 | dmesg | grep -i "killed process" |
显示 ld 进程被 kill |
| 查看当前内存和 swap | free -h |
检查可用内存和 swap 容量 |
| 查看进程详细内存 | ps aux | sort -k4 -rn | head -20 |
找到占用最高的进程 |
| 查看链接器版本 | ld --version,ld.lld --version |
确认当前用的哪个链接器 |
| 查看 OOM 详细评分 | journalctl -k | grep -i oom |
显示 oom_score 和进程列表 |
| 验证 swap 是否启用 | swapon --show |
查看 swap 设备/文件大小 |
然后是一些常见问题速查:
问题一:已经加了 swap,但链接还是被 kill
可能原因:swap 设置太小,或者编译产生的临时文件占满磁盘导致 swap 文件无法扩展。先用 free -h 确认 swap 真实可用,再用 df -h 检查根分区剩余空间。有时候 fallocate 创建的文件比实际需求小,也会导致这个问题。
问题二:用了 lld 但仍然 signal 9
这种情况多半是物理内存确实不够了,而且 swap 设置也来不及。建议退回“限制并发”这个方案,把 ninja -j16 改成 -j4,先让一个链接器完成,再启动下一个。比起让所有链接器一起挤爆内存,串行执行反而更高效。
问题三:报错里出现 ld block
如果看到 ld block 字样,这表明链接器在写的某个区块(block)太大,或者链接器进程被阻塞。这种情况下,考虑关掉 debug info 或者把输出文件拆成多个共享库,避免单次链接超大文件。Qt 工程链接 LLVM 静态库时常出现这个现象。
问题四:编译其他项目没问题,只有 LLVM 报错
这种大概率是 LLVM 的目标文件数量太大了,不是系统配置的问题。建议优先换 lld,其次调低链接并发。如果还不行,就把 LLVM 拆成多个子项目编译,比如先编 llvm 本体,再编 clang、lld,不要一次性 LLVM_ENABLE_PROJECTS=all。
问题五:Windows 上遇到类似报错
Windows 上 ld terminated with signal 9 偶尔也会出现(MSYS2/MinGW 环境)。排查思路一样:看是不是内存不够。Windows 下的解决办法是增加虚拟内存页面文件大小,或者用 lld-link 替代 ld。Clang-cl 环境里可以用 -fuse-ld=lld。
8. 预防远比修复重要:两个长期有效的编译习惯
第一个习惯是:每次编译大项目前,都先检查内存和 swap。我不管是在自己的服务器上还是客户的环境里,都会先跑一遍:
bash复制free -h
nproc
df -h
三行命令确认了资源底数,才决定配置多少并发。很多人习惯用 make -j$(nproc) 一把梭,图省事,结果就是 OOM 来了才手忙脚乱。其实你在 cmake 阶段就可以用 -DLLVM_PARALLEL_LINK_JOBS=2 指定链接时的并发数,这个参数是 LLVM 项目独家支持的,非常实用。
第二个习惯是:链接器选型和编译器选型同样重要。我从 LLVM 9 时代开始用 lld 替代 GNU ld,这些年体验下来,基本没有因为链接器本身出过大问题。如果你还在用 GNU ld,建议花十分钟装一个 lld 试试,对比一下链接耗时和内存占用,大概率会直接路转粉。其实不只是 LLVM,只要是大型 C++ 项目,比如 Chromium、Qt、Blender,用 lld 都能带来立竿见影的效果。
最后再分享一个小技巧。如果你经常需要编译大型项目,但机器的内存又有限,可以考虑给编译任务单独设置一个别名,比如:
bash复制alias safe-build='ninja -j$(nproc) -l$(($(nproc) / 2))'
这样不管当前系统负载多少,编译任务都会给自己留一半的核心余量,把内存和 IO 的压力都降下来。长期用下来,确实能少遇到很多令人头秃的 signal 9。
