Skip to content

Latest commit

 

History

History
385 lines (279 loc) · 18.9 KB

File metadata and controls

385 lines (279 loc) · 18.9 KB

24 —— 基于 openkal 的交叉构建

**读者:**从本机为另一个操作系统做交叉构建的人。

**本章回答的那一个问题:**一份源码树怎样为多个 hosted 目标构建,而本机并没有它们 的工具链。

**不在这里:**没有操作系统的目标,那是 40 —— 裸机;以及目标 怎样命名,那是 21 —— 目标三元组

传统的交叉构建由载荷承担。一份工具链为一个目标而构建,它的驱动只有一个答案, 到达第二个目标意味着获取第二份工具链。因此一个发行方必须发布的载荷数, 等于它支持的宿主-目标对数。

openkal 改变了「被交叉的是什么」。目标侧 —— 平台接口、C 库、编译器运行时与 C++ 运行时 —— 成为一组由依赖图解析、并由当前运行的编译器从源码构建的包。 留给编译器的是代码生成,而一个 Clang 二进制发出它被构建时所包含的每一种对象格式。

本文陈述该模型、工程需要书写的内容、生态所供给的内容,以及已被实测的界限。

论断

一个由 N 个平台与 M 个架构构成的生态,需要的是一个接口的 N 份实现, 而不是 N×M 份工具链。这个计数来自目标侧所在的位置:一个从源码构建的包, 是为编译器被要求发出的那个目标而构建的,因此一份平台实现只写一次, 就到达编译器支持的每一个架构。

该论断由一个三宿主 × 三目标的矩阵验证,每一格构建同一份源码并运行其结果。

工程书写的内容

[dependencies]
openkal-llvm-runtime = "0.1.1"

[toolchain]
default = "llvm@22.1.8"

两行。第一行选定目标侧的三个层;第二行命名一个编译器, 并且对其余一切来自何处只字未提。

目标在命令行上给出:

mcpp build --target x86_64-linux
mcpp build --target aarch64-macos
mcpp build --target x86_64-windows-gnu

宿主目标不需要 [target.<三元组>] 段,源码中也不需要任何预处理指令。 可运行的示例见 examples/06-openkal-cross

生态供给的内容

内容
openkal 规范,以及声明它的那些 C++ 模块
openkal-linux kernel-abi 参考实现,建立在 Linux 系统调用之上
openkal-macos kernel-abi 建立在 macOS 的系统调用面之上
openkal-windows kernel-abi 建立在 Win32 与对象管理器之上,不使用任何 C 运行时符号
openkal-opensbi kernel-abi 建立在 RISC-V 的 SBI 之上,无操作系统
openkal-uefi kernel-abi 建立在 UEFI Boot Services 之上,在操作系统存在之前
openkal-musl c-abi 被重定向到 openkal 的 musl,只移植一次
openkal-llvm-runtime compiler-runtimec++-abi compiler-rt builtins、libunwind、libc++abi 与 libc++,为 openkal-musl 配置

一个工程命名其中最后一个。其余由它的依赖推出。

编译器必须是 LLVM 的原因

openkal-llvm-runtime 把这项要求声明出来,而不是留待被发现:

requires = ["mcpp:compiler=llvm"]

它的源码是 libc++ 的,其中 std 模块源尤其由 Clang 编译。 把那份源码交给 GCC,会在 libc++ 自己的头文件深处失败, 其消息命名一个读者从未打开过的文件:

fatal error: __config: No such file or directory

有了这项声明,构建在编译任何东西之前拒绝该组合, 并指出选择一个满足它的编译器的那条命令。

目标的选定

mcpp 自身词表的目标行可以携带一条工具链约定。该约定命名的是 供给该目标 C 库的那份载荷,并且仅在两个条件同时成立时生效: 清单对该目标未作陈述,且依赖图中没有任何东西供给该目标的系统。

第二个条件只有在图被解析之后才可知。因此一个 C 库来自 openkal-musl 的工程 保留它所要求的编译器,而一个没有依赖的工程仍然收到该行所命名的载荷。 两种行为均经实测;提前按任一方向决定,对另一方向都是错的。

环境段

在 Linux 上,目标三元组的第三段命名 C 库。在 openkal 之下 C 库来自图, 因此一个命名了 C 库的三元组陈述的是一项图未必兑现的请求:

mcpp build --target x86_64-linux-gnu     # 请求 glibc
      c-abi   musl   (openkal-musl@0.3.3, graph)

以图为准。省略该段即不作请求,并产出相同的产物:

mcpp build --target x86_64-linux

该段存在且不一致时,构建报出这一点。它是报出而非拒绝, 因为该段是被忽略而非被违反。在一台宿主上实测 x86_64-linuxx86_64-linux-musl:两个可执行文件不同,strip 之后逐字节相同。差异在调试信息里,它记录了输出目录,而目录以三元组命名。代码是同一份代码。

