读者: 手上有可选内容的作者 —— 一份额外的源、一个额外的依赖,或者在多个后端 之间做选择。
本章回答的那一个问题: 一个包怎样提供可选内容,消费方又怎样请求它。
不在这里: 一次构建面向哪些设备后端 —— 那看起来像 feature,却不是 —— 它是 42 —— 异构硬件构建。在此之前: 05 —— 依赖与解析。在此之后:07 —— 工作空间。
Feature 是一个包提供可选内容的方式:一个编译宏、一份额外的源文件、一个额外的 依赖,或者在多个后端之间的一次选择。本章是声明与消费 feature 的参考。
相关文档:04 —— mcpp.toml 是 manifest 其余部分的字段参考;
examples/11-features 是一个把三种形态都声明了
一遍、并且用 dev-dependency 写了测试的包;42 —— 异构硬件构建
是这套机制最大的消费方,因为每一条加速器 lane 都是一个 feature。
[features]
default = ["base"] # Default activation set
base = []
docking = ["extra"] # Activating docking implies activating extra (transitive closure)
extra = []- 激活来源:包自己的
default集合,并上显式请求(根包经由mcpp build --features a,b;依赖经由长形式依赖 spec 的features = [...]与backend = "..."糖衣)。 - 每个被激活的 feature,在该包编译时得到宏
-DMCPP_FEATURE_<NAME>(名字转大写, 非字母数字字符变_,例如backend-a→MCPP_FEATURE_BACKEND_A)。 - 严格校验:目标包声明了
[features]表时,请求一个未声明的 feature 会产生 warning,在--strict下是错误。不声明[features]的包接受任意请求(纯宏用法)。
<依赖>/<feature> 形式的记号,打开某个依赖的一个 feature。依赖以消费方 manifest
所写的键命名(spike.fw、compat.opencv),记号是可加的:它只打开依赖更多的
部分,从不把依赖本身拉进来。
[dependencies]
spike.fw = { path = "../fw" }
[features]
windows-installer = ["spike.fw/installer"] # the same as `forward = [...]` in the table form- 命令行上(mcpp 2026.9.16.1+),
mcpp build --features spike.fw/installer为 一条命令打开同一个 feature,效果与根的转发相同;run、test、pack、emit build-database与why deps同样接受这个记号。它从不是根自己的 feature, 也从不变成宏。 - 校验会读遍写下该转发的那份 manifest 里的每一张依赖表:
[dependencies]、[build-dependencies]、[dev-dependencies]与[feature-deps.<name>],覆盖 所有行。一个键只在别的行、或未激活的 feature 下声明,仍算已声明;在当前这一行 上,该转发到达不了任何边,不产生效果。没有任何表声明的键会被报告,--strict下报告为错误。命令行上的这条检查不论根是否声明[features]都一样执行。
[features] 的条目除了写成数组,还可以写成表,从而让该 feature 除了隐含
feature 之外,再携带包自有的预处理 defines、feature 门控的源 glob(sources,
mcpp 0.0.95+ —— 列出的 glob 离开默认构建,仅在 feature 激活时才编译,与 index
描述符的 features.<f>.sources 完全对等;这正是 vendored 大库最高频的形态:
feature = 一组源文件 + 一个 define)、feature 门控的 per-glob 编译旗标
(flags,mcpp 0.0.101+),以及 capability 的 requires / provides(见下文
provides / requires):
[features]
default = []
# Array shorthand: just implied features.
docking = ["extra"]
extra = []
# Table form: contribute a package-owned define when active.
mpl2only = { defines = ["EIGEN_MPL2_ONLY"] }
# Table form: a define + an implied feature.
fast_math = { defines = ["APP_FAST=1"], implies = ["extra"] }
# Table form: feature-gated sources + per-glob flags that co-locate with them.
simd = { sources = ["src/simd/**"], flags = [
{ glob = "src/simd/**/*.avx2.cpp", cxxflags = ["-mavx2"] } ] }- 表形式恰好接受
implies、forward、defines、sources、flags、requires、provides、tools。其余键会被报为一条 schema warning 并忽略 (mcpp 2026.9.1.1+);deps单独报为"保留",并指向[feature-deps.<name>]。 该版本之前,[features]是唯一一个完全没有 schema 检查的结构化段落 —— 把include_dirs误写进 feature 里会零诊断地构建成功,而同样的错误写在[build]里会被报出来。 defines是裸宏名(不带-D);feature 激活时,每个都在该包自己的编译上 脱糖为-D<x>—— 与[targets.*] defines完全一致。按约定,它们仅限于包 自有的、带命名空间的宏:feature 不注入自由的包级cflags/ldflags, 否则会破坏可加的 feature 并集模型。链接旗标来自 provider 依赖(见下文provides/requires),而不是来自 feature。- 每个激活的 feature 仍会得到自动的
-DMCPP_FEATURE_<NAME>,defines叠加在 它之上。 flags(mcpp 0.0.101+)与[build].flags(04 §2.3)共用 同一套有序、inline-table 数组的文法(glob必填,加cflags/cxxflags/asmflags/defines;与[[build.flags]]一样,也接受[[features.<name>.flags]]这种 array-of-tables 拼法)。feature 激活时, 条目追加在 base[build].flags之后(各 feature 按名字排序),"最后一条 旗标胜出"使 feature 规则能覆盖更宽的 base 规则;未激活时,这些条目根本不存在 (不会产生死 glob 告警)。这让 feature 的组内专属旗标与它的sources同居, 而不必写成 base 规则、在 feature 关闭的构建里留下一条注定命中不到的 glob。 与defines不同,feature 的flags是私有的、per-TU 的构建旗标——它们 从不传播给消费方(与[build].flags同一契约),因此不破坏可加模型:由 glob 限定作用面,顺序确定,没有跨包效应。tools(mcpp 2026.9.27.1+)指名该 feature 需要在构建机器上运行的本包bin目标。 启用该 feature 的消费方得到这些工具,与其依赖边上写了tools = [...]相同 (05,构建期依赖);指名的不是本包的bin目标时,加载被拒绝。
两个键能把一个 feature 变成其他包可以使用的构建规则。它们是消费方只写一条 依赖边、不必写构建程序的原因。
[features.rules-spirv]
sources = ["rules/spirv.cppm"]
rule_module = "mcpp.rules.spirv"
device_extensions = [".comp", ".vert", ".frag", ".glsl"]device_extensions 陈述这条规则编译哪些设备源扩展名。激活了该 feature 的
消费方会把它们分类为设备源 —— 不做 import 扫描、不产出 BMI、由 mcpp 不驱动的
编译器编译。这与 [build] module_extensions 是同一个形状:mcpp 知道设备源
是什么,不知道 .cu 是 CUDA,所以一门新的设备语言不需要引擎改动。
42 —— 异构硬件构建 里"第六个后端是一个包而不是
一次引擎改动"这句话由此才成立,而不只是愿望;.slang 已经从 mcpp 的内置表中
移除,现在正是经由这条路到达的。
rule_module 给出消费方的构建程序为够到这条规则而 import 的模块,以及它要调用
的 compile() 所在。它是声明出来的,而不是从源码扫描出来的,因为那个程序
必须在任何东西被编译之前写出来,而一次为了决定写什么、去扫描依赖源码的构建,
会把两者的先后顺序颠倒。
由此得出两件事,而且都没有把任何包名放进 mcpp:
host-module = true被隐含。 一个点名了规则模块的 feature,已经说明那是 使用它的唯一方式,所以依赖边不必再说一遍。- 没有
build.mcpp的包会得到一个。 mcpp 把这些规则描述的程序写进构建目录 并编译它。自带程序的包保留自己的那份:合成只填补缺席,绝不覆盖已有的;而 生成出来的那份,正是这个工程本来要手写的那份,所以接管它是一次复制加一次 编辑。
feature 仍然按名字请求:
[build-dependencies.mcpp]
plugins = { version = "0.3.0", features = ["rules-spirv"] }早先的一版设计,从工程源码里出现的扩展名推导这个集合。它被撤销了,原因有二: 两个包可能认领同一个扩展名 —— 第三方写一条处理 CUDA 的规则是会发生的事 —— 而且 manifest 的职责是描述这次构建,派生出来的 feature 集合不再做到这一点。
两个键必须成对出现。只写其一是一条没有任何东西能据以行动的声明,会在解析期 被拒绝,而不是留到消费方的构建里才发现。
能力(capability)是一个共享的抽象名字(例如 blas)。包可以 provide
(提供)一种能力;feature 可以 require(需要)一种能力而不点名某个具体包,
解析器会从依赖图中绑定恰好一个 provider。这样就能在多个可互换的后端
(OpenBLAS / MKL / …)之间选出一个,而不必把选择写死进库里。
# A provider package satisfies a capability for any dependent that requires it.
[package]
name = "compat.openblas"
version = "0.3.0"
provides = ["blas", "lapack"]# A consumer requires the abstract capability via one of its features.
[features]
use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] }
# When >1 provider is in the graph, pick one (else the build errors and lists them).
[capabilities]
blas = "compat.openblas" # equivalently: mcpp build --cap blas=compat.openblas
[dependencies]
compat.openblas = "0.3.0" # the provider must be a real dependency in the graph保留前缀 mcpp: 命名本引擎解析的目标侧层,这些名字对照一个闭集做校验。包级的
requires 数组承载对称的陈述 —— 某个目标侧层必须解析出什么,本包才可用。
[package]
name = "acme.llvm-runtime"
version = "0.1.0"
provides = ["mcpp:compiler-runtime=compiler-rt", "mcpp:c++-abi=libc++"]
requires = ["mcpp:compiler=llvm"]对产物 ABI 开关的需求,用 requires_abi 陈述,写在包上或某个 feature 上,而不是
写成一层:
[package]
requires_abi = { threads = true }
[features]
mt = { requires_abi = { threads = true } }只有根 manifest 能设置这个开关([target.<selector>.abi],见
22 —— 目标侧);根未满足的需求会在编译之前被拒绝,拒绝信息
点名包与 feature。安装钩子针对某一个 C++ 标准库编译静态库的包,把该实现陈述为
层需求,即 requires = ["mcpp:c++-abi=libstdc++"],原因见同一章。
作为标准库的包,在 [build] 下陈述它的 std 模块源,它所需的 flag 在那里与
任何其他构建输入一样,可以条件化。
[build]
std-module = "llvm-generated/std.cppm"
std-compat-module = "llvm-generated/std.compat.cppm"
std-module-flags = ["--no-default-config", "-nostdinc++"]
[target.'cfg(c-abi = "musl")'.build]
std-module-flags = ["-D_GNU_SOURCE"]五个层、约束它们的规则,以及相应的诊断,见 22 —— 目标侧。
绑定是确定性的:
| 图中某被需要能力的 provider 数量 | 结果 |
|---|---|
| 恰好一个 | 自动绑定(无需配置) |
[capabilities] pin / --cap 指定了一个 |
以 pin 为准 |
| 零个 | 报错:没有包提供 <cap> |
| 两个及以上,且未 pin | 报错并列出候选 —— 绝不静默猜测 |
被绑定 provider 的链接/头文件旗标,经由普通的依赖机制流向消费方;capability 层是那道选择与校验步骤,把"静默选错后端"或"缺后端"变成构建期的显式报错。
绑定选中的是 provider,它不裁剪链接行。 依赖包的目标文件一律进入消费方的
链接,与它的能力是否被绑定无关。实测:两个包都提供同一能力,都定义
cap_probe;未 pin 时,解析按上表报错;用 [capabilities] pin 其中一个之后,
构建走到链接器才失败 ——
ld: obj/mcpplibs_pa/src/impl.o: in function `cap_probe':
multiple definition of `cap_probe'; obj/mcpplibs_pb/src/impl.o: first defined here
这一点对多个 provider 定义同一批符号的能力有影响 —— 一个全程序单例(例如
operator new),或一个名字集合固定的 C 接口。对这类能力而言,图中出现两个
provider 是一个待修的缺陷,而不是一个可以 pin 的歧义:pin 会把一个点名两个
候选的报错,换成一个点名 mangled 符号的报错。可互换的库(各 BLAS 实现导出
不同的符号集合,按链接各自选一个)不受此影响。
上一段描述的是一个引擎看不见的缺陷:要看出两个 provider 定义了同一批符号, 需要它们的目标文件,而绑定 capability 时那些文件还不存在;而"一律拒绝重复 provider"这条规则,又会打断同一段里那个合法的 BLAS 用例。
所以由包自己声明:
[package]
name = "compat.cublas"
provides = ["gpu-blas"]
exclusive = ["gpu-blas"]两个都提供 gpu-blas 的包,只要其中至少一个声明了独占,就在绑定 capability
时被拒绝 —— 在任何东西被编译之前,并点名该能力与双方 provider:
error: capability 'gpu-blas' is provided by more than one package, and they
declare it EXCLUSIVE.
providers: [compat.cublas, compat.rocblas]
exclusive: [compat.cublas, compat.rocblas]
Two implementations of one interface define the same symbols, so the
link would resolve every call to whichever archive it reached first.
Keep one of them — a `[capabilities]` pin selects a provider for a
REQUIREMENT and cannot make two definitions of one symbol safe.
这个拒绝在 --format json 里报 exclusive-capability(见第 11 章)。
关于一台机器的某些事实约束着能为它构建什么,而忽略它们时的失败来得很晚:一个 针对比它将遇到的驱动更新的运行时构建出来的程序,链接得干干净净,却在第一次 使用时失败,而且错误消息不点名任何一侧。
包声明它需要什么:
[[runtime.requirements]]
kind = "version-floor"
value = "cuda.driver >= 12.0"而某个在安装期(探测本该发生的地方)确立了机器上某项事实的包,声明它:
[runtime]
provides = ["cuda.driver=12.4"]mcpp 在绑定 capability 时比较二者,并在任何东西被编译之前拒绝,报
version-floor-unmet:
error: `toolkitnew` requires cuda.driver >= 13.0, and cuda.driver is stated as 12.4.
stated by: driverfact
没有任何厂商词汇抵达引擎。 它读到的是一个名字、一个关系和一个版本;
cuda.driver 只是流过它的数据,一个 mcpp 从未听说过的后端,比较方式完全相同。
没人回答过的下界是沉默的。 一台从未声明过自己有什么的机器,不是"未满足
下界"的机器,而是"没人问过"的机器。把"我们不知道"变成"不行",正是这个机制
要避免的那种失败,而且有一条直接判据:tests/e2e/603_version_floor.sh 构建了
一个下界指向无人提供之物的工程。
引擎陈述目标的平台下限(mcpp 2026.9.14.2+)。在编译器接受一个最低平台版本 的那一行上,引擎以该平台自己的说法,把这个版本陈述为一项事实,包像对待其他 事实一样,对它写下界:
| 事实 | 适用行 | 设定来源 |
|---|---|---|
android.api-level |
*-linux-android |
[target.<triple>] min_api_level,否则取工具链支持的最低级别 |
ios.deployment-target |
iOS 真机与模拟器各行 | [build] ios_deployment_target,否则取定位到的 SDK 版本 |
macos.deployment-target |
macOS 各行 | [build] macos_deployment_target,否则取 mcpp 在 macOS 上的默认值 |
[[runtime.requirements]]
kind = "version-floor"
value = "android.api-level >= 23"error: `fw` requires android.api-level >= 23, and this build targets 21.
set by: [target.x86_64-linux-android] min_api_level
不陈述这类事实的行,让这条要求保持沉默,因此要求本身不需要选择器。下限不会替 依赖抬高:这个键设定的值,就是编译器实际面向的值,而应用要安装到哪些设备上, 是应用自己的决定。一个包陈述同名事实,不会替换引擎陈述的那一项。
这是一条关于本包自己的符号的声明,所以一条指向本包并不提供的能力的条目, 会被报为一条 schema warning:那里没有可独占的东西。而无人声明独占的能力, 行为完全不变 —— 两个 BLAS 实现照常共存,既有的"两个或更多、未 pin"报错也仍然 只在有人 require 该能力时才出现。
在 [feature-deps.<name>] 下声明的依赖是可选的:仅当该 feature 激活时
(根的 --features,或某依赖 spec 的 features = [...])才会被解析。
[dependencies] 中的依赖始终被解析;可选性由声明的位置表达,而不是由某个
标志位表达。
[features]
use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] }
backend-openblas = { implies = ["use_blas"] }
# Pulled ONLY when `backend-openblas` is active. Each entry is a full dependency
# spec (version/path/git + its own features).
[feature-deps.backend-openblas]
compat.openblas = "0.3"写 "^0.3.0",而不是 "0.3.x" 或 "0.3"。 以索引中确定存在的一个包做
对照,判据取构建成功:
| 写法 | 结果 |
|---|---|
cmdline = "0.0.1" |
构建通过 |
cmdline = "^0.0.1" |
构建通过 |
cmdline = "0.0" |
解析通过,随后 install path missing after fetch |
cmdline = "0.0.x" |
E_NOT_FOUND,点名的是那个存在的包 |
这三种结果值得分开看,因为有两个更弱的判据,各自会放行一种不可用的写法:
"没有 E_NOT_FOUND"放行两段式前缀,"解析通过"同样放行它。只有对着真实索引
构建一次才能定论。
这一点在此处比在 [dependencies] 里更要紧。一个取不回实现的 feature,等于
一个不存在的 feature;而开发期使用path 依赖的工程根本不查索引 —— 这个
失败只在发布之后才出现,而且出现在别人身上。
这一机制与能力(见上文 provides / requires)组合使用:单个
backend-openblas feature 既拉取 provider(compat.openblas,其
provides = ["blas"]),又开启消费方的开关(implies = ["use_blas"],
其 requires = ["blas"])。图中只有一个 provider 时,能力自动绑定 ——
消费方只需写 features = ["backend-openblas"]。
在索引包的 Lua 描述符中,同样的内容写成内联形式:
features = {
use_blas = { defines = { "EIGEN_USE_BLAS" }, requires = { "blas" } },
["backend-openblas"] = {
implies = { "use_blas" },
deps = { ["compat.openblas"] = "0.3.x" },
},
}同样这三件东西,也覆盖了"库希望提供一份实现,但不强加一份"的情形 ——
一个全程序单例,例如 operator new、一个日志 sink、一个 panic handler:
[features]
default = []
# The consumer-side switch: "I use the part of this library that needs an allocator".
alloc = { requires = ["freestanding-allocator"] }
# The built-in default: activating this one is enough.
alloc-kal = { implies = ["alloc"] }
# Resolved only when `alloc-kal` is active, so the library itself carries no
# dependency on the implementation.
[feature-deps.alloc-kal]
std-freestanding-alloc-kal = "0.1.x"三种用法各一行:
| 消费方需要 | manifest 中的写法 |
|---|---|
| 不用会分配的那部分 | std-freestanding = "0.2.0" —— 分配器不进图 |
| 内置的默认实现 | features = ["alloc-kal"] —— 实现随之进图,不必知道它的包名 |
| 自己的或第三方的实现 | features = ["alloc"] 加一个 provides = ["freestanding-allocator"] 的包 |
有两条性质,使这个形状优于随包无条件提供实现。随包提供实现的库,替程序做了
一个本该属于程序的决定,而且撤销不掉:feature 是可加的,消费方没有
把某个默认实现关掉的手段。此外,由于依赖包的目标文件无条件参与链接
(见上文 provides / requires),随包的默认实现加上程序自备的一份,
是重复定义而不是替换 —— 让 C++ 标准库能提供一个可替换的 operator new
的那套归档语义,并不适用于普通的包依赖。把实现放在开关之后,意味着两者
从不共存。
一个绑定到某个平台的包 —— 它需要那个平台的头文件才能实现某项功能,而不是为了
陈述自己的接口 —— 在 [feature-deps.<feature>] 下用 visibility = "private"
来依赖那个 SDK:
[features]
windows-crt = {}
# Resolved only on the row that activates it, and its headers reach ONLY
# this package's own translation units.
[feature-deps.windows-crt]
some.windows-headers = { version = "1.0", visibility = "private" }visibility 是任意依赖 spec 的一个字段(默认 public,还可以是 private
或 interface —— 见 05 —— 依赖)。让 SDK 不跨越包边界的
正是 private:该依赖的头文件目录、宏定义与 flag 只并入本包自己的构建,到此
为止,正如 privateIncludeDirs 让一个包自己的内部头文件不到达它的消费方。
一个依赖该平台绑定包、并激活 windows-crt 的消费方,会得到这项功能;它不会在
自己的 -I 列表里得到 some.windows-headers 的目录,甚至无法按名字
#include 它的头文件。
这正是 24 —— openkal 与由依赖图供给的目标 为一个需要
超出其已声明层(kernel-abi、c-abi、c++-abi)之外平台头文件的包所指向
的模式:这条依赖是合法的,但它不能变成每一个消费方的问题。把它写成 public
(或者不写 visibility,二者等价)正是本节要指出的错误 —— 对声明它的包而言,
这样能工作;但这会把 SDK 的头文件不由分说地交给消费方,而消费方构建的目标上
很可能根本不该出现这个 SDK。
visibility = "private" 回答的是"这个依赖会不会泄漏到消费方的 -I 列表",
不回答"这个依赖到底在不在图里" —— 后者是
22 —— 目标侧 要为整个构建回答的问题。
SDK 包自己陈述报告或拒绝所需要的那项事实:
[package]
name = "some.windows-headers"
version = "1.0.0"
provides = ["platform-sdk"]platform-sdk 是一个普通的、不带命名空间前缀的能力 —— 像上文的 blas,不像
mcpp:c-abi=<impl> —— 因为它不指代引擎解析的任何一层,只是包对自己陈述的一项
事实。一次构建的 Target 报告会列出图中每一个声明了它的包(没有则为空);
[build] platform-dependencies = "refuse" 会在它出现时直接让构建失败 —— 这是
"本次构建完全是基于其 kernel-abi 实现的闭包,不多不少"这句话的机器可核验形式。
同时声明 provides = ["platform-sdk"] 与 visibility = "private",才是完整
的陈述:private 让头文件不出现在消费方的搜索路径上,platform-sdk 让这项事实
不从任何人的报告里消失。
默认 feature 在 manifest 里关掉,不在命令行上关掉。 没有
--no-default-features。mcpp build --features metrics 激活的是
default ∪ {metrics};要在不带 default 中某个成员的情况下构建,得改
[features] default。实测于 2026.9.8.1。
依赖不能以加速器为条件。 accelerator 是从依赖图中解析出来的,因此由它
选择的依赖会决定它自己所问的那个答案。mcpp 会报告该谓词并忽略它。包要么无
条件,要么以平台为条件;由加速器选择的是 [build] sources。