Skip to content

Commit 44cd2b3

Browse files
committed
feat: exports -- one neutral statement of the symbol surface, three renderings
Stage two of the general build-infrastructure design. Both platforms already publish everything: ELF gives symbols default visibility, and PE gets an auto-generated .def listing every symbol (mcpp.build.coff_exports, WINDOWS_EXPORT_ALL_SYMBOLS semantics). What was missing is the other direction. A runtime with a stable ABI publishes a reviewed set so that what is outside it stays free to change; a plugin loaded beside its rivals must not collide -- a Vulkan ICD that exports its internals collides with the loader and with the other ICDs in the process. This repository has the symptom on file: mcpp's own duplicate-symbol check on the SYCL example reports 68 _Unwind_* symbols, because one image holds two C++ runtimes and both export them. exports takes a file of symbol patterns or an inline list, and the backend renders it per platform -- version script, -exported_symbols_list, or the .def that replaces the all-exports one. One statement, three renderings, which is the shape [runtime] already established and the reason this is a manifest key rather than three platform-specific flag lists. IT DOES NOT IMPLY HIDDEN VISIBILITY, and that CORRECTS the design doc, which said it should. The narrowing is a link-time property on all three formats, so implying a compile-time one would give a single key two effects -- and the second effect also changes how this library's own translation units see each other, which is a separate decision with a separate reason. -fvisibility=hidden stays available through [build] cxxflags for the code generation it buys. The export list is read at manifest load rather than at plan time, so every later stage sees one representation; origin's parent is the package root and that holds for the root, a path dependency and a store dependency alike, which is the "same decision in N places" this would otherwise become. e2e 621 builds one source twice and requires the two readings to DIFFER. Asserting only that the public symbol is present passes for a library that exports everything, which is the state before this change; asserting only that the internal one is absent cannot distinguish "correctly hidden" from "never linked at all".
1 parent a51142f commit 44cd2b3

8 files changed

Lines changed: 357 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@
55
66
## [Unreleased]
77

8+
### 共享库能说出自己发布哪些符号:`exports`
9+
10+
两个平台的默认都是"全导出":ELF 给符号默认可见性,PE 由引擎自动生成列出全部符号的
11+
`.def`**缺的是反方向** —— 声明式地只发布一组。
12+
13+
两类工程需要它。有稳定 ABI 的运行时只发布一份经评审的集合;与同类并存的插件不能撞名
14+
—— 一个把内部符号也导出的 Vulkan ICD 会与 loader 以及同进程内另一个 ICD 相撞。本仓库
15+
自己就有现成的例子:SYCL 示例构建时重复符号检查报的那 68 个 `_Unwind_*`,是一个镜像里
16+
两个 C++ 运行时都在导出 unwinder 符号。
17+
18+
`exports` 接受一个符号模式文件或一个内联数组,由引擎按平台渲染成 version script /
19+
`-exported_symbols_list` / `.def` —— 一句中立的话三种渲染,与 `[runtime]` 已确立的形状
20+
相同,而不是让作者写三份平台专用文件。
21+
22+
**它不隐含编译期 hidden。** 三种格式上收窄都是链接期属性,所以一个键只有一个效果;
23+
`-fvisibility=hidden` 仍可经 `[build] cxxflags` 取得代码生成收益,而那是单独的决定,
24+
因为它同时改变本库各 TU 之间如何看见彼此。符号**版本化**(`foo@@LIB_1.0`)不在此列,
25+
它是 ELF 独有、无法中立表达的能力。
26+
27+
判据 e2e 621 把同一份源码构建两次并要求两次读数**不同**:只断言公开符号在,会对"导出
28+
全部"同样成立(那正是本特性之前的状态);只断言内部符号不在,分不开"正确地隐藏了"与
29+
"根本没链上"。
30+
31+
832
### 构建程序能发出它算出来的链接标志:`mcpp:link-flag`
933

1034
`link-lib``link-search``link-script` 各自命名一类东西,于是一条**算出来的**标志无处

docs/05-mcpp-toml.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,48 @@ the loader opens and the import library the linker consumes, with the export
145145
list generated from the objects on the MSVC ABI (which exports nothing without
146146
`__declspec(dllexport)` or a `.def`). See `tests/e2e/08`, `257` and `259`.
147147