在 Windows 上,同一段命名的是对象 ABI —— gnu 是 PE 加 GNU ABI, msvc 是 PE 加微软的 —— 而两者都与不止一种 C 库相容。 因此该项报出被限定在该段命名 C 库的那些平台上。

沉默作为诊断是对的,作为报告是不够的。读者看到

Target x86_64-windows-gnu → x86_64-w64-windows-gnu
       c-abi   musl   (openkal-musl@0.3.3, graph)

在其中找不到一行叫 gnu,于是把它映到最像 C 库名字的那一行。

对这次构建的产物实测:

观测
导入的库 ntdllKERNEL32SHELL32 —— 无 msvcrt,无 ucrtbase
Itanium 修饰符号(_Z…) 4507
MSVC 修饰符号(?…) 0

第一行说明 c-abi musl 是老实的:MinGW 的 C 运行时一点没链进来。 后两行是 gnu 实际选中的东西 —— Itanium C++ ABI,而不是微软那套。

而它不对应报告里的任何一行,这正是要点。五层记录的是每一层 由谁供给;gnu 命名的是这些对象遵循哪套约定,是若干层必须一致的 横切事项。把它读成 c++-abi libc++ 是第二个错误答案:libc++ 是标准库的 一个实现,libstdc++ 是另一个,两者都坐在 Itanium ABI 上。

因此报告直接说出那套 ABI 本身 —— 它的名字不出现在任何一行里, 于是不会被误当成某一层:

Target x86_64-windows-gnu → x86_64-w64-windows-gnu   (gnu selects the Itanium C++ ABI, not a C library)

该段在每个平台上承载不同的轴 —— Linux 上是 C 库,Windows 上是对象 ABI, 没有操作系统时是对象格式 —— 因此记录的是它是哪一个, 而不是一个只记录「是否为第一种」的布尔。

这个模型下的 Android、Web 与 iOS

mcpp 在 2026.9.11.3 里加出目标行的这三个平台不是同一个问题。决定每一个的是它的 实现相对一个 C 库该落在哪一侧,而三个答案各不相同。

Android 共用 Linux 的实现,一行都不用改

openkal-linux 写在 Linux 内核自己的系统调用接口上,不向任何 C 库借用任何东西 —— 这正是它能被放到一个 C 库底下的原因。Android 的内核就是 Linux,给定架构上 的系统调用 ABI 完全相同,而 src/sys.h__x86_64__ / __aarch64__ 分支,也就是 按架构而不是按操作系统。它里面没有任何属于 glibc 或 bionic 的东西。

所以一个可移植程序不需要新增任何一行。cfg(os = "linux") 对一个 Android triple 为真,因为 Android 是 linux OS 上的一个 env 值 —— 21 — 目标三元组 记着这处建模决定 —— 于是实现由一个 Linux 消费者本来就会写的那一行选出:

[target.'cfg(os = "linux")'.dependencies]
openkal-linux = "0.12.0"

实测 2026-09-11,一个只针对 openkal 写的程序 —— 没有 C 库,也没有 import std:

mcpp build --target x86_64-linux-android
       kernel-abi   openkal   (openkal-linux@0.12.0, graph)
    ->  ELF 64-bit LSB pie, x86-64, interpreter /system/bin/linker64

mcpp build --target aarch64-linux-android
    ->  ELF 64-bit LSB pie, ARM aarch64, 同一个 interpreter

而那个 x86_64 产物被推到一台 API 24 的模拟器镜像上执行:

openkal: 1-2-3          exit 0

openkal-linux 自己也能为两个 Android 目标原样编译,这是两条里较弱的那一条,值得 分开陈述:前者说的是实现构建得起来,后者说的是它上面的程序跑得起来。

iOS 共用 macOS 的实现,而 SDK 是被定位的而不是被打包的

同样的论证在 Apple 这一侧成立:iOS 与 macOS 共用 Darwin 内核、同一套调用号、同一 套调用约定,而 openkal-macos 是按同样方式按架构分支的。两者之间不同的是 SDK 与 部署目标标志,而这两样都属于构建工具而不属于实现 —— 所以 iOS 是清单里的一行 cfg, 不是一个新包。

被阻塞的是 SDK,而解开它的是把问题问得更小。 iPhoneOS 与 iPhoneSimulator 的 SDK 在 Xcode 里且不可再分发,这界定的是打包它们。它没有界定定位它们: aarch64-macos 早在这三行存在之前就以完全相同的切分方式是 verified —— xim:llvm 负责编译,机器自己的 macOS SDK 通过 xcrun 被找到。iOS 三行取的是同一种切分加上 第二个 SDK,所以它们钉 llvm@22.1.8 且不带 sysroot 条目:那一列命名的是一个包, 而一个被定位的目录不是包。

