Repository navigation
feat/RFC: 依赖的链接形态(static/shared)应是一根可选择的轴,而不是由包作者定死 —— 现状会静默符号劫持 #519
Description
feat/RFC: 依赖的链接形态(static / shared)应当是一根可选择的轴,而不是由包作者定死
现状核实基于本地检出
f2dbeec(2026-08-23)与 mcpp 2026.8.27.1。
下面每一条"实测"都附了复现方式,没有推演。
1. 现状
mcpp 对一个依赖只有两种形态,且由包作者在描述符里定死:
| 描述符 | 消费者那边实际发生的事 |
|---|---|
kind = "lib" |
该包的 .o 全量直接进消费者的链接行。没有 .a,没有归档懒选 |
kind = "shared" |
mcpp 在消费者 bin/ 里真建一个 .so,消费者链它 |
读 mcpp-index 的 tests/examples/eui-neo 生成的 build.ninja 可见两者并存:
# kind = "shared"(compat.x11)
build bin/libX11.so : cxx_shared obj/compat_x11/src/**.o | bin/libxcb.so …
build bin/libX11.so.6 : runtime_alias bin/libX11.so
# kind = "lib"(compat.zlib)—— 对象直接出现在消费者的 link 行,没有中间归档
build bin/consumer : c_link obj/main.o obj/compat_zlib/*.o …消费者对这个选择没有任何发言权。
2. 后果:混形态会静默符号劫持
这不是理论问题。mcpp-index 里 compat.* 基本都是 kind = "lib"(源码静态),
而 xim 侧的预构建 payload 是共享库。把两者拼进同一次链接就会出事。
复现(完整脚手架见文末):
- 消费者依赖
compat.zlib@1.3.2(kind = "lib",源码构建); - 同时依赖一个预构建包,其
libgio-2.0.so的DT_NEEDED里有libz.so.1,
且该libz.so.1随包提供。
结果:
$ readelf --dyn-syms bin/consumer4 | grep -cE '<zlib 符号>'
86 # 全部 DEFINED —— 静态 zlib 的符号被导出进了可执行文件的 .dynsym
# (链接行里没有 -rdynamic / --export-dynamic,查过 mcpp-clean-link.specs)
$ LD_DEBUG=bindings ./consumer4
binding file …/libgio-2.0.so.0 to …/bin/consumer4 : normal symbol `inflate'
binding file …/libgio-2.0.so.0 to …/bin/consumer4 : normal symbol `inflateEnd'
binding file …/libgio-2.0.so.0 to …/bin/consumer4 : normal symbol `inflateGetHeader' [ZLIB_1.2.2]
binding file …/libgio-2.0.so.0 to …/bin/consumer4 : normal symbol `deflate'
… # 12 次调用全部绑到 exe,一次都没落到 libz.so.1
$ ./consumer4
exe zlib version = 1.3.2 / glib 2.80.0 / conn=yes # 跑得好好的libgio 被整体切换到了消费者的静态 zlib 上,包里随附的 libz.so.1 完全被遮蔽成死重。
链接零警告,运行零告警,没有任何一个工具会说什么。
顺带一条:inflateGetHeader@ZLIB_1.2.2 是带版本的引用,绑到了 exe 里无版本的定义。
loader 接受了。同一情形在链接期(跨 .so 时)是硬错误,在这里静默通过。
放大器:因为 kind = "lib" 依赖是对象全量入链而不是归档懒选,
compat.zlib 的 15 个 .o 全部进了 exe,导出 86 个符号,
而 libgio 只用到其中 12 个。若是普通 .a 懒选,可劫持面会小得多。
这是 ELF 全局扁平命名空间 + "可执行文件排第一"的固有性质(同 LD_PRELOAD 的机制),
不是 bug;但没有任何机制阻止或诊断这种混形态,是缺口。
3. 为什么这不该由包作者定死
src/build/flags.cppm:444:
bool need_pic = false;
for (auto& lu : plan.linkUnits)
if (lu.kind == LinkUnit::SharedLibrary) { need_pic = true; break; }
std::string pic_flag = (need_pic && !isMsvcDialect && !peTarget) ? " -fPIC" : "";-fPIC 已经是整个 build 全局的:只要 plan 里存在任何一个 shared link unit,
所有对象都带 PIC。再加上 docs/05-mcpp-toml.md §2.2 明文的 compile-once 模型
(a source maps to one object),同一组对象本来就能同时喂给两条路。
也就是说:能力已经有了,缺的是选择权。 障碍全在声明层:
-
kind说的是"我是什么",而对一个源码分发的库来说,
静态还是动态本该是消费者的决定; -
没有全图开关。
⚠️ [target.*] linkage = "static"(docs/05§2.7.1、:824)
这个键存在,但是另一根轴——它说的是 libc / CRT(musl、MSVC/MT)。
名字撞车,容易被误读成本 issue 说的这根轴; -
features 纯加性,做不出互斥的
kind。用required_features门控写得出来:targets = { ["zlib"] = { kind = "lib" }, ["zlib-shared"] = { kind = "shared", soname = "libz.so.1", required_features = { "shared" } }, }
但开了
shared之后两个 target 都会出——对象并进消费者并且再建一个.so,
重复符号。
4. 提案
4.1 kind 从"我是什么"变成"我能是什么"
未声明 = 两种形态都支持,这是源码分发库的正确默认。
作者只在确有约束时才声明,而约束是有真实理由的:
- 必须 shared:会被第三方
dlopen,进程里必须是唯一那一个
(compat.vulkan的 loader、X11 家族——mcpp-index的
docs/zh/package-types.md形态 F 已经把这条理由写下来了); - 必须 static:不希望给消费者引入运行期依赖
(compat.openssl用-l:libssl.a而不是-lssl,其描述符注释写明了理由:
一个多余的-L就会让链接静默捡到宿主的libssl.so.3)。
4.2 消费者侧选择
[build]
library_linkage = "static" # 或 "shared";全图默认,可放 [profile.*]
[dependencies]
"compat.zlib" = { version = "1.3.2", linkage = "shared" } # 单包 override(键名待定——因为 linkage 已被 libc 轴占用,本 issue 暂用 library_linkage
以免歧义。若认为该复用同一个词,需要先解决那处撞车。)
4.3 一次链接里,同一个库只允许一种形态
这是最关键的一条:冲突时报错,而不是像 §2 那样静默劫持。
诊断应当指名两个提供者和各自的形态,例如:
error: zlib is linked twice in the same image, in two different forms.
compat.zlib@1.3.2 static (objects merged into `consumer4`)
compat.glib@2.80.0 shared (lib/x86_64-linux-gnu/libz.so.1, via DT_NEEDED of libgio-2.0.so.0)
The static copy wins for every symbol both provide (ELF global namespace),
so the shared one is dead weight and libgio runs against a zlib it was not built with.
Fix: set [build] library_linkage, or make one of them not provide zlib.
即便 §4.1/§4.2 暂不实现,单独把这条诊断做出来也有独立价值——
它把一个 latent 的 ODR 问题变成一个可见的构建错误。
5. 生态参照
| 怎么处理 | |
|---|---|
| vcpkg | triplet 强制:x64-linux 全静态 / x64-linux-dynamic 全动态,一次构建里不许混 |
| Conan | shared=True/False 作为 option 沿整张依赖图传播 |
| Zig | 库的 build.zig 用 b.option(std.builtin.LinkMode, "linkage", …) 暴露,消费者在 b.dependency("foo", .{ .linkage = .dynamic }) 里选,再 linkLibrary(dep.artifact("foo"))。新的 addLibrary(.{ .linkage = … }) 就是为这个而生(ziglang/zig#22554)。这与本提案 §4.1+§4.2 形状一致。 |
| Cargo | 走的是另一条路:Rust 侧靠 symbol mangling 带 per-crate 哈希,重复版本共存是安全的,所以没有这个问题;对原生库则用 manifest 的 links = "foo" 键强制全依赖图内至多一个包可以 link 某个原生库,build script 再用 cargo:rustc-link-lib=static=foo / =dylib=foo 逐库指定形态。注意 Cargo 也没有per-dependency 的 shared/static 选择器(rust-lang/cargo#12848 仍开着)——它是靠"唯一提供者"这条不变量绕开的。 |
| 发行版 apt/dnf | 全系统每个库一份且都 shared,混不起来 |
| PE / Mach-O | 结构上没有这个问题:PE 按 (DLL 名, 符号) 解析;Mach-O 默认 two-level namespace |
两条可选的落点,对应两种哲学:
- Zig / vcpkg / Conan 路线 = §4.1 + §4.2(让形态可选);
- Cargo 路线 = §4.3 的加强版(不给选择,但强制"一个库一个提供者"并在违反时报错)。
§4.3 的诊断在两条路线下都需要,建议先做它。
6. 与既有 issue 的关系
- runtime.library_dirs also lands on the link line, where it can shadow another package's library #304(
runtime.library_dirs也落在链接行、会遮蔽另一个包的库)是同一族的另一面:
那里是路径顺序造成的遮蔽,这里是符号层面的遮蔽。两者的共同点是
"链接行里同一个库有两个来源,而没有机制说这是错的"。 - feat/RFC: [system_deps] —— 经 pkg-config 集成宿主系统库 #493(
[system_deps]经 pkg-config 集成宿主系统库)落地后,
宿主的共享库会直接进入同一次链接——那时这根轴从"最好有"变成"必须有",
因为宿主库的形态完全不由我们决定。
7. 复现脚手架
# consumer/mcpp.toml
[package]
name = "consumer4"
version = "0.1.0"
[targets.consumer4]
kind = "bin"
main = "src/main.c"
[build]
c_standard = "c11"
[dependencies]
"compat.zlib" = "1.3.2"
"compat.glib" = { path = "../vendor/glib-2.80.0-x86_64-linux-gnu" }
[indices]
compat = { path = "<mcpp-index checkout>" }../vendor/glib-…/ 是一个手工组装的预构建分发包(mcpp.toml + include/ +
lib/x86_64-linux-gnu/,后者含 glib 五件套与其闭包,含 libz.so.1),
其 mcpp.toml 只声明:
[build]
sources = []
include_dirs = ["include/glib-2.0"]
[targets.glib]
kind = "shared"
[target.'cfg(all(arch = "x86_64", os = "linux", env = "gnu"))'.build]
ldflags = ["-Llib/x86_64-linux-gnu", "-lgio-2.0", "-lgobject-2.0", "-lglib-2.0"]
[runtime]
runtime_search_dirs = ["lib/x86_64-linux-gnu"]
transitive_needed_dirs = ["lib/x86_64-linux-gnu"]src/main.c 同时 #include <gio/gio.h> 与 #include <zlib.h> 并各调一个函数即可。
mcpp build 后按 §2 的三条命令观察。
Activity
fix(#519): 诊断里三条出路的次序,被真实场景的实测改掉了
fix(#519): 自我 review 的三处,其中一处让打出来的包起不来
docs(#519): 记下第三处被实测推翻的推理(pack 不收合成的共享库)
fix(#519): 依赖目标的 feature 门只管库目标 —— 主机工具不是被门控的对象
refactor(#519): 让请求认得带版本的标签,而不是把标签换成裸名
docs(#519): CHANGELOG 补上 `mcpp pack` 不收合成共享库那条
docs(#519): 落地记录 —— 两条被下限挡住的后续,与生态侧的 CN 镜像
docs(#519): §7.1 回填「分批表是计划,实际做成了一个 PR」
docs(#519): 发布与生态核验 —— 已发布的 mcpp 在已发布的生态里抓到了这条 issue 本身
docs(#519): §15.5 记下 ci-fresh-install 那条红腿,以及它为什么不是这次的
feat: 依赖的链接形态是一根可选择的轴,而混形态会被抓住 (mcpp-community#519) (mcpp-community#521)
prepare.cppm: extract P9-P12 (target side, linkage form, scan, finger…
T6 (mcpp#722, W7): split phase9_target_side (target_side.cpp) into su…
2026.9.28.1: eight reports (#717, #718, #720, #722-#726), one downloa…