148+
#### `exports` — which symbols the artifact publishes (mcpp 2026.9.6.5+)
149+
150+
```toml
151+
[targets.mydriver]
152+
kind = "shared"
153+
soname = "libmydriver.so.1"
154+
exports = "abi/mydriver.exports" # or inline: exports = ["vk_icd*"]
155+
```
156+
157+
**Omitting the key publishes everything, which is what both platforms already
158+
do** — ELF gives symbols default visibility, and PE gets an auto-generated
159+
`.def` listing every symbol. `exports` narrows that.
160+
161+
Two projects need the narrowing. A **runtime with a stable ABI** publishes a
162+
reviewed set and nothing else, so that what is not in the set stays free to
163+
change. A **plugin loaded beside its rivals** must not collide: a Vulkan ICD is
164+
found by name for `vk_icdGetInstanceProcAddr`, and one that also exports its
165+
internals collides with the loader and with the other ICDs in the process.
166+
167+
The file lists one symbol pattern per line, `#` starts a comment, and `*` is the
168+
only wildcard. The inline array says the same thing and is for the two or three
169+
entry points where a separate file would be ceremony.
170+
171+
One statement, three renderings:
172+
173+
| Platform | Rendered as |
174+
|---|---|
175+
| ELF | a version script, `-Wl,--version-script=` |
176+
| Mach-O | `-Wl,-exported_symbols_list` (the leading underscore is supplied by the engine) |
177+
| PE | the `.def`, replacing the auto-generated all-exports one |
178+
179+
**It does not change compile-time visibility, and that is deliberate.** The
180+
narrowing is a link-time property on all three formats, so one key has one
181+
effect. `-fvisibility=hidden` remains available through `[build] cxxflags` for
182+
the code-generation benefit it brings, and it is a separate decision because it
183+
also changes how this library's own translation units see each other.
184+
185+
**Symbol versioning is not this key.** `foo@@LIB_1.0` alongside `foo@LIB_0.9`
186+
is an ELF-only capability that cannot be stated neutrally; a package that needs
187+
it writes the version script itself and passes it through `[build] ldflags`, or
188+
computes it and emits `mcpp:link-flag=` (docs/07).
189+
148190
A `soname` is meaningful on `kind = "lib"` too — see
149191
[`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision)
150192
below, where the form a library takes becomes the consumer's decision.

docs/zh/05-mcpp-toml.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,42 @@ soname = "libmylib.so.1" # 可选: Linux/ELF ABI 名称,运行时会生成同
138138
MSVC ABI 上从对象生成导出表(该 ABI 没有 `__declspec(dllexport)``.def`
139139
不导出任何符号)。参见 `tests/e2e/08``257``259`
140140

141+
#### `exports` —— 产物发布哪些符号(mcpp 2026.9.6.5+)
142+
143+
```toml
144+
[targets.mydriver]
145+
kind = "shared"
146+
soname = "libmydriver.so.1"
147+
exports = "abi/mydriver.exports" # 或内联:exports = ["vk_icd*"]
148+
```
149+
150+
**不写这个键就发布全部,而那正是两个平台今天的默认**——ELF 给符号默认可见性,PE 会
151+
自动生成列出全部符号的 `.def``exports` 把它收窄。
152+
153+
两类工程需要收窄。**有稳定 ABI 的运行时**只发布一份经过评审的集合,不在集合里的东西
154+
才保持可改。**与同类并存的插件**不能撞名:Vulkan loader 按名字找
155+
`vk_icdGetInstanceProcAddr`,一个把内部符号也导出的 ICD 会与 loader 以及同进程内另一个
156+
ICD 相撞。
157+
158+
文件一行一条符号模式,`#` 起注释,`*` 是唯一的通配符。内联数组说的是同一件事,用于
159+
只有两三个入口、单开一个文件反而是仪式的场合。
160+
161+
一句话,三种渲染:
162+
163+
| 平台 | 渲染为 |
164+
|---|---|
165+
| ELF | version script,`-Wl,--version-script=` |
166+
| Mach-O | `-Wl,-exported_symbols_list`(前导下划线由引擎补) |
167+
| PE | `.def`,取代自动生成的全导出版本 |
168+
169+
**它不改变编译期可见性,这是有意的。** 三种格式上收窄都是链接期属性,所以一个键只有
170+
一个效果。`-fvisibility=hidden` 仍可经 `[build] cxxflags` 使用以取得代码生成上的收益,
171+
而它是一个**单独**的决定,因为它同时改变本库各翻译单元之间如何看见彼此。
172+
173+
**符号版本化不是这个键。** `foo@@LIB_1.0``foo@LIB_0.9` 并存是 ELF 独有的能力,
174+
无法中立表达;需要它的包自己写 version script 经 `[build] ldflags` 传入,或者算出来后
175+
`mcpp:link-flag=` 发出(docs/07)。
176+
141177
`soname``kind = "lib"` 同样有意义 —— 见下文的 `dependency_linkage`,
142178
库以何种形态出现是**消费者**的决定。
143179

modules/manifest/src/toml.cppm

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,65 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
10421042
if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) {
10431043
return std::unexpected(error(origin, *msg));
10441044
}
1045+
// `exports` -- a file of symbol patterns, or the patterns inline.
1046+
//
1047+
// BOTH FORMS, because the two are used at different scales and the ABI
1048+
// contract of a real library is reviewed as a unit. A file is what a
1049+
// library with a stable ABI wants (it is the contract, and it belongs
1050+
// in review beside the headers); the inline array is for the two or
1051+
// three entry points a plugin publishes, where a separate file would
1052+
// be ceremony.
1053+
if (auto eit = tt.find("exports"); eit != tt.end()) {
1054+
if (eit->second.is_string()) {
1055+
t.exportsFile = eit->second.as_string();
1056+
// READ IT HERE, so every later stage sees one representation.
1057+
// The alternative -- carrying the path and reading it at plan
1058+
// time -- would need the package root at three call sites (the
1059+
// root, a path dependency, a store dependency) and is the
1060+
// "same decision derived in N places" shape this repository
1061+
// has paid for. `origin` is the manifest's own path, so the
1062+
// root is its parent, and that is true for all three.
1063+
auto file = origin.parent_path() / t.exportsFile;
1064+
std::ifstream in(file);
1065+
if (!in)
1066+
return std::unexpected(error(origin, std::format(
1067+
"targets.{}.exports names '{}', which does not exist "
1068+
"(looked at '{}')", tname, t.exportsFile,
1069+
file.generic_string())));
1070+
for (std::string line; std::getline(in, line); ) {
1071+
if (auto h = line.find('#'); h != std::string::npos)
1072+
line.erase(h);
1073+
auto b = line.find_first_not_of(" \t\r");
1074+
if (b == std::string::npos) continue;
1075+
auto e = line.find_last_not_of(" \t\r");
1076+
t.exportPatterns.push_back(line.substr(b, e - b + 1));
1077+
}
1078+
if (t.exportPatterns.empty())
1079+
return std::unexpected(error(origin, std::format(
1080+
"targets.{}.exports: '{}' names no symbol. An empty "
1081+
"export list is not how a library says 'publish "
1082+
"everything' -- omitting the key is.",
1083+
tname, t.exportsFile)));
1084+
} else if (eit->second.is_array()) {
1085+
for (auto& v : eit->second.as_array()) {
1086+
if (!v.is_string())
1087+
return std::unexpected(error(origin, std::format(
1088+
"targets.{}.exports: every entry must be a symbol "
1089+
"pattern (a string)", tname)));
1090+
t.exportPatterns.push_back(v.as_string());
1091+
}
1092+
if (t.exportPatterns.empty())
1093+
return std::unexpected(error(origin, std::format(
1094+
"targets.{}.exports is an empty list. A library that "
1095+
"publishes nothing is not what an empty list means "
1096+
"here -- omit the key to publish everything, which is "
1097+
"the default, or name the symbols.", tname)));
1098+
} else {
1099+
return std::unexpected(error(origin, std::format(
1100+
"targets.{}.exports must be a path to a file of symbol "
1101+
"patterns, or an inline list of them", tname)));
1102+
}
1103+
}
10451104