对这份文档的后果是,这三行不再是一个结构性论证。openkal-macos 能为它们编译,而 读者需要知道的是:SDK 是一个具名的宿主依赖 —— 这个平台恰好新增两个,另一个是 simctl —— 并且它的缺失是一次点名 SDK 的拒绝,而不是一次悄悄产出 macOS 产物的 构建。

Web 需要过一份新的实现,而 openkal-emscripten 就是它

Emscripten 是三者里改变模型而不是扩展表格的那一个。那里没有内核,也没有系统 调用可发:Emscripten 在一个 JavaScript 宿主之上供给它自己的 C 库。所以给它写的 openkal 实现不可能按 openkal-linux 的方式写 —— 落在一个 C 库底下 —— 而必须落在 一个 C 库之上。规范恰好允许这一点(「一个实现可以建立在一个 C 库之上、之下, 或者不依赖 C 库」),所以这是新软件而不是一个共用决定。

openkal-emscripten 是这个生态里第一份按那个方向写的实现。那个方向让代码变薄而 不是变容易:每个函数大体是一次转发加一次错误翻译,而它必须做对的地方,恰好是 C 库 的词汇与 openkal 的词汇不对应的那些地方 —— 一个是对齐而不是页的粒度、一个分辨 率被浏览器有意变粗的单调时钟、一个在 node 下存在而在页面里不存在的终端。

一个不完整的面也可以是一个符合规范的面,而规范写明了怎么做。 6.2 条给出三个 时刻,每一个都是相应信息最早存在的时刻,而这份实现的三组各取一种处理:

处理 理由
streamfstimeenvmemoryrandomabortterminal 提供,转发到 Emscripten 的 libc MEMFS 与 JavaScript 宿主服务了其中每一项
netdatagramtimeout 提供,由能力字报告哪些可被实际运用 调用是真的,而承载它们的是一个 WebSocket 代理,所以 kal_net_props 既不声称 IPv6 也不声称半关闭
processexecspace 不提供 那里没有 fork、没有 exec,也没有第二个地址空间

第三行是值得直说的那个决定:一个缺席的符号就是那份报告。 实测:

wasm-ld: error: obj/main.o: undefined symbol: kal_process_spawn

这正是 6.2 条的第二个时刻。提供一个返回错误的 kal_process_spawn 会是规范禁止的 那种形状 —— 存在而永远失败,而调用方无法把它与一个条件区分开 —— 并且会把一个在 链接期已知的事实挪到运行期。

openkal.task 由一个 feature 承载,理由是这个平台特有的:线程需要 -pthread, 而那个开关选定的是另一份 C 库构建、另一个内存模型和另一套加载器契约。不带这个 feature 时,那个翻译单元是空的,八个符号不存在 —— 与上面三个缺席接口同一种处理。 带上它时它们存在,而 kal_interfaces() 跟随链接本身,而不是跟随一个由包自己发明 的名字。

以上都不取代载荷那条路。wasm32-emscripten 仍然走普通那条路 —— xim:emsdk 自带 编译器、sysroot 和一份 libc++ 的模块面,所以一个用 import std 的程序就能为 Web 构建并运行,而 openkal 完全不参与,这正是那一行的 verified 层级所记录的。openkal 是一个程序想让一份源码落在若干平台接口之上时才会用到的东西。

平台 实现 状态
Linux(glibc、musl) openkal-linux 参考实现
Android(两个 ABI) openkal-linux,原样 构建通过;它上面的程序在模拟器上跑过
macOS openkal-macos 在 macOS 的系统调用面上
iOS、iOS 模拟器 openkal-macos,原样 Darwin 就是 Darwin;SDK 被定位而不是被打包
Windows openkal-windows 在 Win32 与对象管理器上
Web(Emscripten) openkal-emscripten 写在 C 库之上;十五个接口里的十二个

裸机

一个没有操作系统的目标,是同一个模型,只是平台层由固件而非内核供给。 riscv64-none-elf 之上的 OpenSBI 运行与宿主目标相同的源码,含 import std, 因为它所使用的标准库是图供给的那份,而不是编译器自带的那份。

有两样东西必须声明,两者都是板子的性质而非默认值:

[target.riscv64-none-elf]
sysroot = ""
runner  = ["qemu-system-riscv64", "-machine", "virt", "-nographic",
           "-no-reboot", "-bios", "default", "-kernel"]

sysroot = "" 选定零 libc 档。使用哪个机器模型与哪种固件模式是板子的事实, 而一个去猜测它的引擎,是另一块板子必须与之搏斗的引擎。

hosted 交叉目标使用同一个键,配用户态模拟器(2026.9.2.1)。在 x86_64 宿主上构建的 aarch64-linux-musl 产物,在工程声明后通过 qemu-aarch64-static 执行;提供模拟器的包 按能安装它的宿主声明:

