读者: 要把一个程序交付到没有 mcpp 的机器上的人。
本章回答的那一个问题: 一次构建怎样变成另一台机器能运行的东西,以及每一种 打包模式各自携带什么。
不在这里: 交付一个供其它包在构建时使用的库,那是 12 —— 分发预构建库。在此之后: 11 —— 发布一个库到 mcpp-index。
本页讲的是打包一个程序。若要以「接口 + 预构建二进制」的形式发布一个库, 见 12 —— 分发预构建库。
mcpp pack具体做哪一种,由 target 的kind决定,不由某个 flag 决定:mcpp pack <name>打包[targets.<name>],一个bin会变成一个 bundle, 而一个lib/shared会变成一个库包。不给名字时,mcpp 选择唯一可打包的 target。
mcpp build默认产出的动态链接二进制,其 loader 与 RUNPATH 都指向构建 沙盒。它是开发产物,不是交付物。有三条路径能把它变成交付物 —— 没有一条会 用到宿主的 C 库。
下面每一种路径产出的产物,其 C 运行时都来自生态,从不来自 /lib64。这是有意
安排的:mcpp 之所以针对私有 glibc 构建,正是为了让产物的行为不取决于底层是
哪一个发行版;如果分发的最后一步又伸手去用宿主的 libc,前面这一步做的事就白
费了。
| 路径 | 命令 | C 运行时的来源 | 适用条件 | |
|---|---|---|---|---|
| A | 走生态 | mcpp emit xpkg → xlings install <pkg> |
目标机自己的 xlings 载荷 | 目标机已装 xlings |
| B | 单个静态文件 | mcpp build --target x86_64-linux-musl |
不来自任何地方 —— 已链接进去 | 需要一个没有任何运行时依赖的单文件 |
| C | 自带运行时 | mcpp pack --mode self-contained |
随 bundle 一起分发 | 任意 Linux,含比构建机更旧的版本 |
关于路径 A。 刚构建出的二进制中记录的 PT_INTERP 指向构建机的载荷,因此
把该文件手工复制到另一台机器上不会运行:那条路径在目标机上不存在。这是「手工
复制」这个动作的性质,不是产物本身的性质 —— 经 xlings 安装时,包内的 ELF 会
在安装期被重新指向目标机自己的载荷。记录的路径是构建机的细节,不是分发格式的
一部分。需要在机器间手工搬运二进制时,能存活下来的是路径 B 与 C。
关于路径 B。 --target …-musl 隐含静态链接,因此没有 loader、没有
RUNPATH,运行期不需要查找任何东西。它的结果最小也最可移植,是程序不需要
glibc 专有行为(NSS 查询、dlopen 宿主插件)时的首选。
关于路径 C。 bundle 内携带这套工具链的 glibc 及其 loader,因此能在比构建机
更旧的发行版上运行 —— 这是路径 B 覆盖不到、而又确实需要 glibc 时的那一格。选
它之前先读下面 /proc/self/exe 一节:经由内置 loader 启动会改变程序对「自己
在哪」的认知。
分发有两项正交的选择:
- libc / static 是一项构建 target 属性:
--target …-linux-gnu(glibc) 与--target …-linux-musl(musl,静态)。--target …-musl隐含static。 - 打包深度 是一项pack 属性:产物随身携带多少共享库闭包,由
--mode选择。
| Mode | 宿主必须提供 | 体积 | 使用场景 |
|---|---|---|---|
system |
每一个 .so(含第三方) |
最小 | .deb/.rpm,同发行版机群(由包管理器声明依赖) |
vendored(默认) |
libc / libstdc++ / loader | +几 MB | 主流发行版(Ubuntu 22+、Debian 12+、RHEL 9+) |
self-contained |
无 | +30–50 MB | 任意 Linux,含较旧的 glibc;携带闭包与 run.sh wrapper |
static |
无(单文件) | +5–10 MB | musl;架构匹配的 Linux x86_64 或 aarch64,Docker scratch,Alpine |
如何选择:
- 分发格式打包(
.deb/.rpm)或同发行版内部部署 →system - 面向主流发行版的桌面 / 服务端发布 →
vendored(默认) - 跨发行版,或目标是较旧的 glibc(旧版 CentOS、麒麟等) →
self-contained - 单个便携文件、不依赖宿主 →
static
没有任何 mode 会携带构建机的路径。 开发构建有意寻址的是本机:它的
DT_RPATH 命名工具链的载荷目录与 SubOS 的库视图(<subos>/lib),它的
PT_INTERP 命名一个私有 loader。每一种 mode 都改写这两者 —— vendored 与
self-contained 改写为相对 $ORIGIN 的路径,system 则整个清空搜索路径并
恢复平台标准的 interpreter。system 不是「原样保留构建时的样子」,而是「目标
机会提供一切」这一断言 —— 这是关于目标机的陈述,不可能用本机的绝对路径拼出
来。e2e 215 会扫描 bundle 里每一个 ELF,查找任何落在 $MCPP_HOME 之下的路径,
命中即失败。
「自包含」有一个下限。有些库只能来自目标机器:图形驱动的用户态部分与正在
运行的内核模块版本绑定,而对专有栈而言,再分发是不被允许的。这类依赖应声明为
运行期能力需求(docs/04-mcpp-toml.md §2.11),模式表因此多出一列:
| Mode | 需要宿主提供能力的程序 |
|---|---|
system |
yes |
vendored(默认) |
对这类程序而言正确的默认值 |
self-contained |
打包期拒绝 |
static |
打包期拒绝 |
两处拒绝出自同一个事实:自带 libc 的 bundle 无法消费宿主提供的库。 那个
.so 带着它对目标机 libc 的要求到达,而该进程没有那份 libc —— 双向实测记录
于 mcpp#392 / mcpp#401:私有 glibc 遇上宿主加载的对象,会在重定位阶段、
main 之前崩溃。此前这两种 mode 的行为要么是链接通过、启动时失败,要么是
静默降级(就图形栈而言表现为软件渲染,且没有任何提示)。
vendored 会打包这类程序,并在 bundle 根目录写出一个 HOST-REQUIREMENTS
文件,说明目标机必须提供什么:
capability=opengl.glx.driver discovery=rpath-of-dispatch
discovery 是可操作的那一半 —— 各机制彼此独立,满足其一不代表满足另一个。该
文件只在确有内容需要说明时才写出:一个空文件等于声称什么都不需要。
上表给出规范名称。旧名称仍是永久性的兼容别名:bundle-project =
vendored,bundle-all = self-contained。tarball 的文件名后缀是被冻结的
wire 格式(由 install.sh 消费),不跟随改名:vendored 无后缀,
self-contained 是 -bundle-all,static 是 -static,system 是
-system。
mcpp pack # vendored by default
mcpp pack --mode system
mcpp pack --mode static
mcpp pack --mode self-contained # alias: --mode bundle-all
mcpp pack --target x86_64-linux-musl # equivalent to --mode static
mcpp pack --target aarch64-linux-musl # ARM64 equivalent
mcpp pack --format dir # output as a directory, no tarball
mcpp pack --format appimage # a format a package in the graph provides
mcpp pack -o myapp.tar.gz # filename only: lands at target/dist/myapp.tar.gz
mcpp pack -o /abs/path/myapp.tar.gz # includes a directory: output to the literal path
mcpp pack --profile dev # build with a different profile (default: release)
mcpp pack --dev # the same, as `build` and `run` spell it; --profile wins over it
mcpp pack --message-format json # one mcpp.pack envelope on stdout (mcpp 2026.9.16.1+)
mcpp pack --no-strip # ship the artifacts as built
mcpp pack -p app --format release # a workspace member, as if run in its directory
mcpp pack --workspace --format release # every member with a program, planned and built once
mcpp pack -p cli -p gui -o dist/ # two members, each archive below dist/
mcpp pack --debug-symbols dbg/ # write the separated *.debug files under dbg/
mcpp pack --format msi --features installer # activate root-package features for the pack--features <LIST>(mcpp 2026.9.15.2+)为打包所执行的每一次构建启用根包
feature:每一条 --target 腿,以及被分派格式的两遍构建。它接受的取值与
mcpp build --features 相同,因此只为某一种发布才需要的宿主工具,可以声明在
带 tools = [...] 的 [feature-deps.<f>] 下,只由点名 <f> 的那次打包构建
出来。mcpp run --format <name> --features <LIST> 把同样的 feature 交给它
执行的那次打包。
-p <member>(mcpp 2026.9.28.3+)在工作区根目录打包某个成员:成员的解析方式与其他 -p
相同,打包在该成员目录中进行,因此结果与在该目录执行 mcpp pack 相同。相对路径的 -o 仍以
执行命令时所在的目录为基准。
--release 与 --dev(mcpp 2026.9.16.1+)是 build、run 已接受的简写,
优先级相同:三条命令上都是 --profile 优先于它们。
打包对它所执行的构建的陈述方式与 mcpp build 相同(mcpp 2026.10.1.2+):每个有工作的包
一行,运行期间显示状态行,并在第一行 Packing 之前写出 Finished。跨多个配置的打包先构建
全部配置,只写一次 Finished。由包提供的 --format 的第二遍以同样方式陈述,其后是
Distributing 与 Packed 行。构建同时运行 [build] jobs 个编译,与每一条会编译的命令相同。
2026.10.1.2 之前,打包中的构建不写出上述任何内容,重新编译数分钟的打包只显示 Planning。
-p 可以重复,--workspace 与 --exclude 选择成员的方式与 mcpp build 相同
(07 —— 工作区,§5.3)。这些成员作为一个选择一起规划、只构建一次,
然后每个成员在各自的暂存树中暂存并打包:
mcpp pack --workspace --format release # 每个有程序的成员
mcpp pack --workspace --exclude updater # 除一个之外的每个这样的成员
mcpp pack -p cli -p gui --format release对每个成员分别运行 mcpp pack -p <member>,每次都会重新规划图、重新运行构建程序、
重新启动构建,它们共用的成员也不例外。对多个成员的打包,每个配置(即 mcpp build
使用的分组)规划一次、构建一次,每个构建程序每遍运行一次:被多个成员依赖的包,
例如带构建程序的共享库,只准备并编译一次,它的程序在这次打包里不会再次运行。
每个成员得到的,就是 mcpp pack -p <member> 给它的:
- 产物。 成员的归档或目录,就是它单独打包时得到的那一份,位于它自己的
target/dist/之下。给出--output <dir>时,它写在该目录之下(目录不存在时会创建),使用默认的名字。 - 自己的暂存树。 使用被分派的
--format时,打包如上所述准备两次,第二遍告诉每个 构建程序它所代表的成员的暂存树。mcpp::pack_stage_dir()与${mcpp.stage_dir}按成员回答。一个程序代表它所属的成员;只有一个被打包成员依赖到的包(成员所依赖的 提供者)代表那个成员;被多个被打包成员依赖到的包不代表其中任何一个,它既不会被告知 格式,也不会被告知任何树,因此它的程序运行一次就够所有成员使用。 - 自己的可分发物。 本次请求引入的
artifactaction,属于提交它的那个程序或提供者 所代表的成员。每个成员的输出都会核实存在,并以该成员的Packed报告。
--workspace 只打包有程序 target 的成员,并说明跳过了哪些;用 -p 点名的成员若没有
程序 target 则被拒绝。成员按配置逐组打包,按 [workspace] members 的顺序报告,与 -p 的书写顺序无关。
某个成员失败(例如它的提供者对所请求的格式什么也没有提交)时,按名字报告,其余成员
照常打包;只要有一个成员失败,退出状态就非零。一个成员 —— 无论是用一个 -p 点名,还是
由命令所在的目录决定 —— 与以前完全一样地打包:mcpp pack -p X 只规划 X 的闭包。在虚拟
工作区根目录不带任何选择器的 mcpp pack 仍旧打包第一个有程序的成员;--workspace 则
打包全部。
在任何东西被编译之前拒绝,并点名被拒绝的对象:
| 输入 | 原因 |
|---|---|
| 位置参数 target 名 | 它点名的是某一个包的 target |
多于一个 --target |
一个程序只为一个 target 构建,而多 target 的 Android 打包暂存的是一个成员的各条腿 |
已存在且是文件的 --output |
每个成员的归档或目录写在该目录之下 |
用 -p 点名、却没有程序 target 的成员 |
它没有可打包的东西 |
使用被分派的 --format 时,没有任何提供者代表它的成员 |
该格式由成员依赖不到的包提供,或由被多个成员依赖到的包提供 |
| 两个会写同一个归档或目录的成员 | --output 只点名一个目录,而名字、版本与 target 相同的两个包共用同一个归档名 |
一个为多个成员提供格式、带构建程序的包,无法同时服务它们:提供者只会得到一棵暂存树。 请从每个成员自己的构建程序提供该格式(两者共用一个辅助函数即可),或者逐个打包成员。
--message-format json(mcpp 2026.9.16.1+)在命令结束后于 stdout 上输出一个
mcpp.pack 信封,所有给人看的行都改走 stderr。它的 data.artifacts 列出
产出的每一个文件或目录,带绝对路径、type(file 或 directory)、
--format 取值以及各条腿的三元组;data.stage 给出暂存树、它的 manifest,
以及闭包是否已经走通(见 50 —— 机器输出)。对多个成员的
打包列出每个成员的产物,每一条点名它的 member,data.stage 为 null,并在
data.stages 中为每个成员给出一棵树(2026.10.1.1+)。这条命令
上的 --format 表示的是包格式,因此机器输出改用 mcpp test 请求它的那种
方式。
tar 与 dir 回答的问题,和 msi 与 appimage 回答的问题是同一个 ——
输出取什么形状 —— 所以它们是同一个 flag 的不同取值,而不是第二个 flag 的开端。
引擎持有什么、包持有什么,分界如下:
mcpp pack拥有机制,以及那一种通用格式。其余每一种格式都住在某个包里, 由mcpp pack分派过去。
那种通用格式就是它已经在产出的东西:一个解开即可运行的归档。它「通用」只在这 里唯一要紧的那个意义上成立 —— 它不需要了解任何别人的发布方式。此外的一切都 需要。dpkg 的 control 字段、AppImage 的 runtime、WiX 的 schema、Apple 的 公证、Android 的签名方案:其中任何一个被绑进引擎,都会把一次 mcpp 的发布耦合 到一次 mcpp 并不控制的发布上。这与本项目已经为语言做过的论证是同一个 —— Slang 被支持,而引擎里没有它的名字。
所以取值集合是开放的(mcpp 2026.9.11.1+)。--format <name> 在解析后的图里
找到声明了 <name> 的那个包,并把暂存树交给它;一个未知的取值点名的是
当下确实可用的那些,而不是一份固定清单:
error: unknown --format 'bogus'.
available in this build: tar, dir, appimage
A format past `tar` and `dir` comes from a package in the resolved graph, which declares
it with `mcpp::provides_pack_format("<name>")` in its build program. Add the package
that provides 'bogus' to [build-dependencies] and activate its feature.
这次拒绝发生在任何东西被编译之前。怎样写这样一个包,见
产出可分发物;
引擎为此新增的三样东西是:一棵 artifact action 可以消费的暂存树、
[package] 里其余字段进入构建程序、以及这次分派本身。每一样都与具体格式无关
—— 「与格式无关」正是判断某样东西该不该进引擎的判据。
被分派的格式作用于一个程序 target —— kind = "app" 也不例外,无论这一行
把它链接成什么文件(见 04 §2.2)。库包发布的是一份接口
加上按三元组给出的预构建产物,没有单独一棵暂存树,所以
mcpp pack <库> --format <name> 会被拒绝,而不是被忽略。
产物是共享目标文件的 kind = "app" target 可以接受一个以上的 --target
(mcpp 2026.9.13.2+):在每一行 Android 上,应用就是平台加载的那个共享库,
因此 mcpp pack myapp --target aarch64-linux-android --target x86_64-linux-android
会构建两条腿并把它们暂存进同一棵树,与库包本已具备的多三元组做法完全一致。
每条腿落在 lib/<abi>/lib<name>.so(aarch64 → arm64-v8a,x86_64 →
x86_64),各自的闭包暂存在它旁边(见
Android),声明的部署文件只暂存
一次,随后只对这棵合并后的树跑一次分派 —— 这正是 dist-apk 这样的成员能构建
出一个通用 APK 的原因。只给一个 --target 时,今天这种扁平的
lib/lib<name>.so 布局保持不变。产物在任何被请求的一行上是可执行文件的
target,第二个 --target 依旧被拒绝:为多个三元组打包同一个可执行文件需要
多个可执行文件,那是另一种机制(lipo 的通用二进制),不在这份能力之内。
-o 接受裸文件名时,输出落在 target/dist/ 下;含目录(相对或绝对)时,按
字面路径输出。
完整选项参见 mcpp pack --help。
mcpp run --target x86_64-linux-android --format apk
mcpp run --target aarch64-ios-sim --format app--format 与 mcpp pack 用的是同一个 flag,这里复用它是为了覆盖一种普通
mcpp run 够不到的情形:一个 Android 应用程序是一个 .apk,一个已安装的 iOS
应用程序是一个 .app,两者都不是 mcpp run 默认执行的链接产物。
mcpp run --format <name> 先为 <name> 打包 —— 与 mcpp pack --format <name> 相同的两遍构建与暂存树 —— 再经由为一个程序解析出的 runner 运行打包
报出的那个产物:先看项目的 [target.<triple>] runner,其次看依赖的
mcpp::runner(...),再次看载荷描述文件的。
打包报出的产物是这次请求的终端产物:在这次请求引入的 artifact 动作中,
没有被任何其他被引入的动作当作输入的那一个输出。一个提供者常常是一条链
(dist-apk:链接、加库、对齐、签名),链上每个输出都会被核验存在,但只有
最后一个是发布物,也只有它以 Packed 报出。链的末端产出两个文件的格式会被
mcpp run --format 拒绝并点名两者,因为一个 runner 只接受一个操作数。
一个格式可能报出很多输出,发布目录中的每个文件各一个。不超过八个时逐行打印;更多时按它们
在共同目录之下所处的条目打印,并附数量(Packed Release/app (1309 files)),--verbose
打印全部(2026.9.29.5+)。--message-format json 在两种情况下都列出全部输出。
未知的 <name> 会被拒绝,点名已解析图提供的格式集合,与
mcpp pack --format bogus 报出的是同一个集合。--format 与 --no-runner
同时出现会被拒绝 —— 一个 .apk 或一个已安装的 .app 无法被直接执行。在
kind = "app" 的形态是一个库的那一行上、不带 --format 运行它,同样会被
拒绝,点名该 flag 与同一个格式集合。mcpp test 不受影响:测试二进制在每一行
上都是一个程序。
与 mcpp build 相比有两点不同,都源于同一个原因:「这个产物要离开本机」。
profile 的兜底值是 release,不是 dev。 其余优先级不变 ——
--profile 优先于 [build] default-profile,后者优先于兜底值。只有最后
一步不同,因此已经声明过 profile 的工程仍然拿到它声明的那个,mcpp pack 也
绝不会产出一个 mcpp build 在同样条件下产不出来的 flag 组合。
有一条后果值得知道:裸
mcpp build与裸mcpp pack现在会写进不同的target/<triple>/<fingerprint>/目录 —— 指纹把 profile 算了进去。手工放到 构建产物旁边的文件(一个 DLL、一份数据)因此只在两条命令解析到同一个 profile 时才会被pack看见:要么把它写进[build] default-profile,要么 两条命令都带上--profile。声明式通道([runtime] deploy_files、runtime_search_dirs)不受影响。
调试信息会被剥离,发布者的路径随之消失。 未 strip 的产物携带 DWARF,而 DWARF 携带发布者源码树与构建目录的绝对路径。具体剥离什么取决于产物是什么 —— 这是 dh_strip 的分档方式,而其中归档那一行是要紧的:
| 产物 | strip 参数 | 不能剥得更彻底的原因 |
|---|---|---|
| 可执行文件 | --strip-all |
没有谁会链接它 |
| 共享库 | --strip-unneeded |
保留 .dynsym —— 那就是导出表 |
| 静态归档 | --strip-debug --enable-deterministic-archives |
--strip-all 会删掉归档的符号索引,消费方链接时随即报出 archive has no index; run ranlib to add one |
三档都会去掉 .comment 与 .note。段的删除按精确名字匹配,因此
.note.gnu.build-id 会被保留,--add-gnu-debuglink 仍然有东西可以配对。
--no-strip(或 [pack] strip = false)按构建原样发货。
--debug-symbols <dir> 则是分离而不是丢弃:写出 <dir>/<产物>.debug,并给
发货的产物加上指向它的 .gnu_debuglink —— 这正是调试器与 debuginfod 所
遵循的约定。
[pack] strip不是[profile.<name>].strip。后者给链接加上-s, 既碰不到静态归档,也无法分离出任何东西;前者管的是包里带什么。两个不同 的决定,两个不同的名字。
图构建出来的东西,在每一条把调试信息放在映像内部的行上都会被剥离;来自 store 或宿主的库不会(mcpp 2026.9.16.1+)。规则是 dh_strip 的「一个包 只剥离它自己构建的东西」,覆盖范围如下:
| 暂存的文件 | 是否剥离 | 原因 |
|---|---|---|
| 程序 | 是,按可执行文件剥离;在 Android 行上按共享库剥离,因为那里的程序本身就是共享库 | 本次构建编译了它 |
图中 SharedLibrary 链接单元产出的共享库 |
是,--strip-unneeded |
本次构建从源码编译了它 |
工具链自身运行时的暂存副本(NDK 的 libc++_shared.so) |
是,--strip-unneeded |
树里的副本不是那份共享载荷本身;Android Gradle 插件也会剥离同一个文件 |
| 来自 store、宿主,或某个包部署的预构建库 | 否 | 它的字节属于它的发布者,供应商的库可能带着签名 |
Mach-O 与 MSVC 的 PE 把调试信息放在映像之外,因此那里什么都不剥离,Packing
这一行只在确实发生剥离的那一行上写「stripped」。--no-strip 与
--debug-symbols 管辖每一个被剥离的文件。自己暂存库的构建程序,通过
mcpp::pack_strip() 与 mcpp::pack_debug_symbols_dir() 读到同一个决定
(30 —— build.mcpp)。
tarball 的内容包在一个顶层目录里,该目录的名字与 tarball 文件名(去掉
.tar.gz 后)保持一致 —— 这样图形界面的「右键解压」和命令行的 tar -xzf
都会得到同一个自包含的目录,而不会把内容散落到当前路径下。
闭包会被记录,一个不完整的闭包会使归档被拒绝(mcpp 2026.9.14.2+)。
暂存树旁边的 <tree>.stage-manifest 列出闭包解析过的每一个库名:树里携带的
库给出其暂存路径,目标机自己提供的库记为 platform,哪里都找不到的记为
unresolved(行格式见 50)。只要有一个名字
是 unresolved,--format tar 与 --format dir 就会失败并点名它,因为一棵
缺了这个库的树在安装处无法启动;被分派的格式仍然会收到这棵树,并带上
closure = not-walked。目标机提供、而本机没有的库,写进
[pack.bundle-project] also_skip。不打包任何东西的 mode(system、
static)不记录闭包。
target/dist/myapp-0.1.0-x86_64-linux-musl-static.tar.gz
└── myapp-0.1.0-x86_64-linux-musl-static/
├── bin/myapp ← fully static ELF (no PT_INTERP / RUNPATH)
├── myapp ← top-level entry point (thin shell wrapper, run ./myapp directly)
├── README.md ← copied automatically from the project root
└── LICENSE
target/dist/myapp-0.1.0-x86_64-linux-gnu.tar.gz
└── myapp-0.1.0-x86_64-linux-gnu/
├── bin/myapp ← dynamically linked, RUNPATH=$ORIGIN/../lib
│ PT_INTERP=/lib64/ld-linux-x86-64.so.2
├── lib/
│ ├── libcurl.so.4 ← project third-party dependency
│ ├── libssl.so.3
│ └── ...
├── myapp ← top-level entry point
├── README.md
└── LICENSE
跳过列表遵循
PEP 600 / manylinux2014 ——
默认假定目标系统已经具备 libc、libm、libstdc++、libgcc_s、
ld-linux-* 一类基础库,因此不把它们打进 tarball。
target/dist/myapp-0.1.0-x86_64-linux-gnu-bundle-all.tar.gz
└── myapp-0.1.0-x86_64-linux-gnu-bundle-all/
├── bin/myapp
├── lib/
│ ├── ld-linux-x86-64.so.2 ← complete loader and libc
│ ├── libc.so.6
│ ├── libstdc++.so.6
│ ├── libgcc_s.so.1
│ └── ...project dependencies
├── myapp ← one of two entry points
├── run.sh ← the other entry point (identical contents)
├── README.md
└── LICENSE
带 -o foo.tar.gz 时,顶层目录名也会变成 foo(包名与目录名始终保持一致)。
ELF 规范禁止 PT_INTERP 使用 $ORIGIN,因此在 self-contained 模式下,
loader 经由 run.sh(以及顶层的同名 wrapper)以绝对路径的方式被调用:
exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myapp" "$@"上面的布局与 wrapper 以 x86_64 为例。打包器会按 target 推导 loader 的名字;
在 aarch64 上它是 ld-linux-aarch64.so.1。
「由 loader 启动」带来一个上述布局看不出的后果:内核会把 /proc/self/exe
设为指向 loader,而不是程序本身;/proc/self/cmdline 里也混入了
--library-path 这个参数。于是每一处「到可执行文件旁边找资源」的逻辑,解析
出来的都是 lib/ 而不是 bundle 根目录 —— 而且是静默发生的。实际表现
是:GUI 工具箱因为找不到字体而渲染出空白文字、一个 assets/ 目录看起来像是
缺失了、随包分发的辅助二进制找不到自己的位置。从 /proc/self/cmdline 解析
argv 的代码,拿到的会是混入了 loader 参数的结果。
这只影响 self-contained。vendored、system 与 static 携带的都是内核
能直接使用的 PT_INTERP,/proc/self/exe 在那三种模式下是正确的。
wrapper 为此导出了 MCPP_BUNDLE_DIR(bundle 根目录)。应优先按它解析,
只在它未设置时才回退:
const char *base = getenv("MCPP_BUNDLE_DIR"); /* set by run.sh */
if (!base) {
/* not launched through the wrapper — /proc/self/exe is trustworthy */
}如果应用本身改不了 —— 比如某个第三方 GUI 框架自己做路径解析 —— 改用
--mode vendored。它把 PT_INTERP 重新指向宿主的 loader,代价是要求宿主的
glibc 版本不低于产物构建时所用的那份。
target/dist/myapp-0.1.0-wasm32-emscripten.tar.gz
└── myapp-0.1.0-wasm32-emscripten/
├── bin/myapp.js ← the launcher; the file a runner executes
├── bin/myapp.wasm ← implicit output of the link, staged with it
├── bin/myapp.data ← present only when the link carries --preload-file
├── README.md
└── LICENSE
打包器暂存启动器,以及同一次链接在它旁边写出的每一个 myapp.<任意后缀> ——
即词干家族。myapp.wasm 是必需的:链接边把它声明为隐式输出,它的缺席说明
构建目录与图对不上,mcpp pack 因此会拒绝,而不是暂存一个没有模块的启动器。
myapp.data 以及 emcc 用同一个词干写出的其余文件(myapp.worker.js、
myapp.wasm.map)是可选的,链接产出了才会跟着走。
一个 Windows target 产出的是一个 .zip,不是 .tar.gz,并且是扁平
布局:
target/dist/myapp-0.1.0-x86_64-pc-windows-msvc.zip
└── myapp-0.1.0-x86_64-pc-windows-msvc/
├── myapp.exe
├── vcruntime140.dll ← only under cxx_runtime = "toolchain-coupled"
├── mydep.dll ← third-party dependencies
├── README.md
└── LICENSE
没有 bin/ + lib/ 的分层,也没有入口 wrapper —— 这两点都不是风格选择。
Win32 的 loader 从可执行文件所在目录解析一个 DLL;PE 没有 RUNPATH 可以
指向别处,因此「放在 .exe 旁边」就是 ELF 上 $ORIGIN/../lib 提供的
那个机制。
Windows 自己的 DLL 永远不会被打进包里 —— kernel32.dll、ntdll.dll、
ucrtbase.dll,以及 api-ms-win-* 一族 API set。携带一份操作系统组件的私有
拷贝,不是让包变大,而是让程序坏掉(进程里出现了两份本应唯一的东西),
Microsoft 的再分发条款也从另一个角度说了同一件事。[pack.bundle-project] force_bundle 仍然可以覆盖这条规则,就像它能覆盖 ELF 的跳过列表一样。
vcruntime140.dll 与 msvcp140.dll 不是 Windows 自己的:它们属于 MSVC
toolset,正如 libstdc++.so 属于 gcc。它们要不要随产物一起走,由
cxx_runtime 决定(见 docs/04-mcpp-toml.md),不由上面这张表决定 ——
而 mcpp pack 会拒绝那些无法兑现契约的组合:
$ mcpp pack --mode system # with cxx_runtime = "toolchain-coupled"
error: cxx_runtime = "toolchain-coupled" and --mode system contradict each other.
这是可行的,而且不是什么特殊模式 —— 只需针对 Windows target 构建,然后打包:
mcpp pack --target x86_64-windows-gnu # from a Linux hostmcpp pack 曾经直接拒绝 Windows。真正的原因不在打包工具:ELF 的依赖闭包是
靠把产物跑起来(用 LD_TRACE_LOADED_OBJECTS)求出来的,这既跨不了操作
系统,也跨不了架构。PE 的闭包改为直接从文件的导入表里读出来,因此不需要
执行任何东西,打包所在的宿主也就自由了。压缩包本身同样由 mcpp 自己写出,理由
相同 —— 不是每个宿主上都存在某个 zip 工具。
有两点值得知道:
- 条目是存储而非压缩的,因此 Windows 包的体积大致等于其内容之和。压缩是 体积上的优化,不是正确性问题,目前尚未实现。
- 压缩包是确定性的:不读取任何时间戳,因此同一棵树打两次包字节完全一致, 公布出去的校验和才有意义。
反方向 —— 在 Windows 上给 Linux 产物打包 —— 仍然不支持:ELF 程序的闭包要靠 目标自己的动态链接器解析,而 Windows 宿主没有办法运行它。Mach-O 程序的闭包是 从文件里读出来的,因此一个 macOS 程序可以在任何宿主上打包。
target/dist/myapp-0.1.0-aarch64-macos.tar.gz
└── myapp-0.1.0-aarch64-macos/
├── bin/myapp
├── bin/libmydep.dylib ← a dependency's dylib, beside the program
├── myapp ← entry-point wrapper
├── README.md
└── LICENSE
Mach-O 程序的闭包从它的 load command 里读出(mcpp 2026.9.14.2+),因此打包
时不运行任何东西。它的 dylib 暂存在它旁边的 bin/ 下,这也正是一个图内构建的
dylib 的消费者,其链接所带的 @loader_path rpath 本就会查找的位置:不编辑
任何 load command,不重新签名,也不 strip 这个程序。
一个被需要的名字,只有在加载器能把它解析到程序所在目录时才会被暂存:
| 名字 | 处理方式 |
|---|---|
/usr/lib/…、/System/Library/… |
不暂存;由操作系统提供 |
@rpath/<file> |
当需要它的映像或程序本身,有一条恰好等于 @loader_path 或 @executable_path 的 rpath 时暂存 |
@loader_path/<file>、@executable_path/<file> |
暂存到该路径,该路径必须留在程序所在目录之内 |
任何其他名字都会使闭包不完整,tar 与 dir 会拒绝并点名它。最常见的情形是
落在操作系统目录之外的绝对 install name:加载器会在每一台机器上都从那个路径
去读,无论树里携带了什么。--mode system 与 --mode static 在这一行上不
打包任何东西,与 PE 相同。kind = "lib" / "shared" 的 target 走库打包
路线,不受影响。
target/dist/myapp-0.1.0-x86_64-linux-android/
├── lib/libmyapp.so ← the application object
├── lib/libmydep.so ← a dependency's shared library
├── lib/libc++_shared.so ← the NDK's C++ runtime, when the object needs it
└── bin/<to>/… ← files `[runtime] deploy` placed
在 Android 行上,应用是一个暂存在 lib/ 下的共享目标文件,它的闭包从文件中
读出并暂存在它旁边(mcpp 2026.9.14.2+)。设备提供的名字不会被暂存:即出现在
该 API level 桩目录中的名字,这个目录由 mcpp 向该行自己的编译器询问得到(即
编译器找到 libc.so 的那个目录)—— libc.so、libm.so、libdl.so、
liblog.so 以及其他平台库。其余每一个名字都必须能在链接用过的某个目录中
解析到:构建的输出目录、图中各包的 [runtime] library_dirs 与
link_library_dirs,以及编译器的库搜索路径(libc++_shared.so 就在其中)。
哪里都解析不到的名字会使 tar 与 dir 拒绝,并点名它以及被搜索过的那些
目录。
给出多个 --target 三元组时,每条腿暂存在 lib/<abi>/ 下,各自带着自己的
闭包。--mode 不适用于这一行:设备恰好提供了平台库,因此既没有可以留给宿主
的库集合,也没有需要携带的 loader。
打包行为通过 mcpp.toml 中的 [pack] 节配置,常用字段如下:
[pack]
default_mode = "static" # override the normal vendored default for bare `mcpp pack`
strip = true # default. false ships the artifacts as built
debug_symbols = "dist/debug" # separate the debug info here instead of discarding it
include = ["share/**", "config/*.toml"] # extra files to bundle
exclude = ["debug/**"]
# Fine-tune the vendored filtering policy. The configuration key keeps its
# established `bundle-project` spelling.
[pack.bundle-project]
also_skip = ["libcustom.so"] # libraries assumed to exist on the target system
force_bundle = ["libfoo.so"] # bundle even if matched by the PEP 600 list[pack].default_mode 当前接受既有的 manifest 拼写 static、
bundle-project 与 bundle-all;system 这一 mode 只能通过
mcpp pack --mode system 显式选择。CLI 输入同时接受上文的规范名称与兼容
名称。
static mode 还需要在 [target.<triple>] 下配置一套 musl 工具链;完整写法
参见 examples/03-pack-static 中的
mcpp.toml。
当前 .zip 之外的 Windows DLL 分发方式在规划中。
.deb、.rpm、AppImage、.msi 这些分发格式不在这份清单上,而这是一个
决定而不是一处遗漏:它们住在各自的包里,经由 --format <name> 到达用户,
理由见上一节。[pack] 内建的这几种 mode 不需要再添任何新成员。
本文档随 mcpp pack 的实现演进;最新选项以 mcpp pack --help 为准。