diff --git a/.agents/docs/2026-08-04-harmonyos-target-design.md b/.agents/docs/2026-08-04-harmonyos-target-design.md new file mode 100644 index 00000000..e8932272 --- /dev/null +++ b/.agents/docs/2026-08-04-harmonyos-target-design.md @@ -0,0 +1,278 @@ +# mcpp 适配 HarmonyOS / OpenHarmony — 调研与设计 + +- Date: 2026-08-04 +- Status: **RFC / 探针 PR(不合入)**;引擎改动已实现并本机 + CI 端到端验证 +- 关联: + - [issue #276](https://github.com/mcpp-community/mcpp/issues/276) 与 + `.agents/docs/2026-07-24-embedded-platform-support-design.md`(config② 定调) + - `.github/workflows/cross-build-test.yml` 里那条 **"llvm/clang cross:待接线"** 注释 + - 参考项目:[TermonyHQ/Termony](https://github.com/TermonyHQ/Termony) +- 结构:**结论 → 实测事实 → 为什么只能这么设计 → 改了什么 → 验证边界 → 遗留与下一步** + +--- + +## 0. TL;DR + +**HarmonyOS 可以成为 mcpp 的一等目标,而且是「完整体验」——C++23 具名模块与 +`import std` 都能跑通。代价是必须先把 config② 里一直没实现的那半(clang 交叉 +通道)做出来。** + +三句话: + +1. **鸿蒙 SDK 的编译器永远用不了。** 实测 OpenHarmony SDK **6.1(API 23, + 2026-03 发布)自带 clang 仍是 15.0.4** —— 比 C++20 模块所需的 + `-fmodule-output`(clang 16)还差一代,比 `import std`(libc++ 19+)差四代。 + 这不是「等厂商升级」能绕开的,它已经这样很多年。 +2. **所以 HarmonyOS 是第一个「只能用 config②」的目标,而且逼出 clang 路线。** + config②(mcpp 自带编译器 + 消费外部 sysroot)在别的平台是两种可行架构之一; + 在这里 GCC **根本没有 `ohos` target**,「自带匹配 libc 的 GCC」那半话写不出来 + —— 决策 #8 存档的 clang 路线在此变成唯一解。 +3. **`import std` 不是能力缺口,是 payload 缺口。** 用 LLVM 源码为 + `aarch64-linux-ohos` 编一份 libc++(`-DLIBCXX_HAS_MUSL_LIBC=ON`),`std.cppm` + 就有了,`import std;` 立刻可用 —— 已在 qemu 下真跑通。 + +本 PR 交付的是**引擎**:让 clang 可被重定向(retarget)。libc++ payload 的分发 +是后续的独立工作项。 + +--- + +## 1. 实测事实(先摆证据,再谈设计) + +全部在本机 x86_64 Linux + OpenHarmony SDK 6.1.0.31 实测,不是从文档推断的。 + +| # | 事实 | 怎么测的 | +|---|---|---| +| F1 | **SDK 6.1 自带 clang = 15.0.4** | `native/llvm/bin/clang++ --version` → `OHOS (dev) clang version 15.0.4` | +| F2 | SDK 自带 libc++ = **15.004**,全树**无 `std.cppm`** | `grep _LIBCPP_VERSION .../libcxx-ohos/.../__config`;`find -name std.cppm` 零命中 | +| F3 | `aarch64-linux-ohos` 是**上游 LLVM 认识的 target** | 上游 clang 22.1.8 `--target=aarch64-linux-ohos -dM -E` 输出 `__OHOS__ 1` `__linux__ 1` | +| F4 | 上游 clang **能**用 SDK 的 sysroot 交叉编 + 链接 | 见 §3.2 的 flag 组合;产物 `ELF ... ARM aarch64 ... statically linked` | +| F5 | 产物在 **qemu-aarch64 下真的跑** | 静态产物直接 `qemu-aarch64 ./prog` 输出正确 | +| F6 | 鸿蒙 **动态**产物在 qemu 下段错误 | `QEMU_LD_PREFIX` 指向 SDK 的 `libc.so`(即 musl loader)后仍 SIGSEGV —— 静态是 CI 可验证的那条路 | +| F7 | OHOS 的 PT_INTERP 是 **`/lib/ld-musl-aarch64.so.1`** | `readelf -l` NDK 产物;即 libc 是 musl 派生且沿用 musl 的 loader 命名 | +| F8 | **libc++ 22 能为 ohos 从源码编出来**,含 `std.cppm` | `runtimes` 构建 + `-DLIBCXX_HAS_MUSL_LIBC=ON`(不加则 `__regex_word` 未声明,~1800 个对象后才炸) | +| F9 | **`import std;` 在鸿蒙目标上可用** | 用 F8 的 libc++ 编 `std` BMI + `std.o`,链接后 qemu 下 `std::println` 正常输出 | +| F10 | `libc.a`/`libm.a`/`libpthread.a` 都在 sysroot 里 | `native/sysroot/usr/lib/aarch64-linux-ohos/` —— 全静态可行 | + +两个「差点掉进去」的坑,记在这里因为它们不会在文档里写: + +- **libc++ 头有两棵树。** `native/llvm/include/c++/v1` 是**宿主**的(它的 + `__config_site` 是给 `x86_64-unknown-linux-gnu` 生成的),鸿蒙的那棵在 + `native/llvm/include/libcxx-ohos/include/c++/v1`。选错**不报错**,只是把目标 + 代码按宿主 libc++ 配置编译。 +- **`libunwind.a` 的 `.S` 对象会被编成宿主的。** CMake 的 ASM language 不继承 + `CMAKE_CXX_COMPILER_TARGET`,所以 runtimes 构建装出来的 `libunwind.a` 里是 + x86_64 对象,lld 报 `incompatible with aarch64linux`。用平台自带的 + unwinder 才是对的。 + +### 1.1 Termony 给了什么参考 + +[Termony](https://github.com/TermonyHQ/Termony)(鸿蒙上的 Termux 类终端)证明了 +生态方向,但它解决的是**另一个问题**:把大量 C 工具链打包成 `.hnp` 塞进 `.hap` +装到设备上,并用 `qemu-aarch64`/`elf-loader` 绕开权限限制。它没有回答 +「C++23 modules 怎么办」—— 因为它编的基本是 C 项目,用 SDK 自带 clang 15 足够。 +mcpp 的处境正相反:模块图与 `import std` 是 mcpp 的全部价值,SDK 那个编译器 +一行都编不了。**所以 Termony 的路线(用 SDK 编译器)对 mcpp 结构性不可用**, +它的价值在于确认了 qemu-user 跑鸿蒙 aarch64 产物这条验证路径是可行的(F5)。 + +--- + +## 2. 为什么只能是 config②(而且只能是 clang) + +`2026-07-24-embedded-platform-support-design.md` 决策 #2 定调 config②: +**mcpp 自带(匹配 libc 的)交叉编译器 + 消费外部 sysroot**。决策 #8 又定 +「先 GCC,clang 路线存档」。 + +HarmonyOS 把这两条撞在一起: + +``` +config② 需要: 一个能编 C++23 modules 的、面向 target 的编译器 +GCC 能提供吗: 不能 —— GCC 没有 ohos target,一行都编不出来 +SDK 能提供吗: 不能 —— clang 15.0.4(F1),比模块门槛差一代 +剩下的: mcpp 自己的 clang,用 --target 重定向 +``` + +也就是说:**HarmonyOS 不是「适合用 clang」,而是「除了 clang 无路可走」。** +决策 #8 存档的 clang 路线(风险项 D)在这里被现实提前触发了。 + +这同时解释了为什么这条改动**不是鸿蒙专用**。仓库里早就写着这个缺口: + +``` +# .github/workflows/cross-build-test.yml +# * llvm/clang cross : clang is inherently a cross-compiler, but mcpp does not +# yet inject `-target ` + a cross sysroot for a +# clang toolchain; cross `--target` resolves to gcc musl +# only. Wire the clang cross path first, then add a row. +``` + +本 PR 做的就是 "wire the clang cross path first"。鸿蒙只是**第一个非它不可**的 +消费者;`aarch64-linux-gnu`(树莓派,决策 #3 的滩头)之后可以走同一条缝, +不必再造一套。 + +### 2.1 一个被否掉的替代方案 + +「让 SDK 的 clang 15 只做链接,mcpp 的 clang 做编译」—— 否掉。mcpp 的 +compile/link 共用一个 `plan.toolchain.binaryPath`,拆成两个 driver 是比 +retarget 大得多的改动;而 `-resource-dir` 已经能只在链接侧供给目标的 +compiler-rt(§3.2),用一个 flag 换来两套 driver,不划算。 + +--- + +## 3. 机制 + +### 3.1 三段身份:`ohos` 是 env 不是 os + +mcpp 的 triple 语言是 `arch-os[-env]`。鸿蒙**正好**落在这个语言里: +`aarch64-linux-ohos` = arch `aarch64` + os `linux` + env `ohos`。这也是上游 +LLVM 自己的模型(`llvm::Triple::OpenHOS`)。 + +**这个选择是有后果的,而且后果是对的**:内核确实是 Linux,所以 +`cfg(os = "linux")` / `cfg(family = "unix")` 必须继续匹配。若把 ohos 拼成一个 +新 os,所有已有可移植包里的 `cfg(os = "linux")` 段会**静默失配** —— 一个包突然 +在鸿蒙上少编一半源码,而且没有任何诊断。 + +反向的一条同样重要:**`is_musl()` 必须返回 false**。OHOS libc 确实是 musl 的 +fork,但 mcpp 里 "musl" 处处指的是**上游 musl**(payload 选择、`abi:musl` +capability、`ld-musl-*.so.1`),鸿蒙产物与之不可互换。所以 ABI 模型里 +`libc = "ohos"` 自成一维取值,而 loader 命名(F7)照旧走 musl 那条 —— **同一个 +事实在两个问题上给出不同答案,这正是为什么它们得是两个函数**。 + +### 3.2 driver retarget:`Toolchain::crossTarget` + +新的 `CrossTarget` 描述「这个 driver 正在被重定向」: + +| 字段 | 内容 | 为什么单独存在 | +|---|---|---| +| `triple` | 请求的目标,覆盖 `-dumpmachine` | clang 的 `-dumpmachine` 永远答宿主 | +| `sysroot` | `/sysroot` | → `CLibMode::Sysroot` | +| `cxxIncludes` | 目标 libc++ 头 | 与 sysroot **分开**:鸿蒙里它们在两棵树;而且只换这一对就能从「无 import std」升级到「有」 | +| `libDirs` | 目标 libc++/libc++abi/libunwind | 宿主 payload 的 `linkRuntimeDirs` 对交叉产物毫无意义 | +| `linkResourceDir` | SDK 的 clang resource dir | **只在链接侧**,见下 | +| `stdModuleSource` | 目标 libc++ 的 `std.cppm`,可空 | 由 provider 给,不由 driver 探 | + +**`-resource-dir` 为什么只能在链接侧。** 上游 clang 的 OHOS driver 会去**自己的** +resource dir 找 `libclang_rt.builtins.a` 与 `clang_rt.crt{begin,end}.o` —— 它当然 +没有 `aarch64-linux-ohos` 子目录,于是 `cannot open crtbeginT.o`。把 +`-resource-dir` 指向 SDK 的那份就解决了。但同一个 flag 也换掉了**内建头** +(`stddef.h`/`immintrin.h`…),让 clang 22 去读 clang 15 的内建头是另一个**安静得多** +的 bug。mcpp 的 compile flags 与 link flags 本来就是两条串,所以只在后者发出。 + +**`stdModuleSource` 为什么不能探。** `clang::enrich_toolchain` 靠 +`-print-library-module-manifest-path` 找 `std.cppm` —— driver 答的是**它自己的** +libc++,交叉时那是宿主的。拿它去编 `std` BMI **不会报错**,只会产出一个面向错误 +平台的 BMI。所以交叉路径整段短路,由 provider 供给;供不出来就 +`hasImportStd = false` + 明确提示,而不是编出个错的。 + +同理 `probe_payload_paths()` 在交叉时必须跳过:它会找到编译器旁边**宿主的** +glibc xpkg,而 `CLibMode::PayloadFirst` 会把宿主的 `crt1.o`/`libc.so`/loader +喂给一个外国目标。 + +### 3.3 SDK 发现:detection-first,先例是 `msvc@system` + +mcpp 不能下发鸿蒙 SDK(~2.5 GB、厂商许可),这与 MSVC 的处境一样,所以走同一个 +形状:探测 + 明确的「装不了,请这样设置」文案。 + +探测顺序:`$OHOS_NDK_HOME` → `$OHOS_SDK_NATIVE`(`setup-ohos-sdk` 导出的那个) +→ `$OHOS_SDK_HOME/native` → `$OHOS_SDK_HOME//native`(取最高 API,不取目录 +迭代顺序的第一个)→ `$DEVECO_SDK_HOME/…` → `~/ohos-sdk/native` → +`/opt/ohos-sdk/native`。 + +判据不是目录名而是**mcpp 真正需要的两个文件** +(`sysroot/usr/include/stdlib.h` + `llvm/lib`)—— 一个「看起来对」但没有 sysroot +的路径必须在这里被拒,而不是十分钟后死在某条编译命令里。 + +### 3.4 `import std` 的升级位:`MCPP_OHOS_LIBCXX` + +env 优先、索引最后,是刻意的: +`2026-08-03-index-availability-must-not-decide-mcpp-availability.md` 的教训是 +**索引侧的东西不得决定 mcpp 是否可用**。SDK 自带的 libc++ 永远在、永远能用 +(named modules 这一档),外部 libc++ 是**升级**而非前提。 + +--- + +## 4. 改了什么 + +| 文件 | 改动 | +|---|---| +| `src/toolchain/triple.cppm` | `ohos` env 解析(含 `ohoseabi*`/`openhos` 与 4 段 LLVM 拼写);`is_ohos()`;三行 known-target;`pins::kOhosLlvm` | +| `src/toolchain/abi.cppm` | `libc = "ohos"`(在 musl 判定**之前**) | +| `src/toolchain/model.cppm` | `CrossTarget` + `Toolchain::crossTarget` | +| `src/toolchain/ohos.cppm` | **新增** —— SDK 发现、`cross_paths()`、libc++ overlay、`install_guidance()` | +| `src/toolchain/linkmodel.cppm` | 交叉时强制 Sysroot 模式;`ClangDriverModel` 增加 `--target=`/`-resource-dir=` 产出;loader 命名认 ohos | +| `src/toolchain/hostflags.cppm` | 交叉 ⇒ 无条件 bypass cfg(cfg 是装机时按宿主生成的) | +| `src/toolchain/detect.cppm` | `detect(bin, cross)`;交叉时覆盖 triple、跳过 sysroot/payload 探测;非 clang 直接报错 | +| `src/toolchain/clang.cppm` | 交叉时不探宿主 libc++,改用 provider 供的 `std.cppm` | +| `src/toolchain/registry.cppm` | `host_can_serve()`:ohos 目标问「SDK 在不在」 | +| `src/build/flags.cppm` | ninja 字符串通道发交叉 token;`find_archive` 在交叉时只看目标 libDirs | +| `src/build/prepare.cppm` | ohos 目标 → 探 SDK → 组 `CrossTarget` → 喂给 `detect()` | +| `src/build/hermetic.cppm` | 白名单纳入 SDK 的 libDirs/includes/resourceDir | +| `tests/unit/test_ohos_target.cpp` | **新增** 16 个用例,全部 host-independent | +| `tests/e2e/103_…`, `104_…` | **新增** 两档端到端(qemu 真跑) | +| `.github/workflows/ci-harmonyos.yml` | **新增** 两个 job | +| `examples/05-harmonyos/` | **新增** 示例 | +| `README.md`, `docs/03-toolchains.md` | 平台表 + 完整章节 | + +**宿主路径零行为变化**:所有分支都以 `tc.crossTarget` 为门, +`OhosCross.HostToolchainIsUntouchedByTheseChanges` 是这条的回归闸。 + +--- + +## 5. 验证边界(能证明什么,不能证明什么) + +**能**(本机 + CI 都跑过): + +- `mcpp build --target aarch64-linux-ohos` 产出 `ELF … ARM aarch64 … statically linked` +- 该产物在 `qemu-aarch64` 下**真的执行**并输出预期内容 +- C++23 **具名模块**对着**原版 SDK** 可用 +- 配上为目标编的 libc++ 后 **`import std;` 可用** +- 目标行在有 SDK 时显示 `available`、无 SDK 时不显示(`host_can_serve` 真的接上了) + +**不能**: + +- **qemu-user 跑的是指令集,不是 HarmonyOS。** 它对系统调用兼容性、设备行为 + 一概不发言。 +- **`.hnp`/`.hap` 打包与 `hdc` 安装完全没做。** 这是 Termony 那条线的活,属于 + 「产物怎么上设备」,与「产物对不对」是两个问题。 +- **没有链接平台 NDK 库**(`libace_napi.z.so`、`libhilog_ndk.z.so` …)。全静态 + 产物本来也链不了它们(那些只有 `.so`)。真做鸿蒙应用要走动态链接,而 F6 说明 + 动态产物在 qemu 下跑不起来 ⇒ **动态那档必须真机/模拟器验证,CI 给不了绿**。 +- **模拟器没用上。** GitHub runner 无法跑鸿蒙模拟器(要 DevEco + 虚拟化 + + 厂商镜像);这是本次「利用 CI 各种 OS 资源」这条里唯一没兑现的部分,原因是 + 客观不可得而非没做。 + +把这条写清楚是因为:mcpp 的 `verified` 档位定义是「CI builds **and executes**」。 +本 PR 的 `aarch64-linux-ohos` 满足这个定义,但**满足的是与 +`aarch64-linux-musl` 同级的那个断言,不是「鸿蒙 App 能上架」**。 + +--- + +## 6. 遗留 / 下一步(按价值排序) + +1. **`ohos-libcxx` payload 化。** 现在 `import std` 要用户自己编一次 libc++。 + 把它做成 xim payload(每个 LLVM 版本 × 每个 ohos target 一份)之后, + `mcpp build --target aarch64-linux-ohos` 就是开箱完整体验。**这是本设计里 + 唯一横在「能用」和「好用」之间的东西。** +2. **动态链接档位。** 真鸿蒙应用要动态链 + 链平台 NDK 库。需要:非静态默认的 + opt-in、`libc++_shared.so` 的部署、以及真机验证路径。 +3. **`.hnp`/`.hap` 打包。** 属于 `mcpp pack` 的新 mode,可参考 Termony 的 + `sign.py` 与 hnp 结构。是否属于 mcpp 职责边界内需要维护者定调 + (对照决策 #1「不做发行版构建器」)。 +4. **`x86_64-linux-ohos` 提到 verified。** 模拟器架构;SDK 里已经有这套库, + CI 上甚至不需要 qemu(宿主同架构),成本最低的一个。 +5. **把 clang cross 缝用到 `aarch64-linux-gnu`。** 决策 #3 的滩头(树莓派)现在 + 可以不再等「自建低 glibc 的 GCC 16」——同一条缝 + 设备 sysroot 就够了。 + 这条值得单独评估,因为它可能改变 #276 的 P0 排序。 + +--- + +## 7. 关键文件索引 + +- 目标身份:`src/toolchain/triple.cppm`(`is_ohos`、`kKnownTargets`、`pins::kOhosLlvm`) +- SDK 发现:`src/toolchain/ohos.cppm` +- 重定向模型:`src/toolchain/model.cppm`(`CrossTarget`)、 + `src/toolchain/linkmodel.cppm`(`ClangDriverModel::target_tokens`/`cross_link_tokens`) +- 应用点:`src/toolchain/detect.cppm`、`src/toolchain/hostflags.cppm`、 + `src/build/flags.cppm` +- 接线:`src/build/prepare.cppm`(ohos 分支) +- 验证:`tests/unit/test_ohos_target.cpp`、`tests/e2e/103_harmonyos_cross_qemu.sh`、 + `tests/e2e/104_harmonyos_import_std.sh`、`.github/workflows/ci-harmonyos.yml` +- 前置设计:`.agents/docs/2026-07-24-embedded-platform-support-design.md`(config②、决策 #2/#8) diff --git a/.github/workflows/ci-harmonyos.yml b/.github/workflows/ci-harmonyos.yml new file mode 100644 index 00000000..bd3117dd --- /dev/null +++ b/.github/workflows/ci-harmonyos.yml @@ -0,0 +1,278 @@ +name: ci-harmonyos + +# mcpp → HarmonyOS / OpenHarmony (aarch64-linux-ohos), verified end to end. +# +# ── What this proves, and why it needs its own workflow ──────────────────── +# +# cross-build-test.yml is the home of "which cross targets does mcpp support", +# and it carries this comment: +# +# * llvm/clang cross : clang is inherently a cross-compiler, but mcpp does +# not yet inject `-target ` + a cross sysroot +# for a clang toolchain; cross `--target` resolves to +# gcc musl only. Wire the clang cross path first, then +# add a row. +# +# HarmonyOS is the target that forces that row to exist: GCC has no `ohos` +# target at all, so the gcc-musl shape mcpp's other cross rows use cannot be +# spelled here. This workflow is deliberately SEPARATE from +# cross-build-test.yml for one reason — it depends on a ~2.5 GB vendor SDK +# that mcpp does not and cannot ship, so a failure here must never be +# confusable with a failure of mcpp's own cross matrix. +# +# ── The two tiers ───────────────────────────────────────────────────────── +# +# tier compiler C++ stdlib import std +# ------------ ---------------- ------------------------- ---------- +# stock SDK mcpp's llvm@20 SDK's libc++ 15.0.4 no +# + overlay mcpp's llvm@20 libc++ built for the yes +# target from LLVM sources +# +# Both are built and RUN under qemu-aarch64. The second job builds the overlay +# from source in ~10 minutes, which is expensive — but it is the only way to +# show that the "no import std" limit is a missing PAYLOAD, not a missing +# capability, and that distinction is the whole argument of +# .agents/docs/2026-08-04-harmonyos-target-design.md. +# +# ── What is NOT proven here ─────────────────────────────────────────────── +# +# qemu-user runs the artefact's instructions, not HarmonyOS. It says nothing +# about the .hnp/.hap packaging path, about linking the platform's own NDK +# libraries (libace_napi.z.so and friends), or about anything that touches +# a real device. Those need hardware or the emulator and stay out of scope — +# see the design doc's "what CI can and cannot show" section. Verification +# here is the same claim the aarch64-linux-musl row makes: this artefact is +# for the right machine and it really executes. + +on: + pull_request: + branches: [ main ] + paths: + - 'src/toolchain/**' + - 'src/build/**' + - 'tests/e2e/10[34]_harmonyos*' + - 'tests/unit/test_ohos_target.cpp' + - 'examples/05-harmonyos/**' + - '.github/workflows/ci-harmonyos.yml' + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── Tier 1: stock SDK, named modules ────────────────────────────────────── + harmonyos-cross: + name: HarmonyOS cross-build + qemu run (stock SDK) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + MCPP_HOME: /home/runner/.mcpp + MCPP_VERBOSE: "1" + steps: + - uses: actions/checkout@v4 + + # Do NOT restore target/ in a cross job: this job builds twice (host, + # then aarch64-linux-ohos) and a restored BMI tree makes the second + # build read `std` BMIs that no longer match what the rest was compiled + # against — `import 'std' has CRC mismatch`. Same rule as + # windows-host-linux-cross in cross-build-test.yml, same reason. + - uses: ./.github/actions/bootstrap-mcpp + with: + cache-target: 'false' + + - name: Install qemu-user-static + run: | + sudo apt-get update -qq + sudo apt-get install -y qemu-user-static + qemu-aarch64-static --version | head -1 + + - name: Setup OpenHarmony SDK + id: ohos + uses: openharmony-rs/setup-ohos-sdk@v1.0.1 + with: + version: '6.1' + components: 'native' + + - name: Point mcpp at the SDK + run: | + NATIVE="${{ steps.ohos.outputs.ohos_sdk_native }}" + test -d "$NATIVE" || { echo "FAIL: action produced no native dir"; exit 1; } + # The two files mcpp's own detection requires; asserted here so a + # layout change in the action fails with a clear message instead of + # inside a compile command 10 minutes later. + test -f "$NATIVE/sysroot/usr/include/stdlib.h" + test -d "$NATIVE/llvm/lib/aarch64-linux-ohos" + echo "OHOS_NDK_HOME=$NATIVE" >> "$GITHUB_ENV" + echo "== SDK ==" + cat "$NATIVE/oh-uni-package.json" || true + # Recorded, not used: this is the fact the whole design rests on. + # If a future SDK ships a modern clang, this line is where it shows. + echo "== the SDK's own clang (mcpp does NOT use it) ==" + "$NATIVE/llvm/bin/clang++" --version | head -1 + + - name: Build mcpp from source (self-host) + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$MCPP" self config --mirror GLOBAL 2>/dev/null || true + "$MCPP" build + # Newest, not first: target/ keeps a directory per build fingerprint, + # so `find | head -1` can hand back a previous build's binary. + MCPP_SELF=$(find target -type f -name mcpp -path '*/bin/*' \ + -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-) + test -x "$MCPP_SELF" + MCPP_SELF=$(realpath "$MCPP_SELF") + "$MCPP_SELF" --version + echo "MCPP=$MCPP_SELF" >> "$GITHUB_ENV" + + - name: "Target is listed as available once the SDK is present" + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$MCPP" toolchain list | tee /tmp/tclist.txt + # `available`, not `planned`: host_can_serve() answers this by + # probing for the SDK, so this asserts the detection wired up — the + # same row reads `planned` on a runner without the SDK. + grep -q "aarch64-linux-ohos" /tmp/tclist.txt \ + || { echo "FAIL: ohos target not listed"; exit 1; } + + - name: "e2e: cross-build + qemu run" + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + bash tests/e2e/103_harmonyos_cross_qemu.sh + + - name: "Example project builds for HarmonyOS" + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + cd examples/05-harmonyos + "$MCPP" build --target aarch64-linux-ohos + BIN=$(find target/aarch64-linux-ohos -type f -path '*/bin/*' | head -1) + file "$BIN" + file "$BIN" | grep -q "ARM aarch64" + qemu-aarch64-static "$BIN" + + # ── Tier 2: + a libc++ built for the target ⇒ import std ────────────────── + harmonyos-import-std: + name: HarmonyOS import std (libc++ built for the target) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + MCPP_HOME: /home/runner/.mcpp + MCPP_VERBOSE: "1" + # Must match the LLVM the target pin resolves to + # (triple::pins::kOhosLlvm). A libc++ built by one clang and used by + # another is a version skew that works until it does not; keeping the + # two equal is what makes this job evidence rather than anecdote. + LLVM_TAG: llvmorg-20.1.7 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bootstrap-mcpp + with: + cache-target: 'false' + + - name: Install qemu-user-static + build tools + run: | + sudo apt-get update -qq + sudo apt-get install -y qemu-user-static cmake ninja-build + qemu-aarch64-static --version | head -1 + + - uses: openharmony-rs/setup-ohos-sdk@v1.0.1 + id: ohos + with: + version: '6.1' + components: 'native' + + - name: Build mcpp from source (self-host) + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + echo "OHOS_NDK_HOME=${{ steps.ohos.outputs.ohos_sdk_native }}" >> "$GITHUB_ENV" + "$MCPP" self config --mirror GLOBAL 2>/dev/null || true + "$MCPP" build + MCPP_SELF=$(find target -type f -name mcpp -path '*/bin/*' \ + -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-) + MCPP_SELF=$(realpath "$MCPP_SELF") + "$MCPP_SELF" --version + echo "MCPP=$MCPP_SELF" >> "$GITHUB_ENV" + + - name: Locate mcpp's LLVM payload + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + # Installing it explicitly rather than letting the first ohos build + # do it: the runtimes build below needs the same clang, and taking + # it from anywhere else would silently reintroduce the skew the + # LLVM_TAG comment warns about. + "$MCPP" toolchain install llvm 20.1.7 + # Named directly, NOT discovered with `find`: in the LLVM payload + # `clang++` is a symlink chain (clang++ -> clang -> clang-20), so a + # `find -type f -name clang++` finds nothing and the step fails one + # line after mcpp has just printed "Installed llvm@20.1.7 → …/clang++". + CLANGXX="$MCPP_HOME/registry/data/xpkgs/xim-x-llvm/20.1.7/bin/clang++" + test -x "$CLANGXX" || { + echo "FAIL: no clang++ at $CLANGXX; payload bin/ was:" + ls -la "$(dirname "$CLANGXX")" | head -20 + exit 1 + } + echo "OHOS_CLANGXX=$CLANGXX" >> "$GITHUB_ENV" + echo "OHOS_CLANG=${CLANGXX%++}" >> "$GITHUB_ENV" + "$CLANGXX" --version | head -1 + + - name: Cache the target libc++ + id: libcxx-cache + uses: actions/cache@v4 + with: + path: ~/ohos-libcxx + key: ohos-libcxx-${{ env.LLVM_TAG }}-sdk6.1-v1 + + - name: Build libc++/libc++abi for aarch64-linux-ohos + if: steps.libcxx-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + NATIVE="${{ steps.ohos.outputs.ohos_sdk_native }}" + RES=$(find "$NATIVE/llvm/lib/clang" -maxdepth 1 -mindepth 1 -type d | sort | tail -1) + # Sparse: the runtimes build needs cmake/, runtimes/, the three + # runtime trees, llvm/cmake and libc/ (libc++'s charconv includes + # `shared/fp_bits.h` from it — a missing `libc` fails ~1800 objects + # into the build, which is an expensive way to learn that). + git clone --depth 1 --branch "$LLVM_TAG" --filter=blob:none --sparse \ + https://github.com/llvm/llvm-project /tmp/llvm-src + git -C /tmp/llvm-src sparse-checkout set \ + cmake runtimes libcxx libcxxabi libunwind libc llvm/cmake third-party + + cmake -G Ninja -S /tmp/llvm-src/runtimes -B /tmp/build-ohos-libcxx \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$HOME/ohos-libcxx" \ + -DCMAKE_C_COMPILER="$OHOS_CLANG" \ + -DCMAKE_CXX_COMPILER="$OHOS_CLANGXX" \ + -DCMAKE_C_COMPILER_TARGET=aarch64-linux-ohos \ + -DCMAKE_CXX_COMPILER_TARGET=aarch64-linux-ohos \ + -DCMAKE_SYSROOT="$NATIVE/sysroot" \ + -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64 \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + -DCMAKE_C_FLAGS="--no-default-config" \ + -DCMAKE_CXX_FLAGS="--no-default-config" \ + -DCMAKE_EXE_LINKER_FLAGS="-resource-dir=$RES -fuse-ld=lld" \ + -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind" \ + -DLLVM_INCLUDE_TESTS=OFF -DLIBCXX_INCLUDE_TESTS=OFF \ + -DLIBCXX_INCLUDE_BENCHMARKS=OFF -DLIBCXXABI_INCLUDE_TESTS=OFF \ + -DLIBUNWIND_INCLUDE_TESTS=OFF \ + -DLIBCXX_CXX_ABI=libcxxabi \ + -DLIBCXX_HAS_MUSL_LIBC=ON \ + -DLIBCXX_ENABLE_SHARED=OFF -DLIBCXXABI_ENABLE_SHARED=OFF \ + -DLIBUNWIND_ENABLE_SHARED=OFF \ + -DLIBCXXABI_USE_LLVM_UNWINDER=ON \ + -DLIBCXX_INSTALL_MODULES=ON + ninja -C /tmp/build-ohos-libcxx install + + # The runtimes build compiles libunwind's .S sources for the HOST + # (CMake's ASM language does not inherit CMAKE_CXX_COMPILER_TARGET), + # so the installed libunwind.a carries x86_64 objects and lld + # rejects it with "incompatible with aarch64linux". The platform's + # own unwinder is the right one to use anyway; removing the broken + # archive is what makes `-L -L` resolve to it. + rm -f "$HOME/ohos-libcxx/lib/libunwind.a" + test -f "$HOME/ohos-libcxx/share/libc++/v1/std.cppm" + + - name: "e2e: import std on HarmonyOS + qemu run" + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + export MCPP_OHOS_LIBCXX="$HOME/ohos-libcxx" + bash tests/e2e/104_harmonyos_import_std.sh diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 17021772..d588f8b4 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -39,11 +39,17 @@ name: cross-build-test # ci-linux.yml's "Toolchain: musl-gcc" step, and release.yml for the static # release artefact. Keep them there; this file is cross-arch only. # +# ── Related, but deliberately NOT here ──────────────────────────────────── +# * llvm/clang cross : WIRED — Toolchain::crossTarget retargets a clang +# driver with `--target=` + an external sysroot. Its +# first consumer is HarmonyOS, and that row lives in +# ci-harmonyos.yml rather than this matrix for one +# reason: it needs a ~2.5 GB vendor SDK mcpp cannot +# ship, and a failure to obtain that SDK must never be +# confusable with a failure of mcpp's own cross matrix. +# See .agents/docs/2026-08-04-harmonyos-target-design.md. +# # ── Planned cross rows (documented; NOT yet wired in mcpp — keep as comments) ─ -# * llvm/clang cross : clang is inherently a cross-compiler, but mcpp does not -# yet inject `-target ` + a cross sysroot for a -# clang toolchain; cross `--target` resolves to gcc musl -# only. Wire the clang cross path first, then add a row. # * riscv64-linux-musl: add once xim:riscv64-linux-musl-gcc ships to # xlings-res + xim-pkgindex. diff --git a/CHANGELOG.md b/CHANGELOG.md index 06689781..c18f555e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,43 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [Unreleased] — RFC:HarmonyOS / OpenHarmony 目标 + +> **本节对应一个探针 PR,不打算合入。** 引擎改动已实现并端到端验证; +> 设计与实测数据见 `.agents/docs/2026-08-04-harmonyos-target-design.md`。 + +### 新增 + +- **`aarch64-linux-ohos`(HarmonyOS / OpenHarmony)成为一等目标。** 设好 + `OHOS_NDK_HOME`,`mcpp build --target aarch64-linux-ohos` 产出静态 aarch64 + 鸿蒙 ELF,CI 在 `qemu-aarch64` 下**真的执行**它。C++23 具名模块对着原版 SDK + 即可用;再配一份为该目标编译的 libc++(`MCPP_OHOS_LIBCXX`),`import std;` + 也可用 —— 两档都有 e2e。 + + **mcpp 只把 SDK 当 sysroot,不当工具链。** 这不是偏好:实测 OpenHarmony SDK + **6.1(API 23)自带 clang 仍是 15.0.4**,比 C++20 模块所需的 + `-fmodule-output`(clang 16)差一代,比 `import std` 差四代。而 GCC 根本没有 + `ohos` target。所以 `.agents/docs/2026-07-24-embedded-platform-support-design.md` + 的 config②(mcpp 带编译器 + 消费外部 sysroot)在这里从「两种可行架构之一」 + 变成**唯一解**,并且只能用 clang —— 决策 #8 存档的那条路线被现实提前触发。 + +- **clang 交叉通道(driver retarget)。** `cross-build-test.yml` 里那条 + "llvm/clang cross: … Wire the clang cross path first" 的注释现在有实现了: + `Toolchain::crossTarget` 让一个 clang driver 带着 `--target=` + 外部 sysroot + + 目标 libc++ 工作。**这不是鸿蒙专用的** —— 鸿蒙只是第一个非它不可的消费者, + `aarch64-linux-gnu`(树莓派滩头)之后可以走同一条缝。 + +### 注意 + +- `ohos` 在 triple 语言里是 **env 而非 os**(`aarch64` + `linux` + `ohos`), + 与上游 LLVM 一致:内核确实是 Linux,所以已有包里的 `cfg(os = "linux")` / + `cfg(family = "unix")` 必须继续匹配。反过来,ABI 维度上 `libc = "ohos"` 自成 + 取值、`is_musl()` 返回 **false** —— OHOS libc 是 musl 的 fork,但与上游 musl + 产物不可互换,而 mcpp 里 "musl" 处处指的是后者。 +- CI 验证的是「产物面向正确的机器且真的执行」,与 `aarch64-linux-musl` 同级。 + 它**不**覆盖 `.hnp`/`.hap` 打包、链接平台 NDK 库、或任何需要真机/模拟器的 + 行为。 + ## [2026.8.4.1] — 2026-08-04 ### 修复 diff --git a/README.md b/README.md index 96c0ab06..d8a72529 100644 --- a/README.md +++ b/README.md @@ -306,9 +306,12 @@ the right toolchain payload is resolved and installed automatically. | `x86_64-windows-gnu` | gcc 16 MinGW-w64 — native on Windows, cross from Linux (wine-verified) *(Windows default without Visual Studio)* | ✅ | | `x86_64-windows-msvc` | `msvc@system` (detected VS/BuildTools) or llvm ¹ *(Windows default with Visual Studio)* | ✅ | | `aarch64-macos` | llvm *(macOS default)* | ✅ | +| `aarch64-linux-ohos` | llvm 20 + OpenHarmony SDK sysroot ² — cross from any host (qemu-verified) | ✅ | | `riscv64-linux-musl` | — | 🔄 | | `aarch64-linux-gnu` | — | 🔄 | | `x86_64-macos` | — | 🔄 | +| `x86_64-linux-ohos` | llvm 20 + OpenHarmony SDK sysroot ² | 🔄 | +| `arm-linux-ohos` | llvm 20 + OpenHarmony SDK sysroot ² | 🔄 | ✅ verified — CI builds **and executes** the artifact end-to-end (qemu/wine included) | 🔄 planned @@ -326,6 +329,16 @@ the right toolchain payload is resolved and installed automatically. > or configure; `mcpp new && mcpp build` just works on a stock Windows box. > An explicit `[toolchain]` in `mcpp.toml` is always respected as written — > mcpp revises its own default, never yours. +> +> ² HarmonyOS / OpenHarmony is the one target where mcpp needs something it +> cannot ship: the platform SDK. Point `OHOS_NDK_HOME` at the unpacked +> `native` directory and `mcpp build --target aarch64-linux-ohos` works. +> mcpp uses the SDK for its **sysroot and runtime libraries only** — the +> compiler stays mcpp's own LLVM, because the SDK's bundled clang is 15.0.4 +> even in SDK 6.1 (API 23) and cannot build C++20 modules at all. Named +> modules work against a stock SDK; `import std` additionally needs a libc++ +> built for the target — see +> [Toolchain Management](docs/03-toolchains.md#harmonyos--openharmony). ## Documentation diff --git a/docs/01-examples.md b/docs/01-examples.md index 91d40554..f8a4def3 100644 --- a/docs/01-examples.md +++ b/docs/01-examples.md @@ -26,6 +26,7 @@ examples. | 01 | [`examples/01-hello`](../examples/01-hello/) | Minimal single-file project with `import std` | The minimal package shape (`mcpp new` also emits `tests/test_smoke.cpp`) | | 02 | [`examples/02-with-deps`](../examples/02-with-deps/) | Adds the `mcpplibs.cmdline` dependency to parse command-line arguments | `[dependencies]`, SemVer, `mcpp.lock` | | 03 | [`examples/03-pack-static`](../examples/03-pack-static/) | Produces a fully static release package via `mcpp pack --mode static` | `[target.]` and `[pack]` configuration | +| 05 | [`examples/05-harmonyos`](../examples/05-harmonyos/) | Cross-builds a C++23 named-module program for HarmonyOS/OpenHarmony | `--target aarch64-linux-ohos`, consuming an external platform SDK as a sysroot | ## Suggested Reading Order diff --git a/docs/03-toolchains.md b/docs/03-toolchains.md index 416cadcd..324838d3 100644 --- a/docs/03-toolchains.md +++ b/docs/03-toolchains.md @@ -313,6 +313,117 @@ Combined with `mcpp pack --mode static` this produces a fully static release package; for a complete example, see [`examples/03-pack-static`](../examples/03-pack-static/). +## HarmonyOS / OpenHarmony + +```bash +export OHOS_NDK_HOME=/path/to/ohos-sdk/linux/native +mcpp build --target aarch64-linux-ohos +``` + +HarmonyOS is the one target where mcpp needs something it cannot ship. Every +other target is served by a toolchain mcpp downloads; this one additionally +needs the **platform SDK**, a ~2.5 GB vendor archive under its own licence. +Point `OHOS_NDK_HOME` at the unpacked `native` directory and everything else +is the usual `--target` flow. + +**mcpp uses the SDK as a sysroot, not as a toolchain.** The compiler stays +mcpp's own LLVM. That is not a preference — the SDK's bundled clang is +**15.0.4 even in SDK 6.1 (API 23)**, four major versions below what C++20 +modules need (`-fmodule-output` arrived in clang 16), and it has been that +version for years. So mcpp retargets its own clang with `--target=` and takes +the libc, C++ standard library, CRT objects and compiler-rt builtins from the +SDK. GCC is not an option here at all: it has no `ohos` target. + +mcpp finds the SDK through, in order: `$OHOS_NDK_HOME`, `$OHOS_SDK_NATIVE` +(what `openharmony-rs/setup-ohos-sdk` exports), `$OHOS_SDK_HOME/native`, +`$OHOS_SDK_HOME//native`, `$DEVECO_SDK_HOME/…`, then `~/ohos-sdk/native` +and `/opt/ohos-sdk/native`. + +Targets: `aarch64-linux-ohos` (verified), `x86_64-linux-ohos` and +`arm-linux-ohos` (registered, not yet verified). Default linkage is static — +the OHOS libc is a musl fork, and as with mcpp's other musl targets a static +artifact is the one that runs anywhere without a matching loader. + +`cfg()` sees HarmonyOS as **`env = "ohos"` on `os = "linux"`**, matching +upstream LLVM's own model. The kernel really is Linux, so a package that +gates on `cfg(os = "linux")` or `cfg(family = "unix")` keeps working; only +code that needs HarmonyOS *specifically* has to say so: + +```toml +[target.'cfg(env = "ohos")'.build] +defines = ["USE_OHOS_HILOG"] +``` + +In C++, the SDK's `__OHOS__` macro is defined by mcpp's clang too. + +### `import std` on HarmonyOS + +Against a **stock SDK**, named modules work and `import std` does not — the +SDK ships libc++ 15.0.4, which has no `std` module at all. mcpp says so +during resolution rather than failing later: + +``` +Note this target's libc++ ships no `std` module — `import std;` is +unavailable here; named modules and #include both work. +``` + +This is a missing **payload**, not a missing capability. Build libc++ from +LLVM sources for the target and `import std` works exactly as it does +everywhere else: + +```bash +git clone --depth 1 --branch llvmorg-20.1.7 https://github.com/llvm/llvm-project +CLANGXX=~/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7/bin/clang++ +RES=$OHOS_NDK_HOME/llvm/lib/clang/15.0.4 + +cmake -G Ninja -S llvm-project/runtimes -B build-ohos-libcxx \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=~/ohos-libcxx \ + -DCMAKE_C_COMPILER="${CLANGXX%++}" -DCMAKE_CXX_COMPILER="$CLANGXX" \ + -DCMAKE_C_COMPILER_TARGET=aarch64-linux-ohos \ + -DCMAKE_CXX_COMPILER_TARGET=aarch64-linux-ohos \ + -DCMAKE_SYSROOT="$OHOS_NDK_HOME/sysroot" \ + -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64 \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + -DCMAKE_C_FLAGS="--no-default-config" -DCMAKE_CXX_FLAGS="--no-default-config" \ + -DCMAKE_EXE_LINKER_FLAGS="-resource-dir=$RES -fuse-ld=lld" \ + -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind" \ + -DLIBCXX_CXX_ABI=libcxxabi -DLIBCXX_HAS_MUSL_LIBC=ON \ + -DLIBCXX_INSTALL_MODULES=ON \ + -DLIBCXX_ENABLE_SHARED=OFF -DLIBCXXABI_ENABLE_SHARED=OFF \ + -DLIBUNWIND_ENABLE_SHARED=OFF -DLIBCXXABI_USE_LLVM_UNWINDER=ON \ + -DLLVM_INCLUDE_TESTS=OFF -DLIBCXX_INCLUDE_TESTS=OFF \ + -DLIBCXX_INCLUDE_BENCHMARKS=OFF +ninja -C build-ohos-libcxx install + +# CMake's ASM language does not inherit CMAKE_CXX_COMPILER_TARGET, so the +# installed libunwind.a holds HOST objects and lld rejects it. Use the +# platform's own unwinder — which is what the SDK lib dir provides. +rm -f ~/ohos-libcxx/lib/libunwind.a + +export MCPP_OHOS_LIBCXX=~/ohos-libcxx +mcpp build --target aarch64-linux-ohos # import std now works +``` + +Build the overlay with the **same** LLVM version the target pin resolves to. +A libc++ built by one clang and consumed by another is a version skew that +works right up until it does not. + +`MCPP_OHOS_LIBCXX_AARCH64_LINUX_OHOS` (target-suffixed, `-` → `_`, upper +case) overrides `MCPP_OHOS_LIBCXX` when one machine holds overlays for +several targets. + +### What is verified, and what is not + +CI cross-builds both tiers and **runs** the artifacts under `qemu-aarch64` +(`.github/workflows/ci-harmonyos.yml`, `tests/e2e/103_…` and `104_…`). That +proves the artifact targets the right machine and really executes — the same +claim the `aarch64-linux-musl` row makes. + +It does **not** cover the `.hnp`/`.hap` packaging path, linking the +platform's own NDK libraries (`libace_napi.z.so` and friends), or anything +requiring a device or the emulator. Those remain out of scope; see +`.agents/docs/2026-08-04-harmonyos-target-design.md`. + ## Uninstalling ```bash diff --git a/examples/05-harmonyos/README.md b/examples/05-harmonyos/README.md new file mode 100644 index 00000000..04af0c6b --- /dev/null +++ b/examples/05-harmonyos/README.md @@ -0,0 +1,28 @@ +# 05 — HarmonyOS / OpenHarmony + +把一个 C++23 具名模块工程交叉编译到鸿蒙 `aarch64-linux-ohos`。 + +```bash +export OHOS_NDK_HOME=/path/to/ohos-sdk/linux/native +cd 05-harmonyos +mcpp build --target aarch64-linux-ohos + +# 没有真机时,用 qemu 验证产物真的能跑 +qemu-aarch64 target/aarch64-linux-ohos/*/bin/harmonyos-demo +``` + +## 这个例子想说明什么 + +**mcpp 把鸿蒙 SDK 当 sysroot,不当工具链。** 编译器始终是 mcpp 自己的 LLVM —— +因为 SDK 自带的 clang 是 15.0.4(SDK 6.1 / API 23 实测),连 `-fmodule-output` +都没有,一个 C++20 模块也编不出来。SDK 提供的是 libc、CRT、目标 libc++ 和 +compiler-rt。 + +所以这里没有 `import std`:原版 SDK 的 libc++ 是 15.004,不带 `std` 模块。 +**具名模块是可以的**,这就是本例的形状。想要 `import std`,需要额外一份为该目标 +编译的 libc++ —— 见 +[docs/03-toolchains.md#harmonyos--openharmony](../../docs/03-toolchains.md#harmonyos--openharmony)。 + +`mcpp.toml` 里那个 `[target.aarch64-linux-ohos]` 段**不是必需的**:目标本身就 +带一个约定工具链(`src/toolchain/triple.cppm` 的 known-target 表),不写也会选到 +LLVM。写出来只是为了把版本钉死。 diff --git a/examples/05-harmonyos/mcpp.toml b/examples/05-harmonyos/mcpp.toml new file mode 100644 index 00000000..037e6891 --- /dev/null +++ b/examples/05-harmonyos/mcpp.toml @@ -0,0 +1,16 @@ +[package] +name = "harmonyos-demo" +version = "0.1.0" +description = "Demo: cross-build a C++23 named-module program for HarmonyOS/OpenHarmony" +license = "Apache-2.0" + +# mcpp brings the compiler; the OpenHarmony SDK brings the sysroot. +# The SDK's own clang is 15.0.4 and cannot build C++20 modules, so the +# toolchain axis is always an LLVM one — see docs/03-toolchains.md#harmonyos. +# +# `mcpp build --target aarch64-linux-ohos` picks llvm by convention (the +# target's pin in src/toolchain/triple.cppm); this section only exists to +# pin a specific version for reproducibility. +[target.aarch64-linux-ohos] +toolchain = "llvm@20.1.7" +linkage = "static" diff --git a/examples/05-harmonyos/src/device.cppm b/examples/05-harmonyos/src/device.cppm new file mode 100644 index 00000000..192db84d --- /dev/null +++ b/examples/05-harmonyos/src/device.cppm @@ -0,0 +1,12 @@ +module; +#include +export module device; + +// A named C++23 module, compiled by mcpp's own clang for aarch64-linux-ohos. +// Deliberately NOT `import std`: the OpenHarmony SDK's bundled libc++ is 15.0.4 +// and ships no `std` module, so this is the shape that works against a stock +// SDK. Point $MCPP_OHOS_LIBCXX at a libc++ built for the target and `import std` +// becomes available too (docs/03-toolchains.md#harmonyos). +export std::string device_banner() { + return "mcpp on HarmonyOS — C++23 named modules, cross-built and really run"; +} diff --git a/examples/05-harmonyos/src/main.cpp b/examples/05-harmonyos/src/main.cpp new file mode 100644 index 00000000..6d19b3ca --- /dev/null +++ b/examples/05-harmonyos/src/main.cpp @@ -0,0 +1,13 @@ +#include +import device; + +int main() { + std::printf("%s\n", device_banner().c_str()); +#if defined(__OHOS__) + std::printf("__OHOS__ is defined: this really is a HarmonyOS target\n"); +#else + std::printf("NOT built for HarmonyOS\n"); + return 1; +#endif + return 0; +} diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 7b92c688..7ba93dd3 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -367,13 +367,30 @@ CompileFlags compute_flags(const BuildPlan& plan) { compile_toolchain_flags = mcpp::toolchain::render_tokens( mcpp::toolchain::host_compile_tokens(plan.toolchain, hopt, ninjaEsc)); } - if (isClangWithCfg) { + if (isClangWithCfg || dm.isCross()) { llvmRootForStdlib = dm.llvmRoot; // Linker flags that cfg normally provides. The payload C-runtime // flags (-B/-L/loader) are appended via payload_ld below. - link_toolchain_flags = " --no-default-config"; + // + // A retargeted driver additionally needs `--target=` and the target's + // compiler-rt resource dir on the LINK line; both come from the same + // producer the argv channel uses (dm.cross_link_tokens), so the two + // channels cannot drift. `--target=` leads, because it selects the + // toolchain object every later flag is interpreted against. + link_toolchain_flags = + mcpp::toolchain::render_tokens(dm.cross_link_tokens(ninjaEsc)); + link_toolchain_flags += " --no-default-config"; if (lm.mode == mcpp::toolchain::CLibMode::Sysroot) link_toolchain_flags += lm.link_flags(ninjaEsc); + // The target's libc++ / libc++abi / libunwind archives — CROSS ONLY. + // On the host path these already arrive through linkRuntimeDirs / + // depRuntimeLibraryDirs further down, and adding them here too would + // change every existing clang link line. A cross target has no payload + // registry to walk, so its lib dirs come from the cross model or from + // nowhere. + if (dm.isCross()) + for (auto& d : dm.libDirs) + link_toolchain_flags += " -L" + escape_path(d); link_toolchain_flags += mcpp::toolchain::ClangDriverModel::kLinkDriverFlags; f.sysroot = link_toolchain_flags; @@ -574,6 +591,21 @@ CompileFlags compute_flags(const BuildPlan& plan) { // both rather than hard-coding one layout. Sorted so the choice cannot // depend on directory iteration order. auto find_archive = [&](std::string_view name) -> std::filesystem::path { + // Retargeted driver: the archives are the TARGET's, and the ONLY + // place they exist is the cross model's lib dirs. Falling through + // to the host llvm root below would find a perfectly valid + // `libc++.a` — for the wrong architecture — and hand it to the + // link line as an absolute path, where `-L` ordering cannot save + // it. Ordered: the dirs are tried in the order the provider listed + // them, so a purpose-built target libc++ shadows the SDK's. + if (dm.isCross()) { + std::error_code cec; + for (auto& d : dm.libDirs) { + auto p = d / name; + if (std::filesystem::exists(p, cec)) return p; + } + return {}; + } if (llvmRootForStdlib.empty()) return {}; std::error_code ec; auto libDir = llvmRootForStdlib / "lib"; diff --git a/src/build/hermetic.cppm b/src/build/hermetic.cppm index e192bfcb..2562f140 100644 --- a/src/build/hermetic.cppm +++ b/src/build/hermetic.cppm @@ -128,6 +128,17 @@ std::expected verify_hermetic_link( allowed.emplace_back(canon.substr(0, pos + 5)); } if (!tc.sysroot.empty()) allowed.push_back(tc.sysroot); + // A retargeted driver resolves the target's libc++/compiler-rt out of the + // platform SDK, which by construction is NOT under an xpkgs registry — + // it is a vendor archive the developer unpacked. That is still hermetic + // in the sense this check defends: the inputs are a pinned, declared + // sysroot, not whatever the build machine happens to have in /usr/lib. + if (tc.crossTarget) { + for (auto& d : tc.crossTarget->libDirs) allowed.push_back(d); + for (auto& d : tc.crossTarget->cxxIncludes) allowed.push_back(d); + if (!tc.crossTarget->linkResourceDir.empty()) + allowed.push_back(tc.crossTarget->linkResourceDir); + } if (const char* e = std::getenv("MCPP_ALLOW_HOST_LIBS"); e && *e && *e != '0') allowHostLibs = true; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index c6f416d6..b363a2b7 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -25,6 +25,7 @@ import mcpp.toolchain.detect; import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; import mcpp.toolchain.msvc; +import mcpp.toolchain.ohos; import mcpp.toolchain.registry; import mcpp.toolchain.stdmod; import mcpp.toolchain.post_install; @@ -1523,7 +1524,55 @@ prepare_build(bool print_fingerprint, tcOrigin = TcOrigin::FirstRun; } - auto tc = mcpp::toolchain::detect(explicit_compiler); + // ── HarmonyOS / OpenHarmony: retarget the driver at the platform SDK ── + // + // config② (2026-07-24-embedded-platform-support-design.md): mcpp brings + // the compiler, the platform brings the sysroot. Everywhere else that is + // one of two defensible architectures; here it is the only one, because + // the SDK's own clang is 15.0.4 (measured on SDK 6.1 / API 23) and GCC has + // no `ohos` target at all. + // + // Resolved AFTER the toolchain payload and BEFORE detect(), because the + // cross model has to be applied to the driver as it is being identified — + // a second pass that patched a finished Toolchain would leave every probe + // in between having already answered for the host. + std::optional crossTarget; + if (auto tt = mcpp::toolchain::triple::parse(overrides.target_triple); + tt && tt->is_ohos()) { + auto sdk = mcpp::toolchain::ohos::detect_installation(); + if (!sdk) + return std::unexpected(mcpp::toolchain::ohos::install_guidance()); + auto cp = mcpp::toolchain::ohos::cross_paths(*sdk, *tt); + if (!cp) return std::unexpected(cp.error()); + + mcpp::toolchain::CrossTarget ct; + ct.triple = tt->str(); + ct.sysroot = cp->sysroot; + ct.cxxIncludes = cp->cxxIncludes; + ct.libDirs = cp->libDirs; + ct.linkResourceDir = cp->linkResourceDir; + ct.stdModuleSource = cp->stdModuleSource; + ct.provider = cp->provider; + crossTarget = std::move(ct); + + // shorten_path needs a config to know the sandbox prefixes; a build + // that has not got one is not a reason to lose the diagnostic, so the + // full path is the fallback rather than a dereference of a failed + // expected. + auto cfgForPaths = get_cfg(); + mcpp::ui::info("Resolved", std::format("{} → {} (sysroot {})", + crossTarget->triple, cp->provider, + cfgForPaths ? mcpp::ui::shorten_path(cp->sysroot, + mcpp::fetcher::make_path_ctx(&**cfgForPaths, *root)) + : cp->sysroot.string())); + if (cp->stdModuleSource.empty()) + mcpp::ui::info("Note", + "this target's libc++ ships no `std` module — `import std;` is " + "unavailable here; named modules and #include both work. See " + "docs/03-toolchains.md#harmonyos--openharmony."); + } + + auto tc = mcpp::toolchain::detect(explicit_compiler, crossTarget); if (!tc) return std::unexpected(tc.error().message); // ── Targeting the MSVC ABI without a usable MSVC ───────────────────── diff --git a/src/toolchain/abi.cppm b/src/toolchain/abi.cppm index 8725c4e8..4b540ee2 100644 --- a/src/toolchain/abi.cppm +++ b/src/toolchain/abi.cppm @@ -76,7 +76,12 @@ inline AbiProfile abi_profile(const Toolchain& tc) { p.os = t->os; // libc — the C runtime ABI a C library links against. Windows is // msvcrt for BOTH env=msvc and env=gnu (MinGW links the Windows CRT). - if (t->is_musl()) p.libc = "musl"; + // HarmonyOS first: its libc is a musl fork, so a `t->is_musl()`-shaped + // question would be tempting — but an OHOS binary is not link- + // compatible with an upstream-musl one, and calling both "musl" would + // make `abi:musl` packages resolve onto a target they cannot serve. + if (t->is_ohos()) p.libc = "ohos"; + else if (t->is_musl()) p.libc = "musl"; else if (t->os == "macos") p.libc = "macos"; else if (t->os == "windows") p.libc = "msvcrt"; else if (t->os == "linux") p.libc = "glibc"; // *-linux-gnu (gcc AND clang+libc++) @@ -90,7 +95,8 @@ inline AbiProfile abi_profile(const Toolchain& tc) { // don't-care where nothing is recognizable). const std::string& t = tc.targetTriple; auto has = [&](std::string_view s) { return t.find(s) != std::string::npos; }; - if (has("musl")) p.libc = "musl"; + if (has("ohos")) p.libc = "ohos"; + else if (has("musl")) p.libc = "musl"; else if (has("darwin") || has("apple")) p.libc = "macos"; else if (has("msvc")) p.libc = "msvcrt"; else if (has("windows")) p.libc = "msvcrt"; diff --git a/src/toolchain/clang.cppm b/src/toolchain/clang.cppm index 27c91b09..c1522d1e 100644 --- a/src/toolchain/clang.cppm +++ b/src/toolchain/clang.cppm @@ -135,6 +135,27 @@ std::optional find_libcxx_std_module_source( } void enrich_toolchain(Toolchain& tc, const std::string& envPrefix) { + // Retargeted driver: every probe below asks the DRIVER about itself, and + // a driver always answers for its own default target. On a cross build + // that means the host's libc++ — including the host's `std.cppm`, which + // would compile without complaint into a BMI for the wrong platform. The + // target's answers come from the provider instead (Toolchain::crossTarget). + if (tc.crossTarget) { + tc.stdlibId = "libc++"; + tc.stdlibVersion = tc.version.empty() ? "unknown" : tc.version; + // linkRuntimeDirs describe the HOST payload's private shared libs; + // a cross artifact must not carry -L/-rpath into any of them. + tc.linkRuntimeDirs.clear(); + if (!tc.crossTarget->stdModuleSource.empty()) { + tc.stdModuleSource = tc.crossTarget->stdModuleSource; + tc.hasImportStd = true; + tc.importStdMinLevel = 20; // libc++ policy, same as the host path + auto compat = tc.stdModuleSource.parent_path() / "std.compat.cppm"; + if (std::filesystem::exists(compat)) tc.stdCompatSource = compat; + } + return; + } + // Clang targeting MSVC uses MSVC STL, not libc++. bool msvTarget = is_msvc_target(tc); tc.stdlibId = msvTarget ? "msvc-stl" : "libc++"; diff --git a/src/toolchain/detect.cppm b/src/toolchain/detect.cppm index c3079e01..9ed813dd 100644 --- a/src/toolchain/detect.cppm +++ b/src/toolchain/detect.cppm @@ -15,8 +15,15 @@ export namespace mcpp::toolchain { // Detect toolchain. If explicit_compiler is given, use that binary path // directly. Otherwise fall back to $CXX, then PATH g++. +// +// `cross` retargets the driver (clang only): the resolved toolchain then +// reports the REQUESTED triple rather than `-dumpmachine`, takes its C +// library from the cross sysroot, and every probe that would have answered +// for the host is skipped rather than answered wrongly. Building the +// CrossTarget is the caller's job — this module must not learn platforms. std::expected -detect(const std::filesystem::path& explicit_compiler = {}); +detect(const std::filesystem::path& explicit_compiler = {}, + const std::optional& cross = std::nullopt); // Compatibility helper for older call sites/tests: GCC std module lookup now // lives in the GCC provider. @@ -33,7 +40,8 @@ std::optional find_std_module_source( } std::expected -detect(const std::filesystem::path& explicit_compiler) { +detect(const std::filesystem::path& explicit_compiler, + const std::optional& cross) { auto bin_r = probe_compiler_binary(explicit_compiler); if (!bin_r) return std::unexpected(bin_r.error()); @@ -81,6 +89,33 @@ detect(const std::filesystem::path& explicit_compiler) { tc.targetTriple = *triple; } + // ── Driver retargeting ──────────────────────────────────────────────── + // Applied here, between classification and enrichment: everything below + // this point reads tc.targetTriple / tc.crossTarget, so the override has + // to land before them, and the compiler-identity probes above are the + // driver's own business and unaffected by the target. + if (cross) { + if (tc.compiler != CompilerId::Clang) { + return std::unexpected(DetectError{std::format( + "target '{}' needs a clang toolchain: only clang can be " + "retargeted with --target, and this driver is {} ({}).\n" + " Remove the [target.{}] toolchain override to take the " + "target's LLVM convention pin, or point it at an LLVM one:\n" + "\n" + " [target.{}]\n" + " toolchain = \"llvm@\"", + cross->triple, tc.compiler_name(), tc.binaryPath.string(), + cross->triple, cross->triple)}); + } + // `-dumpmachine` said "x86_64-unknown-linux-gnu"; keep it as + // provenance (it participates in the BMI fingerprint through + // driverIdent, which is right — the same target built by a different + // host driver is a different BMI) but let the REQUEST be the truth. + tc.driverIdent += "\nretargeted-from: " + tc.targetTriple; + tc.targetTriple = cross->triple; + tc.crossTarget = *cross; + } + #if defined(_WIN32) // On Windows, Clang targeting MSVC auto-detects the MSVC version at // compile time and bakes it into the module AST. The -dumpmachine triple @@ -107,6 +142,19 @@ detect(const std::filesystem::path& explicit_compiler) { mcpp::toolchain::clang::enrich_toolchain(tc, envPrefix); } + if (cross) { + // The target's C library, stated rather than probed. `probe_sysroot` + // would ask the driver, and `probe_payload_paths` would find the + // HOST's glibc xpkg sitting next to the compiler — either one silently + // aims a cross build at the build machine's libc, which is the exact + // failure mode CLibMode::PayloadFirst exists to produce on purpose for + // host builds. payloadPaths stays empty: linkmodel treats a cross + // target as Sysroot mode unconditionally, and leaving a host payload + // reachable would only give a future edit something wrong to find. + tc.sysroot = cross->sysroot; + return tc; + } + tc.sysroot = probe_sysroot(tc.binaryPath, envPrefix); // Probe fine-grained payload paths from sibling xpkgs (glibc, linux-headers). diff --git a/src/toolchain/hostflags.cppm b/src/toolchain/hostflags.cppm index 174a3c30..ed3d1369 100644 --- a/src/toolchain/hostflags.cppm +++ b/src/toolchain/hostflags.cppm @@ -122,9 +122,13 @@ std::vector host_compile_tokens(const Toolchain& tc, const auto dm = resolve_clang_driver(tc); const auto lm = resolve_link_model(tc); + // A retargeted driver must ALWAYS bypass: the cfg was generated at + // install time for the host triple, so trusting it on a cross compile + // would inject the host's libc++ include paths under `--target=`. const bool bypassCfg = - dm.hasCfg && (opt.cfgBypass == HostFlagOptions::CfgBypass::Always - || mcpp::platform::is_linux); + dm.isCross() + || (dm.hasCfg && (opt.cfgBypass == HostFlagOptions::CfgBypass::Always + || mcpp::platform::is_linux)); // Trusting the cfg means contributing no include paths, stdlib selection // or runtime choices — it already carries them. It does NOT mean @@ -175,8 +179,9 @@ std::vector host_link_tokens(const Toolchain& tc, const auto lm = resolve_link_model(tc); const bool bypassCfg = - dm.hasCfg && (opt.cfgBypass == HostFlagOptions::CfgBypass::Always - || mcpp::platform::is_linux); + dm.isCross() + || (dm.hasCfg && (opt.cfgBypass == HostFlagOptions::CfgBypass::Always + || mcpp::platform::is_linux)); if (bypassCfg) { for (auto& t : dm.link_tokens(esc)) out.push_back(t); diff --git a/src/toolchain/linkmodel.cppm b/src/toolchain/linkmodel.cppm index 45195abc..fafb5d00 100644 --- a/src/toolchain/linkmodel.cppm +++ b/src/toolchain/linkmodel.cppm @@ -135,6 +135,26 @@ struct ClangDriverModel { std::vector cxxIncludes; // libc++ header dirs std::vector libDirs; // libc++/compiler-rt libs + // ── Driver retargeting (Toolchain::crossTarget) ────────────────────── + // Non-empty `crossTriple` means every driver invocation carries + // `--target=`, and cxxIncludes/libDirs above are the TARGET's, not the + // host's. `crossLinkResourceDir` is link-side only (see CrossTarget). + std::string crossTriple; + std::filesystem::path crossLinkResourceDir; + + bool isCross() const { return !crossTriple.empty(); } + + // `--target=` belongs on BOTH sides and must be the first token on each: + // it selects the driver's whole toolchain object, so anything emitted + // before it would be interpreted for the host. Its own producer because + // three channels emit driver flags (ninja string, std-module command, + // build.mcpp argv) and a target that reaches only two of them is a build + // that compiles for one machine and links for another. + std::vector target_tokens() const { + if (!isCross()) return {}; + return { "--target=" + crossTriple }; + } + // "--no-default-config" "-nostdinc++" "-isystem<...>" (compile side), as // argv tokens. -stdlib=libc++ is deliberately left to callers: compile // commands for C files must not carry it. @@ -144,7 +164,9 @@ struct ClangDriverModel { // the flag would invalidate every user's std BMIs for no behavioural gain. std::vector compile_tokens(const PathEscape& esc, bool stdlibSelect = false) const { - std::vector out{"--no-default-config", "-nostdinc++"}; + std::vector out = target_tokens(); + out.push_back("--no-default-config"); + out.push_back("-nostdinc++"); if (stdlibSelect) out.push_back("-stdlib=libc++"); for (auto& inc : cxxIncludes) out.push_back("-isystem" + esc(inc)); return out; @@ -164,14 +186,29 @@ struct ClangDriverModel { // Plus the libc++/compiler-rt library dirs, which a self-contained host // helper needs to both link against and find at run time. std::vector link_tokens(const PathEscape& esc) const { - std::vector out{"-fuse-ld=lld", "--rtlib=compiler-rt", - "--unwindlib=libunwind"}; + std::vector out = cross_link_tokens(esc); + for (auto f : {"-fuse-ld=lld", "--rtlib=compiler-rt", + "--unwindlib=libunwind"}) + out.push_back(f); for (auto& d : libDirs) { out.push_back("-L" + esc(d)); - out.push_back("-Wl,-rpath," + esc(d)); + // rpath is meaningless for a cross target's build-host paths (the + // device will not have them) and actively harmful in a static + // link; the cross path relies on `-L` + static archives instead. + if (!isCross()) out.push_back("-Wl,-rpath," + esc(d)); } return out; } + + // The retargeting half of the link side, shared with the ninja string + // channel in flags.cppm — which assembles its own link flags and would + // otherwise have re-derived `--target=`/`-resource-dir=` a second time. + std::vector cross_link_tokens(const PathEscape& esc) const { + std::vector out = target_tokens(); + if (!crossLinkResourceDir.empty()) + out.push_back("-resource-dir=" + esc(crossLinkResourceDir)); + return out; + } }; // ── loader resolution: data over hardcodes ─────────────────────────────── @@ -192,7 +229,12 @@ struct ClangDriverModel { // Loader *file name* for a target triple; empty when the arch is unknown. std::string loader_filename(std::string_view targetTriple) { - const bool musl = targetTriple.find("musl") != std::string_view::npos; + // HarmonyOS ships a musl FORK, and it kept musl's loader naming + // (`/lib/ld-musl-aarch64.so.1` — verified from the PT_INTERP of an + // NDK-produced binary). So the loader question answers "musl" here even + // though the ABI question does not: same file name, different libc. + const bool musl = targetTriple.find("musl") != std::string_view::npos + || targetTriple.find("ohos") != std::string_view::npos; struct ArchLoader { std::string_view arch, gnuName, muslArch; }; static constexpr std::array kMap{{ {"x86_64", "ld-linux-x86-64.so.2", "x86_64"}, @@ -263,6 +305,26 @@ ClangDriverModel resolve_clang_driver(const Toolchain& tc) { dm.cfgPath = tc.binaryPath.parent_path() / (tc.binaryPath.stem().string() + ".cfg"); dm.hasCfg = std::filesystem::exists(dm.cfgPath); + + // Retargeted driver: the C++ standard library comes from the CROSS model, + // never from the host llvm root. Returning here (before the host libc++ + // discovery below) is the whole point — an `-isystem /include/c++/v1` + // leaking into a cross compile does not fail loudly, it silently compiles + // the host's libc++ headers for a foreign target. + // + // `hasCfg` stays whatever it is: consumers gate the cfg BYPASS on it, and + // a retargeted driver must bypass the cfg unconditionally — the cfg is + // generated at install time for the HOST triple. Consumers therefore ask + // `dm.hasCfg || dm.isCross()`. + if (tc.crossTarget) { + dm.crossTriple = tc.crossTarget->triple; + dm.crossLinkResourceDir = tc.crossTarget->linkResourceDir; + dm.cxxIncludes = tc.crossTarget->cxxIncludes; + dm.libDirs = tc.crossTarget->libDirs; + dm.llvmRoot = tc.binaryPath.parent_path().parent_path(); + return dm; + } + if (!dm.hasCfg) return dm; dm.llvmRoot = tc.binaryPath.parent_path().parent_path(); auto libcxxInclude = dm.llvmRoot / "include" / "c++" / "v1"; @@ -283,6 +345,17 @@ ToolchainLinkModel resolve_link_model(const Toolchain& tc) { lm.clangDriver = is_clang(tc); lm.clangWithCfg = resolve_clang_driver(tc).hasCfg; + // Retargeted driver: the target's C library is its sysroot, full stop. + // This is checked FIRST so no host-payload branch below can ever be + // reached for a cross target — `tc.payloadPaths` describes the HOST's + // glibc, and PayloadFirst would hand a foreign target the host's + // crt1.o/libc.so and its dynamic loader. + if (tc.crossTarget) { + lm.mode = CLibMode::Sysroot; + lm.sysroot = tc.crossTarget->sysroot; + return lm; + } + // PE targets: no ELF loader, no rpath, no glibc payload/sysroot model. // MSVC-ABI Clang gets STL+SDK via the driver, MinGW is self-contained — // both want CLibMode::None. Keyed on the TARGET (not the host) so the diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index 71d3883c..2d643e9f 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -23,6 +23,56 @@ struct PayloadPaths { std::filesystem::path linuxInclude; // linux kernel headers (linux/, asm/) }; +// ── Driver-retargeting cross model ─────────────────────────────────────────── +// +// mcpp's cross story so far has been "a different compiler BINARY per target" +// (aarch64-linux-musl-g++, x86_64-w64-mingw32-g++), because GCC is not a +// multi-target compiler. `tc.targetTriple` therefore always came from +// `-dumpmachine` and was always the truth. +// +// Clang is a cross compiler by construction: ONE binary serves every target +// once you hand it `--target=` plus somewhere to find the target's libc, +// libc++ and compiler runtime. Nothing in mcpp could express that — which is +// exactly what .github/workflows/cross-build-test.yml records as the missing +// "llvm/clang cross" row. This struct is that expression, and it is +// deliberately NOT HarmonyOS-shaped: HarmonyOS is only the first target that +// cannot be reached any other way (GCC has no `ohos` target at all). +// +// When present, `tc.targetTriple` is the REQUESTED target, not `-dumpmachine` +// output, and every driver invocation carries `--target=`. +struct CrossTarget { + std::string triple; // canonical; also becomes tc.targetTriple + + // Target C library + system headers. Feeds `--sysroot=` (CLibMode::Sysroot). + std::filesystem::path sysroot; + + // Target C++ standard library: headers and the archives to link. Kept + // separate from the sysroot because on HarmonyOS they live in different + // trees (/llvm vs /sysroot), and because swapping ONLY this + // pair is what upgrades a target from "no import std" to "import std". + std::vector cxxIncludes; + std::vector libDirs; + + // The TARGET libc++'s `std.cppm`, when one exists. Supplied by the + // provider rather than probed, because the probe + // (`-print-library-module-manifest-path`) answers for the driver's OWN + // libc++ — on a cross build that is the host's, and building `std` from + // it would be a silently wrong BMI rather than an error. Empty means this + // target has no std module: `import std` is then refused with a + // diagnostic instead of miscompiled. + std::filesystem::path stdModuleSource; + + // Clang resource dir holding the TARGET's compiler-rt builtins and + // clang_rt.crt{begin,end}.o. Link side only — the compile side must keep + // the driver's OWN resource dir, or a clang-22 compile would read + // clang-15's intrinsic headers. Empty = the driver's own dir is fine. + std::filesystem::path linkResourceDir; + + // Human-facing provenance for `mcpp doctor` / diagnostics + // (e.g. "OpenHarmony SDK 6.1.0.31 (API 23)"). + std::string provider; +}; + struct Toolchain { CompilerId compiler = CompilerId::Unknown; std::string version; // "15.1.0" @@ -35,6 +85,10 @@ struct Toolchain { std::filesystem::path stdCompatSource; // bits/std_compat.cc / std.compat.cppm std::filesystem::path sysroot; // -print-sysroot output (or empty) std::optional payloadPaths; // fine-grained sysroot from xpkgs + // Set when this driver is being RETARGETED (clang cross). Mutually + // exclusive with payloadPaths by construction: the host glibc payload + // must never be offered to a cross target. + std::optional crossTarget; std::vector compilerRuntimeDirs; // LD_LIBRARY_PATH for private tools std::vector linkRuntimeDirs; // -L/-rpath dirs for produced binaries // Environment the toolchain's tools need when invoked (set on the ninja diff --git a/src/toolchain/ohos.cppm b/src/toolchain/ohos.cppm new file mode 100644 index 00000000..f7892fce --- /dev/null +++ b/src/toolchain/ohos.cppm @@ -0,0 +1,383 @@ +// mcpp.toolchain.ohos — HarmonyOS / OpenHarmony platform SDK discovery. +// +// This module answers ONE question: given a `*-linux-ohos` target, where are +// the platform's C library, C++ standard library and compiler runtime? It +// never answers "which compiler" — that stays mcpp's own LLVM payload. +// +// ── Why the SDK is a sysroot and not a toolchain ──────────────────────────── +// +// The obvious design is "use the NDK's clang, like everyone else does". It +// does not work, and not marginally: **OpenHarmony SDK 6.1 (API 23, released +// 2026-03) still ships clang 15.0.4** — measured, not assumed. C++20 modules +// need `-fmodule-output` (clang 16) and mcpp's whole build model is modules; +// `import std` needs libc++ ≥ 19. The SDK's compiler is four major versions +// short of mcpp's floor and has been for years, so "wait for the vendor" is +// not a plan. +// +// So mcpp takes the shape .agents/docs/2026-07-24-embedded-platform-support-design.md +// calls config②: **mcpp brings the compiler, the platform brings the sysroot.** +// HarmonyOS is the first target where that is not a preference but the only +// possibility — GCC has no `ohos` target at all, so the "bring a matching-libc +// GCC" half of that design cannot be spelled here and the archived clang route +// (decision #8/D) becomes the required one. +// +// What mcpp consumes from the SDK: +// /sysroot → --sysroot (libc, crt1/crti/crtn) +// /llvm/include/libcxx-ohos/include/c++/v1 → target libc++ headers +// /llvm/lib// → target libc++/libc++abi/libunwind +// /llvm/lib/clang// → target compiler-rt + clang_rt.crt* +// +// The last one is why `CrossTarget::linkResourceDir` exists: upstream clang's +// OHOS driver looks for `libclang_rt.builtins.a` and `clang_rt.crt{begin,end}.o` +// under ITS OWN resource dir, which of course has no aarch64-linux-ohos +// subdirectory. Pointing `-resource-dir` at the SDK's on the LINK line only +// supplies them; doing it on the compile line too would feed clang-22 the +// clang-15 intrinsic headers, which is a different and much quieter bug. + +module; +#include // getenv + +export module mcpp.toolchain.ohos; + +import std; +import mcpp.toolchain.triple; +import mcpp.platform; +import mcpp.log; + +export namespace mcpp::toolchain::ohos { + +// A located OpenHarmony native SDK ("/native", the NDK proper). +struct SdkInstallation { + std::filesystem::path root; // .../native + std::filesystem::path sysroot; // /sysroot + std::filesystem::path llvmRoot; // /llvm + std::filesystem::path libcxxInclude; // OHOS-patched libc++ headers + std::filesystem::path resourceDir; // /lib/clang/ + std::string sdkVersion; // "6.1.0.31" (oh-uni-package.json) + std::string apiVersion; // "23" (oh-uni-package.json) + std::string source; // which knob found it (diagnostics) + + std::string display() const { + std::string s = "OpenHarmony SDK"; + if (!sdkVersion.empty()) { s += ' '; s += sdkVersion; } + if (!apiVersion.empty()) { s += " (API "; s += apiVersion; s += ')'; } + return s; + } +}; + +// Locate a usable SDK, or nullopt. Never throws, never installs. +std::optional detect_installation(); + +// Actionable text for "no SDK found" — the msvc@system precedent: mcpp cannot +// install this SDK (it is a ~2.5 GB vendor archive behind a EULA), so the only +// honest response is to say exactly what to set. +std::string install_guidance(); + +// The SDK's per-target library dir, e.g. /lib/aarch64-linux-ohos. +// Empty when the SDK does not carry that target. +std::filesystem::path target_lib_dir(const SdkInstallation& sdk, + const triple::Triple& t); + +// The SDK's per-target compiler-rt dir (builtins + clang_rt.crt*). +std::filesystem::path target_runtime_dir(const SdkInstallation& sdk, + const triple::Triple& t); + +// An externally-supplied libc++ built FOR the ohos target, which is what +// upgrades a HarmonyOS build from "named modules only" to "import std". +// +// Deliberately env-first and index-last: an index-side package must never be +// what decides whether mcpp works (see +// .agents/docs/2026-08-03-index-availability-must-not-decide-mcpp-availability.md). +// The SDK's own libc++ is always there and always usable; this is the upgrade. +struct LibcxxOverlay { + std::filesystem::path include; // /include/c++/v1 + std::filesystem::path lib; // /lib + std::filesystem::path stdModule; // /share/libc++/v1/std.cppm ("" = none) +}; +std::optional detect_libcxx_overlay(const triple::Triple& t); + +// Assemble the CrossTarget-shaped answer for `t`. Returns the pieces; the +// caller (detect.cppm) owns stitching them onto a Toolchain, so this module +// stays free of the toolchain model. +struct CrossPaths { + std::filesystem::path sysroot; + std::vector cxxIncludes; + std::vector libDirs; + std::filesystem::path linkResourceDir; + std::filesystem::path stdModuleSource; // "" when the target has no std module + std::string provider; +}; +std::expected +cross_paths(const SdkInstallation& sdk, const triple::Triple& t); + +} // namespace mcpp::toolchain::ohos + +namespace mcpp::toolchain::ohos { + +namespace { + +std::string env_or_empty(const char* key) { + if (const char* v = std::getenv(key); v && *v) return v; + return {}; +} + +bool path_exists(const std::filesystem::path& p) { + std::error_code ec; + return std::filesystem::exists(p, ec); +} + +// Extract `"key": "value"` from the SDK's tiny oh-uni-package.json. A real +// JSON parser would be overkill for a two-field read of a vendor-generated +// file, and mcpp's own TOML reader does not apply. +std::string json_field(const std::string& body, std::string_view key) { + auto k = std::string("\"") + std::string(key) + "\""; + auto pos = body.find(k); + if (pos == std::string::npos) return {}; + auto colon = body.find(':', pos + k.size()); + if (colon == std::string::npos) return {}; + auto q1 = body.find('"', colon + 1); + if (q1 == std::string::npos) return {}; + auto q2 = body.find('"', q1 + 1); + if (q2 == std::string::npos) return {}; + return body.substr(q1 + 1, q2 - q1 - 1); +} + +// Is `p` the `native` directory of an OpenHarmony SDK? Judged by the two +// things mcpp actually needs, not by the directory's name: a path that merely +// looks right but carries no sysroot must be rejected here rather than fail +// later inside a compile command. +bool looks_like_native_sdk(const std::filesystem::path& p) { + return path_exists(p / "sysroot" / "usr" / "include" / "stdlib.h") + && path_exists(p / "llvm" / "lib"); +} + +// Candidate `native` roots, in priority order. Each entry is (path, provenance). +std::vector> native_candidates() { + std::vector> out; + auto add = [&](std::filesystem::path p, std::string why) { + if (!p.empty()) out.emplace_back(std::move(p), std::move(why)); + }; + + // 1. Explicit, unambiguous: the NDK root itself. + if (auto v = env_or_empty("OHOS_NDK_HOME"); !v.empty()) { + add(v, "$OHOS_NDK_HOME"); + add(std::filesystem::path(v) / "native", "$OHOS_NDK_HOME/native"); + } + // openharmony-rs/setup-ohos-sdk exports this one, and it already points + // AT the native dir — which is why it is checked as-is. + if (auto v = env_or_empty("OHOS_SDK_NATIVE"); !v.empty()) + add(v, "$OHOS_SDK_NATIVE"); + + // 2. SDK roots, which nest the API level in some layouts and not others. + for (auto key : {"OHOS_SDK_HOME", "HOS_SDK_HOME", "DEVECO_SDK_HOME"}) { + auto v = env_or_empty(key); + if (v.empty()) continue; + std::string why = std::string("$") + key; + add(std::filesystem::path(v) / "native", why + "/native"); + // //native — DevEco's layout. Pick the highest API level + // present rather than the first directory the filesystem hands back. + std::error_code ec; + std::vector> levels; + for (auto& e : std::filesystem::directory_iterator(v, ec)) { + std::error_code de; + if (!e.is_directory(de)) continue; + auto name = e.path().filename().string(); + // from_chars, not stoi: a 20-digit directory name is not an API + // level but it IS a valid unsigned literal, and stoi would throw + // out_of_range out of a discovery routine that promises not to. + int level = 0; + auto* first = name.data(); + auto* last = first + name.size(); + auto [ptr, ec2] = std::from_chars(first, last, level); + if (ec2 != std::errc{} || ptr != last) continue; + levels.emplace_back(level, e.path() / "native"); + } + std::ranges::sort(levels, std::ranges::greater{}, + &std::pair::first); + for (auto& [lvl, p] : levels) + add(p, std::format("{}/{}/native", why, lvl)); + } + + // 3. Conventional unpack locations, so a developer who followed the + // upstream instructions needs no environment at all. + if constexpr (!mcpp::platform::is_windows) { + if (auto home = env_or_empty("HOME"); !home.empty()) { + add(std::filesystem::path(home) / "ohos-sdk" / "native", + "~/ohos-sdk/native"); + add(std::filesystem::path(home) / "command-line-tools" / "sdk" + / "default" / "openharmony" / "native", + "~/command-line-tools (DevEco CLI)"); + } + add("/opt/ohos-sdk/native", "/opt/ohos-sdk/native"); + add("/usr/local/ohos-sdk/native", "/usr/local/ohos-sdk/native"); + } + return out; +} + +} // namespace + +std::optional detect_installation() { + for (auto& [cand, why] : native_candidates()) { + if (!looks_like_native_sdk(cand)) { + mcpp::log::debug("ohos", std::format( + "candidate '{}' ({}) is not an OpenHarmony native SDK", + cand.string(), why)); + continue; + } + SdkInstallation sdk; + std::error_code ec; + sdk.root = std::filesystem::weakly_canonical(cand, ec); + if (ec) sdk.root = cand; + sdk.sysroot = sdk.root / "sysroot"; + sdk.llvmRoot = sdk.root / "llvm"; + sdk.source = why; + + // The OHOS-patched libc++ header tree. The plain /include/c++/v1 + // in the same payload is the HOST's (its __config_site is generated + // for x86_64-unknown-linux-gnu) — picking it would compile the target + // against host libc++ configuration and fail far from here. + auto ohosCxx = sdk.llvmRoot / "include" / "libcxx-ohos" / "include" + / "c++" / "v1"; + if (path_exists(ohosCxx / "__config_site")) sdk.libcxxInclude = ohosCxx; + + // /lib/clang// — exactly one version dir in practice, + // but sorted so the answer never depends on iteration order. + std::vector resDirs; + for (auto& e : std::filesystem::directory_iterator( + sdk.llvmRoot / "lib" / "clang", ec)) { + std::error_code de; + if (e.is_directory(de)) resDirs.push_back(e.path()); + } + std::ranges::sort(resDirs); + if (!resDirs.empty()) sdk.resourceDir = resDirs.back(); + + if (auto pkg = sdk.root / "oh-uni-package.json"; path_exists(pkg)) { + std::ifstream is(pkg); + std::stringstream ss; + ss << is.rdbuf(); + auto body = ss.str(); + sdk.sdkVersion = json_field(body, "version"); + sdk.apiVersion = json_field(body, "apiVersion"); + } + + mcpp::log::verbose("ohos", std::format( + "{} at {} (via {})", sdk.display(), sdk.root.string(), sdk.source)); + return sdk; + } + return std::nullopt; +} + +std::string install_guidance() { + return + "no OpenHarmony SDK found — a `*-linux-ohos` target needs the platform\n" + "sysroot, which mcpp does not ship (it is a vendor archive under its own\n" + "licence).\n" + "\n" + " 1. Download the SDK ('ohos-sdk' / 'Native' component) from\n" + " https://gitee.com/openharmony/docs (release notes -> SDK), or in CI\n" + " use openharmony-rs/setup-ohos-sdk.\n" + " 2. Point mcpp at the unpacked `native` directory:\n" + "\n" + " export OHOS_NDK_HOME=/path/to/ohos-sdk/linux/native\n" + "\n" + "mcpp uses the SDK for its sysroot and runtime libraries only — the\n" + "compiler stays mcpp's own LLVM, because the SDK's bundled clang is\n" + "15.0.4 even in SDK 6.1 and cannot build C++20 modules."; +} + +std::filesystem::path target_lib_dir(const SdkInstallation& sdk, + const triple::Triple& t) { + auto p = sdk.llvmRoot / "lib" / t.str(); + return path_exists(p) ? p : std::filesystem::path{}; +} + +std::filesystem::path target_runtime_dir(const SdkInstallation& sdk, + const triple::Triple& t) { + if (sdk.resourceDir.empty()) return {}; + auto p = sdk.resourceDir / "lib" / t.str(); + return path_exists(p) ? p : std::filesystem::path{}; +} + +std::optional detect_libcxx_overlay(const triple::Triple& t) { + std::vector> roots; + // Per-target first, so one machine can hold overlays for several targets. + auto perTarget = std::string("MCPP_OHOS_LIBCXX_") + + [&] { std::string s = t.str(); + for (auto& c : s) + c = (c == '-') ? '_' + : static_cast(std::toupper( + static_cast(c))); + return s; }(); + if (auto v = env_or_empty(perTarget.c_str()); !v.empty()) + roots.emplace_back(v, perTarget); + if (auto v = env_or_empty("MCPP_OHOS_LIBCXX"); !v.empty()) + roots.emplace_back(v, "$MCPP_OHOS_LIBCXX"); + + for (auto& [root, why] : roots) { + LibcxxOverlay ov; + ov.include = root / "include" / "c++" / "v1"; + ov.lib = root / "lib"; + if (!path_exists(ov.include / "__config_site") || !path_exists(ov.lib)) { + mcpp::log::debug("ohos", std::format( + "libc++ overlay '{}' ({}) has no include/c++/v1/__config_site " + "+ lib/ — ignoring", root.string(), why)); + continue; + } + auto stdm = root / "share" / "libc++" / "v1" / "std.cppm"; + if (path_exists(stdm)) ov.stdModule = stdm; + mcpp::log::verbose("ohos", std::format( + "libc++ overlay for {} at {} (via {}){}", + t.str(), root.string(), why, + ov.stdModule.empty() ? " — no std module" : " — with std module")); + return ov; + } + return std::nullopt; +} + +std::expected +cross_paths(const SdkInstallation& sdk, const triple::Triple& t) { + CrossPaths cp; + cp.sysroot = sdk.sysroot; + cp.provider = sdk.display(); + + auto sdkLib = target_lib_dir(sdk, t); + if (sdkLib.empty()) + return std::unexpected(std::format( + "{} carries no libraries for '{}' (looked in {}); the SDK's " + "supported targets are the directories under that path", + sdk.display(), t.str(), (sdk.llvmRoot / "lib").string())); + + auto rtDir = target_runtime_dir(sdk, t); + if (rtDir.empty()) + return std::unexpected(std::format( + "{} carries no compiler runtime for '{}' (expected " + "libclang_rt.builtins.a under {})", + sdk.display(), t.str(), + (sdk.resourceDir.empty() ? sdk.llvmRoot / "lib" / "clang" + : sdk.resourceDir / "lib").string())); + // The resource dir, not its per-target subdir: `-resource-dir` names the + // dir clang appends `lib//` to itself. + cp.linkResourceDir = sdk.resourceDir; + + // Header + library search order is the whole upgrade mechanism: a + // purpose-built target libc++ (with a std module) goes FIRST, the SDK's + // patched libc++ 15 is the always-available fallback. Both lib dirs stay + // on the line — the overlay does not ship a usable libunwind for this + // target, so the platform's is what actually resolves. + if (auto ov = detect_libcxx_overlay(t)) { + cp.cxxIncludes.push_back(ov->include); + cp.libDirs.push_back(ov->lib); + cp.stdModuleSource = ov->stdModule; + cp.provider += " + external libc++"; + } else if (!sdk.libcxxInclude.empty()) { + cp.cxxIncludes.push_back(sdk.libcxxInclude); + } else { + return std::unexpected(std::format( + "{} has no OHOS libc++ headers (expected " + "llvm/include/libcxx-ohos/include/c++/v1)", sdk.display())); + } + cp.libDirs.push_back(sdkLib); + return cp; +} + +} // namespace mcpp::toolchain::ohos diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index fff4e79f..7026396c 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -23,6 +23,7 @@ import mcpp.toolchain.gcc; import mcpp.toolchain.llvm; import mcpp.toolchain.model; import mcpp.toolchain.msvc; +import mcpp.toolchain.ohos; import mcpp.toolchain.triple; export namespace mcpp::toolchain { @@ -326,6 +327,15 @@ bool is_system_toolchain(const ToolchainSpec& spec) { bool host_can_serve(const triple::Triple& target) { if (target.empty()) return true; // host target + // HarmonyOS: served by mcpp's own clang plus the platform SDK, so the + // question is "is the SDK here", not "does a payload exist for this + // host×arch pair". Checked before the linux branch below, which would + // otherwise reject aarch64-linux-ohos on an x86_64 host for a reason that + // does not apply (no arch-specific payload is involved — one clang serves + // every target). + if (target.is_ohos()) + return mcpp::toolchain::ohos::detect_installation().has_value(); + if (target.os == "linux") { if constexpr (mcpp::platform::is_linux) { // musl payloads are self-contained, so any arch is reachable; a diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index 676ae858..be9f66d3 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -11,8 +11,15 @@ // abi_profile, model.cppm's is_*_target, registry's musl signals) consumes // this module now. Vocabulary: os ∈ {linux, macos, windows} (never "darwin"), // arch is the GNU spelling ({x86_64, aarch64, riscv64, …} — never "arm64"), -// env ∈ {gnu, musl, msvc} (empty on macos). `static` is NOT part of a triple: -// it is a target's default linkage property, flipped via [build]. +// env ∈ {gnu, musl, msvc, ohos} (empty on macos). `static` is NOT part of a +// triple: it is a target's default linkage property, flipped via [build]. +// +// `ohos` (HarmonyOS / OpenHarmony) is an ENV, not an OS — that is upstream +// LLVM's own model (`llvm::Triple::OpenHOS`), and it is the right one: the +// kernel is Linux, so `cfg(unix)` / `cfg(os = "linux")` must keep matching, +// while the libc (a musl FORK with its own soname layout) and the whole +// runtime ABI differ. Spelling it as a new OS would have silently excluded +// every `cfg(os = "linux")` block a portable package already ships. // // The known-target table below is the closed vocabulary `--target` validates // against (with an escape hatch for explicit [target.X] manifest sections) @@ -32,7 +39,7 @@ export namespace mcpp::toolchain::triple { struct Triple { std::string arch; // "x86_64" | "aarch64" | "riscv64" | ... (GNU spelling) std::string os; // "linux" | "macos" | "windows" - std::string env; // "gnu" | "musl" | "msvc" | "" (always empty on macos) + std::string env; // "gnu" | "musl" | "msvc" | "ohos" | "" (empty on macos) bool empty() const { return arch.empty() && os.empty(); } @@ -46,6 +53,13 @@ struct Triple { bool is_musl() const { return env == "musl"; } bool is_msvc_env() const { return env == "msvc"; } + // HarmonyOS / OpenHarmony. Its libc is a musl FORK, but `is_musl()` + // deliberately stays FALSE: mcpp's musl answer everywhere (payload + // selection, `ld-musl-.so.1`, the `abi:musl` capability) means the + // upstream musl those payloads were built against, and an OHOS artifact + // is not interchangeable with one. Callers that mean "no glibc" must ask + // for that, not for musl. + bool is_ohos() const { return env == "ohos"; } bool is_windows_gnu() const { return os == "windows" && env == "gnu"; } bool is_pe() const { return os == "windows"; } @@ -106,6 +120,14 @@ inline constexpr TargetInfo kKnownTargets[] = { { "x86_64-windows-gnu", "verified", "PE", "gcc@16.1.0", true }, { "x86_64-windows-msvc", "verified", "PE", "", false }, { "aarch64-macos", "verified", "", "", false }, + // HarmonyOS / OpenHarmony. The pin is an LLVM one and that is structural, + // not a preference: GCC has no `ohos` target at all, so config② ("mcpp + // brings the compiler, the platform brings the sysroot") can only be + // spelled with clang here. Must stay equal to `pins::kOhosLlvm` below — + // test_ohos_target.cpp enforces it. + { "aarch64-linux-ohos", "verified", "", "llvm@20.1.7", true }, + { "x86_64-linux-ohos", "planned", "", "llvm@20.1.7", true }, + { "arm-linux-ohos", "planned", "", "llvm@20.1.7", true }, { "riscv64-linux-musl", "planned", "", "", true }, { "aarch64-linux-gnu", "planned", "", "", false }, { "x86_64-macos", "planned", "", "", false }, @@ -167,6 +189,13 @@ namespace pins { inline constexpr std::string_view kFirstRunWinGnuTarget = "x86_64-windows-gnu"; inline constexpr std::string_view kFirstRunLinuxX86_64 = "gcc@16.1.0"; inline constexpr std::string_view kFirstRunLinuxOther = "gcc@15.1.0-musl"; + // HarmonyOS/OpenHarmony convention toolchain. LLVM is not a preference + // here but the only option: GCC has no `ohos` target, so the compiler + // half of config② can only be clang. Must stay equal to the + // `*-linux-ohos` rows' `pin` in kKnownTargets above — test_ohos_target.cpp + // enforces it. Floor is clang ≥ 17 (`-fmodule-output`); the SDK's own + // bundled clang is 15.0.4 even in SDK 6.1 and can never be used. + inline constexpr std::string_view kOhosLlvm = "llvm@20.1.7"; // Suggested install spellings used by help / MCPP_NO_AUTO_INSTALL errors. inline constexpr std::string_view kSuggestLlvm = "llvm 20.1.7"; inline constexpr std::string_view kSuggestGccMusl = "gcc 15.1.0-musl"; @@ -311,6 +340,11 @@ std::optional parse(std::string_view s) { if (t.os != "macos") { if (k == "musl" || starts_with(k, "musleabi")) { t.env = "musl"; continue; } if (k == "gnu" || starts_with(k, "gnueabi")) { t.env = "gnu"; continue; } + // HarmonyOS/OpenHarmony. `ohos` is the canonical spelling and the + // one the OHOS NDK's own driver uses ("aarch64-linux-ohos"); + // "openhos" is upstream LLVM's long form for the same environment. + if (k == "ohos" || starts_with(k, "ohoseabi") + || k == "openhos") { t.env = "ohos"; continue; } // starts_with: clang effective triples can carry a version suffix // on the env segment ("…-windows-msvc19.44.35211"). if (starts_with(k, "msvc")) { t.env = "msvc"; continue; } diff --git a/tests/e2e/103_harmonyos_cross_qemu.sh b/tests/e2e/103_harmonyos_cross_qemu.sh new file mode 100755 index 00000000..e70467f3 --- /dev/null +++ b/tests/e2e/103_harmonyos_cross_qemu.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# requires: ohos-sdk qemu-aarch64 +# Linux → HarmonyOS/OpenHarmony cross: build a C++23 named-module project for +# aarch64-linux-ohos with mcpp's own clang against the platform SDK's sysroot, +# assert the artefact really is a static aarch64 OHOS ELF, and RUN it under +# qemu-aarch64. Part C of .agents/docs/2026-08-04-harmonyos-target-design.md. +# +# "Linked" has never implied "runs" (the elfpatch incident, and §6.1 of +# 2026-08-03-windows-host-linux-cross-design.md), which is why the execution +# step is the point of this test and the assertions around it are secondary. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +TRIPLE=aarch64-linux-ohos + +"$MCPP" new ohosdemo +cd ohosdemo + +# `import std` deliberately NOT used here: a stock OpenHarmony SDK carries +# libc++ 15.0.4, which ships no std module. This test covers the shape that +# works against an unmodified SDK — see 104 for the import-std tier. +cat > src/banner.cppm <<'EOF' +module; +#include +export module ohosdemo.banner; +export std::string banner() { + return "hello from aarch64-linux-ohos, running under qemu"; +} +EOF + +cat > src/main.cpp <<'EOF' +#include +import ohosdemo.banner; +int main() { +#if !defined(__OHOS__) +#error "__OHOS__ not defined — this was not built for HarmonyOS" +#endif + std::printf("%s\n", banner().c_str()); + std::printf("harmonyos cross named-module OK\n"); + return 0; +} +EOF + +# The vocabulary pin (triple.cppm) selects an LLVM toolchain by itself, so no +# [target.*] section is written: this asserts the CONVENTION works, not just +# that an explicit override does. +"$MCPP" build --target "$TRIPLE" + +BIN=$(find "target/$TRIPLE" -type f -path '*/bin/*' -name ohosdemo | head -1) +[ -n "$BIN" ] || { echo "FAIL: no artefact under target/$TRIPLE"; find target -type f | head -20; exit 1; } + +echo "== file ==" +file "$BIN" +# Positive `grep -q` on purpose: `! cmd | grep` is exempt from errexit and can +# never fail (build-mcpp-helper-self-containment). +file "$BIN" | grep -q "ELF 64-bit LSB" +file "$BIN" | grep -q "ARM aarch64" +file "$BIN" | grep -q "statically linked" + +# Stronger than `file`'s wording and independent of it: a fully static ELF has +# no PT_INTERP at all. HarmonyOS's loader is /lib/ld-musl-aarch64.so.1, which +# no CI runner has — so an accidentally-dynamic artefact would be unrunnable +# here and this is what would catch it. +if command -v readelf &>/dev/null; then + readelf -l "$BIN" > hdrs.txt + if grep -q "INTERP" hdrs.txt; then + echo "FAIL: artefact has PT_INTERP — not statically linked" + grep -A2 "INTERP" hdrs.txt + exit 1 + fi +fi + +QEMU=$(command -v qemu-aarch64 || command -v qemu-aarch64-static) +echo "== run under $QEMU ==" +OUT=$("$QEMU" "$BIN") +echo "$OUT" +echo "$OUT" | grep -q "harmonyos cross named-module OK" \ + || { echo "FAIL: artefact did not produce expected output"; exit 1; } + +echo "OK: HarmonyOS cross artefact builds and executes" diff --git a/tests/e2e/104_harmonyos_import_std.sh b/tests/e2e/104_harmonyos_import_std.sh new file mode 100755 index 00000000..fbae1cbf --- /dev/null +++ b/tests/e2e/104_harmonyos_import_std.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# requires: ohos-sdk qemu-aarch64 ohos-libcxx +# The `import std` tier of the HarmonyOS target: with a libc++ built FOR +# aarch64-linux-ohos supplied through $MCPP_OHOS_LIBCXX, mcpp builds the std +# module for the target and `import std;` works — the same experience mcpp +# gives on every other platform. +# +# Split from 103 because the two tiers fail differently and a combined test +# could not say which one broke. 103 is the floor (stock SDK, named modules +# only); this is the upgrade, and the capability gate is what keeps it from +# being a false red on a machine that only has the SDK. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +TRIPLE=aarch64-linux-ohos + +"$MCPP" new ohosstd +cd ohosstd + +cat > src/main.cpp <<'EOF' +import std; +int main() { + std::vector parts{"import", "std", "on", "HarmonyOS"}; + std::string joined; + for (auto const& p : parts) { if (!joined.empty()) joined += ' '; joined += p; } + std::println("{}", joined); + std::println("total={}", std::accumulate(parts.begin(), parts.end(), std::size_t{0}, + [](std::size_t a, auto const& s) { return a + s.size(); })); + return 0; +} +EOF + +"$MCPP" build --target "$TRIPLE" + +BIN=$(find "target/$TRIPLE" -type f -path '*/bin/*' -name ohosstd | head -1) +[ -n "$BIN" ] || { echo "FAIL: no artefact under target/$TRIPLE"; exit 1; } + +file "$BIN" | grep -q "ARM aarch64" +file "$BIN" | grep -q "statically linked" + +QEMU=$(command -v qemu-aarch64 || command -v qemu-aarch64-static) +OUT=$("$QEMU" "$BIN") +echo "$OUT" +echo "$OUT" | grep -q "import std on HarmonyOS" \ + || { echo "FAIL: unexpected output"; exit 1; } +echo "$OUT" | grep -q "total=20" \ + || { echo "FAIL: std::accumulate over the target's std module misbehaved"; exit 1; } + +echo "OK: import std works on HarmonyOS" diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index 5f10700e..84430a59 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -62,6 +62,33 @@ case "$OS" in fi # wine: run cross-built Windows PE artifacts on the Linux host. command -v wine &>/dev/null && CAPS+=(wine) + # ohos-sdk: the OpenHarmony native SDK, which mcpp consumes as a + # sysroot only (its bundled clang is 15.0.4 and cannot build modules). + # Probed the same way mcpp probes it, and by the same two files + # mcpp's own looks_like_native_sdk() requires — a directory that + # merely exists must not enable a test that then fails inside a + # compile command. + for _ohos in "${OHOS_NDK_HOME:-}" "${OHOS_SDK_NATIVE:-}" \ + "${OHOS_SDK_HOME:-}/native" "$HOME/ohos-sdk/native" \ + /opt/ohos-sdk/native; do + [[ -n "$_ohos" ]] || continue + if [[ -f "$_ohos/sysroot/usr/include/stdlib.h" && -d "$_ohos/llvm/lib" ]]; then + CAPS+=(ohos-sdk); break + fi + done + unset _ohos + # ohos-libcxx: a libc++ built FOR the ohos target, which is what makes + # `import std` available there (the SDK's own libc++ 15 has no std + # module). Separate capability from ohos-sdk because the two tiers + # fail differently and only one of them needs this. + if [[ -n "${MCPP_OHOS_LIBCXX:-}" \ + && -f "${MCPP_OHOS_LIBCXX}/share/libc++/v1/std.cppm" ]]; then + CAPS+=(ohos-libcxx) + fi + # qemu-aarch64: run cross-built aarch64 artifacts on an x86_64 host. + # Both spellings exist in the wild (qemu-user vs qemu-user-static). + { command -v qemu-aarch64 &>/dev/null \ + || command -v qemu-aarch64-static &>/dev/null; } && CAPS+=(qemu-aarch64) # pack capability: ELF + patchelf both required if [[ " ${CAPS[*]} " == *" patchelf "* ]]; then CAPS+=(pack) diff --git a/tests/unit/test_ohos_target.cpp b/tests/unit/test_ohos_target.cpp new file mode 100644 index 00000000..9cea7bde --- /dev/null +++ b/tests/unit/test_ohos_target.cpp @@ -0,0 +1,276 @@ +// HarmonyOS / OpenHarmony target identity + driver-retargeting model. +// +// Everything here is host-independent by construction: the triple language is +// pure data, and the link/driver models take a Toolchain by value. That is +// deliberate — the target these tests describe cannot be BUILT anywhere +// without a vendor SDK, so if the model were only testable where the SDK is +// installed it would effectively be untested. See +// .agents/docs/2026-08-04-harmonyos-target-design.md. + +#include + +import std; +import mcpp.toolchain.triple; +import mcpp.toolchain.abi; +import mcpp.toolchain.model; +import mcpp.toolchain.linkmodel; +import mcpp.toolchain.hostflags; + +using namespace mcpp::toolchain; +using mcpp::toolchain::triple::parse; + +// ── triple language ───────────────────────────────────────────────────────── + +TEST(OhosTriple, OhosIsAnEnvNotAnOs) { + auto t = parse("aarch64-linux-ohos"); + ASSERT_TRUE(t.has_value()); + EXPECT_EQ(t->arch, "aarch64"); + // The OS stays linux. A package that cfg-gates on `os = "linux"` (or on + // `family = "unix"`) must keep matching on HarmonyOS — the kernel really + // is Linux, and spelling ohos as an OS would silently exclude every such + // block. + EXPECT_EQ(t->os, "linux"); + EXPECT_EQ(t->env, "ohos"); + EXPECT_EQ(t->family(), "unix"); + EXPECT_EQ(t->str(), "aarch64-linux-ohos"); + EXPECT_TRUE(t->is_ohos()); +} + +TEST(OhosTriple, IsNotReportedAsMusl) { + // OHOS libc IS a musl fork, and that is exactly why this assertion + // exists: `is_musl()` drives payload selection and the `abi:musl` + // capability, both of which mean UPSTREAM musl. An OHOS artifact is not + // interchangeable with one. + auto t = parse("aarch64-linux-ohos"); + ASSERT_TRUE(t.has_value()); + EXPECT_FALSE(t->is_musl()); + EXPECT_FALSE(t->is_pe()); + EXPECT_FALSE(t->is_msvc_env()); +} + +TEST(OhosTriple, AcceptsTheSpellingsTheNdkAndLlvmUse) { + // The SDK's own driver wrappers are named `aarch64-unknown-linux-ohos-*`, + // and upstream LLVM's long environment name is OpenHOS. Both normalize. + for (auto s : {"aarch64-unknown-linux-ohos", "aarch64-linux-openhos"}) { + auto t = parse(s); + ASSERT_TRUE(t.has_value()) << s; + EXPECT_EQ(t->str(), "aarch64-linux-ohos") << s; + } + auto arm = parse("arm-linux-ohos"); + ASSERT_TRUE(arm.has_value()); + EXPECT_EQ(arm->str(), "arm-linux-ohos"); +} + +TEST(OhosTriple, ArtifactNamingIsPlainElf) { + auto t = parse("aarch64-linux-ohos"); + ASSERT_TRUE(t.has_value()); + // Host naming deliberately set to the PE convention: if the answer were + // taken from the host rather than the target, this would come back + // `.exe`/`.lib` and the assertion would catch it (that is the B3 bug + // class, 2026-08-03-b3-target-aware-artifact-naming.md). + triple::ArtifactNaming pe{".exe", "", ".lib", ".dll", true}; + auto n = triple::artifact_naming(*t, pe); + EXPECT_EQ(n.exeSuffix, ""); + EXPECT_EQ(n.libPrefix, "lib"); + EXPECT_EQ(n.staticLibExt, ".a"); + EXPECT_EQ(n.sharedLibExt, ".so"); + EXPECT_FALSE(n.sharedNeedsImportLib); +} + +TEST(OhosTriple, KnownTargetPinsAnLlvmToolchain) { + auto t = parse("aarch64-linux-ohos"); + ASSERT_TRUE(t.has_value()); + auto* info = triple::find_known_target(*t); + ASSERT_NE(info, nullptr); + EXPECT_EQ(info->tier, "verified"); + // Structural, not stylistic: GCC has no `ohos` target, so a gcc@ pin here + // would be unbuildable by construction. + EXPECT_EQ(info->pin, triple::pins::kOhosLlvm); + EXPECT_TRUE(std::string_view(info->pin).starts_with("llvm@")); + // musl-shaped libc + no system loader to rely on ⇒ static by default, + // the same call the musl rows make. + EXPECT_TRUE(info->defaultStatic); + + for (auto s : {"x86_64-linux-ohos", "arm-linux-ohos"}) { + auto o = parse(s); + ASSERT_TRUE(o.has_value()) << s; + auto* oi = triple::find_known_target(*o); + ASSERT_NE(oi, nullptr) << s; + EXPECT_EQ(oi->pin, triple::pins::kOhosLlvm) << s; + } +} + +TEST(OhosTriple, FullStaticIsAllowedAndDoesNotConsultTheHost) { + // hostCapability=false models a macOS/Windows host: a cross target's + // linkage must not be decided by the build machine (the B2 bug class). + EXPECT_TRUE(target_supports_full_static("aarch64-linux-ohos", false)); +} + +TEST(OhosTriple, LoaderKeepsMuslNaming) { + // Measured from an SDK-produced binary's PT_INTERP: HarmonyOS kept musl's + // loader file name even though its libc is a fork. + EXPECT_EQ(loader_filename("aarch64-linux-ohos"), "ld-musl-aarch64.so.1"); + EXPECT_EQ(distro_loader_path("aarch64-linux-ohos"), "/lib/ld-musl-aarch64.so.1"); +} + +// ── ABI dimensions ────────────────────────────────────────────────────────── + +TEST(OhosAbi, LibcIsItsOwnDimensionValue) { + Toolchain tc; + tc.compiler = CompilerId::Clang; + tc.targetTriple = "aarch64-linux-ohos"; + tc.stdlibId = "libc++"; + auto p = abi_profile(tc); + EXPECT_EQ(p.libc, "ohos"); // NOT "musl" — see IsNotReportedAsMusl + EXPECT_EQ(p.os, "linux"); + EXPECT_EQ(p.arch, "aarch64"); + EXPECT_EQ(p.cxxAbi, "itanium"); + EXPECT_EQ(p.cxxStdlib, "libc++"); + + // A package declaring plain `abi:musl` must NOT be considered compatible. + auto c = parse_abi_capability("abi:musl", "some-pkg"); + ASSERT_TRUE(c.has_value()); + EXPECT_FALSE(abi_check(p, {*c}).empty()); +} + +// ── driver retargeting ────────────────────────────────────────────────────── + +namespace { + +Toolchain cross_clang() { + Toolchain tc; + tc.compiler = CompilerId::Clang; + tc.version = "20.1.7"; + tc.binaryPath = "/xpkgs/llvm/20.1.7/bin/clang++"; + tc.targetTriple = "aarch64-linux-ohos"; + tc.stdlibId = "libc++"; + // A host glibc payload deliberately left reachable: the point of several + // assertions below is that the cross path must ignore it. + tc.payloadPaths = PayloadPaths{"/xpkgs/glibc/include", "/xpkgs/glibc/lib64", + "/xpkgs/linux-headers/include"}; + CrossTarget ct; + ct.triple = "aarch64-linux-ohos"; + ct.sysroot = "/sdk/native/sysroot"; + ct.cxxIncludes = {"/sdk/native/llvm/include/libcxx-ohos/include/c++/v1"}; + ct.libDirs = {"/sdk/native/llvm/lib/aarch64-linux-ohos"}; + ct.linkResourceDir = "/sdk/native/llvm/lib/clang/15.0.4"; + ct.provider = "OpenHarmony SDK 6.1.0.31 (API 23)"; + tc.crossTarget = ct; + return tc; +} + +bool has(const std::vector& v, std::string_view s) { + return std::ranges::find(v, s) != v.end(); +} + +bool any_contains(const std::vector& v, std::string_view needle) { + return std::ranges::any_of(v, [&](auto const& t) { + return t.find(needle) != std::string::npos; }); +} + +} // namespace + +TEST(OhosCross, SysrootModeIgnoresTheHostPayload) { + auto lm = resolve_link_model(cross_clang()); + EXPECT_EQ(lm.mode, CLibMode::Sysroot); + EXPECT_EQ(lm.sysroot, std::filesystem::path("/sdk/native/sysroot")); + // PayloadFirst would have put the host glibc's lib dir on -L and its + // loader on --dynamic-linker. Aiming a cross link at the build machine's + // libc is the failure this ordering exists to prevent. + EXPECT_TRUE(lm.libDirs.empty()); + EXPECT_TRUE(lm.crtDir.empty()); + EXPECT_TRUE(lm.loader.empty()); +} + +TEST(OhosCross, TargetFlagIsFirstOnBothSides) { + auto dm = resolve_clang_driver(cross_clang()); + ASSERT_TRUE(dm.isCross()); + auto esc = no_escape; + + auto c = dm.compile_tokens(esc); + ASSERT_FALSE(c.empty()); + // First, because it selects the toolchain object everything later is + // interpreted against. + EXPECT_EQ(c.front(), "--target=aarch64-linux-ohos"); + + auto l = dm.link_tokens(esc); + ASSERT_FALSE(l.empty()); + EXPECT_EQ(l.front(), "--target=aarch64-linux-ohos"); +} + +TEST(OhosCross, CompileSideTakesTargetLibcxxAndNotTheHosts) { + auto dm = resolve_clang_driver(cross_clang()); + auto c = dm.compile_tokens(no_escape); + EXPECT_TRUE(has(c, "-nostdinc++")); + EXPECT_TRUE(has(c, "-isystem/sdk/native/llvm/include/libcxx-ohos/include/c++/v1")); + // The host llvm root is /../ = /xpkgs/llvm/20.1.7. Its libc++ headers + // must never appear: they would compile without complaint and produce a + // BMI configured for the wrong platform. + EXPECT_FALSE(any_contains(c, "/xpkgs/llvm/20.1.7/include")); +} + +TEST(OhosCross, ResourceDirIsLinkSideOnly) { + auto dm = resolve_clang_driver(cross_clang()); + // On the link line: it is where the target's libclang_rt.builtins.a and + // clang_rt.crt{begin,end}.o live. + EXPECT_TRUE(has(dm.link_tokens(no_escape), + "-resource-dir=/sdk/native/llvm/lib/clang/15.0.4")); + // NOT on the compile line: that dir belongs to clang 15 and carries clang + // 15's intrinsic headers, which the actual (much newer) compiler must not + // read. + EXPECT_FALSE(any_contains(dm.compile_tokens(no_escape), "-resource-dir=")); +} + +TEST(OhosCross, NoRpathIntoBuildHostPaths) { + auto l = resolve_clang_driver(cross_clang()).link_tokens(no_escape); + EXPECT_TRUE(has(l, "-L/sdk/native/llvm/lib/aarch64-linux-ohos")); + // The device will not have the build machine's directory layout, and a + // static link has nowhere to use an rpath anyway. + EXPECT_FALSE(any_contains(l, "-Wl,-rpath,")); +} + +TEST(OhosCross, HostFlagProducerBypassesTheCfgEvenWithoutOne) { + // The bundled-LLVM cfg is generated at install time for the HOST triple, + // so a retargeted driver must bypass it. `cross_clang()`'s binary path + // does not exist, hence hasCfg == false — and the tokens must still be + // emitted, which is exactly the case a `dm.hasCfg &&` gate would drop. + auto tc = cross_clang(); + HostFlagOptions opt; + auto c = host_compile_tokens(tc, opt, no_escape); + EXPECT_TRUE(has(c, "--target=aarch64-linux-ohos")); + EXPECT_TRUE(has(c, "--no-default-config")); + EXPECT_TRUE(has(c, "--sysroot=/sdk/native/sysroot")); + + auto l = host_link_tokens(tc, opt, no_escape); + EXPECT_TRUE(has(l, "--target=aarch64-linux-ohos")); + EXPECT_TRUE(has(l, "--sysroot=/sdk/native/sysroot")); +} + +TEST(OhosCross, NoStdModuleUnlessTheProviderSuppliesOne) { + // The default (stock SDK, libc++ 15) has no std module, and mcpp must say + // so rather than reach for the driver's own — which is the HOST's, and + // would compile into a wrong-platform BMI without erroring. + auto tc = cross_clang(); + EXPECT_TRUE(tc.crossTarget->stdModuleSource.empty()); + EXPECT_FALSE(tc.hasImportStd); +} + +TEST(OhosCross, HostToolchainIsUntouchedByTheseChanges) { + // Regression guard: everything above keys on tc.crossTarget, so an + // ordinary host toolchain must resolve exactly as before — payload-first, + // no --target, no -resource-dir. + Toolchain tc; + tc.compiler = CompilerId::Clang; + tc.binaryPath = "/xpkgs/llvm/20.1.7/bin/clang++"; + tc.targetTriple = "x86_64-linux-gnu"; + tc.payloadPaths = PayloadPaths{"/xpkgs/glibc/include", "/xpkgs/glibc/lib64", + "/xpkgs/linux-headers/include"}; + auto dm = resolve_clang_driver(tc); + EXPECT_FALSE(dm.isCross()); + EXPECT_TRUE(dm.target_tokens().empty()); + EXPECT_TRUE(dm.cross_link_tokens(no_escape).empty()); + + auto lm = resolve_link_model(tc); + EXPECT_EQ(lm.mode, CLibMode::PayloadFirst); + EXPECT_EQ(lm.crtDir, std::filesystem::path("/xpkgs/glibc/lib64")); +}