[xlings.workspace]
"xim:qemu-user-aarch64" = { linux = "" }

[target.aarch64-linux-musl]
runner = ["qemu-aarch64-static"]

没有这个键时,mcpp run 报告内核的拒绝(Exec format error)与应当写的键,mcpp test 把每个测试报告为未运行并以 2 退出。能原生执行该产物的宿主传 --no-runner。规则见 04 —— mcpp.toml §2.7.3。

源码是同一份,程序不是

「同一份源码」是关于工具链与标准库的断言,而它成立:import std 可用, C++ 运行时是图供给的那份,没有任何 #if 区分目标。它不是「任何程序都能 为任何目标构建」的断言,而规范把原因写得很清楚。

一个裸机后端提供一部分接口而不提供另一部分。openkal-opensbi 提供 abortstreammemoryenvtime;它不提供文件系统也不提供任务, 因为这台机器没有。6.1 条把这种缺席定为链接期的事实:

实现不提供的接口,作为链接期定义是缺席的,使用它的消费者链接失败。

因此能力字回答的问题比它初看上去更窄。它说的是一个实现在它提供的接口内 如何表现 —— 名字是否区分大小写、一个时钟的粒度是多少。接口是否存在, 由更早的东西回答:依赖图,以及退而求其次的链接器。

这个区分容易丢,因为查询是数据对象上的内联函数:一个程序只是提问 「有没有文件系统」,就取了 kal_fs_props 的地址,于是在整份源码没有任何 文件系统调用的情况下链接失败。在后端把那个字定义为零可以消掉这个错误, 而它恰是该条禁止的唯一补法 —— 程序随后越过了链接器存在的意义。 这条路走过,发布为 openkal-opensbi@0.1.3,并已撤回。

到达一台裸 x86_64 机器的两条路线

一台没有操作系统的 x86_64 机器有两种到达方式,区别在于谁加载这个程序

路线 目标 平台层 入口
UEFI 应用 x86_64-windows-gnu openkal-uefi 固件,Boot Services 可用
内核,或裸机 x86_64-none-elf 无,或 openarch 复位向量,其下一无所有

UEFI 应用是 PE/COFF,经微软 x64 调用约定进入。两者都是 LLVM 工具链已有的性质, 因此它的目标与一个 Windows 程序是同一个三元组,而固件函数指针被直接调用。 把它与一次 Windows 构建区分开的,是图解析出的平台接口实现, 以及三个选定 IMAGE_SUBSYSTEM_EFI_APPLICATION 的链接 flag。

一个内核没有固件服务可以调用。它的目标是 x86_64-none-elf,即零 libc 档: 编译行上没有 C 库,链接上没有库目录,#include <stdio.h> 不解析。 程序在它自己的 _start 被进入,并直接触达硬件。

openarch 是这类程序所建立于其上的层。它不是平台接口,也不应答 mcpp:kernel-abi;它是架构机制 —— 执行上下文、陷阱、每 CPU 状态与地址空间 —— 作为一个跨若干指令集的接口呈现,每个指令集一个后端包。 一个内核依赖它,并供给自己的平台层,或者不供给。

x86_64 裸机需要引擎侧工作的原因

riscv64-none-elfaarch64-none-elf 是表中的行,除此之外别无他物: Clang 对两者都有 BareMetal 工具链,自行驱动它们的链接并到达 ld.lld。 它对 x86_64 没有,于是那个三元组落到通用的 GCC 工具链,而后者的链接器是宿主的 g++:

g++: error: unrecognized command-line option '-fuse-ld=…/ld.lld'

对裸 x86_64 三元组的每一种拼写都实测过,且无法由任何 flag 纠正。 因此该行携带一个链接器 emulation,由 mcpp 自己调用 ld.lld —— 这也是为什么必须证明宿主工具链没有参与这样一次链接。

已实测的界限

三条,记录在此是因为每一条都由构建而非由阅读发现。

一个后端必须定义每一个能力字。 规范的查询是属性对象上的 inline 函数, 因此一个仅仅询问是否存在文件系统的程序,会取 kal_fs_props 的地址。 一个省略了自身所缺层的能力字的后端,使该问题恰恰在这个问题为之存在的那类机器上 链接失败。

同一层的两个供给者是错误而非选择。 C 库、平台接口与 C++ 运行时互斥。 选错不会使链接失败,它产出一个能够运行且间歇性不能运行的程序。

载荷的 C++ 运行时不能位于外来的 C 库之上。 它的 __config_site 记录了 它被构建时的配置。解析器的结构在默认路径上阻止该组合, 而一条诊断覆盖工程显式覆写该契约的那些路径。

参考

docs/22 — 目标侧 给出五个层、四种来源与规则。 SPEC-002 给出能力语法的规范性陈述。