10461105
// Per-target flags (entry-scoped) + required-features gate.
10471106
auto read_list = [&](const char* key, std::vector<std::string>& out) {
@@ -1069,7 +1128,7 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
10691128
// must reach SHARED code is intentionally not a target key; point users
10701129
// at the right axis (workspace / features / profile).
10711130
static constexpr std::string_view kKnownTargetKeys[] = {
1072-
"kind", "main", "soname",
1131+
"kind", "main", "soname", "exports",
10731132
"cflags", "cxxflags", "defines", "required_features",
10741133
};
10751134
for (auto& [key, _] : tt) {

modules/manifest/src/types.cppm

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ struct Target {
117117
enum Kind { Library, Binary, SharedLibrary, TestBinary } kind;
118118
std::string main; // for binary / test
119119
std::string soname; // ABI name for shared libraries, e.g. libfoo.so.1
120+
// WHICH SYMBOLS THIS ARTIFACT PUBLISHES. Empty = every symbol, which is
121+
// what both platforms do today (ELF default visibility; PE gets an
122+
// auto-generated .def listing everything, mcpp.build.coff_exports).
123+
//
124+
// ONE NEUTRAL STATEMENT, THREE RENDERINGS -- the shape `[runtime]` already
125+
// established, and the reason this is a manifest key rather than three
126+
// platform-specific flag lists. ELF gets a version script, Mach-O an
127+
// `-exported_symbols_list`, PE a `.def` that REPLACES the all-exports one.
128+
//
129+
// The entries are symbol patterns, one per line in the named file or one
130+
// per element inline. `*` is the only wildcard, because it is the only one
131+
// all three renderings share.
132+
std::vector<std::string> exportPatterns;
133+
// Where they came from, for diagnostics and for the re-read that a changed
134+
// file must trigger. Empty when `exports` was written inline.
135+
std::string exportsFile;
120136
// Per-target compile flags. SCOPE: applied ONLY to this target's exclusive
121137
// entry source (its `main`) — never to shared module/impl objects, which are
122138
// compiled once and linked into every target (the build's compile-once model;

src/build/ninja_backend.cppm

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,55 @@ std::string shared_soname_flag(const LinkUnit& lu, const BuildPlan& plan) {
272272
return lu.soname.empty() ? "" : "-Wl,-soname," + lu.soname;
273273
}
274274

275+
// WHICH SYMBOLS A SHARED LIBRARY PUBLISHES, rendered per platform from one
276+
// neutral list.
277+
//
278+
// The default on both platforms is "everything": ELF gives symbols default
279+
// visibility, and PE gets an auto-generated .def listing every symbol
280+
// (mcpp.build.coff_exports, CMake's WINDOWS_EXPORT_ALL_SYMBOLS semantics).
281+
// Declaring `exports` narrows that, which is what a runtime with a stable ABI
282+
// and a plugin loaded beside its rivals both need -- an ICD that exports its
283+
// internals collides with the loader and with the other ICDs in the process.
284+
//
285+
// THREE RENDERINGS OF ONE STATEMENT, and that is the reason this is a manifest
286+
// key rather than three flag lists in three `[target.<os>]` blocks. It is the
287+
// same shape `[runtime]` already established for link intent.
288+
//
289+
// The file is written into the build directory rather than read from the
290+
// package, because the inline form has no file to read and because a version
291+
// script's syntax is not what the author wrote.
292+
std::string exports_file_contents(const LinkUnit& lu, std::string_view os) {
293+
std::string out;
294+
if (os == "macos") {
295+
// One symbol per line. Mach-O symbols carry a leading underscore that
296+
// the C++ source never writes, so it is added here -- the author names
297+
// the symbol, not the object format's spelling of it.
298+
for (auto const& p : lu.exportPatterns) out += "_" + p + "\n";
299+
return out;
300+
}
301+
// ELF version script. One anonymous version node: naming versions is a
302+
// separate capability (symbol VERSIONING, `foo@@LIB_1.0`) that cannot be
303+
// stated neutrally, and a package needing it writes the map itself and
304+
// passes it with `[build] ldflags`.
305+
out = "{\n global:\n";
306+
for (auto const& p : lu.exportPatterns) out += " " + p + ";\n";
307+
out += " local:\n *;\n};\n";
308+
return out;
309+
}
310+
311+
// The flag that names the file. PE is absent on purpose: there the export set
312+
// is the `.def`, which lu.defFile already declares and the def-generating step
313+
// already writes, so narrowing it is that step's business rather than a second
314+
// flag on the link line.
315+
std::string exports_flag(const LinkUnit& lu, std::string_view os,
316+
const std::filesystem::path& file) {
317+
if (lu.kind != LinkUnit::SharedLibrary || lu.exportPatterns.empty()) return "";
318+
if (os == "windows") return "";
319+
if (os == "macos")
320+
return "-Wl,-exported_symbols_list," + file.generic_string();
321+
return "-Wl,--version-script=" + file.generic_string();
322+
}
323+
275324
// Write only when the bytes would actually change.
276325
//
277326
// One of these files is a BUILD INPUT: `obj/mcpp_ios_init.c`, the generated
@@ -2081,6 +2130,31 @@ std::string emit_ninja_string(const BuildPlan& plan) {
20812130
implicit.empty() ? std::string{} : " |" + implicit);
20822131
if (auto flag = shared_soname_flag(lu, plan); !flag.empty())
20832132
out_line += " soname_flag = " + flag + "\n";
2133+
// The export set, written beside the artifact and named on the link.
2134+
//
2135+
// Folded into `soname_flag` rather than given a rule variable of its
2136+
// own: both are "a property of THIS shared library's link", the rule
2137+
// already interpolates that variable in the right place, and a second
2138+
// one would have to be added to every link rule that mentions the
2139+
// first -- the "same decision in N places" shape this repository keeps
2140+
// paying for.
2141+
if (lu.kind == LinkUnit::SharedLibrary && !lu.exportPatterns.empty()) {
2142+
const auto tr = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple);
2143+
const std::string os = tr ? tr->os
2144+
: (mcpp::platform::is_macos ? "macos"
2145+
: mcpp::platform::is_windows ? "windows" : "linux");
2146+
const auto file = plan.outputDir / "obj"
2147+
/ (lu.targetName + ".exports.gen");
2148+
std::error_code mkec;
2149+
std::filesystem::create_directories(file.parent_path(), mkec);
2150+
write_file(file, exports_file_contents(lu, os));
2151+
if (auto ef = exports_flag(lu, os, file); !ef.empty()) {
2152+
if (out_line.find(" soname_flag = ") == std::string::npos)
2153+
out_line += " soname_flag = " + ef + "\n";
2154+
else
2155+
out_line.insert(out_line.rfind('\n'), " " + ef);
2156+
}
2157+
}
20842158
// Where the linker is told to write it. A rule-level `$out.lib` would
20852159
// spell the msvc case `foo.dll.lib`, i.e. a name nothing else in mcpp
20862160
// agrees with — the name belongs to plan.cppm's import_library_for, and

src/build/plan.cppm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ struct LinkUnit {
9494
// wins, so the emitter puts this after every other linker argument.
9595
// Deciding it stays here; placing it is the emitter's business.
9696
std::string loaderTagFlag;
97+
// The symbol patterns this unit publishes, empty when it publishes all.
98+
// Carried as the neutral list rather than a rendered flag: the file to
99+
// write and the flag that names it are both platform-shaped, and the
100+
// backend is where the target OS is known.
101+
std::vector<std::string> exportPatterns;
97102
std::filesystem::path output; // relative to plan.outputDir
98103
// The import library a PE shared library also produces — empty on ELF and
99104
// Mach-O, and empty for every non-shared unit. It is a SECOND output of the
@@ -1675,6 +1680,7 @@ make_plan(const mcpp::manifest::Manifest& manifest,
16751680
if (msvcTarget && !lu.importLibrary.empty())
16761681
lu.defFile = std::filesystem::path("bin") / (dep.target.name + ".def");
16771682
lu.soname = dep.target.soname;
1683+
lu.exportPatterns = dep.target.exportPatterns;
16781684
lu.runtimeAliases = runtime_aliases_for_target(dep.target, naming);
16791685
lu.loaderTagFlag = loader_tag_flag(lu.kind);
16801686
append_package_objects(lu, dep.packageName);
@@ -1707,6 +1713,7 @@ make_plan(const mcpp::manifest::Manifest& manifest,
17071713
if (msvcTarget && !lu.importLibrary.empty())
17081714
lu.defFile = std::filesystem::path("bin") / (t.name + ".def");
17091715
lu.soname = t.soname;
1716+
lu.exportPatterns = t.exportPatterns;
17101717
lu.runtimeAliases = runtime_aliases_for_target(t, naming);
17111718
} else if (t.kind == mcpp::manifest::Target::TestBinary) {
17121719
lu.kind = LinkUnit::TestBinary;

0 commit comments

Comments
 (0)