From c9c70a0834e9741f7e0f138ae3939a0f2f97bdac Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 21 Aug 2026 02:43:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?0.4.0=20=E2=80=94=E2=80=94=20=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=8C=85=E3=80=81=E4=B8=A4=E4=B8=AA=E9=97=A8=E9=9D=A2?= =?UTF-8?q?=E3=80=81=E4=B8=89=E4=B8=AA=E5=90=8E=E7=AB=AF,=E8=80=8C?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E5=8F=B0=E6=9C=BA=E5=99=A8=E6=8A=8A=E9=97=A8?= =?UTF-8?q?=E6=A7=9B=E5=8F=98=E6=88=90=E4=BA=86=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 第三台机器 riscv64 与 aarch64 都是弱内存序、定长指令的 load/store RISC 机器。一个同时适配两者 的接口,可能是因为它对,也可能是因为它们像 —— 在这两台机器上再怎么测也分不开这两种 情况。 x86_64 两样都不是:变长指令;total store order,四条屏障里有三条根本不需要指令;中断 机制是 256 个门的表而不是一个基址寄存器;控制台由 `out` 到达而不是由一次存储到达, 没有任何指针能命名它。**三台都活下来的才是抽象。** 一份探针源码在三台机器上构建并运行,输出逐字节相同。 ### 第三台机器改了什么 - **`MAIR_EL1` 那个决定不再是 aarch64 的例外。** 两台机器时是一比一,"这一层拥有属性 寄存器"还可以被称作 aarch64 的权宜。x86_64 做同一件事:`PWT`/`PCD`/`PAT` 三个分散的 位构成 `IA32_PAT` 的索引。现在是二比一,而且方向反了过来。 ⚠️ 它的规则更严:未编程的 `MAIR_EL1` 字段读作最严格的类型,过早的 aarch64 映射只是 慢而正确;`IA32_PAT` 的复位值在索引 1 上是 **write-through**,过早的设备映射是被缓存 的 —— 写在程序没有选择的时刻到达设备,且不触发任何异常。 - **`pc` 在每台机器上并不指同一件事,接口吸收了它而不是复述它。** 两台 RISC 机器都报告 出错指令的地址;x86_64 把异常分为 *fault*(如此)与 *trap*(报告**下一条**的地址), 而 `int3` —— `instr_len` 正是为跨过它而存在的断点 —— 是 trap。后端做归一化,于是 `f->pc += f->instr_len` 在三台机器上都恢复到同一处。另一条路是告诉每一个将要被写出来 的处理函数(包括永远只跑在 RISC 机器上的那些)`pc` 在这里含义不同。 - **接口的一条承诺在这台机器的页表项里无法表达。** riscv 用 `U` 限定 `X`,aarch64 有 独立的 `PXN`/`UXN`,所以"用户映射不可被内核执行"在两者都是编码的属性。x86_64 只有一 个覆盖全部特权级的 `NX`,该规则改由 `CR4.SMEP` 提供,由 `install_memory_attributes()` 设置 —— 与 `MAIR_EL1` 同形的答案,却出于不同的理由。 ## 一个包、两个门面 根 `mcpp.toml` 同时是 `[package]` 与 `[workspace]`。虚拟 workspace 会把接口放进成员目录, `openarch = "0.4.0"` 就得指名它。 消费者写一行依赖,然后二选一:`#include ` 或 `import mcpplibs.openarch;`。两者是一个库的两种拼写,不是两份互相对齐的声明:模块的 `trap_frame` **就是** `::arch_trap_frame`(`using`,不是同形体),枚举由契约的枚举量 *定义而来* —— `illegal = ARCH_TRAP_ILLEGAL`。`tests/faces.cpp` 检查的是这个推导,而不是 一致性,后者是更弱的东西。 ## 后端由 feature 选择 三个曾由一个机制回答的问题被分开了: - `backend-auto` —— 默认开启,按 target 解析。 - `backend-` —— 显式指定,为一个 ISA 有多个后端的情形留出位置(riscv 会需要: 这个后端陷入 M 模式,SBI 之下的内核陷入 S 模式)。 - `backend-external` —— **使用者自己实现**。它不指名任何包,而是 *require 能力* `openarch-backend`;图里没有提供者时构建在 configure 阶段就停下并说明,而不是在链接期 报出一个改过名的符号。这与 `std-freestanding` 的分配器同形。 ⚠️ `backend-auto` 刻意**不** require 该能力,而第一版让它 require 了。feature 是可加的, 而 `requires` 是无条件的(哪怕满足它的 `feature-deps` 是 target 条件化的)—— 于是本包 自己的宿主测试无法构建: error: no package provides capability 'openarch-backend' required by 'openarch' 宿主目标没有后端是关于目标的事实,不是消费者能处理的错误。 ## 类型集中到一处 `openarch/types.h` 定义 `arch_u32`/`arch_u64`/`arch_uptr` 并**断言它们的宽度**。此前每处 用点各自拼出 `unsigned long long`,顶上一段注释解释为什么不是 `unsigned long` —— 一条被 描述而从未被检查的规则。它唯一一次被违反(`1UL << 53`)是靠运气发现的:那个移位恰好在 `constexpr` 里,编译器被迫求值。 ## CI ⚠️ **两处修正,而 CI 从 0.3.0 起就是红的,我此前没有去看。** 1. "探针不按架构分支"这条断言太宽:探针必须在恰好一处指名架构 —— 陷入指令,`ebreak` / `brk #0` / `int3` 是同一个想法的三种拼写,没有可移植的第四种。断言收窄为:一个条件块, 块内除指令外别无他物。 2. portability 作业在仓库根跑 `mcpp build --target riscv64-none-elf`,而 0.3.1 的根是虚拟 workspace —— 于是它对**所有成员**扇出,把 aarch64 汇编喂给 riscv 汇编器 (`unrecognized instruction mnemonic, did you mean: sra, srl?`)。混合式的根修好了它: 根现在是接口包,构建它只拉入该 target 的后端。 x86_64 一行的模拟器来自 apt 并注明了原因:xPack 按目标族发布 QEMU 而没有 x86 构建, 所以生态里没有 `xim:qemu-x86` 可装。 --- .github/workflows/ci.yml | 147 +++++++++++++--- README.md | 211 +++++++++++++++++------ abi/include/mcpplibs/openarch.h | 39 +++++ abi/include/openarch/abi.h | 95 +++++++--- abi/include/openarch/pte_encode.h | 181 ++++++++++++++----- abi/include/openarch/types.h | 102 +++++++++++ abi/mcpp.toml | 6 +- backends/aarch64/mcpp.toml | 14 +- backends/aarch64/src/context_init.cpp | 19 +- backends/aarch64/src/pte_impl.cpp | 8 +- backends/aarch64/src/trap_impl.cpp | 55 +++--- backends/riscv64/mcpp.toml | 14 +- backends/riscv64/src/context_init.cpp | 21 ++- backends/riscv64/src/pte_impl.cpp | 6 +- backends/riscv64/src/trap_impl.cpp | 49 +++--- backends/x86_64/mcpp.toml | 44 +++++ backends/x86_64/src/context.S | 79 +++++++++ backends/x86_64/src/context_init.cpp | 86 +++++++++ backends/x86_64/src/cpu_impl.cpp | 109 ++++++++++++ backends/x86_64/src/pte_impl.cpp | 110 ++++++++++++ backends/x86_64/src/trap.S | 176 +++++++++++++++++++ backends/x86_64/src/trap_impl.cpp | 230 +++++++++++++++++++++++++ examples/switch/build.mcpp | 15 ++ examples/switch/mcpp.toml | 17 +- examples/switch/src/boot_x86_64.S | 185 ++++++++++++++++++++ examples/switch/src/machine_x86_64.cpp | 96 +++++++++++ examples/switch/src/main.cpp | 27 ++- examples/switch/x86_64.ld | 56 ++++++ mcpp.toml | 186 +++++++++++++++++--- spec/mcpp.toml | 40 ----- {spec/src => src}/context.cppm | 15 +- {spec/src => src}/cpu.cppm | 10 +- src/openarch.cppm | 28 +++ {spec/src => src}/pte.cppm | 22 +-- {spec/src => src}/trap.cppm | 25 ++- tests/abi_shape.cpp | 86 +++++++++ tests/faces.cpp | 90 ++++++++++ {spec/tests => tests}/pte_encoding.cpp | 59 ++++++- 38 files changed, 2437 insertions(+), 321 deletions(-) create mode 100644 abi/include/mcpplibs/openarch.h create mode 100644 abi/include/openarch/types.h create mode 100644 backends/x86_64/mcpp.toml create mode 100644 backends/x86_64/src/context.S create mode 100644 backends/x86_64/src/context_init.cpp create mode 100644 backends/x86_64/src/cpu_impl.cpp create mode 100644 backends/x86_64/src/pte_impl.cpp create mode 100644 backends/x86_64/src/trap.S create mode 100644 backends/x86_64/src/trap_impl.cpp create mode 100644 examples/switch/src/boot_x86_64.S create mode 100644 examples/switch/src/machine_x86_64.cpp create mode 100644 examples/switch/x86_64.ld delete mode 100644 spec/mcpp.toml rename {spec/src => src}/context.cppm (87%) rename {spec/src => src}/cpu.cppm (94%) create mode 100644 src/openarch.cppm rename {spec/src => src}/pte.cppm (88%) rename {spec/src => src}/trap.cppm (84%) create mode 100644 tests/abi_shape.cpp create mode 100644 tests/faces.cpp rename {spec/tests => tests}/pte_encoding.cpp (70%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30669b9..4b5964c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,19 @@ on: pull_request: workflow_dispatch: -# Three jobs, and the first two are ONE job written once: the gate this layer is -# judged by is that a single probe source runs on two genuinely different -# machines, so the two rows below differ only in a triple and an emulator -# package. If they ever need to differ in anything else, the abstraction has -# failed and this workflow is where that becomes visible. +# Three jobs, and the first is ONE job written once: the gate this layer is +# judged by is that a single probe source runs on machines that genuinely +# differ, so the rows below differ only in a triple and an emulator. If they +# ever need to differ in anything else, the abstraction has failed and this +# workflow is where that becomes visible. +# +# ⭐ THE THIRD ROW IS WHAT TURNS THE GATE INTO EVIDENCE. riscv64 and aarch64 are +# both load/store RISC machines with a weak memory model and a fixed instruction +# width, so an interface that fits both may fit because it is right or because +# they are alike. x86_64 is neither: variable-length instructions, total store +# order — under which three of the four barriers need no instruction at all — +# and an interrupt mechanism that is a table of 256 gates rather than a base +# register. What survives all three is an abstraction. jobs: gate: name: the probe runs on ${{ matrix.arch }} @@ -20,10 +28,23 @@ jobs: fail-fast: false matrix: include: - - { arch: riscv64, triple: riscv64-none-elf, qemu: 'xim:qemu-riscv' } - - { arch: aarch64, triple: aarch64-none-elf, qemu: 'xim:qemu-arm' } + - { arch: riscv64, triple: riscv64-none-elf, qemu: 'xim:qemu-riscv', apt: '' } + - { arch: aarch64, triple: aarch64-none-elf, qemu: 'xim:qemu-arm', apt: '' } + # ⚠️ THE THIRD ROW'S EMULATOR COMES FROM apt, AND THAT IS A GAP IN THE + # ECOSYSTEM RATHER THAN A PREFERENCE. + # + # The other two emulators are xlings packages because xPack publishes + # QEMU per target family and the index carries what it publishes. + # xPack has no x86 build, so there is no `xim:qemu-x86` to install — + # see .agents/docs/2026-08-21-freestanding-outstanding-four.md §4 in + # the engine repository, where building one is staged work. + # + # Naming apt here rather than quietly relying on whatever is on the + # runner keeps the difference visible: this row is the one whose + # emulator the ecosystem does not yet own. + - { arch: x86_64, triple: x86_64-none-elf, qemu: '', apt: 'qemu-system-x86' } env: - MCPP_VERSION: 2026.8.20.3 + MCPP_VERSION: 2026.8.21.1 XLINGS_VERSION: v2026.8.17.2 XLINGS_NON_INTERACTIVE: '1' steps: @@ -94,18 +115,25 @@ jobs: # `qemu-arm` only the two Arm ones. Measured; no single package runs both. - name: Install the emulator run: | - xlings install ${{ matrix.qemu }} -y - XLINGS_HOME="$HOME/.mcpp/registry" xlings install ${{ matrix.qemu }} -y + if [ -n "${{ matrix.qemu }}" ]; then + xlings install ${{ matrix.qemu }} -y + XLINGS_HOME="$HOME/.mcpp/registry" xlings install ${{ matrix.qemu }} -y + else + sudo apt-get update -qq + sudo apt-get install -y -qq ${{ matrix.apt }} + qemu-system-x86_64 --version | head -1 + fi - name: The layer builds for ${{ matrix.arch }} run: | # Twice, the first allowed to fail: the toolchain payload is installed # during a build, so the first build on a machine that has never # targeted this triple is the one that installs it. - # ⚠️ THE BACKEND, NOT THE WORKSPACE ROOT. Since 0.3.1 the root is a - # workspace and carries no sources of its own; building the backend - # for this target pulls the ABI it implements and is what the probe - # below will link against. + # ⚠️ THE BACKEND, NOT THE ROOT. Since 0.4.0 the root is BOTH the + # interface package and the workspace, so building it would build the + # interface; what this step wants is the machine half. Building the + # backend for this target pulls the ABI it implements and is what the + # probe below will link against. ( cd backends/${{ matrix.arch }} && mcpp build --target ${{ matrix.triple }} ) || true ( cd backends/${{ matrix.arch }} && mcpp build --target ${{ matrix.triple }} ) @@ -120,10 +148,26 @@ jobs: working-directory: examples/switch run: | set -euo pipefail - mcpp run --target ${{ matrix.triple }} 2>&1 | tee run.log + if [ -n "${{ matrix.qemu }}" ]; then + mcpp run --target ${{ matrix.triple }} 2>&1 | tee run.log + else + # ⚠️ `mcpp build` AND THEN qemu BY HAND, BECAUSE `build.mcpp` + # CONFIGURES NO RUNNER FOR THIS TARGET. It asks mcpp where + # `xim:qemu-x86` landed and there is no such package; a build + # program that fell back to a bare `qemu-system-x86_64` would make + # the build depend on what happens to be installed on the machine, + # which is the thing the other two rows do not do. + mcpp build --target ${{ matrix.triple }} + IMG=$(find target/${{ matrix.triple }} -type f -name switch | head -1) + test -n "$IMG" + timeout -k 5 60 qemu-system-x86_64 -machine q35 -nographic -no-reboot \ + -kernel "$IMG" 2>&1 | tee run.log + fi grep -q "task: arg=42" run.log grep -q "witness=7 before=1234" run.log grep -q "switch ok" run.log + grep -q "trap: back, witness=1" run.log + grep -q "cpu: percpu round-trips" run.log # The same source produced that output. Asserted rather than trusted: a # probe that had quietly grown a per-architecture branch would still pass @@ -132,10 +176,41 @@ jobs: run: | set -euo pipefail test -f examples/switch/src/main.cpp - if grep -qE '__riscv|__aarch64__|MCPP_TARGET_ARCH' examples/switch/src/main.cpp; then - echo "the probe branches on the architecture, which is what it exists to avoid" + + # ⚠️ THIS CHECK USED TO READ "NO ARCHITECTURE MACRO APPEARS AT ALL", + # AND IT WAS WRONG IN A WAY THAT COST TWO RELEASES OF RED CI. + # + # The probe must name an architecture in exactly one place: the trap + # instruction. `ebreak`, `brk #0` and `int3` are three spellings of + # one idea and there is no portable fourth. When the trap interface + # landed in 0.3.0 the old check began failing on every push, correctly + # by its own wording and wrongly by its intent — and it kept failing + # through 0.3.1 because nobody read the runs. + # + # What the gate actually claims is that the probe is not TWO PROGRAMS. + # So: one conditional, and nothing inside it but instructions. A + # second conditional, or a line of logic inside the first, is the + # thing this is here to catch. + NCOND=$(grep -c '^#if defined(__' examples/switch/src/main.cpp || true) + if [ "$NCOND" != "1" ]; then + grep -n '^#if defined(__' examples/switch/src/main.cpp || true + echo "the probe has $NCOND architecture conditionals; it may have exactly one, for the trap instruction" + exit 1 + fi + STRAY=$(awk ' + /^#if defined\(__/ || /^#elif defined\(__/ { inb=1; next } + /^#else/ { next } + /^#endif/ { inb=0; next } + inb && $0 !~ /^[[:space:]]*(\/\/)?[[:space:]]*$/ \ + && $0 !~ /asm volatile/ && $0 !~ /^#[[:space:]]*error/ \ + && $0 !~ /^[[:space:]]*\/\// { print NR": "$0 } + ' examples/switch/src/main.cpp) + if [ -n "$STRAY" ]; then + echo "$STRAY" + echo "the architecture conditional contains something other than an instruction" exit 1 fi + echo "the probe names an architecture once, to emit a trap instruction" # ⚠️ THE SEPARATION IS ASSERTED, NOT TRUSTED TO THE DIRECTORY NAMES. # @@ -143,15 +218,31 @@ jobs: # package as the specification, and the layering was a convention held # up by a path. The split made it a dependency graph; these two checks # make it a test. - if grep -rqE '\basm\b|__asm' spec/src; then - echo "the specification contains an instruction; it must not" + if grep -rqE '\basm\b|__asm' src; then + echo "the interface contains an instruction; it must not" exit 1 fi if grep -rq 'export module' backends; then - echo "a backend exports a module; it must reach the specification only through the ABI" + echo "a backend exports a module; it must reach the interface only through the ABI" exit 1 fi - echo "spec owns modules and no instruction; backends own instructions and no module" + echo "the interface owns modules and no instruction; backends own instructions and no module" + + # ⚠️ THE ROOT IS BOTH A PACKAGE AND A WORKSPACE, AND THAT IS WHAT + # MAKES A CONSUMER'S SIDE ONE LINE. A virtual workspace would put the + # interface in a member directory and `openarch = "0.4.0"` would have + # to name it. Asserted because the two tables are ordinary TOML and + # deleting one would leave a manifest that still builds. + grep -q '^\[package\]' mcpp.toml + grep -q '^\[workspace\]' mcpp.toml + + # The two faces reach the same library. The C one is a header a + # consumer includes; the C++ one is a module it imports. A face that + # disappeared would not fail any build in this repository except the + # test that names it. + test -f abi/include/mcpplibs/openarch.h + grep -q 'export module mcpplibs.openarch;' src/openarch.cppm + echo "one package, two faces, three backends" # --------------------------------------------------------------------------- # The half no emulator can check. @@ -173,7 +264,7 @@ jobs: run: shell: bash env: - MCPP_VERSION: 2026.8.20.3 + MCPP_VERSION: 2026.8.21.1 XLINGS_VERSION: v2026.8.17.2 XLINGS_NON_INTERACTIVE: '1' steps: @@ -218,8 +309,14 @@ jobs: # header now: a target build instantiates only what it calls — measured, # zero foreign symbols in either image — and a host build that calls both # gets both, with nothing to activate. - - name: Both encoders compile here and agree - working-directory: spec + # ⚠️ THREE ENCODERS SINCE 0.4.0, AND THE THIRD SETTLED A QUESTION THE + # FIRST TWO LEFT OPEN. `openarch.pte` owns `MAIR_EL1` because aarch64's + # entry holds an INDEX into it rather than a memory type, while riscv's + # holds the type itself — one against one, and "this layer owns the + # attribute register" could fairly be called a workaround for aarch64. + # x86_64 does the same thing with `IA32_PAT`, on a machine that shares no + # lineage with it, so the majority is now two to one the other way. + - name: The encoders compile here and agree run: mcpp test # --------------------------------------------------------------------------- @@ -257,7 +354,7 @@ jobs: run: shell: bash env: - MCPP_VERSION: 2026.8.20.3 + MCPP_VERSION: 2026.8.21.1 XLINGS_VERSION: v2026.8.17.2 XLINGS_NON_INTERACTIVE: '1' steps: diff --git a/README.md b/README.md index d4f7865..03e25b8 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ The architecture-mechanism layer: execution contexts, traps and address spaces, as one interface over several instruction sets. -⚠️ **Status: 0.1.0 is a probe, not a layer.** One architecture is implemented and -one primitive of the two that decide whether this layer can exist. See -[What has not been shown](#what-has-not-been-shown). +**Status: 0.4.0.** Four interfaces — contexts, page-table entries, traps, +per-CPU state and barriers — over **three** instruction sets: riscv64, aarch64 +and x86_64. One probe source builds and runs on all three and produces +byte-identical output. ## What this is, and what it is not @@ -21,23 +22,37 @@ does not decide what a fault means or how memory is laid out. ## The gate, and what passing it cost -This layer's viability is decided by two primitives, not by the count of +This layer's viability was decided by two primitives, not by the count of interfaces it eventually carries: -1. **Context switching** — implemented for riscv64 and aarch64. -2. **The page-table entry** — implemented for riscv64 and aarch64. +1. **Context switching** +2. **The page-table entry** -Everything else (per-CPU bases, barriers, timer ticks) varies far less between -architectures, and having them would say nothing about whether the layer holds. +Everything else (per-CPU bases, barriers, traps) varies far less between +architectures, and having them first would have said nothing about whether the +layer holds. ### The criterion > A single interface survives a second, genuinely different machine. -aarch64 is that machine: a different callee-saved set, a stricter stack +aarch64 was that machine: a different callee-saved set, a stricter stack alignment rule, a link register instead of a return-address register, and — the part that mattered — a different answer to where a fact lives. +### And then a third, which is what turned the gate into evidence + +⭐ riscv64 and aarch64 are both load/store RISC machines with a weak memory +model and a fixed instruction width. An interface that fits both may fit because +it is right or because they are alike, and no amount of testing on those two +distinguishes the cases. + +**x86_64 is neither.** Variable-length instructions; total store order, under +which three of the four barriers need no instruction at all; an interrupt +mechanism that is a table of 256 gates rather than a base register; and a +console reached by `out` rather than by a store, so no pointer can name it. What +survives all three is an abstraction. + `examples/switch` is **one probe source**. It is built for both targets and run under both emulators, and the output is identical: @@ -45,6 +60,10 @@ under both emulators, and the output is identical: main: switching to task task: arg=42 main: back, witness=7 before=1234 +trap: raising +trap: back, witness=1 +cpu: percpu round-trips +cpu: four barriers accepted switch ok ``` @@ -76,45 +95,146 @@ That is the shape of the finding the gate exists to produce — not "the interfa was wrong", but "the interface was under-specified in a way one machine could not reveal". +### What the third architecture changed + +**The `MAIR_EL1` decision stopped being aarch64's exception.** With two machines +it was one against one, and "this layer owns the attribute register" could +fairly be called a workaround. x86_64 does the same thing: `PWT`, `PCD` and +`PAT` are three scattered bits forming an index into `IA32_PAT`, a +model-specific register. The majority is now two to one the other way, and +owning the register is the general case. + +⚠️ Its version of the rule is *stricter*. An unprogrammed `MAIR_EL1` field reads +as the most restrictive memory type, so a too-early aarch64 mapping is slow and +correct. `IA32_PAT`'s reset value has **write-through** at index 1, so a device +mapping used before `install_memory_attributes()` is cached rather than +uncached: writes reach the device eventually, at a time the program did not +choose, and nothing faults. + +**`pc` does not mean the same thing on every machine, and the interface absorbed +that rather than restating it.** Both RISC machines report the address of the +instruction that trapped. x86_64 divides its exceptions into *faults*, which do +that, and *traps*, which report the address of the NEXT instruction — and `int3`, +the breakpoint `instr_len` exists to step over, is a trap. The backend +normalises it, so `f->pc += f->instr_len` resumes in the same place on all +three. The alternative was to tell every handler ever written, including the +ones that will only ever run on RISC machines, that `pc` means something +different here. + +**One interface promise turned out to be unexpressible in an entry.** riscv +qualifies its `X` bit by `U`, and aarch64 has separate `PXN` and `UXN` bits, so +on both "a user mapping is not kernel-executable" is a property of the encoding. +x86_64 has one `NX` bit covering every privilege level. The rule comes from +`CR4.SMEP` instead, which `install_memory_attributes()` sets — the same shape of +answer as `MAIR_EL1`, arrived at for a different reason. + ## What is checked, and where | | Checked by | |---|---| -| The switch reaches, returns and preserves | One probe source, two emulators, in CI | -| The entry encodings | A host unit test that holds **both** encoders at once | +| The switch reaches, returns and preserves; traps classify; per-CPU round-trips; four barriers are accepted | One probe source, three emulators, in CI | +| The entry encodings | A host unit test that holds **all three** encoders at once | +| The two faces declare one library | A host test of `static_assert`s, on a machine with no backend at all | +| The ABI's frozen layout | `tests/abi_shape.cpp`, in byte offsets rather than in `sizeof` of another member | +| The interface owns no instruction; the backends export no module | Two greps in CI, because until 0.3.1 the layering was a convention held up by a path | | The cross-compilation works from three systems | A build-only matrix on Linux, macOS and Windows | -⭐ The encoders are pure functions in per-architecture namespaces, reachable on -any host through the `probe-riscv64` and `probe-aarch64` features. That is what -lets a test assert that the two **agree** — for instance that neither ever marks -a user page executable by the kernel, which riscv64 gets structurally and -aarch64 needs two explicit bits to achieve. No single-target build could make -that comparison. +⭐ The encoders are pure `inline` functions in per-architecture namespaces, so a +host build holds all three at once. That is what lets a test assert that they +**agree** — for instance that neither RISC machine ever marks a user page +executable by the kernel, which riscv64 gets structurally and aarch64 needs two +explicit bits to achieve. No single-target build could make that comparison. + +⚠️ It is also what lets a test record where they *cannot* agree. x86_64 has one +`NX` bit for every privilege level, so the same assertion is not writable for +it; the test states that instead, and asserts the weaker thing that is true — +that an executable user page and an executable kernel page differ only in `U/S`. -## The repository layout, and why it is three packages +## The repository layout: one package, two faces, three backends ``` openarch/ -├── mcpp.toml [workspace] +├── mcpp.toml [package] openarch AND [workspace] +├── src/ the C++ face — context, pte, trap, cpu, +│ re-exported by module `mcpplibs.openarch` +├── tests/ what the two faces must agree about ├── abi/ the contract — headers only, depends on nothing │ ├── mcpp.toml openarch-abi -│ └── include/openarch/ -│ ├── abi.h what a backend implements -│ └── pte_encode.h the pure entry encoders, host-callable -├── spec/ the C++ modules over the contract -│ ├── mcpp.toml openarch → depends on openarch-abi -│ ├── src/ context.cppm trap.cppm cpu.cppm pte.cppm -│ └── tests/ the encoders, asserted on the host +│ └── include/ +│ ├── mcpplibs/openarch.h the C face, entire +│ └── openarch/ +│ ├── types.h the widths, named once and asserted once +│ ├── abi.h what a backend implements +│ └── pte_encode.h the pure entry encoders, host-callable ├── backends/ one package per instruction set -│ ├── riscv64/mcpp.toml openarch-riscv64 → depends on openarch-abi -│ └── aarch64/mcpp.toml openarch-aarch64 → depends on openarch-abi +│ ├── riscv64/mcpp.toml openarch-riscv64 → provides "openarch-backend" +│ ├── aarch64/mcpp.toml openarch-aarch64 → provides "openarch-backend" +│ └── x86_64/mcpp.toml openarch-x86-64 → provides "openarch-backend" └── examples/switch/ one probe source, run on every machine ``` -⭐ **`spec/` owns every module and no instruction; `backends/` own instructions -and export no module.** Both are asserted in CI rather than left to the -directory names — until 0.3.1 the backends lived under `src/arch//` in the -same package, and the layering was a convention held up by a path. +⭐ **The root is both a package and a workspace, and that is what makes a +consumer's side one line.** A virtual workspace — `[workspace]` with no +`[package]` — would put the interface in a member directory, and +`openarch = "0.4.0"` would have to name it. + +⭐ **The root owns every module and no instruction; the backends own +instructions and export no module.** Both are asserted in CI rather than left to +the directory names — until 0.3.1 the backends lived under `src/arch//` in +the same package, and the layering was a convention held up by a path. + +### The two faces + +A consumer writes one dependency line and then reaches the layer either way: + +```c +#include /* C, and C++ that wants the C names */ +``` +```cpp +import mcpplibs.openarch; // the four modules, re-exported +``` + +They are two spellings of one library rather than two declarations that line up. +The module's `trap_frame` **is** `::arch_trap_frame` — a `using`, not a +lookalike — and its enumerations are *defined from* the contract's: +`illegal = ARCH_TRAP_ILLEGAL`. `tests/faces.cpp` checks the derivation, which is +a weaker thing to have to check than an agreement. + +⚠️ The C face is not a leftover. openarch is what a kernel's earliest code uses, +and that code is frequently not C++: a boot stub in C, a vendor's board file, a +runtime that speaks the C ABI and nothing else. + +### Which backend, and whose + +The backend arrives through a **feature**, and three questions that had been +answered by one mechanism are now separate: + +| The consumer wants | What its manifest says | +|---|---| +| the backend for its target | `openarch = "0.4.0"` | +| a particular one | `{ version = "0.4.0", default-features = false, features = ["backend-riscv64"] }` | +| **its own implementation** | `{ version = "0.4.0", default-features = false, features = ["backend-external"] }` plus a package that `provides = ["openarch-backend"]` | + +`backend-auto` is on by default and resolves per target. +`backend-external` names no package: it *requires the capability*, so a graph +with no provider fails at configure time saying so, rather than at link time +naming a mangled symbol. This is the shape `std-freestanding` uses for its +allocator, so the ecosystem has one pattern for "a default that stays +replaceable" rather than two. + +⚠️ **`backend-auto` deliberately does NOT require the capability**, and the first +version of the table had it do so. Features are additive and a `requires` is +unconditional even when the `feature-deps` that satisfy it are +target-conditional — so requiring it made a host build of this package's own +tests impossible: + +``` +error: no package provides capability 'openarch-backend' required by 'openarch' +``` + +A hosted target having no backend is a fact about the target, not an error the +consumer can act on. `import mcpplibs.openarch` still compiles there, and the +encoders are header-only, which is what `tests/` runs on. ### What the split forced @@ -133,9 +253,9 @@ prefix on every register. ### Why `abi/` is its own package -The first attempt put the header in `spec/` and had the backends depend on it, -while `spec/` pulled a backend through a `cfg` dependency so that a consumer -would never name its own architecture. mcpp rejected that: +The first attempt put the header in the interface package and had the backends +depend on it, while the interface pulled a backend so that a consumer would +never name its own architecture. mcpp rejected that: ``` error: dependency cycle through package 'openarch' @@ -148,22 +268,16 @@ have created two files that must agree with no mechanism to make them. The contract belongs to neither side, and once it is its own package both may depend on it and neither depends on the other. -### What a consumer writes +### How the target chooses ```toml -[dependencies] -openarch = "0.3.1" +[target.'cfg(all(arch = "riscv64", os = "none"))'.feature-deps.backend-auto] +openarch-riscv64 = { path = "backends/riscv64" } ``` -Nothing about the architecture. `spec/mcpp.toml` resolves the backend from the -target: - -```toml -[target.'cfg(all(arch = "riscv64", os = "none"))'.dependencies] -openarch-riscv64 = { path = "../backends/riscv64" } -``` +The feature decides *whether* a backend is linked; the predicate decides *which*. -⚠️ `os = "none"` is part of the predicate. A hosted aarch64 build — a macOS +⚠️ `os = "none"` is part of every predicate. A hosted aarch64 build — a macOS runner, for instance — has an operating system already, and matching on the architecture alone once compiled ELF assembly for Mach-O. @@ -181,6 +295,7 @@ loop that decides whether the layer is viable. | | Status | |---|---| -| Traps, per-CPU bases, barriers, timer ticks | Not started, and deliberately: their shape depends on the two primitives above, and writing them before the gate was passed would have produced code that no test could run | -| A third architecture | x86_64 is the obvious one and is blocked on an emulator: no upstream publishes prebuilt `qemu-system-x86_64` for the five host targets the package index serves, which is the bar `qemu-arm` and `qemu-riscv` both meet | +| Timer ticks | Not started. Unlike the four interfaces here, a tick is not obviously mechanism: riscv's `mtimecmp` is a memory-mapped comparator whose address the *board* decides, aarch64's is an architectural system register, and x86_64 has at least three unrelated sources. An interface over those may be a machine layer's business or a board package's, and the question is worth answering before the code is written | +| `xim:qemu-x86` | The x86_64 row's emulator is not an ecosystem package. xPack publishes QEMU per target family and has no x86 build, so CI installs it with apt and says so. Building one for the five host targets the index serves is staged work | | Page-table **walking** | Out of scope. Building an entry is mechanism; deciding where entries go is policy, and belongs to the kernel | +| A second backend for one ISA | The arrangement now supports it — `backend-riscv64` names a backend rather than an architecture — and riscv will want it: this backend traps into M-mode, and a kernel under SBI traps into S-mode | diff --git a/abi/include/mcpplibs/openarch.h b/abi/include/mcpplibs/openarch.h new file mode 100644 index 0000000..0dd2f01 --- /dev/null +++ b/abi/include/mcpplibs/openarch.h @@ -0,0 +1,39 @@ +/* mcpplibs/openarch.h — openarch's C face, entire. + * + * A consumer writes one dependency line + * + * [dependencies] + * openarch = "0.4.0" + * + * and then either + * + * #include this file, for C and for C++ + * import mcpplibs.openarch; the module, for C++ + * + * The two are the same library seen from two sides, not two libraries: the + * module's enumerations are defined from the enumerators below, its types are + * `using` declarations of the types below, and its functions are inline calls + * to the functions below. `tests/faces.cpp` states that as `static_assert`. + * + * ⭐ WHY THE C FACE IS A FIRST-CLASS FACE AND NOT A LEFTOVER. + * + * openarch is the layer a kernel's earliest code uses, and that code is + * frequently not C++: a boot stub written in C, a vendor's board file, a + * language runtime that speaks the C ABI and nothing else. A machine layer that + * could only be reached from a C++ module would exclude its own earliest + * callers, and would also exclude the second kind of backend the split was made + * to allow — an implementation that is not a C++ module. + * + * ⚠️ THIS HEADER ADDS NOTHING. It exists so that the name a consumer writes + * matches the name of the package and of the module, and so that the contract + * header — which a backend author reads and a consumer does not — keeps its own + * name. If a declaration ever appears here that is not in `openarch/abi.h`, the + * C face and the ABI have started to differ, and a backend built against one + * will link against the other. + */ +#ifndef MCPPLIBS_OPENARCH_H +#define MCPPLIBS_OPENARCH_H + +#include /* which includes */ + +#endif /* MCPPLIBS_OPENARCH_H */ diff --git a/abi/include/openarch/abi.h b/abi/include/openarch/abi.h index 90f095d..60c7784 100644 --- a/abi/include/openarch/abi.h +++ b/abi/include/openarch/abi.h @@ -19,19 +19,75 @@ * * This is the same shape openkal uses, and for the same reason. * - * ⚠️ EVERY TYPE HERE IS FIXED-WIDTH OR A POINTER. `unsigned long long` and not - * `unsigned long`: the latter is 64 bits on the systems this was written on and - * 32 on Windows, and a page-table entry is 64 bits everywhere. That mistake has - * already been made once in this repository and was caught only because the - * constants involved were `constexpr`. + * ⚠️ EVERY TYPE HERE IS FIXED-WIDTH OR A POINTER, AND THE WIDTHS ARE NAMED IN + * ONE PLACE RATHER THAN SPELLED AT EACH USE. + * + * `openarch/types.h` defines `arch_u32`, `arch_u64` and `arch_uptr`, and + * asserts their widths. Until 0.4.0 this file wrote `unsigned long long` forty + * times with a comment explaining why it was not `unsigned long` — a rule that + * was described and never checked. The one time it was broken, in `1UL << 53`, + * the shift was wider than the type on Windows: undefined, and in practice + * silently zero. It was caught by accident, because the constant happened to be + * `constexpr` and the compiler was forced to evaluate it. */ #ifndef OPENARCH_ABI_H #define OPENARCH_ABI_H +#include + #ifdef __cplusplus extern "C" { #endif +/* ── The vocabulary ──────────────────────────────────────────────────────── + * + * ⭐ THESE ENUMERATORS ARE THE CONTRACT'S, NOT THE C++ FACE'S, AND THAT IS THE + * POINT. + * + * Until 0.4.0 the C ABI took bare `int` and named the meanings in a comment, + * while `mcpplibs.openarch` had `enum class perm`, `memory_type`, `trap_kind` + * and `barrier` with the values written out again. Two faces of one library + * held the same four tables, agreeing by inspection. + * + * The tables live here now, and the C++ enumerations are DEFINED from them — + * `read_write = ARCH_PERM_READ_WRITE` — so the faces cannot drift: there is one + * table and the other spelling is derived from it. `tests/faces.cpp` checks the + * derivation rather than the agreement, which is a weaker thing to have to + * check. + * + * ⚠️ THE PROTOTYPES STILL TAKE `int`. A C enumeration's underlying type is + * implementation-defined, so a parameter declared `enum arch_perm` is a + * different parameter under a different compiler — and a contract whose whole + * purpose is to let a backend be built by something else must not have that + * property. The enumerators are names for values passed as `int`. */ +enum arch_perm { + ARCH_PERM_READ = 0, + ARCH_PERM_READ_WRITE = 1, + ARCH_PERM_READ_EXEC = 2, + ARCH_PERM_READ_WRITE_EXEC = 3 +}; + +enum arch_memory_type { + ARCH_MT_NORMAL = 0, + ARCH_MT_DEVICE = 1 +}; + +enum arch_trap_kind { + ARCH_TRAP_BREAKPOINT = 0, + ARCH_TRAP_PAGE_FAULT = 1, + ARCH_TRAP_ILLEGAL = 2, + ARCH_TRAP_UNALIGNED = 3, + ARCH_TRAP_INTERRUPT = 4, + ARCH_TRAP_OTHER = 5 +}; + +enum arch_barrier { + ARCH_BARRIER_MEMORY = 0, + ARCH_BARRIER_STORE = 1, + ARCH_BARRIER_COMPLETE = 2, + ARCH_BARRIER_FETCH = 3 +}; + /* ── openarch.context ─────────────────────────────────────────────────────── * * The storage is the caller's and is opaque to it: 128 bytes, 16-aligned. Each @@ -48,13 +104,12 @@ void arch_context_init(void* ctx, void (*entry)(void*), void* arg, /* ── openarch.pte ────────────────────────────────────────────────────────── * - * `perm`: 0 read, 1 read_write, 2 read_exec, 3 read_write_exec - * `mt` : 0 normal, 1 device - * `user`: non-zero for a mapping reachable from unprivileged code */ -unsigned long long arch_pte_make_leaf(unsigned long long phys, int perm, + * `perm` is an `arch_perm`, `mt` an `arch_memory_type`, and `user` is non-zero + * for a mapping reachable from unprivileged code. */ +arch_u64 arch_pte_make_leaf(arch_u64 phys, int perm, int mt, int user); -int arch_pte_valid(unsigned long long bits); -unsigned long long arch_pte_phys(unsigned long long bits); +int arch_pte_valid(arch_u64 bits); +arch_u64 arch_pte_phys(arch_u64 bits); /* Programs whatever the machine needs before a memory type is meaningful. * Empty on riscv64, where the type is in the entry; writes `MAIR_EL1` on @@ -67,18 +122,17 @@ void arch_pte_install_memory_attributes(void); * stub reserves exactly `sizeof(arch_trap_frame)` bytes above the registers it * saved; a disagreement would corrupt a saved register rather than fail. * - * `kind` uses the same ordering as `arch::trap_kind`: - * 0 breakpoint 1 page_fault 2 illegal 3 unaligned 4 interrupt 5 other + * `kind` is an `arch_trap_kind`, stored as `int` for the reason given above. * * `instr_len` exists because a portable handler that resumes past a breakpoint * cannot derive it: `rv64gc` emits the two-byte `c.ebreak`, aarch64 has one * instruction width. Only the backend knows. */ typedef struct arch_trap_frame { - unsigned long long pc; - unsigned long long addr; - unsigned long long cause; + arch_u64 pc; + arch_u64 addr; + arch_u64 cause; int kind; - unsigned instr_len; + arch_u32 instr_len; } arch_trap_frame; typedef void (*arch_trap_handler_fn)(arch_trap_frame*); @@ -89,9 +143,10 @@ int arch_trap_interrupts_enabled(void); /* ── openarch.cpu ────────────────────────────────────────────────────────── * - * `barrier`: 0 memory, 1 store, 2 complete, 3 fetch — the four orderings both - * machines can state. riscv expresses them with one instruction taking two - * sets; aarch64 with three instructions taking a shareability domain. */ + * `barrier` is an `arch_barrier` — the four orderings both machines can state. + * riscv expresses them with one instruction taking two sets; aarch64 with three + * instructions taking a shareability domain; x86_64 with a memory model under + * which three of the four need no instruction at all. */ void* arch_cpu_percpu(void); void arch_cpu_set_percpu(void* p); void arch_cpu_fence(int barrier); diff --git a/abi/include/openarch/pte_encode.h b/abi/include/openarch/pte_encode.h index 03e546c..1f7a421 100644 --- a/abi/include/openarch/pte_encode.h +++ b/abi/include/openarch/pte_encode.h @@ -34,19 +34,19 @@ * unit its own entity, and an inline function referring to one is an ODR * violation that no compiler is required to report. * - * ⚠️ `unsigned long long`, NOT `unsigned long`, AND CROSS-PLATFORM CI IS WHAT - * FOUND IT. + * ⚠️ `arch_u64` THROUGHOUT, AND CROSS-PLATFORM CI IS WHAT MADE THAT NECESSARY. * - * A page-table entry is 64 bits on both machines, and `unsigned long` is 64 bits - * on the systems this was written on. It is 32 on Windows, where `1UL << 53` is - * a shift wider than the type — undefined, and in practice silently zero rather - * than an error, so an encoder built there would have produced entries with - * every high field missing and no diagnostic at all. The host job caught it only - * because `constexpr` forces the shift to be evaluated at compile time, which - * turns the silent case into `must be initialized by a constant expression`. + * A page-table entry is 64 bits on every machine here. These constants were + * once `unsigned long`, which is 64 bits on the systems this was written on and + * 32 on Windows — so `1UL << 53` was a shift wider than the type: undefined, + * and in practice silently zero rather than an error. An encoder built there + * would have produced entries with every high field missing and no diagnostic + * at all. The host job caught it only because `constexpr` forces the shift to + * be evaluated at compile time, which turns the silent case into `must be + * initialized by a constant expression`. * - * `unsigned long long` is at least 64 bits everywhere, which is the property the - * entries need. + * `arch_u64` is defined and ASSERTED to be eight bytes in `openarch/types.h`, + * which is the difference between a rule and a mechanism. * * The integer parameters mirror the module's enumerators in declaration order: * @@ -59,6 +59,8 @@ #ifndef OPENARCH_PTE_ENCODE_H #define OPENARCH_PTE_ENCODE_H +#include + namespace arch { // ── riscv64: the Sv39/Sv48 leaf, per the privileged specification ─────────── @@ -67,13 +69,13 @@ namespace arch { // walked differs, and that is the walker's business rather than the entry's. namespace riscv64 { -inline constexpr unsigned long long kV = 1ULL << 0; // valid -inline constexpr unsigned long long kR = 1ULL << 1; // readable -inline constexpr unsigned long long kW = 1ULL << 2; // writable -inline constexpr unsigned long long kX = 1ULL << 3; // executable -inline constexpr unsigned long long kU = 1ULL << 4; // reachable from user mode -inline constexpr unsigned long long kA = 1ULL << 6; // accessed -inline constexpr unsigned long long kD = 1ULL << 7; // dirty +inline constexpr arch_u64 kV = 1ULL << 0; // valid +inline constexpr arch_u64 kR = 1ULL << 1; // readable +inline constexpr arch_u64 kW = 1ULL << 2; // writable +inline constexpr arch_u64 kX = 1ULL << 3; // executable +inline constexpr arch_u64 kU = 1ULL << 4; // reachable from user mode +inline constexpr arch_u64 kA = 1ULL << 6; // accessed +inline constexpr arch_u64 kD = 1ULL << 7; // dirty // ⚠️ Svpbmt, bits [62:61]: 0 = PMA (whatever the platform says the region is), // 1 = NC (non-cacheable, idempotent), 2 = IO (non-cacheable, non-idempotent). @@ -83,15 +85,15 @@ inline constexpr unsigned long long kD = 1ULL << 7; // dirty // ⭐ THE ENTIRE MEMORY TYPE IS HERE, IN THE ENTRY. That is what the aarch64 // encoder below cannot reproduce, and the reason `install_memory_attributes` // exists in the interface at all. -inline constexpr unsigned long long kPbmtIo = 2ULL << 61; +inline constexpr arch_u64 kPbmtIo = 2ULL << 61; // The physical page number occupies [53:10]. -inline constexpr unsigned long long kPpnShift = 10; -inline constexpr unsigned long long kPpnMask = ((1ULL << 44) - 1) << kPpnShift; +inline constexpr arch_u64 kPpnShift = 10; +inline constexpr arch_u64 kPpnMask = ((1ULL << 44) - 1) << kPpnShift; -inline unsigned long long encode_leaf(unsigned long long phys, int perm, int mt, +inline arch_u64 encode_leaf(arch_u64 phys, int perm, int mt, bool user) noexcept { - unsigned long long e = kV | kA | kR; + arch_u64 e = kV | kA | kR; // ⚠️ `D` is set whenever the mapping is writable. A hart is permitted to // fault on the first write to a clean page, and nothing in this layer would @@ -109,9 +111,9 @@ inline unsigned long long encode_leaf(unsigned long long phys, int perm, int mt, return e | (((phys >> 12) << kPpnShift) & kPpnMask); } -inline bool entry_valid(unsigned long long bits) noexcept { return (bits & kV) != 0; } +inline bool entry_valid(arch_u64 bits) noexcept { return (bits & kV) != 0; } -inline unsigned long long entry_phys(unsigned long long bits) noexcept { +inline arch_u64 entry_phys(arch_u64 bits) noexcept { if (!entry_valid(bits)) return 0; return ((bits & kPpnMask) >> kPpnShift) << 12; } @@ -132,7 +134,7 @@ inline unsigned long long entry_phys(unsigned long long bits) noexcept { namespace aarch64 { // The descriptor's low two bits: 0b11 is a valid page at the last level. -inline constexpr unsigned long long kValidPage = 3ULL; +inline constexpr arch_u64 kValidPage = 3ULL; // AttrIndx, bits [4:2], indexing the layout install_memory_attributes writes: // @@ -143,31 +145,31 @@ inline constexpr unsigned long long kValidPage = 3ULL; // memory. An unused MAIR field is zero, which reads as Device-nGnRnE — the // strictest type — so a mis-encoded index degrades to "slow and correct" rather // than to "cached device register". -inline constexpr unsigned long long kAttrNormal = 0ULL << 2; -inline constexpr unsigned long long kAttrDevice = 1ULL << 2; +inline constexpr arch_u64 kAttrNormal = 0ULL << 2; +inline constexpr arch_u64 kAttrDevice = 1ULL << 2; // AP, bits [7:6]. ⚠️ aarch64 states permission as a PAIR of levels rather than // as one set of bits per level, which is the second place the two machines // disagree: riscv has a single `U` bit orthogonal to R/W/X. -inline constexpr unsigned long long kApRwEl1 = 0ULL << 6; // read-write, privileged -inline constexpr unsigned long long kApRwEl0El1 = 1ULL << 6; // read-write, both -inline constexpr unsigned long long kApRoEl1 = 2ULL << 6; // read-only, privileged -inline constexpr unsigned long long kApRoEl0El1 = 3ULL << 6; // read-only, both +inline constexpr arch_u64 kApRwEl1 = 0ULL << 6; // read-write, privileged +inline constexpr arch_u64 kApRwEl0El1 = 1ULL << 6; // read-write, both +inline constexpr arch_u64 kApRoEl1 = 2ULL << 6; // read-only, privileged +inline constexpr arch_u64 kApRoEl0El1 = 3ULL << 6; // read-only, both // SH, bits [9:8]. Inner shareable for normal memory; device memory ignores the // field, and zero is what the manual's examples leave there. -inline constexpr unsigned long long kShInner = 3ULL << 8; +inline constexpr arch_u64 kShInner = 3ULL << 8; -inline constexpr unsigned long long kAf = 1ULL << 10; // access flag -inline constexpr unsigned long long kPxn = 1ULL << 53; // privileged execute never -inline constexpr unsigned long long kUxn = 1ULL << 54; // unprivileged execute never +inline constexpr arch_u64 kAf = 1ULL << 10; // access flag +inline constexpr arch_u64 kPxn = 1ULL << 53; // privileged execute never +inline constexpr arch_u64 kUxn = 1ULL << 54; // unprivileged execute never // The output address occupies [47:12]. -inline constexpr unsigned long long kOaMask = ((1ULL << 36) - 1) << 12; +inline constexpr arch_u64 kOaMask = ((1ULL << 36) - 1) << 12; -inline unsigned long long encode_leaf(unsigned long long phys, int perm, int mt, +inline arch_u64 encode_leaf(arch_u64 phys, int perm, int mt, bool user) noexcept { - unsigned long long e = kValidPage | kAf; + arch_u64 e = kValidPage | kAf; const bool writable = (perm == 1 || perm == 3); const bool executable = (perm == 2 || perm == 3); @@ -200,15 +202,112 @@ inline unsigned long long encode_leaf(unsigned long long phys, int perm, int mt, return e | (phys & kOaMask); } -inline bool entry_valid(unsigned long long bits) noexcept { return (bits & 1ULL) != 0; } +inline bool entry_valid(arch_u64 bits) noexcept { return (bits & 1ULL) != 0; } -inline unsigned long long entry_phys(unsigned long long bits) noexcept { +inline arch_u64 entry_phys(arch_u64 bits) noexcept { if (!entry_valid(bits)) return 0; return bits & kOaMask; } } // namespace aarch64 +// ── x86_64: the 4-level paging leaf entry, 4KiB page ──────────────────────── +// +// ⭐ THE THIRD MACHINE SETTLED A QUESTION THE FIRST TWO LEFT OPEN. +// +// `openarch.pte` owns `MAIR_EL1` because aarch64's entry holds an INDEX rather +// than a memory type, and riscv's holds the type itself. With two machines that +// was one-against-one, and "this layer owns the attribute register" could +// fairly be called a workaround for aarch64. +// +// x86_64 does the same thing. `PWT`, `PCD` and `PAT` are three scattered bits +// that together form a three-bit index into `IA32_PAT`, a model-specific +// register holding eight one-byte memory types. The entry says "type number +// one"; what number one means is whatever was last written to the MSR. That is +// aarch64's arrangement with different names, on a machine that shares no +// lineage with it — so the majority is now two-to-one the other way, and owning +// the register is the general case rather than the exception. +// +// ⚠️ THE THREE INDEX BITS ARE NOT ADJACENT, WHICH IS THE ONE PLACE THIS +// ENCODING IS EASY TO GET WRONG. `PWT` is bit 3, `PCD` is bit 4, and `PAT` is +// bit 7 — for a 4KiB entry. In a 2MiB or 1GiB entry the `PAT` bit moves to bit +// 12, because bit 7 is `PS` there. This encoder builds 4KiB leaves only, which +// is what the interface offers. +namespace x86_64 { + +inline constexpr arch_u64 kP = 1ULL << 0; // present +inline constexpr arch_u64 kRw = 1ULL << 1; // writable +inline constexpr arch_u64 kUs = 1ULL << 2; // reachable from user mode +inline constexpr arch_u64 kPwt = 1ULL << 3; // PAT index bit 0 +inline constexpr arch_u64 kPcd = 1ULL << 4; // PAT index bit 1 +inline constexpr arch_u64 kA = 1ULL << 5; // accessed +inline constexpr arch_u64 kD = 1ULL << 6; // dirty +inline constexpr arch_u64 kPat = 1ULL << 7; // PAT index bit 2 (4KiB leaf) +inline constexpr arch_u64 kG = 1ULL << 8; // global +inline constexpr arch_u64 kNx = 1ULL << 63; // no-execute + +// The indices into the layout `install_memory_attributes` writes: +// +// index 0 0x06 write-back → PAT=0 PCD=0 PWT=0, i.e. no bits +// index 1 0x00 uncacheable → PAT=0 PCD=0 PWT=1 +// +// ⚠️ INDEX 1 IS WRITE-THROUGH AT RESET, NOT UNCACHEABLE. The reset value of +// `IA32_PAT` is 0x0007040600070406, whose entry 1 is `WT`. An encoder relying +// on reset values would map `device` to write-through memory: writes reach +// memory eventually rather than immediately, and a device register written that +// way is written at a time the program did not choose. Nothing faults. +// +// This is exactly why the register is programmed rather than assumed — and it +// is a stronger reason than aarch64's, where an unwritten `MAIR_EL1` field is +// zero and reads as the STRICTEST type, so the failure degrades safely. Here it +// does not. +inline constexpr arch_u64 kAttrNormal = 0ULL; +inline constexpr arch_u64 kAttrDevice = kPwt; + +// The physical address occupies [51:12]. Bits [62:52] are software-available +// and the MMU ignores them; masking them off keeps `entry_phys` honest. +inline constexpr arch_u64 kAddrMask = ((1ULL << 40) - 1) << 12; + +inline arch_u64 encode_leaf(arch_u64 phys, int perm, int mt, + bool user) noexcept { + arch_u64 e = kP | kA; + + const bool writable = (perm == 1 || perm == 3); + const bool executable = (perm == 2 || perm == 3); + + if (writable) e |= kRw | kD; + if (user) e |= kUs; + + // ⚠️ NO PER-LEVEL EXECUTE CONTROL, WHICH IS THE THIRD DISAGREEMENT AND THE + // ONLY ONE THIS LAYER CANNOT HIDE IN THE ENTRY. + // + // riscv qualifies `X` by `U`; aarch64 has separate `PXN` and `UXN` bits. + // x86_64 has one `NX` bit that applies to every privilege level, so the + // rule the other two encoders enforce — a user mapping is never + // kernel-executable — is not expressible here. It is enforced instead by + // `SMEP`, a bit in `CR4` that faults when privileged code fetches from a + // user page, which `install_memory_attributes` sets when the processor + // reports it. + // + // Stating it is the price of hiding it, the same way the `MAIR_EL1` + // precondition was. + if (!executable) e |= kNx; + + if (mt == 1) e |= kAttrDevice; + else e |= kAttrNormal; + + return e | (phys & kAddrMask); +} + +inline bool entry_valid(arch_u64 bits) noexcept { return (bits & kP) != 0; } + +inline arch_u64 entry_phys(arch_u64 bits) noexcept { + if (!entry_valid(bits)) return 0; + return bits & kAddrMask; +} + +} // namespace x86_64 + } // namespace arch #endif diff --git a/abi/include/openarch/types.h b/abi/include/openarch/types.h new file mode 100644 index 0000000..bbb96ab --- /dev/null +++ b/abi/include/openarch/types.h @@ -0,0 +1,102 @@ +/* openarch's types: the widths every other header in this package is written + * in, stated once. + * + * ⚠️ THIS FILE EXISTS BECAUSE A WIDTH WAS AN ASSUMPTION IN FORTY PLACES AND A + * STATEMENT IN NONE. + * + * Until 0.4.0 every 64-bit quantity in the ABI was spelled `unsigned long long` + * at each use, with a comment at the top of `abi.h` explaining why it was not + * `unsigned long`. That comment was correct and it was not a mechanism: the + * property it described — "these are 64 bits" — was never checked anywhere, and + * the one time it was violated, in `1UL << 53`, the violation was caught by + * accident. The shift was inside a `constexpr`, so the compiler was forced to + * evaluate it and reported `must be initialized by a constant expression`. Had + * it been an ordinary expression, `unsigned long` being 32 bits on Windows + * would have made it silently zero and produced page-table entries with every + * high field missing, with no diagnostic at all. + * + * So the widths are named here, and the naming is what makes them assertable — + * the `_Static_assert`s below are the whole point of the file, not decoration. + * One place says what `arch_u64` is; one place checks it; every other file says + * `arch_u64` and says nothing about widths. + * + * ⭐ WHY NOT ``, WHICH DOES WORK HERE. + * + * Measured on llvm 22.1.8 for all three bare-metal targets with no sysroot at + * all: `#include ` resolves and `uint64_t` is available, because the + * header is the compiler's own rather than a C library's. + * + * The reason not to is the consumer this package exists to serve. openarch is + * reached by the earliest code in a system, and some of that code is compiled + * with `-nostdinc` — a C library being ported onto this layer is exactly that + * consumer, and it has no header to include, not even the compiler's. openkal + * made this decision first and for the same reason; matching it means the two + * lowest layers of the ecosystem answer the question the same way. + * + * This file includes nothing. + */ +#ifndef OPENARCH_TYPES_H +#define OPENARCH_TYPES_H + +/* ⚠️ TAKEN FROM THE COMPILER'S OWN SPELLING WHERE THERE IS ONE. + * + * Two of the three compilers this package is built with publish the type; + * the third publishes the property the type is defined by and not the type, + * so for that one the type is written from the property. Deriving it from + * that compiler's own header instead would give this file an include, and + * the consumer this file exists for has none. */ +#if defined(__UINT64_TYPE__) +typedef __UINT32_TYPE__ arch_u32; +typedef __UINT64_TYPE__ arch_u64; +#elif defined(_MSC_VER) +typedef unsigned int arch_u32; +typedef unsigned __int64 arch_u64; +#else +# error "openarch requires a compiler that states a sixty-four bit type" +#endif + +#if defined(__UINTPTR_TYPE__) +typedef __UINTPTR_TYPE__ arch_uptr; +#elif defined(_MSC_VER) +# if defined(_WIN64) +typedef unsigned __int64 arch_uptr; +# else +typedef unsigned int arch_uptr; +# endif +#else +# error "openarch requires a compiler that states the width of a pointer" +#endif + +/* ── The checks ───────────────────────────────────────────────────────────── + * + * ⚠️ `arch_uptr` IS NOT ASSERTED TO BE EIGHT BYTES, AND THAT OMISSION IS + * DELIBERATE. A page-table entry is 64 bits on every machine openarch serves + * including a 32-bit one, so `arch_u64` has a fixed width; a pointer does not, + * and `riscv32-none-elf` is a target this repository intends to reach. An + * assertion that a pointer is eight bytes would pass on every machine tested + * today and would be exactly the mistake openkal made in `fs.h`, where + * `offsetof(modified_ns) == sizeof(kal_uintptr)` held on 64-bit targets and + * failed on 32-bit ones because a four-byte member is followed by four bytes of + * padding. What is asserted about a pointer is only that it fits its own type. + * + * Written for both languages: this header is included from C by a backend a + * vendor supplies and from C++ by everything in this repository. */ +#if defined(__cplusplus) +# define OPENARCH_STATIC_ASSERT(cond, msg) static_assert(cond, msg) +#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +# define OPENARCH_STATIC_ASSERT(cond, msg) _Static_assert(cond, msg) +#else +/* A compiler older than C11 gets no check rather than a broken one. The + * declaration below is a no-op that keeps the macro usable as a statement. */ +# define OPENARCH_STATIC_ASSERT(cond, msg) struct openarch_unused_##__LINE__ +#endif + +OPENARCH_STATIC_ASSERT(sizeof(arch_u32) == 4, + "arch_u32 must be exactly four bytes"); +OPENARCH_STATIC_ASSERT(sizeof(arch_u64) == 8, + "arch_u64 must be exactly eight bytes: a page-table " + "entry is 64 bits on every machine openarch serves"); +OPENARCH_STATIC_ASSERT(sizeof(arch_uptr) == sizeof(void*), + "arch_uptr must hold a pointer"); + +#endif /* OPENARCH_TYPES_H */ diff --git a/abi/mcpp.toml b/abi/mcpp.toml index 503e525..34dc67c 100644 --- a/abi/mcpp.toml +++ b/abi/mcpp.toml @@ -3,8 +3,8 @@ # ⚠️ THIS PACKAGE EXISTS BECAUSE THE DEPENDENCY GRAPH SAID SO, NOT BECAUSE # THREE PACKAGES LOOK TIDIER THAN TWO. # -# The first attempt at the split put the ABI header in the specification and had -# the backends depend on it, while the specification pulled a backend through a +# The first attempt at the split put the ABI header in the interface package and +# had the backends depend on it, while the interface pulled a backend through a # `cfg` dependency so that a consumer would never name its own architecture. # mcpp rejected that outright: # @@ -24,7 +24,7 @@ [package] namespace = "mcpplibs" name = "openarch-abi" -version = "0.3.1" +version = "0.4.0" description = "openarch's C ABI: the contract between the interface and an instruction set's backend" license = "Apache-2.0" authors = ["mcpplibs"] diff --git a/backends/aarch64/mcpp.toml b/backends/aarch64/mcpp.toml index fae5b84..a01d1a6 100644 --- a/backends/aarch64/mcpp.toml +++ b/backends/aarch64/mcpp.toml @@ -1,9 +1,9 @@ # openarch's aarch64 backend: instructions, and nothing else. # # ⚠️ THIS PACKAGE EXPORTS NO MODULE. It defines the C entry points declared in -# `spec/include/openarch/abi.h` and is reached only through them. A consumer -# never names it — `spec/mcpp.toml` pulls it in through a `cfg` dependency on -# the target's architecture. +# `abi/include/openarch/abi.h` and is reached only through them. A consumer +# never names it: the root manifest pulls it in through the `backend` feature, +# whose target-conditional `feature-deps` row selects this one. # # The separation is what lets a second implementation of the same ABI exist # without touching the specification: a vendor's assembler file, a simulator's @@ -14,12 +14,18 @@ [package] namespace = "mcpplibs" name = "openarch-aarch64" -version = "0.3.1" +version = "0.4.0" description = "openarch's aarch64 backend: the instructions behind the ABI" license = "Apache-2.0" authors = ["mcpplibs"] repo = "https://github.com/mcpplibs/openarch" +# ⭐ THE CAPABILITY, NOT THE PACKAGE NAME, IS WHAT A CONSUMER BINDS TO. The root +# package's `backend` feature requires `openarch-backend` and names nobody; the +# resolver binds the one provider in the graph. A consumer's own implementation +# of `openarch/abi.h` declares the same line and is selected the same way. +provides = ["openarch-backend"] + [build] sources = ["src/**"] diff --git a/backends/aarch64/src/context_init.cpp b/backends/aarch64/src/context_init.cpp index 6adb2da..c66cf84 100644 --- a/backends/aarch64/src/context_init.cpp +++ b/backends/aarch64/src/context_init.cpp @@ -6,6 +6,13 @@ // and once in the assembly that consumes it, with a static assertion binding // the two. +// ⚠️ `openarch/types.h` AND NOT `openarch/abi.h`. This file implements a +// function the ABI declares, and it needs the ABI's WIDTHS rather than its +// declarations — the register-file mirror below is this backend's own layout, +// not something the contract names. Including the contract would compile and +// would state a dependency that is not there. +#include + extern "C" void arch_context_entry(); // the trampoline in context.S namespace { @@ -17,8 +24,8 @@ namespace { // twice; the assertion below catches a size disagreement, and the field order // is checked by the probe actually running rather than by a comment. struct Saved { - unsigned long x[12]; // x19 .. x30 (x29 = frame pointer, x30 = link) - unsigned long sp; + arch_u64 x[12]; // x19 .. x30 (x29 = frame pointer, x30 = link) + arch_u64 sp; }; static_assert(sizeof(Saved) == 13 * 8, @@ -43,7 +50,7 @@ extern "C" void arch_context_init(void* ctx, void (*entry)(void*), void* arg, // kernel computing `base + size` has no reason to know this architecture's // alignment, and an unaligned SP faults in a way that points nowhere near // the cause. - auto top = reinterpret_cast(stack_top) & ~15UL; + auto top = reinterpret_cast(stack_top) & ~15UL; for (int i = 0; i < 12; ++i) s->x[i] = 0; @@ -52,8 +59,8 @@ extern "C" void arch_context_init(void* ctx, void (*entry)(void*), void* arg, // argument register — x0 holds `&from` on the way in. Callee-saved // registers are the only ones that survive, which is why the trampoline // reads them rather than being handed parameters. - s->x[kX19] = reinterpret_cast(entry); - s->x[kX20] = reinterpret_cast(arg); - s->x[kX30] = reinterpret_cast(&arch_context_entry); + s->x[kX19] = reinterpret_cast(entry); + s->x[kX20] = reinterpret_cast(arg); + s->x[kX30] = reinterpret_cast(&arch_context_entry); s->sp = top; } diff --git a/backends/aarch64/src/pte_impl.cpp b/backends/aarch64/src/pte_impl.cpp index 05dfaa0..6b324c4 100644 --- a/backends/aarch64/src/pte_impl.cpp +++ b/backends/aarch64/src/pte_impl.cpp @@ -3,13 +3,13 @@ #include -extern "C" unsigned long long arch_pte_make_leaf(unsigned long long phys, +extern "C" arch_u64 arch_pte_make_leaf(arch_u64 phys, int perm, int mt, int user) { return arch::aarch64::encode_leaf(phys, perm, mt, user != 0); } -extern "C" int arch_pte_valid(unsigned long long bits) { return arch::aarch64::entry_valid(bits) ? 1 : 0; } -extern "C" unsigned long long arch_pte_phys(unsigned long long bits) { return arch::aarch64::entry_phys(bits); } +extern "C" int arch_pte_valid(arch_u64 bits) { return arch::aarch64::entry_valid(bits) ? 1 : 0; } +extern "C" arch_u64 arch_pte_phys(arch_u64 bits) { return arch::aarch64::entry_phys(bits); } // The canonical MAIR_EL1 layout the encoder's AttrIndx values index into. // @@ -29,7 +29,7 @@ extern "C" unsigned long long arch_pte_phys(unsigned long long bits) { return ar // mappings installed after boot may be walked against the reset value of the // register, which is architecturally UNKNOWN. extern "C" void arch_pte_install_memory_attributes(void) { - constexpr unsigned long long kMair = 0x00FFUL; + constexpr arch_u64 kMair = 0x00FFUL; asm volatile("msr mair_el1, %0\n\tisb" :: "r"(kMair) : "memory"); } diff --git a/backends/aarch64/src/trap_impl.cpp b/backends/aarch64/src/trap_impl.cpp index b475a28..bdcea8b 100644 --- a/backends/aarch64/src/trap_impl.cpp +++ b/backends/aarch64/src/trap_impl.cpp @@ -1,37 +1,42 @@ // openarch.trap — the aarch64 backend. +// ⚠️ `openarch/abi.h` AT THE TOP, NOT BELOW THE HELPERS. The register readers +// under it are written in `arch_u64`, and until 0.4.0 they were written in a +// builtin type that needed no header — so the include sat wherever it was first +// needed, which was after them. +#include + namespace { // ESR_EL1's exception class occupies bits [31:26]. -constexpr unsigned long long kEcShift = 26; -constexpr unsigned long long kEcMask = 0x3FULL; - -constexpr unsigned long long kEcUnknown = 0x00; -constexpr unsigned long long kEcIllegalState = 0x0E; -constexpr unsigned long long kEcPcAlignment = 0x22; -constexpr unsigned long long kEcSpAlignment = 0x26; -constexpr unsigned long long kEcInstrAbortLo = 0x20; -constexpr unsigned long long kEcInstrAbortEq = 0x21; -constexpr unsigned long long kEcDataAbortLo = 0x24; -constexpr unsigned long long kEcDataAbortEq = 0x25; -constexpr unsigned long long kEcBrk = 0x3C; - -inline unsigned long long read_esr() noexcept { - unsigned long long v; asm volatile("mrs %0, esr_el1" : "=r"(v)); return v; +constexpr arch_u64 kEcShift = 26; +constexpr arch_u64 kEcMask = 0x3FULL; + +constexpr arch_u64 kEcUnknown = 0x00; +constexpr arch_u64 kEcIllegalState = 0x0E; +constexpr arch_u64 kEcPcAlignment = 0x22; +constexpr arch_u64 kEcSpAlignment = 0x26; +constexpr arch_u64 kEcInstrAbortLo = 0x20; +constexpr arch_u64 kEcInstrAbortEq = 0x21; +constexpr arch_u64 kEcDataAbortLo = 0x24; +constexpr arch_u64 kEcDataAbortEq = 0x25; +constexpr arch_u64 kEcBrk = 0x3C; + +inline arch_u64 read_esr() noexcept { + arch_u64 v; asm volatile("mrs %0, esr_el1" : "=r"(v)); return v; } -inline unsigned long long read_elr() noexcept { - unsigned long long v; asm volatile("mrs %0, elr_el1" : "=r"(v)); return v; +inline arch_u64 read_elr() noexcept { + arch_u64 v; asm volatile("mrs %0, elr_el1" : "=r"(v)); return v; } -inline void write_elr(unsigned long long v) noexcept { +inline void write_elr(arch_u64 v) noexcept { asm volatile("msr elr_el1, %0" :: "r"(v)); } -inline unsigned long long read_far() noexcept { - unsigned long long v; asm volatile("mrs %0, far_el1" : "=r"(v)); return v; +inline arch_u64 read_far() noexcept { + arch_u64 v; asm volatile("mrs %0, far_el1" : "=r"(v)); return v; } } // namespace -#include extern "C" { extern unsigned char arch_vector_table[]; // the 2 KiB table in trap.S @@ -49,7 +54,7 @@ arch_trap_handler_fn g_handler = nullptr; // synchronous exception actually was. A backend that read only `ESR_EL1` would // classify every interrupt as whatever `ESR_EL1` happened to hold from the last // synchronous trap, which is the kind of defect that works until it does not. -int classify(unsigned long long slot, unsigned long long esr) noexcept { +int classify(arch_u64 slot, arch_u64 esr) noexcept { switch (slot & 3ULL) { case 1: case 2: return 4; // IRQ, FIQ case 3: return 5; // SError: asynchronous, and not @@ -73,7 +78,7 @@ int classify(unsigned long long slot, unsigned long long esr) noexcept { // Called by the common path in trap.S. `slot` is the vector index the hardware // selected, which no register records. extern "C" void arch_trap_dispatch(arch_trap_frame* f, - unsigned long long slot) { + arch_u64 slot) { static_assert(sizeof(arch_trap_frame) == 32, "trap.S reserves 32 bytes above the saved registers"); @@ -101,7 +106,7 @@ extern "C" void arch_trap_dispatch(arch_trap_frame* f, extern "C" arch_trap_handler_fn arch_trap_set_handler(arch_trap_handler_fn h) { const auto prev = g_handler; g_handler = h; - const auto base = reinterpret_cast(arch_vector_table); + const auto base = reinterpret_cast(arch_vector_table); // ⚠️ `isb` after the write. The barrier is what makes the new table apply // to exceptions that follow; without it an exception taken immediately // afterwards may still use the previous base, which at boot is architecturally @@ -120,7 +125,7 @@ extern "C" void arch_trap_enable_interrupts(int on) { } extern "C" int arch_trap_interrupts_enabled(void) { - unsigned long long v; + arch_u64 v; asm volatile("mrs %0, daif" : "=r"(v)); return (v & (1ULL << 7)) == 0 ? 1 : 0; // I bit clear means enabled } diff --git a/backends/riscv64/mcpp.toml b/backends/riscv64/mcpp.toml index 59ed31a..ea6ec61 100644 --- a/backends/riscv64/mcpp.toml +++ b/backends/riscv64/mcpp.toml @@ -1,9 +1,9 @@ # openarch's riscv64 backend: instructions, and nothing else. # # ⚠️ THIS PACKAGE EXPORTS NO MODULE. It defines the C entry points declared in -# `spec/include/openarch/abi.h` and is reached only through them. A consumer -# never names it — `spec/mcpp.toml` pulls it in through a `cfg` dependency on -# the target's architecture. +# `abi/include/openarch/abi.h` and is reached only through them. A consumer +# never names it: the root manifest pulls it in through the `backend` feature, +# whose target-conditional `feature-deps` row selects this one. # # The separation is what lets a second implementation of the same ABI exist # without touching the specification: a vendor's assembler file, a simulator's @@ -14,12 +14,18 @@ [package] namespace = "mcpplibs" name = "openarch-riscv64" -version = "0.3.1" +version = "0.4.0" description = "openarch's riscv64 backend: the instructions behind the ABI" license = "Apache-2.0" authors = ["mcpplibs"] repo = "https://github.com/mcpplibs/openarch" +# ⭐ THE CAPABILITY, NOT THE PACKAGE NAME, IS WHAT A CONSUMER BINDS TO. The root +# package's `backend` feature requires `openarch-backend` and names nobody; the +# resolver binds the one provider in the graph. A consumer's own implementation +# of `openarch/abi.h` declares the same line and is selected the same way. +provides = ["openarch-backend"] + [build] sources = ["src/**"] diff --git a/backends/riscv64/src/context_init.cpp b/backends/riscv64/src/context_init.cpp index bed0175..8f3db1e 100644 --- a/backends/riscv64/src/context_init.cpp +++ b/backends/riscv64/src/context_init.cpp @@ -6,6 +6,13 @@ // and once in the assembly that consumes it, with a static assertion binding // the two. +// ⚠️ `openarch/types.h` AND NOT `openarch/abi.h`. This file implements a +// function the ABI declares, and it needs the ABI's WIDTHS rather than its +// declarations — the register-file mirror below is this backend's own layout, +// not something the contract names. Including the contract would compile and +// would state a dependency that is not there. +#include + extern "C" void arch_context_entry(); // the trampoline in context.S namespace { @@ -15,9 +22,9 @@ namespace { // catches a size disagreement, and the field order is checked by the probe // actually running rather than by a comment. struct Saved { - unsigned long ra; - unsigned long sp; - unsigned long s[12]; // s0-s11 + arch_u64 ra; + arch_u64 sp; + arch_u64 s[12]; // s0-s11 }; static_assert(sizeof(Saved) == 14 * 8, @@ -36,15 +43,15 @@ extern "C" void arch_context_init(void* ctx, void (*entry)(void*), void* arg, // to do it: a kernel computing `base + size` has no reason to know this // architecture's alignment, and an unaligned stack fails in a way that // points nowhere near the cause. - auto top = reinterpret_cast(stack_top) & ~15UL; + auto top = reinterpret_cast(stack_top) & ~15UL; - s->ra = reinterpret_cast(&arch_context_entry); + s->ra = reinterpret_cast(&arch_context_entry); s->sp = top; // s1 and s2 carry the entry point and its argument through the switch. // ⚠️ Not a0/a1: those are argument registers, and the switch does not // restore them — a0 holds `&from` on the way in. s->s[0] = 0; // s0 (frame ptr) - s->s[1] = reinterpret_cast(entry); // s1 - s->s[2] = reinterpret_cast(arg); // s2 + s->s[1] = reinterpret_cast(entry); // s1 + s->s[2] = reinterpret_cast(arg); // s2 for (int i = 3; i < 12; ++i) s->s[i] = 0; } diff --git a/backends/riscv64/src/pte_impl.cpp b/backends/riscv64/src/pte_impl.cpp index 2d32d79..8691278 100644 --- a/backends/riscv64/src/pte_impl.cpp +++ b/backends/riscv64/src/pte_impl.cpp @@ -6,13 +6,13 @@ #include -extern "C" unsigned long long arch_pte_make_leaf(unsigned long long phys, +extern "C" arch_u64 arch_pte_make_leaf(arch_u64 phys, int perm, int mt, int user) { return arch::riscv64::encode_leaf(phys, perm, mt, user != 0); } -extern "C" int arch_pte_valid(unsigned long long bits) { return arch::riscv64::entry_valid(bits) ? 1 : 0; } -extern "C" unsigned long long arch_pte_phys(unsigned long long bits) { return arch::riscv64::entry_phys(bits); } +extern "C" int arch_pte_valid(arch_u64 bits) { return arch::riscv64::entry_valid(bits) ? 1 : 0; } +extern "C" arch_u64 arch_pte_phys(arch_u64 bits) { return arch::riscv64::entry_phys(bits); } // ⚠️ EMPTY, AND CORRECTLY SO RATHER THAN AS A STUB. // diff --git a/backends/riscv64/src/trap_impl.cpp b/backends/riscv64/src/trap_impl.cpp index a849fad..2a23701 100644 --- a/backends/riscv64/src/trap_impl.cpp +++ b/backends/riscv64/src/trap_impl.cpp @@ -9,38 +9,43 @@ // disagreement about the size would corrupt the saved `a0` immediately below // it, which is the register the handler's argument arrives in — a fault whose // symptom is arbitrarily far from its cause. +// ⚠️ `openarch/abi.h` AT THE TOP, NOT BELOW THE HELPERS. The register readers +// under it are written in `arch_u64`, and until 0.4.0 they were written in a +// builtin type that needed no header — so the include sat wherever it was first +// needed, which was after them. +#include + namespace { -constexpr unsigned long long kCauseInterrupt = 1ULL << 63; +constexpr arch_u64 kCauseInterrupt = 1ULL << 63; // The exception codes this backend maps. Everything else reaches `other` with // its numeric cause intact, which is the honest answer for a machine-specific // event rather than a guess at the nearest portable name. -constexpr unsigned long long kInstrMisaligned = 0; -constexpr unsigned long long kIllegalInstr = 2; -constexpr unsigned long long kBreakpoint = 3; -constexpr unsigned long long kLoadMisaligned = 4; -constexpr unsigned long long kStoreMisaligned = 6; -constexpr unsigned long long kInstrPageFault = 12; -constexpr unsigned long long kLoadPageFault = 13; -constexpr unsigned long long kStorePageFault = 15; - -inline unsigned long long read_mcause() noexcept { - unsigned long long v; asm volatile("csrr %0, mcause" : "=r"(v)); return v; +constexpr arch_u64 kInstrMisaligned = 0; +constexpr arch_u64 kIllegalInstr = 2; +constexpr arch_u64 kBreakpoint = 3; +constexpr arch_u64 kLoadMisaligned = 4; +constexpr arch_u64 kStoreMisaligned = 6; +constexpr arch_u64 kInstrPageFault = 12; +constexpr arch_u64 kLoadPageFault = 13; +constexpr arch_u64 kStorePageFault = 15; + +inline arch_u64 read_mcause() noexcept { + arch_u64 v; asm volatile("csrr %0, mcause" : "=r"(v)); return v; } -inline unsigned long long read_mepc() noexcept { - unsigned long long v; asm volatile("csrr %0, mepc" : "=r"(v)); return v; +inline arch_u64 read_mepc() noexcept { + arch_u64 v; asm volatile("csrr %0, mepc" : "=r"(v)); return v; } -inline void write_mepc(unsigned long long v) noexcept { +inline void write_mepc(arch_u64 v) noexcept { asm volatile("csrw mepc, %0" :: "r"(v)); } -inline unsigned long long read_mtval() noexcept { - unsigned long long v; asm volatile("csrr %0, mtval" : "=r"(v)); return v; +inline arch_u64 read_mtval() noexcept { + arch_u64 v; asm volatile("csrr %0, mtval" : "=r"(v)); return v; } } // namespace -#include extern "C" void arch_trap_entry(); // the stub in trap.S @@ -48,7 +53,7 @@ namespace { arch_trap_handler_fn g_handler = nullptr; -int classify(unsigned long long cause) noexcept { +int classify(arch_u64 cause) noexcept { if (cause & kCauseInterrupt) return 4; switch (cause) { case kBreakpoint: return 0; @@ -106,19 +111,19 @@ extern "C" arch_trap_handler_fn arch_trap_set_handler(arch_trap_handler_fn h) { // vectored mode would scale interrupt causes into separate entries — which // is the aarch64 arrangement, and is exactly what this interface hides. One // entry point on both machines is what makes `handler` mean one thing. - const auto vec = reinterpret_cast(&arch_trap_entry); + const auto vec = reinterpret_cast(&arch_trap_entry); asm volatile("csrw mtvec, %0" :: "r"(vec & ~3ULL) : "memory"); return prev; } extern "C" void arch_trap_enable_interrupts(int on) { - constexpr unsigned long long kMie = 1ULL << 3; // mstatus.MIE + constexpr arch_u64 kMie = 1ULL << 3; // mstatus.MIE if (on) asm volatile("csrs mstatus, %0" :: "r"(kMie) : "memory"); else asm volatile("csrc mstatus, %0" :: "r"(kMie) : "memory"); } extern "C" int arch_trap_interrupts_enabled(void) { - unsigned long long v; + arch_u64 v; asm volatile("csrr %0, mstatus" : "=r"(v)); return (v & (1ULL << 3)) != 0 ? 1 : 0; } diff --git a/backends/x86_64/mcpp.toml b/backends/x86_64/mcpp.toml new file mode 100644 index 0000000..e801bdd --- /dev/null +++ b/backends/x86_64/mcpp.toml @@ -0,0 +1,44 @@ +# openarch's x86_64 backend: instructions, and nothing else. +# +# ⚠️ THIS PACKAGE EXPORTS NO MODULE. It defines the C entry points declared in +# `abi/include/openarch/abi.h` and is reached only through them. A consumer +# never names it: the root manifest pulls it in through the `backend` feature, +# whose target-conditional `feature-deps` row selects this one. +# +# ⚠️ THIS BACKEND ASSUMES LONG MODE IS ALREADY ENTERED, AND THAT ASSUMPTION IS +# LARGER HERE THAN ON THE OTHER TWO MACHINES. +# +# riscv64 and aarch64 begin executing 64-bit instructions at reset, so a backend +# for them starts from the machine's own initial state. x86_64 begins in 16-bit +# real mode, and reaching the state this code runs in requires a GDT, a page +# table, and a documented sequence of writes to `CR0`, `CR4` and `EFER`. None of +# that is here, for the same reason no linker script is here: it is a property +# of how a particular image is loaded, and a multiboot loader, a UEFI stub and a +# `-kernel` image reach long mode by three different routes. +# +# What this backend requires on entry: long mode, a valid `GDT` with a 64-bit +# code segment selected by `CS`, and a stack. It reads `CS` rather than assuming +# a selector value, so the loader's choice of GDT layout is its own. +[package] +namespace = "mcpplibs" +name = "openarch-x86-64" +version = "0.4.0" +description = "openarch's x86_64 backend: the instructions behind the ABI" +license = "Apache-2.0" +authors = ["mcpplibs"] +repo = "https://github.com/mcpplibs/openarch" + +# ⭐ THE CAPABILITY, NOT THE PACKAGE NAME, IS WHAT A CONSUMER BINDS TO. The root +# package's `backend` feature requires `openarch-backend` and names nobody; the +# resolver binds the one provider in the graph. A consumer's own implementation +# of `openarch/abi.h` declares the same line and is selected the same way. +provides = ["openarch-backend"] + +[build] +sources = ["src/**"] + +[targets.openarch-x86-64] +kind = "lib" + +[dependencies] +openarch-abi = { path = "../../abi" } diff --git a/backends/x86_64/src/context.S b/backends/x86_64/src/context.S new file mode 100644 index 0000000..97ca703 --- /dev/null +++ b/backends/x86_64/src/context.S @@ -0,0 +1,79 @@ +/* arch_context_switch — System V AMD64. + * + * ⚠️ THE CALLEE-SAVED SET IS SIX REGISTERS AND A STACK POINTER, WHICH IS THE + * SMALLEST OF THE THREE MACHINES BY A WIDE MARGIN. + * + * riscv64 saves fourteen doublewords, aarch64 thirteen, x86_64 seven. That is + * not this backend being clever: the System V AMD64 ABI names only `rbx`, + * `rbp` and `r12`-`r15` callee-saved, because the architecture has sixteen + * general registers rather than thirty-two and the convention spends most of + * them on arguments and scratch. + * + * The consequence for the interface is that `arch::context`'s 128 bytes are + * generous here and tight on riscv — which is the right way round, and is + * checked by a static assertion in context_init.cpp rather than assumed. + * + * Arguments: `from` in `rdi`, `to` in `rsi`. + * + * ⚠️ THE RETURN ADDRESS IS ON THE STACK, NOT IN A REGISTER, AND THAT CHANGES + * WHAT A CONTEXT IS. On both other machines the saved `ra`/`x30` holds where to + * resume; here `call` has already pushed it, so saving `rsp` saves the + * resumption point with it. Switching therefore restores one fewer field and + * ends in `ret` rather than a jump — and `arch_context_init` must PLANT a + * return address on the new stack rather than store one in the context. + */ + .section .text.arch_context_switch,"ax",@progbits + .globl arch_context_switch + .type arch_context_switch, @function + .balign 16 + +arch_context_switch: + movq %rbx, 0*8(%rdi) + movq %rbp, 1*8(%rdi) + movq %r12, 2*8(%rdi) + movq %r13, 3*8(%rdi) + movq %r14, 4*8(%rdi) + movq %r15, 5*8(%rdi) + movq %rsp, 6*8(%rdi) + + movq 0*8(%rsi), %rbx + movq 1*8(%rsi), %rbp + movq 2*8(%rsi), %r12 + movq 3*8(%rsi), %r13 + movq 4*8(%rsi), %r14 + movq 5*8(%rsi), %r15 + movq 6*8(%rsi), %rsp + + /* Returns through the resumed context's stack, which is where that context + * was when it called here — or, for a context that has never run, the + * trampoline address `arch_context_init` planted. */ + ret + .size arch_context_switch, . - arch_context_switch + +/* The trampoline a freshly initialised context returns into. + * + * `arch_context_init` leaves `entry` in the slot restored to `r12` and `arg` in + * the slot restored to `r13`, so by the time this runs both are in registers + * the switch has just loaded. + * + * ⚠️ THE STACK MUST BE 16-BYTE ALIGNED AT THE `call`, NOT AT THE ENTRY. The + * System V ABI requires `rsp + 8` to be 16-aligned on entry to a function, + * because the `call` has pushed eight bytes. `arch_context_init` arranges the + * initial stack so that this holds here; a `jmp` rather than a `call` preserves + * it, and also matches the other two backends' tail call. */ + .section .text.arch_context_entry,"ax",@progbits + .globl arch_context_entry + .type arch_context_entry, @function + .balign 16 +arch_context_entry: + movq %r13, %rdi /* arg */ + jmpq *%r12 /* entry(arg) — a tail call, so there is no frame + * and no return address for `entry` to use. The + * interface says `entry` must not return. */ + .size arch_context_entry, . - arch_context_entry + +/* ⚠️ Marks the object as not requiring an executable stack. Without it lld + * emits `missing .note.GNU-stack section implies executable stack` for every + * hand-written assembler file, which on a bare-metal link is noise that hides + * real diagnostics. The other two backends carry the same note. */ + .section .note.GNU-stack,"",@progbits diff --git a/backends/x86_64/src/context_init.cpp b/backends/x86_64/src/context_init.cpp new file mode 100644 index 0000000..779d56f --- /dev/null +++ b/backends/x86_64/src/context_init.cpp @@ -0,0 +1,86 @@ +// arch_context_init — x86_64. +// +// ⚠️ THIS BACKEND IS THE ONLY ONE THAT WRITES TO THE NEW CONTEXT'S STACK, AND +// THE REASON IS ARCHITECTURAL RATHER THAN STYLISTIC. +// +// riscv64 and aarch64 hold the return address in a register (`ra`, `x30`), so +// preparing a context that has never run means storing the trampoline's address +// into that register's slot. x86_64 holds it on the stack, and `ret` reads it +// from there — so the trampoline address has to be PLANTED at the top of the +// new stack, and the saved `rsp` has to point at it. +// +// The consequence is that a context here is not self-contained: it refers to a +// stack that must already exist and must remain valid. That is true on the +// other two machines as well, but only here is it true before the context has +// executed a single instruction. + +// ⚠️ `openarch/types.h` AND NOT `openarch/abi.h`. This file implements a +// function the ABI declares, and it needs the ABI's WIDTHS rather than its +// declarations — the register-file mirror below is this backend's own layout, +// not something the contract names. Including the contract would compile and +// would state a dependency that is not there. +#include + +extern "C" void arch_context_entry(); // the trampoline in context.S + +namespace { + +struct Saved { + arch_u64 rbx; + arch_u64 rbp; + arch_u64 r12; // entry + arch_u64 r13; // arg + arch_u64 r14; + arch_u64 r15; + arch_u64 rsp; +}; + +static_assert(sizeof(Saved) == 7 * 8, + "context.S stores seven quadwords"); +static_assert(sizeof(Saved) <= 128, + "arch::context reserves 128 bytes; x86_64 needs 56"); + +} // namespace + +extern "C" void arch_context_init(void* ctx, void (*entry)(void*), void* arg, + void* stack_top) noexcept { + auto* s = static_cast(ctx); + + auto top = reinterpret_cast(stack_top) & ~15ULL; + + // ⚠️ TWO SLOTS, NOT ONE, AND THE SECOND IS WHAT MAKES THE ALIGNMENT RIGHT. + // + // The System V AMD64 ABI states the requirement at the CALL, not at the + // entry: `rsp` is 16-byte aligned when `call` executes, so a function + // begins with `rsp ≡ 8 (mod 16)` — the return address `call` pushed. Code + // the compiler generates aligns its own frame from that assumption, and a + // frame built from the wrong residue faults on the first 16-byte spill and + // is silently misaligned otherwise. + // + // The chain here is: `arch_context_switch` restores the saved `rsp` and + // executes `ret`, which pops eight bytes. So for `arch_context_entry` — and + // therefore for `entry`, which it reaches by `jmp` — to begin at `≡ 8`, the + // saved `rsp` must be 16-ALIGNED, not eight below the top. + // + // One slot would leave `rsp ≡ 0` at `entry`. That is the arrangement the + // other two backends have, because on riscv64 and aarch64 the ABI requires + // the stack pointer to be 16-aligned AT function entry: there is no pushed + // return address, so the residue is zero and porting this by analogy gives + // exactly the wrong answer. + // + // The lower slot holds the trampoline's address; the upper holds zero, + // which is where `arch_context_entry` would return to if the interface + // permitted `entry` to return. It does not, and a stack walker reading zero + // stops rather than following whatever was there. + auto* stack = reinterpret_cast(top); + *--stack = 0; + *--stack = reinterpret_cast(&arch_context_entry); + + s->rbx = 0; + s->rbp = 0; // frame chain ends here + s->r12 = reinterpret_cast(entry); + s->r13 = reinterpret_cast(arg); + s->r14 = 0; + s->r15 = 0; + s->rsp = reinterpret_cast(stack); +} diff --git a/backends/x86_64/src/cpu_impl.cpp b/backends/x86_64/src/cpu_impl.cpp new file mode 100644 index 0000000..d5f36f3 --- /dev/null +++ b/backends/x86_64/src/cpu_impl.cpp @@ -0,0 +1,109 @@ +// The per-CPU pointer and the barriers, on x86_64. +// +// ⭐ THIS FILE IS WHERE THE THIRD MACHINE PAYS FOR THE BARRIER INTERFACE, AND +// WHAT IT PAYS IS ALMOST NOTHING — WHICH IS THE INTERESTING RESULT. +// +// riscv has one `fence` instruction over a cross-product of sets; aarch64 has +// three instructions over a shareability domain. Both are weakly ordered, so +// every one of `openarch.cpu`'s four barriers costs an instruction on both. +// +// x86_64 is total-store-order: loads are not reordered with loads, stores are +// not reordered with stores, and a store is not reordered with an older load. +// The only reordering the model permits is a load moving ahead of an older +// store to a different address. So three of the four barriers need no +// instruction at all — a compiler barrier is the whole of `memory` and `store` +// — and only the `complete` case, which must also order across device memory +// and serialise, needs `mfence`. +// +// ⚠️ THAT MAKES THE INTERFACE'S NAMES LOAD-BEARING RATHER THAN DECORATIVE. If +// `barrier` had been spelled the way either RISC machine spells it — as an +// instruction to emit — this backend would either emit instructions it does not +// need or would quietly ignore requests. Because the names say what is +// GUARANTEED, "guaranteed by the memory model" is a legitimate implementation +// and the interface survives a machine that was not consulted when it was +// designed. That is the only evidence available that a two-machine abstraction +// was an abstraction. +#include + +namespace { + +// `IA32_GS_BASE`. The processor keeps the `gs` segment's 64-bit base here, and +// nothing in the architecture reads it: it is the register x86_64 has that +// corresponds to riscv's `tp` and aarch64's `TPIDR_EL1`. +// +// ⚠️ `IA32_KERNEL_GS_BASE` (0xC0000102) IS A DIFFERENT REGISTER AND IS THE +// WRONG ONE HERE. It holds the value `swapgs` will exchange in, which is the +// mechanism for a kernel entered from user mode; a kernel that has not executed +// `swapgs` reads its own pointer from `IA32_GS_BASE`. Using the other one +// produces a per-CPU pointer that is correct only after a syscall. +constexpr unsigned kIa32GsBase = 0xC0000101u; + +inline arch_u64 rdmsr(unsigned msr) noexcept { + unsigned lo, hi; + asm volatile("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr)); + return (static_cast(hi) << 32) | lo; +} + +inline void wrmsr(unsigned msr, arch_u64 v) noexcept { + asm volatile("wrmsr" :: + "c"(msr), + "a"(static_cast(v)), + "d"(static_cast(v >> 32))); +} + +} // namespace + +extern "C" void* arch_cpu_percpu(void) { + return reinterpret_cast(rdmsr(kIa32GsBase)); +} + +extern "C" void arch_cpu_set_percpu(void* p) { + wrmsr(kIa32GsBase, reinterpret_cast(p)); +} + +extern "C" void arch_cpu_fence(int b) { + switch (b) { + // ⚠️ A COMPILER BARRIER IS NOT "NOTHING", AND OMITTING IT WOULD MAKE + // THIS THE ONE BACKEND THAT DOES NOT WORK. The processor will not + // reorder these; the compiler will, and the `"memory"` clobber is what + // stops it. A backend that emitted no instruction AND no clobber would + // satisfy the architecture and break the program. + case ARCH_BARRIER_MEMORY: asm volatile("" ::: "memory"); break; + case ARCH_BARRIER_STORE: asm volatile("" ::: "memory"); break; + + // The one case the memory model does not cover. `mfence` orders every + // load and store before it against every one after, including accesses + // to write-combining and uncacheable memory, which is what a caller + // touching a device register or changing a translation needs. + case ARCH_BARRIER_COMPLETE: asm volatile("mfence" ::: "memory"); break; + + // ⚠️ `cpuid` AND NOT `lfence`, AND THE DIFFERENCE IS THE WHOLE QUESTION + // THIS BARRIER ASKS. + // + // `lfence` orders loads. It is not architecturally a serialising + // instruction, so it does not guarantee that instruction FETCH sees + // stores that preceded it — which is exactly what `fetch` promises, and + // what riscv spells `fence.i` and aarch64 spells `isb`. + // + // `cpuid` is serialising by definition and is the sequence Intel's own + // manual gives for self-modifying code across processors. + // + // ⚠️ IT IS WRITTEN WITH OPERANDS RATHER THAN AS A BARE CLOBBER LIST. + // `cpuid` READS `eax` to choose which leaf to report, so an `asm` that + // only declares it clobbered executes the instruction with whatever + // happened to be in the register — a different leaf on every call, and + // on some processors one that faults. Leaf 0 is defined on every + // x86_64. The four outputs are named so the compiler knows the values + // are gone rather than being told to preserve registers it cannot. + case ARCH_BARRIER_FETCH: { + unsigned a, b_, c, d; + asm volatile("cpuid" + : "=a"(a), "=b"(b_), "=c"(c), "=d"(d) + : "a"(0) + : "memory"); + break; + } + + default: break; + } +} diff --git a/backends/x86_64/src/pte_impl.cpp b/backends/x86_64/src/pte_impl.cpp new file mode 100644 index 0000000..e2e3da1 --- /dev/null +++ b/backends/x86_64/src/pte_impl.cpp @@ -0,0 +1,110 @@ +// The page-table entry, on x86_64. +// +// The encoding is in `openarch/pte_encode.h`, header-only and testable on any +// host. What is here is the part that cannot be: the writes to the registers +// that give the encoding its meaning. +#include +#include + +extern "C" arch_u64 arch_pte_make_leaf(arch_u64 phys, + int perm, int mt, int user) { + return arch::x86_64::encode_leaf(phys, perm, mt, user != 0); +} + +extern "C" int arch_pte_valid(arch_u64 bits) { + return arch::x86_64::entry_valid(bits) ? 1 : 0; +} + +extern "C" arch_u64 arch_pte_phys(arch_u64 bits) { + return arch::x86_64::entry_phys(bits); +} + +namespace { + +constexpr unsigned kIa32Pat = 0x277u; + +// ⭐ THE LAYOUT THE ENCODER'S INDICES INDEX INTO. +// +// entry 0 0x06 write-back — `memory_type::normal` +// entry 1 0x00 uncacheable — `memory_type::device` +// entry 2 0x01 write-combining — not offered by the interface; present so +// that a kernel needing it has an index +// rather than having to reprogram the +// register underneath this layer +// entry 3 0x04 write-through +// +// Entries 4-7 repeat 0-3. Eight distinct types are available and four are +// enough; repeating rather than leaving them zero means an entry built with a +// stray `PAT` bit set lands on the same memory type as the one intended, rather +// than on uncacheable memory a page at a time. +// +// ⚠️ THIS MUST RUN BEFORE ANY MAPPING BUILT BY `make_leaf` IS USED, and the +// requirement is sharper than aarch64's. There, an unprogrammed `MAIR_EL1` +// field is zero and reads as Device-nGnRnE — the strictest type — so a +// too-early mapping is slow and correct. Here, `IA32_PAT`'s reset value has +// write-through at index 1, so a device mapping used before this call is +// CACHED-ish rather than uncached: writes reach the device eventually, at a +// time the program did not choose, and nothing faults. +constexpr arch_u64 kPatLayout = + (0x06ULL << 0) | (0x00ULL << 8) | (0x01ULL << 16) | (0x04ULL << 24) | + (0x06ULL << 32) | (0x00ULL << 40) | (0x01ULL << 48) | (0x04ULL << 56); + +constexpr arch_u64 kCr4Smep = 1ULL << 20; +constexpr arch_u64 kCr4Smap = 1ULL << 21; + +inline void wrmsr(unsigned msr, arch_u64 v) noexcept { + asm volatile("wrmsr" :: + "c"(msr), + "a"(static_cast(v)), + "d"(static_cast(v >> 32))); +} + +} // namespace + +extern "C" void arch_pte_install_memory_attributes(void) { + wrmsr(kIa32Pat, kPatLayout); + + // ⚠️ SMEP IS PART OF THIS CALL BECAUSE THE ENCODER CANNOT EXPRESS WHAT THE + // OTHER TWO MACHINES EXPRESS IN THE ENTRY. + // + // riscv qualifies its `X` bit by `U`, and aarch64 has separate `PXN` and + // `UXN` bits, so on both of them "a user mapping is not kernel-executable" + // is a property of the entry. x86_64 has one `NX` bit covering every + // privilege level, so the same rule has to come from `CR4.SMEP`, which + // faults when privileged code fetches an instruction from a user page. + // + // Putting it here rather than leaving it to the kernel is the same decision + // `MAIR_EL1` was: the interface promises that `perm` means the same thing + // on every machine, and on this one it does not unless this bit is set. + // + // ⚠️ SET IF SUPPORTED, RATHER THAN SET UNCONDITIONALLY. Writing a reserved + // `CR4` bit raises #GP, so a processor without SMEP would fault inside a + // function whose contract is that it always succeeds. `cpuid` leaf 7, + // sub-leaf 0 reports SMEP in `ebx` bit 7 and SMAP in bit 20. + unsigned max_leaf; + { + unsigned b, c, d; + asm volatile("cpuid" : "=a"(max_leaf), "=b"(b), "=c"(c), "=d"(d) : "a"(0)); + } + if (max_leaf < 7) return; + + unsigned features; + { + unsigned a, c, d; + asm volatile("cpuid" + : "=a"(a), "=b"(features), "=c"(c), "=d"(d) + : "a"(7), "c"(0)); + } + + arch_u64 cr4; + asm volatile("movq %%cr4, %0" : "=r"(cr4)); + if (features & (1u << 7)) cr4 |= kCr4Smep; + // ⚠️ SMAP IS NOT SET, AND LEAVING IT OFF IS DELIBERATE. It faults when + // privileged code READS a user page, which a kernel does on purpose — every + // system call that copies an argument. Enabling it here would break callers + // that have not been written to bracket those accesses with `stac`/`clac`, + // and this layer has no way to know whether they have. The bit is named so + // that a kernel enabling it does not have to rediscover which one it is. + (void)kCr4Smap; + asm volatile("movq %0, %%cr4" :: "r"(cr4) : "memory"); +} diff --git a/backends/x86_64/src/trap.S b/backends/x86_64/src/trap.S new file mode 100644 index 0000000..42f5a44 --- /dev/null +++ b/backends/x86_64/src/trap.S @@ -0,0 +1,176 @@ +/* The trap entry, on x86_64. + * + * ⭐ TWO HUNDRED AND FIFTY-SIX ENTRY POINTS, AND THE MACHINE FORCES ALL OF + * THEM. + * + * riscv has one entry: `stvec` holds an address and `scause` says why. aarch64 + * has sixteen: a 2 KiB table of 128-byte slots selected by exception class. This + * machine has 256 — the IDT — and the vector number is the ONLY place the + * reason is recorded. There is no cause register to read, so a single shared + * stub cannot know what it was called for; each vector must arrive at a + * different instruction and say so. + * + * The stubs below are therefore generated rather than written, and a table of + * their addresses is exported so the IDT builder does not have to depend on + * them being a fixed size. + * + * ⚠️ TEN VECTORS PUSH AN ERROR CODE AND THE OTHER 246 DO NOT, AND THE FRAME + * MUST BE THE SAME SHAPE EITHER WAY. A stub for a vector without one pushes a + * zero in its place. Without that, `arch_trap_common` would find the interrupt + * frame at two different offsets and would read `RIP` out of `CS` for half the + * exception table — with no diagnostic, because both are plausible values. + * + * The vectors that push one: 8, 10, 11, 12, 13, 14, 17, 21, 29, 30. + */ + + .section .text.arch_trap_stubs,"ax",@progbits + .balign 16 + +/* Every stub reaches here with the stack holding, from `rsp` upward: + * + * 0 vector pushed by the stub + * 8 error code pushed by the processor or by the stub + * 16 RIP ─┐ + * 24 CS │ + * 32 RFLAGS ├ pushed by the processor + * 40 RSP │ + * 48 SS ─┘ + * + * ⚠️ `RSP` AND `SS` ARE ALWAYS PUSHED IN 64-BIT MODE, EVEN WITHOUT A PRIVILEGE + * CHANGE. In 32-bit protected mode they are pushed only on a change of ring, + * and a stub ported from 32-bit sources leaves the stack unbalanced on `iret`. + */ + .globl arch_trap_common + .type arch_trap_common, @function +arch_trap_common: + /* The caller-saved registers, in reverse argument order so that the block + * reads the same way it is restored. The callee-saved ones are not saved: + * `arch_trap_dispatch` is an ordinary C function and preserves them. */ + pushq %r11 + pushq %r10 + pushq %r9 + pushq %r8 + pushq %rdi + pushq %rsi + pushq %rdx + pushq %rcx + pushq %rax + + /* ⚠️ THE ALIGNMENT IS EXACT AND IS WORTH CHECKING RATHER THAN TRUSTING. + * + * The processor aligns `rsp` to 16 before pushing the interrupt frame. A + * vector without an error code therefore arrives at `≡ 8 (mod 16)` after + * five pushed quadwords, and its stub's two pushes bring it back to `≡ 8`; + * a vector with one arrives at `≡ 0` after six, and its stub's single push + * makes `≡ 8`. Either way this point is `≡ 8`, the nine pushes above make + * it `≡ 0`, and the 32 reserved below keep it there — which is what the + * System V ABI requires at a `call`. */ + subq $32, %rsp /* the arch_trap_frame */ + movq %rsp, %rdi /* arg 1: the frame to fill */ + leaq 32 + 9*8(%rsp), %rsi /* arg 2: the raw stack, at `vector` */ + call arch_trap_dispatch + addq $32, %rsp + + popq %rax + popq %rcx + popq %rdx + popq %rsi + popq %rdi + popq %r8 + popq %r9 + popq %r10 + popq %r11 + + addq $16, %rsp /* the vector and the error code */ + iretq + .size arch_trap_common, . - arch_trap_common + +/* The stubs. `.altmacro` is needed so that a macro argument can be used to + * build a label name. */ +.altmacro + +.macro STUB_NOERR num + .balign 16 +arch_trap_stub_\num: + pushq $0 + pushq $\num + jmp arch_trap_common +.endm + +.macro STUB_ERR num + .balign 16 +arch_trap_stub_\num: + pushq $\num + jmp arch_trap_common +.endm + +/* ⚠️ EMITTED ONE AT A TIME RATHER THAN BY `.rept`, FOR THE FIRST THIRTY-TWO. + * The set that pushes an error code is not a range and not a pattern, so a loop + * would need a conditional on the counter — and an off-by-one in that condition + * produces a stub with the wrong stack shape for exactly one exception, which + * is the hardest possible thing to notice. */ +STUB_NOERR 0 +STUB_NOERR 1 +STUB_NOERR 2 +STUB_NOERR 3 +STUB_NOERR 4 +STUB_NOERR 5 +STUB_NOERR 6 +STUB_NOERR 7 +STUB_ERR 8 +STUB_NOERR 9 +STUB_ERR 10 +STUB_ERR 11 +STUB_ERR 12 +STUB_ERR 13 +STUB_ERR 14 +STUB_NOERR 15 +STUB_NOERR 16 +STUB_ERR 17 +STUB_NOERR 18 +STUB_NOERR 19 +STUB_NOERR 20 +STUB_ERR 21 +STUB_NOERR 22 +STUB_NOERR 23 +STUB_NOERR 24 +STUB_NOERR 25 +STUB_NOERR 26 +STUB_NOERR 27 +STUB_NOERR 28 +STUB_ERR 29 +STUB_ERR 30 +STUB_NOERR 31 + +/* 32 upward are external interrupts, none of which carries an error code. */ + .set i, 32 + .rept 224 + STUB_NOERR %i + .set i, i + 1 + .endr + +/* The addresses, so that the IDT builder reads a table rather than assuming a + * stride. `.balign 16` inside the macros makes the stride uniform today; a + * table means it does not have to stay that way. */ + .section .rodata.arch_trap_stubs,"a",@progbits + .balign 16 + .globl arch_trap_stub_table + .type arch_trap_stub_table, @object +/* ⚠️ THE POINTER IS EMITTED FROM A MACRO, WHICH IS NOT A STYLE CHOICE. + * `.altmacro`'s `%expr` substitution applies to a macro INVOCATION's arguments + * and nowhere else, so `.quad arch_trap_stub_%i` inside a `.rept` passes the + * two characters `%i` through to the label name and the assembler reports + * `expected relocatable expression` 256 times. */ +.macro STUB_PTR num + .quad arch_trap_stub_\num +.endm + +arch_trap_stub_table: + .set i, 0 + .rept 256 + STUB_PTR %i + .set i, i + 1 + .endr + .size arch_trap_stub_table, . - arch_trap_stub_table + + .section .note.GNU-stack,"",@progbits diff --git a/backends/x86_64/src/trap_impl.cpp b/backends/x86_64/src/trap_impl.cpp new file mode 100644 index 0000000..f795db7 --- /dev/null +++ b/backends/x86_64/src/trap_impl.cpp @@ -0,0 +1,230 @@ +// Traps, on x86_64: building the table and reading what the machine reports. +// +// ⭐ THE THIRD MACHINE CONTRADICTED SOMETHING THE FIRST TWO AGREED ON, AND THE +// INTERFACE HAD TO ABSORB IT RATHER THAN RESTATE IT. +// +// `openarch.trap` documents that `pc` is "the instruction that trapped, not the +// one after it — both machines report it that way". That was true of riscv64 +// and aarch64 and it is not true here. x86_64 divides its exceptions into +// FAULTS, which report the address of the instruction that failed, and TRAPS, +// which report the address of the NEXT one. `int3` — the breakpoint the +// interface's `instr_len` exists to step over — is a trap, so the processor +// reports `RIP` already past it. +// +// Two readings were available. Change the interface to say "pc means whatever +// the machine reports", which pushes the difference into every handler that +// will ever be written including the ones that only run on RISC machines; or +// normalise here, so `pc` means one thing everywhere. The second is what a +// machine-abstraction layer is for, and it is what this file does: for a +// trap-class vector the reported `RIP` is walked back over the instruction, and +// `instr_len` is set so that `pc + instr_len` resumes where the processor would +// have. +// +// ⚠️ ONE ARCHITECTURE CANNOT REVEAL THIS. It is invisible with riscv alone and +// invisible with riscv and aarch64 together, because both are RISC machines +// that report faults and traps identically. It is the same shape of finding as +// `instr_len` itself, which `rv64gc`'s two-byte `c.ebreak` forced and which +// aarch64 could never have shown. +#include + +extern "C" const arch_u64 arch_trap_stub_table[256]; + +namespace { + +arch_trap_handler_fn g_handler = nullptr; + +// ── The interrupt descriptor table ───────────────────────────────────────── +// +// ⚠️ SIXTEEN BYTES PER GATE, WITH THE HANDLER'S ADDRESS SPLIT ACROSS THREE +// NON-ADJACENT FIELDS. The layout is historical: a 32-bit gate had two +// 16-bit halves, and 64-bit mode appended the high 32 bits after the fields +// that used to follow them. Writing it as one 64-bit member would assemble +// cleanly and produce a table the processor reads as garbage. +struct [[gnu::packed]] Gate { + arch_u32 offset_low : 16; + arch_u32 selector : 16; + arch_u32 ist : 3; // 0 = use the current stack + arch_u32 reserved0 : 5; + arch_u32 type_attr : 8; + arch_u32 offset_mid : 16; + arch_u32 offset_high; + arch_u32 reserved1; +}; + +static_assert(sizeof(Gate) == 16, "an x86_64 IDT gate is sixteen bytes"); + +struct [[gnu::packed]] Idtr { + arch_u32 limit : 16; + arch_u64 base; +}; + +static_assert(sizeof(Idtr) == 10, "lidt reads a 16-bit limit and a 64-bit base"); + +// ⚠️ `alignas(16)` IS NOT REQUIRED BY THE ARCHITECTURE AND IS KEPT ANYWAY. +// The manual recommends it so that no gate straddles a cache line, which +// matters for the exception path's latency rather than its correctness. +alignas(16) Gate g_idt[256]; + +// 0x8E: present, descriptor privilege level 0, type 0xE — a 64-bit INTERRUPT +// gate. +// +// ⚠️ AN INTERRUPT GATE AND NOT A TRAP GATE, AND THE DIFFERENCE IS THE ONE +// PROMISE `openarch.trap` MAKES ABOUT HANDLERS. A trap gate (type 0xF) leaves +// `RFLAGS.IF` as it was, so a handler installed through one can be interrupted +// by the very source it is servicing. An interrupt gate clears `IF` on entry, +// which is what "a handler runs with traps disabled" means. The two differ by +// one bit and nothing reports the choice. +constexpr arch_u32 kInterruptGate64 = 0x8Eu; + +arch_u32 current_cs() noexcept { + // ⚠️ READ RATHER THAN ASSUMED. A gate names a code segment by selector, and + // 0x08 is only the conventional first GDT entry. This backend does not own + // the GDT — the loader that reached long mode does — so it asks the + // processor which selector is in use instead of asserting one. A wrong + // selector produces #GP on the first exception, from inside the exception + // path, which is the least diagnosable failure this file could have. + arch_u32 cs; + asm volatile("movl %%cs, %0" : "=r"(cs)); + return cs; +} + +void set_gate(int vector, arch_u64 handler, arch_u32 cs) noexcept { + Gate& g = g_idt[vector]; + g.offset_low = static_cast(handler) & 0xFFFFu; + g.selector = cs; + g.ist = 0; + g.reserved0 = 0; + g.type_attr = kInterruptGate64; + g.offset_mid = static_cast(handler >> 16) & 0xFFFFu; + g.offset_high = static_cast(handler >> 32); + g.reserved1 = 0; +} + +// ── What the machine stopped for ─────────────────────────────────────────── +// +// ⚠️ THE VECTOR IS THE ONLY CAUSE THIS MACHINE REPORTS. riscv has `scause` and +// aarch64 has `ESR_EL1`, both of which encode a class and a reason; here the +// entry point IS the reason, which is why there are 256 of them. +constexpr int kVecDebug = 1; +constexpr int kVecBreakpoint = 3; +constexpr int kVecOverflow = 4; +constexpr int kVecInvalidOpcode = 6; +constexpr int kVecAlignCheck = 17; +constexpr int kVecPageFault = 14; +constexpr int kVecFirstExternal = 32; + +int classify(arch_u64 vector) noexcept { + if (vector >= kVecFirstExternal) return ARCH_TRAP_INTERRUPT; + switch (vector) { + case kVecBreakpoint: + case kVecDebug: return ARCH_TRAP_BREAKPOINT; + case kVecInvalidOpcode: return ARCH_TRAP_ILLEGAL; + case kVecAlignCheck: return ARCH_TRAP_UNALIGNED; + case kVecPageFault: return ARCH_TRAP_PAGE_FAULT; + default: return ARCH_TRAP_OTHER; + } +} + +// The vectors whose reported `RIP` is the instruction AFTER the one that +// trapped. Everything else is a fault or an external interrupt, both of which +// report the instruction itself. +// +// ⚠️ VECTOR 1 IS IN BOTH SETS DEPENDING ON WHY IT WAS RAISED — an instruction +// breakpoint is a fault and a single-step is a trap — and this backend treats +// it as a fault, which is the safe direction. Walking `pc` back for a fault +// would point it into the middle of the preceding instruction; not walking it +// back for a trap makes a handler that resumes re-execute one instruction. The +// interface's own contract covers neither, and `cause` carries the vector so a +// kernel doing single-step debugging can read it and decide. +bool reports_next_instruction(arch_u64 vector) noexcept { + return vector == kVecBreakpoint || vector == kVecOverflow; +} + +arch_u64 read_cr2() noexcept { + arch_u64 v; + asm volatile("movq %%cr2, %0" : "=r"(v)); + return v; +} + +// The raw stack `arch_trap_common` hands over, in the order the stubs and the +// processor pushed it. +struct Raw { + arch_u64 vector; + arch_u64 error_code; + arch_u64 rip; + arch_u64 cs; + arch_u64 rflags; + arch_u64 rsp; + arch_u64 ss; +}; + +} // namespace + +extern "C" void arch_trap_dispatch(arch_trap_frame* f, Raw* raw) { + static_assert(sizeof(arch_trap_frame) == 32, + "trap.S reserves 32 bytes below the saved registers"); + + f->cause = raw->vector; + f->kind = classify(raw->vector); + f->addr = (raw->vector == kVecPageFault) ? read_cr2() : 0; + + if (reports_next_instruction(raw->vector)) { + // ⚠️ ONE BYTE, BECAUSE THE ONLY BREAKPOINT THIS CAN BE IS `0xCC`. + // + // `int3` has two encodings: the one-byte `CC`, which every compiler and + // debugger emits, and the two-byte `CD 03` (`int $3`), which is + // essentially only written by hand. Distinguishing them means reading + // the bytes before `RIP` and deciding which of two overlapping + // encodings ended there — a decode, and a decode that can be fooled by + // data preceding the instruction. + // + // So the common encoding is assumed and the assumption is stated. A + // program that raises a breakpoint with `CD 03` gets a `pc` one byte + // into its own instruction; that is a defect, and it is a defect in a + // place a reader can find rather than an ambiguity spread over the + // handler. + f->instr_len = 1; + f->pc = raw->rip - f->instr_len; + } else { + // ⚠️ ZERO, AND IT IS A STATEMENT RATHER THAN A DEFAULT. On riscv the + // backend reads two bytes at `pc` and knows the length from the low + // bits, because RISC-V encodes it there. x86_64 instructions are one to + // fifteen bytes and their length is only knowable by decoding, which + // needs a disassembler this layer will not carry. A handler that means + // to step past a fault on this machine has to decode it itself, and + // reporting zero says so rather than offering a number that is wrong. + f->instr_len = 0; + f->pc = raw->rip; + } + + if (g_handler) g_handler(f); + + // What the handler left in `pc` is where execution resumes, which is the + // same contract the other two backends have — they write `mepc` and + // `ELR_EL1`, and this one writes the `iret` frame. + raw->rip = f->pc; +} + +extern "C" arch_trap_handler_fn arch_trap_set_handler(arch_trap_handler_fn h) { + const auto prev = g_handler; + g_handler = h; + + const arch_u32 cs = current_cs(); + for (int v = 0; v < 256; ++v) set_gate(v, arch_trap_stub_table[v], cs); + + const Idtr idtr{ sizeof(g_idt) - 1, reinterpret_cast(&g_idt[0]) }; + asm volatile("lidt %0" :: "m"(idtr) : "memory"); + + return prev; +} + +extern "C" void arch_trap_enable_interrupts(int on) { + if (on) asm volatile("sti" ::: "memory"); + else asm volatile("cli" ::: "memory"); +} + +extern "C" int arch_trap_interrupts_enabled(void) { + arch_u64 flags; + asm volatile("pushfq\n\tpopq %0" : "=r"(flags)); + return (flags & (1ULL << 9)) != 0 ? 1 : 0; // RFLAGS.IF +} diff --git a/examples/switch/build.mcpp b/examples/switch/build.mcpp index 809a638..5eb50b8 100644 --- a/examples/switch/build.mcpp +++ b/examples/switch/build.mcpp @@ -16,6 +16,7 @@ int main() { // is this directory. if (arch == "riscv64") mcpp::link_script("riscv64.ld"); else if (arch == "aarch64") mcpp::link_script("aarch64.ld"); + else if (arch == "x86_64") mcpp::link_script("x86_64.ld"); else { std::cerr << "openarch probe: no memory map for arch '" << arch << "'. The probe runs on the two machines openarch has " @@ -32,6 +33,20 @@ int main() { // The two packages are different — xPack builds QEMU per target family, so // `qemu-riscv` carries only the two RISC-V emulators and `qemu-arm` only // the two Arm ones. There is no single package that runs both machines. + // ⚠️ x86_64 IS ABSENT FROM THIS TABLE, AND THE ABSENCE IS THE ECOSYSTEM'S + // RATHER THAN THIS PROBE'S. xPack publishes QEMU per target family, and the + // index carries `qemu-riscv` and `qemu-arm` because those are the families + // it publishes. There is no `xim:qemu-x86`, so on that target this build + // program configures no runner and `mcpp build` is the whole of what CI can + // do — which is stated here rather than worked around, because a runner + // silently pointing at a host `qemu-system-x86_64` would make the build + // depend on what happens to be installed. + if (arch == "x86_64") { + mcpp::rerun_if_env_changed("MCPP_TARGET_ARCH"); + mcpp::rerun_if_changed("x86_64.ld"); + return 0; + } + const char* pkg = (arch == "riscv64") ? "qemu-riscv" : "qemu-arm"; const char* sys = (arch == "riscv64") ? "riscv64" : "aarch64"; if (const char* dir = mcpp::xpkg_dir("xim", pkg); dir && *dir) { diff --git a/examples/switch/mcpp.toml b/examples/switch/mcpp.toml index 1959fba..7b5bf7d 100644 --- a/examples/switch/mcpp.toml +++ b/examples/switch/mcpp.toml @@ -18,14 +18,29 @@ sysroot = "" [target.aarch64-none-elf] sysroot = "" +[target.x86_64-none-elf] +sysroot = "" + [target.'cfg(arch = "riscv64")'.build] sources = ["src/machine_riscv64.cpp"] [target.'cfg(arch = "aarch64")'.build] sources = ["src/machine_aarch64.cpp"] +# ⚠️ TWO FILES AND NOT ONE. Every other machine's half is a single `.cpp` whose +# `asm` block carries a four-instruction entry stub. Reaching the state openarch +# assumes on x86_64 — long mode, a 64-bit code segment, page tables — takes its +# own linker section, so it is a file rather than a block. +[target.'cfg(arch = "x86_64")'.build] +sources = ["src/machine_x86_64.cpp", "src/boot_x86_64.S"] + [dependencies] -openarch = { path = "../../spec" } +# ⭐ ONE LINE, AND IT NAMES NEITHER AN ARCHITECTURE NOR A BACKEND. The `backend` +# feature is on by default and its target-conditional `feature-deps` row selects +# the implementation for whatever `--target` this build was given. That is the +# property the probe exists to demonstrate, so it is stated here rather than +# worked around. +openarch = { path = "../.." } # ⚠️ THE EMULATORS ARE DECLARED, AND THE PROBE DOES NOT WORK WITHOUT THIS. # diff --git a/examples/switch/src/boot_x86_64.S b/examples/switch/src/boot_x86_64.S new file mode 100644 index 0000000..83fcdfe --- /dev/null +++ b/examples/switch/src/boot_x86_64.S @@ -0,0 +1,185 @@ +/* The x86_64 half of the probe's machine: reaching the state openarch assumes. + * + * ⭐ THIS FILE HAS NO COUNTERPART ON THE OTHER TWO MACHINES, AND ITS EXISTENCE + * IS THE THIRD ARCHITECTURE'S MOST VISIBLE DISAGREEMENT. + * + * riscv64 and aarch64 begin executing 64-bit instructions at reset. A probe for + * them sets a stack pointer and calls C. x86_64 begins in 16-bit real mode, and + * a multiboot loader hands over in 32-bit protected mode with paging off — so + * everything openarch's backend assumes (long mode, a 64-bit code segment, a + * stack) has to be constructed first, in about a hundred instructions that + * belong to no layer of the library. + * + * ⚠️ AND THAT IS WHY THEY ARE HERE RATHER THAN IN THE BACKEND. `backends/x86_64` + * states that it assumes long mode is already entered, for the same reason no + * backend carries a linker script: how a particular image reaches that state is + * a property of how it is LOADED, and a multiboot image, a UEFI application and + * a bzImage arrive by three different routes. A backend that picked one would + * be a bootloader wearing a machine layer's name. + */ + +/* ── The multiboot header ────────────────────────────────────────────────── + * + * Bit 16 is the "a.out kludge": the header carries the load addresses itself and + * the loader never parses the ELF. That is what lets QEMU load this image at + * all — its multiboot loader accepts only a 32-bit ELF, and this one is 64-bit. + * See x86_64.ld for why `SIZEOF_HEADERS` makes the address arithmetic come out. + * + * The checksum is defined as the value that makes the first three fields sum to + * zero in 32-bit arithmetic. */ +.set MB_MAGIC, 0x1BADB002 +.set MB_FLAGS, 0x00010000 /* bit 16: the a.out kludge */ +.set MB_CHECKSUM, -(MB_MAGIC + MB_FLAGS) + + .section .multiboot,"a",@progbits + .align 4 +mb_header: + .long MB_MAGIC + .long MB_FLAGS + .long MB_CHECKSUM + /* The five address fields the kludge requires. With them the loader copies + * bytes and jumps; it never looks at the ELF, which is what lets a 64-bit + * image be loaded by a loader that only parses 32-bit ones. */ + .long mb_header /* header_addr */ + .long __load_start /* load_addr */ + .long __load_end /* load_end_addr */ + .long __bss_end /* bss_end_addr — zeroed by the loader */ + .long _start /* entry_addr */ + +/* ── The 32-bit entry ──────────────────────────────────────────────────────*/ + .code32 + .section .text.entry,"ax",@progbits + .globl _start + .type _start, @function +_start: + cli + movl $boot_stack_top, %esp + + /* ⚠️ ZERO `.bss` BEFORE ANYTHING READS IT, AND THE PAGE TABLES ARE WHY + * THIS IS NOT OPTIONAL HERE. On the other two machines an unzeroed `.bss` + * gives the probe a wrong counter. Here it gives the processor a page table + * full of whatever the previous occupant of that memory left, and the walk + * reads those bytes as present entries pointing at addresses that are not + * page tables. */ + movl $__bss_start, %edi + movl $__bss_end, %ecx + subl %edi, %ecx + xorl %eax, %eax + rep stosb + + /* PML4[0] → PDPT, PDPT[0] → PD. Present, writable. */ + movl $pdpt, %eax + orl $0x03, %eax + movl %eax, pml4 + + movl $pd, %eax + orl $0x03, %eax + movl %eax, pdpt + + /* PD[i] maps a 2 MiB page at i * 2 MiB, identity, for the first gigabyte. + * + * ⚠️ 2 MiB PAGES RATHER THAN 1 GiB ONES. A single PDPT entry with `PS` set + * would map the whole gigabyte in one line of code, and 1 GiB pages are an + * OPTIONAL feature — `cpuid` leaf 0x80000001, `edx` bit 26. Emulators and + * processors that lack it do not fault on the attempt; they interpret the + * `PS` bit as reserved and take a page fault on the first access, from + * inside the code that has just enabled paging. */ + xorl %ecx, %ecx +1: movl %ecx, %eax + shll $21, %eax + orl $0x83, %eax /* present | writable | page-size */ + movl %eax, pd(,%ecx,8) + incl %ecx + cmpl $512, %ecx + jb 1b + + movl $pml4, %eax + movl %eax, %cr3 + + /* CR4.PAE. Long mode requires it; without it the mode switch below is a + * #GP rather than a transition. */ + movl %cr4, %eax + orl $(1 << 5), %eax + movl %eax, %cr4 + + /* EFER: LME to enable long mode, NXE so that bit 63 of a page-table entry + * means "no execute" rather than "reserved". + * + * ⚠️ NXE IS NOT DECORATION HERE. `openarch.pte`'s encoder sets that bit for + * a non-executable mapping. With NXE clear the processor treats it as a + * reserved bit and every access through such an entry is a page fault — + * which is the encoder producing entries that are correct by the manual and + * fatal on the machine. The backend's `install_memory_attributes` sets it + * too, for a kernel that reaches this layer by some other route. */ + movl $0xC0000080, %ecx + rdmsr + orl $((1 << 8) | (1 << 11)), %eax + wrmsr + + /* CR0.PG. Paging on; the processor is in long mode from this instruction, + * but still executing in a 32-bit compatibility segment. */ + movl %cr0, %eax + orl $(1 << 31), %eax + movl %eax, %cr0 + + lgdt gdt64_descriptor + ljmp $0x08, $long_mode_entry + .size _start, . - _start + +/* ── The 64-bit entry ──────────────────────────────────────────────────────*/ + .code64 + .section .text.entry64,"ax",@progbits + .type long_mode_entry, @function +long_mode_entry: + /* ⚠️ THE DATA SEGMENTS STILL HOLD 32-BIT SELECTORS AND MUST BE RELOADED. + * In long mode the processor ignores the base and limit of `ds`, `es` and + * `ss`, which makes it easy to believe they need no attention — but their + * SELECTORS are still checked, and one left pointing at a descriptor the + * new GDT does not define faults on the first stack operation. */ + movw $0x10, %ax + movw %ax, %ds + movw %ax, %es + movw %ax, %ss + movw %ax, %fs + movw %ax, %gs + + movq $__stack_top, %rsp + xorq %rbp, %rbp + + call kmain +1: hlt + jmp 1b + .size long_mode_entry, . - long_mode_entry + +/* ── The descriptor table ────────────────────────────────────────────────── + * + * Three entries: the required null descriptor, a 64-bit code segment, and a + * data segment. + * + * ⚠️ THE `L` BIT — BIT 53, THE `0xA` IN THE SIXTH BYTE — IS WHAT MAKES THE CODE + * SEGMENT 64-BIT, AND `D` MUST BE CLEAR BESIDE IT. A descriptor with both set + * is reserved: the far jump below faults instead of transferring, and the fault + * is delivered through an IDT that does not exist yet, so the machine triple- + * faults and the emulator resets with nothing printed. */ + .section .rodata.gdt,"a",@progbits + .align 8 +gdt64: + .quad 0x0000000000000000 /* null */ + .quad 0x00AF9A000000FFFF /* code: P, DPL0, execute/read, L=1, D=0 */ + .quad 0x00AF92000000FFFF /* data: P, DPL0, read/write */ +gdt64_end: + +gdt64_descriptor: + .word gdt64_end - gdt64 - 1 + .long gdt64 + +/* ── Storage ───────────────────────────────────────────────────────────────*/ + .section .bss + .align 4096 +pml4: .skip 4096 +pdpt: .skip 4096 +pd: .skip 4096 +boot_stack: .skip 4096 +boot_stack_top: + + .section .note.GNU-stack,"",@progbits diff --git a/examples/switch/src/machine_x86_64.cpp b/examples/switch/src/machine_x86_64.cpp new file mode 100644 index 0000000..2e548b7 --- /dev/null +++ b/examples/switch/src/machine_x86_64.cpp @@ -0,0 +1,96 @@ +// The x86_64 half of the probe's machine: QEMU's `pc`/`q35`. +// +// ⭐ THE CONSOLE IS NOT MEMORY, WHICH IS THE THIRD MACHINE'S QUIETEST +// DISAGREEMENT. +// +// Both other halves of this probe reach a terminal by storing to an address: +// riscv's 16550A at 0x10000000, aarch64's PL011 at 0x09000000. x86 has a +// SEPARATE ADDRESS SPACE for devices, reached only by the `in` and `out` +// instructions, and no pointer can name port 0x3F8. A `machine.h` that had +// offered "the console's address" instead of "print a character" would have +// been an interface that two machines could implement and a third could not. +#include "machine.h" + +extern "C" int probe_main(); + +namespace { + +constexpr unsigned short kCom1 = 0x3F8; + +// Line status register bit 5: the transmit holding register is empty. +constexpr unsigned char kThre = 0x20; + +inline void outb(unsigned short port, unsigned char v) noexcept { + asm volatile("outb %0, %1" :: "a"(v), "Nd"(port)); +} + +inline void outw(unsigned short port, unsigned short v) noexcept { + asm volatile("outw %0, %1" :: "a"(v), "Nd"(port)); +} + +inline unsigned char inb(unsigned short port) noexcept { + unsigned char v; + asm volatile("inb %1, %0" : "=a"(v) : "Nd"(port)); + return v; +} + +// ⚠️ A NAMESPACE-SCOPE `bool` AND NOT A FUNCTION-LOCAL `static`. A local static +// with a non-constant initialiser needs `__cxa_guard_acquire`, which lives in +// the C++ runtime this image does not link. The compiler emits the call without +// complaint and the link fails naming a symbol that appears nowhere in the +// source. +bool g_uart_ready = false; + +void uart_init() noexcept { + outb(kCom1 + 1, 0x00); // no interrupts + outb(kCom1 + 3, 0x80); // DLAB: the next two writes are the divisor + outb(kCom1 + 0, 0x01); // 115200 baud + outb(kCom1 + 1, 0x00); + outb(kCom1 + 3, 0x03); // 8 bits, no parity, one stop bit + outb(kCom1 + 2, 0xC7); // enable and clear the FIFOs + outb(kCom1 + 4, 0x03); // data terminal ready, request to send + g_uart_ready = true; +} + +} // namespace + +namespace machine { + +void putc(char c) { + if (!g_uart_ready) uart_init(); + while ((inb(kCom1 + 5) & kThre) == 0) { } + outb(kCom1, static_cast(c)); +} + +void print(const char* s) { while (s && *s) putc(*s++); } + +void print_int(int v) { + if (v < 0) { putc('-'); v = -v; } + char d[12]; int n = 0; + do { d[n++] = static_cast('0' + v % 10); v /= 10; } while (v); + while (n-- > 0) putc(d[n]); +} + +// ⚠️ TWO PORTS, BECAUSE QEMU MOVED THE REGISTER AND BOTH SPELLINGS ARE STILL +// IN THE FIELD. Writing 0x2000 to the ACPI PM1a control block requests soft-off; +// QEMU's `q35` and modern `pc` place that block at 0x604, and versions before +// 2.0 placed it at 0xB004. Neither write faults on a machine that does not +// decode the port, so issuing both costs nothing and removes a dependency on +// which QEMU is installed. +// +// The `hlt` loop is what runs if neither is decoded — a machine that will not +// power itself off, which is a correct outcome rather than a hang to diagnose. +[[noreturn]] void poweroff(int code) { + (void)code; // the ACPI request carries no status + outw(0x604, 0x2000); + outw(0xB004, 0x2000); + for (;;) { asm volatile("hlt"); } +} + +} // namespace machine + +// ⚠️ NO `_start` HERE. The other two machine files carry an entry stub in an +// `asm` block, because on those machines the entry sets a stack pointer and +// calls C. Reaching that state on x86_64 takes a hundred instructions and its +// own linker section, so it lives in `boot_x86_64.S` beside this file. +extern "C" [[noreturn]] void kmain() { machine::poweroff(probe_main()); } diff --git a/examples/switch/src/main.cpp b/examples/switch/src/main.cpp index 4354ca4..cc656b2 100644 --- a/examples/switch/src/main.cpp +++ b/examples/switch/src/main.cpp @@ -21,9 +21,11 @@ // ⚠️ Observation 3 is the one that catches a half-correct backend. A switch // that saves the return address and the stack pointer and nothing else passes // the first two and corrupts the caller. -import openarch.context; -import openarch.trap; -import openarch.cpu; +// ⭐ ONE IMPORT. The layer divides into four modules and a consumer does not +// have to know that; `mcpplibs.openarch` re-exports them. Importing the parts +// individually still works and is what a boot path that needs only `pte` would +// do. +import mcpplibs.openarch; #include "machine.h" @@ -41,7 +43,7 @@ volatile int g_witness = 0; machine::print_int(static_cast(reinterpret_cast(arg))); machine::putc('\n'); g_witness = 7; - arch::arch_context_switch(g_task, g_main); + arch::context_switch(g_task, g_main); // Unreachable: nothing switches back to this context. for (;;) { } } @@ -85,8 +87,19 @@ void probe_trap() { machine::print("trap: raising\n"); #if defined(__riscv) asm volatile("ebreak"); -#else +#elif defined(__aarch64__) asm volatile("brk #0"); +#elif defined(__x86_64__) + // ⚠️ AND THIS ONE IS A TRAP RATHER THAN A FAULT, WHICH THE HANDLER ABOVE + // NEVER LEARNS. x86_64 reports `int3` with `RIP` already past it, where + // both RISC machines report the address of the trapping instruction. The + // backend normalises that before the handler runs — walking `pc` back and + // setting `instr_len` to match — so `f->pc += f->instr_len` resumes in the + // same place on all three. The alternative was to tell every handler ever + // written that `pc` means something different here. + asm volatile("int3"); +#else +# error "the probe has no breakpoint instruction for this architecture" #endif machine::print("trap: back, witness="); machine::print_int(g_trapped); @@ -119,7 +132,7 @@ void probe_cpu() { } // namespace extern "C" int probe_main() { - arch::arch_context_init(g_task, &task, reinterpret_cast(42L), + arch::context_init(g_task, &task, reinterpret_cast(42L), g_stack + sizeof g_stack); // ⚠️ `volatile` and read after the round trip. A plain local would be @@ -128,7 +141,7 @@ extern "C" int probe_main() { volatile int before = 1234; machine::print("main: switching to task\n"); - arch::arch_context_switch(g_main, g_task); + arch::context_switch(g_main, g_task); machine::print("main: back, witness="); machine::print_int(g_witness); diff --git a/examples/switch/x86_64.ld b/examples/switch/x86_64.ld new file mode 100644 index 0000000..3e195ae --- /dev/null +++ b/examples/switch/x86_64.ld @@ -0,0 +1,56 @@ +/* QEMU's x86 machines load a multiboot `-kernel` image at the addresses its + * multiboot header names. + * + * ⚠️ QEMU'S MULTIBOOT LOADER ACCEPTS ONLY A 32-BIT ELF, AND THIS IMAGE IS A + * 64-BIT ONE. Measured, with the image linked the obvious way: + * + * qemu-system-x86_64: Cannot load x86-64 image, give a 32bit one. + * + * There is no way to produce an ELF32 here — the code is x86-64 and an ELF's + * class is a property of the whole file — so the image is loaded through + * multiboot's OTHER route: the "a.out kludge", flag bit 16, under which the + * header carries explicit load addresses and the loader never parses the ELF + * at all. GRUB2 accepts ELF64 and does not need this; QEMU does. + * + * ⭐ `SIZEOF_HEADERS` IS WHAT MAKES THE KLUDGE'S ARITHMETIC COME OUT. + * + * The loader computes the file offset it starts copying from as + * + * header_addr - load_addr + * + * so that difference must equal the multiboot header's ACTUAL offset in the + * file. Starting the image at `0x100000 + SIZEOF_HEADERS` makes the ELF + * headers occupy exactly the bytes between `load_addr` and `header_addr`: + * offset and address stay congruent, the linker emits no padding, and the + * difference is the offset by construction rather than by a number somebody + * has to keep correct. + * + * Linking it the plain way — `. = 0x100000` — put `.multiboot` at file offset + * 0x1000 with address 0x100000, so `load_addr` would have had to be 0xFF000, + * inside the legacy BIOS window where a write is discarded. + */ +ENTRY(_start) +SECTIONS { + __load_start = 0x100000; + . = __load_start + SIZEOF_HEADERS; + + .multiboot : { KEEP(*(.multiboot)) } + .text : { *(.text.entry) *(.text.entry64) *(.text*) } + .rodata : { *(.rodata*) } + .data : { *(.data*) } + __load_end = .; + + /* ⚠️ 4 KiB, BECAUSE THE PAGE TABLES LIVE HERE. The boot stub builds a + * PML4, a PDPT and a PD in `.bss`, and the processor requires each to be + * page-aligned; a table at a lower alignment is not diagnosed, it is walked + * with the low bits of its address read as flags. */ + .bss ALIGN(4096) : { + __bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(16); + __bss_end = .; + } + + . = ALIGN(16); . = . + 0x4000; __stack_top = .; +} diff --git a/mcpp.toml b/mcpp.toml index 6118fd4..4a85a00 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,37 +1,167 @@ # openarch — the architecture-mechanism layer. # -# ⚠️ THE DIRECTORY TREE IS THE ARCHITECTURE, AND IT WAS NOT UNTIL 0.3.1. -# -# Until then the specification and both backends lived in one package, with the -# backends under `src/arch//`. That arrangement compiled, and it hid a -# constraint: `openarch.pte`, `openarch.trap` and `openarch.cpu` were -# implemented by MODULE IMPLEMENTATION UNITS, which C++ requires to live in the -# same module — and therefore, here, in the same package. The layering was a -# convention held up by a directory name. -# -# Splitting the packages made that constraint visible and forced the fix: every -# backend now crosses into the specification through a C ABI -# (`spec/include/openarch/abi.h`), which is what `openarch.context` already did -# and what openkal has always done. The gain is not tidiness — it is that a -# specification whose boundary is a C ABI can be implemented by something that -# is not a C++ module, and one whose boundary is a C++ module cannot. -# -# abi/ the contract. Headers only; depends on nothing. -# spec/ the C++ modules over the contract. Owns every -# module; owns no instruction. -# backends// one package per instruction set. Owns instructions; -# exports no module. -# examples/switch/ one probe source, run on every machine. -# -# ⚠️ A backend is selected by the TARGET, not by the consumer. `spec/mcpp.toml` -# pulls it through a `cfg` dependency, so a consumer writes `openarch = "..."` -# and nothing about its own architecture — which is the property the -# single-package layout had and which a split must not cost. +# ⚠️ THE DIRECTORY TREE IS THE ARCHITECTURE. +# +# mcpp.toml this file: the interface package AND the workspace. +# include/… nothing. The C face lives in `abi/`; see below. +# src/*.cppm the C++ face — module `mcpplibs.openarch`. +# tests/ what the two faces must agree about. +# abi/ the contract. Headers only; depends on nothing. +# backends// one package per instruction set. Owns instructions; +# exports no module. +# examples/switch/ one probe source, run on every machine. +# +# ⭐ THE ROOT IS BOTH A PACKAGE AND A WORKSPACE, AND THAT IS WHAT MAKES THE +# CONSUMER'S SIDE ONE LINE. +# +# A virtual workspace — `[workspace]` with no `[package]` — would mean the +# interface lived in a member directory, and `openarch = "0.4.0"` would have to +# name it. The hybrid form removes that: the thing a consumer depends on is the +# repository, and the members are the parts it is assembled from. +# +# ⚠️ `abi/` IS A SEPARATE PACKAGE BECAUSE THE DEPENDENCY GRAPH SAID SO. The +# contract cannot live in this package: the backends need it, this package pulls +# a backend, and mcpp rejects the loop outright — +# +# error: dependency cycle through package 'openarch' +# while computing its build-cache key +# +# Breaking it by giving up automatic backend selection would make every consumer +# write its architecture down; breaking it by copying the header into each +# backend would create files that must agree and no mechanism to make them. The +# contract belongs to neither side. +[package] +namespace = "mcpplibs" +name = "openarch" +version = "0.4.0" +description = "openarch: the architecture-mechanism layer — execution contexts, traps, per-CPU state and address spaces, as one interface over several instruction sets" +license = "Apache-2.0" +authors = ["mcpplibs"] +repo = "https://github.com/mcpplibs/openarch" + [workspace] members = [ "abi", - "spec", "backends/riscv64", "backends/aarch64", + "backends/x86_64", "examples/switch", ] + +# ⚠️ THIS PACKAGE CONTAINS NO INSTRUCTION FOR ANY MACHINE. Every `asm` in the +# project lives under `backends/`, and the boundary between the two is +# `abi/include/openarch/abi.h`. If a register name appears here, the layering +# has been lost; `tests/` and CI both check it. +[build] +sources = ["src/*.cppm"] + +[targets.openarch] +kind = "lib" + +# The contract, always. A consumer that only ever calls the C face still reaches +# it through this package, because `include-dirs` propagate to consumers along a +# public dependency edge — which is what makes `openarch = "0.4.0"` the whole of +# the consumer's manifest for either face. +[dependencies] +openarch-abi = { path = "abi" } + +# ── Which backend, whether one at all, and whose ──────────────────────────── +# +# ⭐ THREE QUESTIONS THAT HAD BEEN ANSWERED BY ONE MECHANISM. +# +# 1. Is an implementation of the ABI linked into this program? +# 2. If one of ours, which architecture's? +# 3. Or is it the consumer's own? +# +# Until 0.3.1 the backend arrived through `[target.'cfg(…)'.dependencies]`, +# which answers (2) well and the other two not at all. A kernel that has already +# written its own trap vector — the case openkal's implementations occupy for +# the kernel ABI — had no way to say so, and would have got a second one linked +# beside it: not a diagnostic but a duplicate definition of every symbol. +# +# The three are now separate, and the shape is the one `std-freestanding` uses +# for its allocator, so the ecosystem has one pattern rather than two. +# +# `backend-auto` the BUILT-IN DEFAULT, on unless switched off. Resolves, +# per target, to the backend for that architecture; on a +# target with no backend it resolves to nothing, which is +# the header-only configuration `tests/` builds on a host. +# This is what makes `openarch = "0.4.0"` the whole of an +# ordinary consumer's manifest. +# +# `backend-` the SAME BACKENDS, NAMED. For a consumer that wants the +# choice written down, and for the case `backend-auto` +# cannot serve: one ISA with more than one backend, which +# riscv will need — this one traps into M-mode and a +# kernel under SBI traps into S-mode. +# +# `backend-external` THE CONSUMER IMPLEMENTS THE ABI ITSELF. Names no +# package: it requires the CAPABILITY `openarch-backend`, +# and the resolver binds the one provider in the graph. +# With none, the build stops at configure time saying so, +# rather than at link time naming a mangled symbol. +# +# Three usages, one line each: +# +# | The consumer wants | What its manifest says | +# |-----------------------------|-------------------------------------------------| +# | the backend for its target | openarch = "0.4.0" | +# | a particular one | { version = "0.4.0", default-features = false, | +# | | features = ["backend-riscv64"] } | +# | its own implementation | { version = "0.4.0", default-features = false, | +# | | features = ["backend-external"] } + a package | +# | | that provides = ["openarch-backend"] | +# +# ⚠️ `default-features = false` IS REQUIRED IN THE LAST TWO, AND NOT BECAUSE OF +# TIDINESS. Features are additive: there is no feature a consumer can turn ON +# that turns `backend-auto` off. Leaving it on alongside a second implementation +# puts two definitions of the same C symbols in the link, which is a multiple +# definition rather than a choice. +# +# ⚠️ `backend-auto` DOES NOT REQUIRE THE CAPABILITY, AND THE FIRST VERSION OF +# THIS TABLE HAD IT DO SO. The comment then read "on a hosted target the feature +# is active and resolves to nothing, which is correct". Measured, it is not: +# +# error: no package provides capability 'openarch-backend' required by +# 'openarch'; add a dependency that declares +# `provides = ["openarch-backend"]` +# +# A `requires` is unconditional even when the `feature-deps` that satisfy it are +# target-conditional, so requiring it made a host build of this package's own +# tests impossible. The requirement belongs on `backend-external`, where the +# consumer has asserted that an implementation exists; `backend-auto` resolving +# to nothing on a machine openarch does not implement is a fact about the +# target, not an error the consumer can act on. +# +# ⚠️ `os = "none"` is part of every predicate. A hosted aarch64 build — a macOS +# runner, for instance — has an operating system already, and pulling a kernel's +# machine layer into it once produced ELF assembly compiled for Mach-O. +[features] +default = ["backend-auto"] + +backend-auto = [] +backend-external = { requires = ["openarch-backend"] } + +backend-riscv64 = [] +backend-aarch64 = [] +backend-x86-64 = [] + +[target.'cfg(all(arch = "riscv64", os = "none"))'.feature-deps.backend-auto] +openarch-riscv64 = { path = "backends/riscv64" } + +[target.'cfg(all(arch = "aarch64", os = "none"))'.feature-deps.backend-auto] +openarch-aarch64 = { path = "backends/aarch64" } + +[target.'cfg(all(arch = "x86_64", os = "none"))'.feature-deps.backend-auto] +openarch-x86-64 = { path = "backends/x86_64" } + +# Named explicitly, so unconditional: asking for a backend by name on a target +# it does not serve should fail while assembling it, not resolve to nothing. +[feature-deps.backend-riscv64] +openarch-riscv64 = { path = "backends/riscv64" } + +[feature-deps.backend-aarch64] +openarch-aarch64 = { path = "backends/aarch64" } + +[feature-deps.backend-x86-64] +openarch-x86-64 = { path = "backends/x86_64" } diff --git a/spec/mcpp.toml b/spec/mcpp.toml deleted file mode 100644 index a9413be..0000000 --- a/spec/mcpp.toml +++ /dev/null @@ -1,40 +0,0 @@ -# openarch — the specification: modules, and the C ABI a backend implements. -# -# ⚠️ THIS PACKAGE CONTAINS NO INSTRUCTION FOR ANY MACHINE. Every `asm` in the -# project lives under `backends/`, and the boundary between the two is -# `include/openarch/abi.h`. If a register name ever appears here, the layering -# has been lost. -[package] -namespace = "mcpplibs" -name = "openarch" -version = "0.3.1" -description = "openarch: the architecture-mechanism layer — execution contexts, traps, per-CPU state and address spaces, as one interface over several instruction sets" -license = "Apache-2.0" -authors = ["mcpplibs"] -repo = "https://github.com/mcpplibs/openarch" - -[build] -sources = ["src/*.cppm"] - -[dependencies] -openarch-abi = { path = "../abi" } - -[targets.openarch] -kind = "lib" - -# ⚠️ THE BACKEND IS SELECTED BY THE TARGET, NOT BY THE CONSUMER. -# -# A consumer writes `openarch = "0.3.1"` and says nothing about its -# architecture; the row below resolves the matching backend. That is the -# property the single-package layout had, and a split that cost it would be a -# worse arrangement wearing a better directory tree. -# -# `os = "none"` is part of the predicate for the same reason it is in the -# backends' own `cfg`: a hosted aarch64 build — a macOS runner, for instance — -# has an operating system already, and pulling a kernel's machine layer into it -# once produced ELF assembly compiled for Mach-O. -[target.'cfg(all(arch = "riscv64", os = "none"))'.dependencies] -openarch-riscv64 = { path = "../backends/riscv64" } - -[target.'cfg(all(arch = "aarch64", os = "none"))'.dependencies] -openarch-aarch64 = { path = "../backends/aarch64" } diff --git a/spec/src/context.cppm b/src/context.cppm similarity index 87% rename from spec/src/context.cppm rename to src/context.cppm index e52e6d6..8bc1e57 100644 --- a/spec/src/context.cppm +++ b/src/context.cppm @@ -37,7 +37,7 @@ module; #include -export module openarch.context; +export module mcpplibs.openarch.context; export namespace arch { @@ -81,13 +81,20 @@ struct context { // contract cannot name a C++ type; this module takes `context&` because a // caller should not be able to pass the wrong thing. The wrapper is the only // place that conversion happens, and it is `inline`, so it costs nothing. +// +// ⚠️ `arch::context_switch` AND NOT `arch::arch_context_switch`, WHICH IS WHAT +// 0.3.1 EXPORTED. The `arch_` prefix is C's way of having a namespace; repeating +// it inside `namespace arch` produced `arch::arch_context_switch` beside +// `arch::set_handler`, `arch::fence` and `arch::make_leaf` — the C++ face was +// inconsistent with itself, and only in the module that came first. Renamed at +// 0.4.0, which is the release that reorganised the package anyway. // Saves the current context into `from` and resumes `to`. // // Returns when something switches back to `from`. The first return therefore // happens in a different context from the call, which is why the assembly form // is load-bearing rather than an optimisation. -inline void arch_context_switch(context& from, context& to) noexcept { +inline void context_switch(context& from, context& to) noexcept { ::arch_context_switch(&from, &to); } @@ -101,8 +108,8 @@ inline void arch_context_switch(context& from, context& to) noexcept { // ⚠️ `entry` must not return. There is no context to return TO: the initial // return address is a trampoline that has no caller. A kernel gives each task // an entry that ends by switching away. -inline void arch_context_init(context& ctx, void (*entry)(void*), void* arg, - void* stack_top) noexcept { +inline void context_init(context& ctx, void (*entry)(void*), void* arg, + void* stack_top) noexcept { ::arch_context_init(&ctx, entry, arg, stack_top); } diff --git a/spec/src/cpu.cppm b/src/cpu.cppm similarity index 94% rename from spec/src/cpu.cppm rename to src/cpu.cppm index 96c7e1f..70f8580 100644 --- a/spec/src/cpu.cppm +++ b/src/cpu.cppm @@ -23,7 +23,7 @@ module; #include -export module openarch.cpu; +export module mcpplibs.openarch.cpu; export namespace arch { @@ -43,23 +43,23 @@ inline void set_percpu(void* p) noexcept { ::arch_cpu_set_percpu(p); } enum class barrier { // Everything before is ordered before everything after, as observed by // other agents. riscv `fence rw, rw`; aarch64 `dmb sy`. - memory, + memory = ARCH_BARRIER_MEMORY, // Stores before are ordered before stores after. Cheaper than `memory` // where a machine distinguishes the two, and identical where it does not. // riscv `fence w, w`; aarch64 `dmb st`. - store, + store = ARCH_BARRIER_STORE, // Everything before has COMPLETED, not merely been ordered. This is the // one a caller wants before touching a device register or changing a // translation. riscv `fence rw, rw` is already completion-flavoured for // device accesses; aarch64 needs `dsb sy`, which `dmb` does not provide. - complete, + complete = ARCH_BARRIER_COMPLETE, // Instruction fetch sees the writes that came before. Required after // writing code, and after changing anything the fetch path caches. riscv // `fence.i`; aarch64 `isb`. - fetch, + fetch = ARCH_BARRIER_FETCH, }; inline void fence(barrier b) noexcept { ::arch_cpu_fence(static_cast(b)); } diff --git a/src/openarch.cppm b/src/openarch.cppm new file mode 100644 index 0000000..c5da66f --- /dev/null +++ b/src/openarch.cppm @@ -0,0 +1,28 @@ +// mcpplibs.openarch — openarch's C++ face, entire. +// +// A consumer writes one dependency line and one import: +// +// import mcpplibs.openarch; +// +// and has `arch::context`, `arch::pte`, `arch::trap_frame`, `arch::percpu` and +// the rest. The four modules below remain importable on their own for a +// consumer that wants only one of them — a boot path that needs `openarch.pte` +// and has no traps yet — but nothing requires knowing they exist. +// +// ⭐ WHY AN UMBRELLA AND NOT FOUR NAMES. +// +// The four are one layer. A kernel that switches contexts also takes traps, and +// a caller forced to enumerate the parts is being asked to know the layer's +// internal division — which is exactly the knowledge a machine-abstraction +// layer exists to remove. The division is real and is worth keeping in the +// source; it is not worth putting in every consumer's manifest. +// +// ⚠️ `export import` AND NOT `import`. A plain import would make the names +// visible while compiling this module and invisible to whoever imports it, +// which compiles cleanly here and fails at every call site. +export module mcpplibs.openarch; + +export import mcpplibs.openarch.context; +export import mcpplibs.openarch.pte; +export import mcpplibs.openarch.trap; +export import mcpplibs.openarch.cpu; diff --git a/spec/src/pte.cppm b/src/pte.cppm similarity index 88% rename from spec/src/pte.cppm rename to src/pte.cppm index 85e39be..a0d3635 100644 --- a/spec/src/pte.cppm +++ b/src/pte.cppm @@ -39,17 +39,19 @@ module; #include -export module openarch.pte; +export module mcpplibs.openarch.pte; export namespace arch { // What an access may do. Ordered so that a wider permission is a superset of a // narrower one, which is what lets a caller compare them. +// ⚠️ Defined from the C contract's enumerators rather than written out again; +// see the note above them in `openarch/abi.h`. enum class perm { - read, - read_write, - read_exec, - read_write_exec, + read = ARCH_PERM_READ, + read_write = ARCH_PERM_READ_WRITE, + read_exec = ARCH_PERM_READ_EXEC, + read_write_exec = ARCH_PERM_READ_WRITE_EXEC, }; // What kind of memory is mapped. @@ -60,15 +62,15 @@ enum class perm { // A third value would have to mean the same thing on every machine openarch // serves, and "write-combining" does not. enum class memory_type { - normal, - device, + normal = ARCH_MT_NORMAL, + device = ARCH_MT_DEVICE, }; // One leaf entry. Opaque in the same sense as `context`: the value is the // architecture's, and a consumer that read its fields would be writing code // that only builds on one machine. struct pte { - unsigned long long bits; + arch_u64 bits; }; // Builds a leaf entry mapping the physical address `phys`. @@ -80,7 +82,7 @@ struct pte { // ⚠️ The entry is VALID and ACCESSED. Neither machine faults on a first touch // in the arrangement this layer serves, and leaving the accessed bit clear // costs a fault on every machine to record something nothing here reads. -inline pte make_leaf(unsigned long long phys, perm p, memory_type mt, +inline pte make_leaf(arch_u64 phys, perm p, memory_type mt, bool user) noexcept { return pte{ ::arch_pte_make_leaf(phys, static_cast(p), static_cast(mt), user ? 1 : 0) }; @@ -90,7 +92,7 @@ inline pte make_leaf(unsigned long long phys, perm p, memory_type mt, inline bool is_valid(pte e) noexcept { return ::arch_pte_valid(e.bits) != 0; } // The physical address an entry maps, or zero if it maps nothing. -inline unsigned long long phys_of(pte e) noexcept { return ::arch_pte_phys(e.bits); } +inline arch_u64 phys_of(pte e) noexcept { return ::arch_pte_phys(e.bits); } // Programs whatever the machine needs before `memory_type` is meaningful. // diff --git a/spec/src/trap.cppm b/src/trap.cppm similarity index 84% rename from spec/src/trap.cppm rename to src/trap.cppm index ef88f02..a059323 100644 --- a/spec/src/trap.cppm +++ b/src/trap.cppm @@ -38,7 +38,7 @@ module; #include -export module openarch.trap; +export module mcpplibs.openarch.trap; export namespace arch { @@ -49,13 +49,15 @@ export namespace arch { // `other`, whose numeric cause the caller can still read. riscv distinguishes // load faults from store faults and aarch64 does not report the direction in // the same field, so `page_fault` covers both and `addr` carries the address. +// ⚠️ EACH ENUMERATOR IS DEFINED FROM THE C CONTRACT'S, NOT WRITTEN OUT AGAIN. +// The two faces held the same table until 0.4.0 and agreed by inspection. enum class trap_kind { - breakpoint, // a deliberate trap instruction - page_fault, // a translation or permission failure - illegal, // an instruction the machine will not execute - unaligned, // an access the machine will not perform - interrupt, // asynchronous; `cause` names the source - other, // everything neither machine agrees about + breakpoint = ARCH_TRAP_BREAKPOINT, // a deliberate trap instruction + page_fault = ARCH_TRAP_PAGE_FAULT, // a translation or permission failure + illegal = ARCH_TRAP_ILLEGAL, // an instruction the machine will not execute + unaligned = ARCH_TRAP_UNALIGNED, // an access the machine will not perform + interrupt = ARCH_TRAP_INTERRUPT, // asynchronous; `cause` names the source + other = ARCH_TRAP_OTHER, // everything neither machine agrees about }; // What the handler is told. @@ -94,7 +96,14 @@ using trap_frame = ::arch_trap_frame; // `kind` crosses the ABI as an int. This reads it back as the enumeration, // whose order the header states. -inline trap_kind kind_of(const trap_frame& f) noexcept { +// +// ⭐ `constexpr`, and it is the ONLY function in this layer that can be. Every +// other one reaches a backend, whose definition is an instruction and is not +// available to constant evaluation. This one touches no machine: it converts a +// field a backend already wrote. Marking it says which side of that line it is +// on, and lets `tests/faces.cpp` check the conversion on a host that has no +// backend at all. +constexpr trap_kind kind_of(const trap_frame& f) noexcept { return static_cast(f.kind); } diff --git a/tests/abi_shape.cpp b/tests/abi_shape.cpp new file mode 100644 index 0000000..61da207 --- /dev/null +++ b/tests/abi_shape.cpp @@ -0,0 +1,86 @@ +// The shape of the ABI, asserted where a disagreement is a compile error. +// +// ⭐ THE FRAME IS SHARED WITH ASSEMBLY, AND ASSEMBLY CANNOT BE TOLD IT IS +// WRONG. +// +// Each backend's trap entry stub reserves exactly `sizeof(arch_trap_frame)` +// bytes above the registers it saved and stores into fixed offsets within them. +// The assembler does not consult this header; the numbers are written in the +// `.S` file. If a member is added, reordered, or changes width, the stub keeps +// writing to the old offsets and the handler keeps reading the new ones, and +// what the caller sees is a plausible-looking value in the wrong field. There +// is no crash and no diagnostic. +// +// So the layout is frozen, and freezing it means writing it down somewhere a +// build can check. That is this file. +// +// ⚠️ THE ASSERTIONS ARE WIDTH-INDEPENDENT, AND THE LESSON COST A CI FAILURE +// ELSEWHERE. openkal asserted `offsetof(modified_ns) == sizeof(kal_uintptr)`, +// which is true on a 64-bit target and false on a 32-bit one, where a four-byte +// member is followed by four bytes of padding before an eight-byte one. The +// correct assertion names the byte offset. Nothing here may be written in terms +// of a pointer's width. + +#include + +#include +#include +#include + +// ── Fixed widths everywhere ──────────────────────────────────────────────── +// +// ⚠️ RESTATED HERE EVEN THOUGH `openarch/types.h` ALREADY ASSERTS IT, BECAUSE +// THE TWO ASSERTIONS ARE ABOUT DIFFERENT THINGS. That one says the typedef +// resolved to something eight bytes wide on the compiler that read the header. +// This one says the ABI a consumer sees through `` is that +// same thing — which is what the offsets below are computed against, and what a +// backend assembled by a different compiler has to agree with. +static_assert(sizeof(arch_u64) == 8, + "the ABI's 64-bit type must be 64 bits"); +static_assert(sizeof(arch_u32) == 4); + +// ── The trap frame ───────────────────────────────────────────────────────── +static_assert(offsetof(arch_trap_frame, pc) == 0); +static_assert(offsetof(arch_trap_frame, addr) == 8); +static_assert(offsetof(arch_trap_frame, cause) == 16); +static_assert(offsetof(arch_trap_frame, kind) == 24); +// ⚠️ 28 and not `offsetof(kind) + sizeof(int)`. Writing it in terms of another +// member's size states the same number twice and would follow a mistake rather +// than catch one. +static_assert(offsetof(arch_trap_frame, instr_len) == 28); +static_assert(sizeof(arch_trap_frame) == 32, + "each backend's stub reserves this many bytes; see the .S files"); +static_assert(alignof(arch_trap_frame) == 8); + +// A trivially copyable, standard-layout aggregate: what assembly can construct +// and C can pass. A member with a constructor would make the stub's stores +// undefined rather than merely unusual. +static_assert(std::is_standard_layout_v); +static_assert(std::is_trivially_copyable_v); + +// ── The vocabulary is dense and starts at zero ───────────────────────────── +// +// Both facts are relied on: `kind_of` is a `static_cast` rather than a switch, +// and each backend's `.S` and `.cpp` map an architecture's own cause codes onto +// these by table lookup. A gap would make the cast produce a value no +// enumerator names. +static_assert(ARCH_PERM_READ == 0 && ARCH_PERM_READ_WRITE_EXEC == 3); +static_assert(ARCH_MT_NORMAL == 0 && ARCH_MT_DEVICE == 1); +static_assert(ARCH_TRAP_BREAKPOINT == 0 && ARCH_TRAP_OTHER == 5); +static_assert(ARCH_BARRIER_MEMORY == 0 && ARCH_BARRIER_FETCH == 3); + +// ── The context store ────────────────────────────────────────────────────── +// +// The ABI documents 128 bytes, 16-aligned, and each backend asserts its own +// register set fits. Nothing here can check the backends — there is none on the +// host — so what is checked is that the documented figure is expressible as an +// object a caller can declare. +struct alignas(16) context_store { unsigned char bytes[128]; }; +static_assert(sizeof(context_store) == 128); +static_assert(alignof(context_store) == 16); + +int main() { + std::printf("abi shape: trap frame %zu bytes, context store %zu bytes\n", + sizeof(arch_trap_frame), sizeof(context_store)); + return 0; +} diff --git a/tests/faces.cpp b/tests/faces.cpp new file mode 100644 index 0000000..d3ee685 --- /dev/null +++ b/tests/faces.cpp @@ -0,0 +1,90 @@ +// The two faces are one library. +// +// openarch is reachable two ways — `#include ` and +// `import mcpplibs.openarch;` — and the claim this file checks is that those +// are two spellings of one thing rather than two parallel declarations that +// happen to line up today. +// +// ⭐ THE CHECKS ARE ABOUT DERIVATION, NOT AGREEMENT, AND THE DIFFERENCE IS THE +// WHOLE VALUE OF THE FILE. +// +// "The C `ARCH_TRAP_ILLEGAL` and the C++ `trap_kind::illegal` are both 2" is an +// agreement: it can be true this morning and false this afternoon, and the only +// thing keeping it true is that somebody edits both places. "`trap_kind::illegal` +// IS `ARCH_TRAP_ILLEGAL`" is a derivation: there is one table and the other +// spelling is computed from it, so the assertions below cannot fail unless +// somebody deliberately unpicks the definition. +// +// A test that can only fail deliberately looks useless, and would be if the +// derivation were obvious in the source. It is not: an enumerator written +// `illegal = ARCH_TRAP_ILLEGAL` and one written `illegal,` compile identically +// today, because the C table happens to be 0,1,2,… in the same order. The +// second form is what this repository had until 0.4.0, and the way it would +// have failed is by someone inserting a value into the middle of one table. +// +// ⚠️ THIS RUNS ON THE HOST, WHERE THERE IS NO BACKEND. That is deliberate. The +// `backend` feature resolves to nothing on a hosted target, so nothing here +// links an implementation of the ABI, and everything asserted below is a +// property of the interface alone. If a future edit made the interface's +// declarations depend on having a backend, this file would stop building — and +// that dependency is precisely what the layering forbids. + +#include + +import mcpplibs.openarch; + +#include +#include + +// ── The types are the same types, not compatible ones ─────────────────────── +// +// `arch::trap_frame` is a `using` declaration of `::arch_trap_frame`. Were it a +// separate `struct` with the same members, this assertion is the only thing +// that would notice, and the symptom in the field would be a backend's assembly +// writing a saved register at an offset the handler reads a different field +// from. +static_assert(std::is_same_v, + "the module's trap_frame must BE the ABI's, not resemble it"); +static_assert(std::is_same_v, + "the module's handler type must BE the ABI's"); + +// ── The enumerations are defined from the ABI's ───────────────────────────── +static_assert(static_cast(arch::perm::read) == ARCH_PERM_READ); +static_assert(static_cast(arch::perm::read_write) == ARCH_PERM_READ_WRITE); +static_assert(static_cast(arch::perm::read_exec) == ARCH_PERM_READ_EXEC); +static_assert(static_cast(arch::perm::read_write_exec) == ARCH_PERM_READ_WRITE_EXEC); + +static_assert(static_cast(arch::memory_type::normal) == ARCH_MT_NORMAL); +static_assert(static_cast(arch::memory_type::device) == ARCH_MT_DEVICE); + +static_assert(static_cast(arch::trap_kind::breakpoint) == ARCH_TRAP_BREAKPOINT); +static_assert(static_cast(arch::trap_kind::page_fault) == ARCH_TRAP_PAGE_FAULT); +static_assert(static_cast(arch::trap_kind::illegal) == ARCH_TRAP_ILLEGAL); +static_assert(static_cast(arch::trap_kind::unaligned) == ARCH_TRAP_UNALIGNED); +static_assert(static_cast(arch::trap_kind::interrupt) == ARCH_TRAP_INTERRUPT); +static_assert(static_cast(arch::trap_kind::other) == ARCH_TRAP_OTHER); + +static_assert(static_cast(arch::barrier::memory) == ARCH_BARRIER_MEMORY); +static_assert(static_cast(arch::barrier::store) == ARCH_BARRIER_STORE); +static_assert(static_cast(arch::barrier::complete) == ARCH_BARRIER_COMPLETE); +static_assert(static_cast(arch::barrier::fetch) == ARCH_BARRIER_FETCH); + +// ── `kind_of` reads what a backend wrote ──────────────────────────────────── +// +// ⚠️ A `constexpr` frame and not a call to the backend, because there is no +// backend here. What is being checked is the conversion `kind_of` performs, and +// the value it converts is the one the ABI says the backend stores. +constexpr arch::trap_frame make(int kind) { + return arch::trap_frame{ 0, 0, 0, kind, 4 }; +} +static_assert(arch::kind_of(make(ARCH_TRAP_BREAKPOINT)) == arch::trap_kind::breakpoint); +static_assert(arch::kind_of(make(ARCH_TRAP_INTERRUPT)) == arch::trap_kind::interrupt); +static_assert(arch::kind_of(make(ARCH_TRAP_OTHER)) == arch::trap_kind::other); + +int main() { + // Everything above is a compile-time assertion, so reaching here is the + // result. Printing it keeps the test's output the same shape as the others' + // and gives a runner something to see. + std::printf("faces: the C header and the module declare one library\n"); + return 0; +} diff --git a/spec/tests/pte_encoding.cpp b/tests/pte_encoding.cpp similarity index 70% rename from spec/tests/pte_encoding.cpp rename to tests/pte_encoding.cpp index 367b296..2f5287d 100644 --- a/spec/tests/pte_encoding.cpp +++ b/tests/pte_encoding.cpp @@ -28,7 +28,7 @@ void check(bool ok, const char* what) { if (!ok) { std::printf("FAILED: %s\n", what); ++g_failed; } } -void check_eq(unsigned long long got, unsigned long long want, const char* what) { +void check_eq(arch_u64 got, arch_u64 want, const char* what) { if (got != want) { std::printf("FAILED: %s\n want 0x%016lx\n got 0x%016lx\n", what, want, got); @@ -40,8 +40,9 @@ void check_eq(unsigned long long got, unsigned long long want, const char* what) constexpr int kRead = 0, kReadWrite = 1, kReadExec = 2, kReadWriteExec = 3; constexpr int kNormal = 0, kDevice = 1; -constexpr unsigned long long kRvPage = 0x80200000UL; // where riscv `virt` has RAM -constexpr unsigned long long kA64Page = 0x40200000UL; // where aarch64 `virt` does +constexpr arch_u64 kRvPage = 0x80200000UL; // where riscv `virt` has RAM +constexpr arch_u64 kA64Page = 0x40200000UL; // where aarch64 `virt` does +constexpr arch_u64 kX86Page = 0x00200000UL; // where an x86 `-kernel` image sits // ── The exact encodings, derived from the manuals ─────────────────────────── // @@ -71,6 +72,17 @@ void exact_values() { check_eq(aarch64::encode_leaf(kA64Page, kReadWrite, kDevice, false), 0x0060000000000000UL | kA64Page | 0x407UL, "aarch64 device read-write kernel page"); + + // x86_64 4-level leaf: P|RW|A|D = 0x63, plus NX in bit 63 because the + // mapping is not executable. PAT index 0, so none of PWT/PCD/PAT is set. + check_eq(x86_64::encode_leaf(kX86Page, kReadWrite, kNormal, false), + 0x8000000000000000UL | kX86Page | 0x63UL, + "x86_64 normal read-write kernel page"); + + // Device: PAT index 1, which on this machine is the PWT bit alone. + check_eq(x86_64::encode_leaf(kX86Page, kReadWrite, kDevice, false), + 0x8000000000000000UL | kX86Page | 0x6BUL, + "x86_64 device read-write kernel page"); } // ── The properties both machines must satisfy ─────────────────────────────── @@ -96,13 +108,16 @@ void agreement() { for (const auto& c : cases) { const auto rv = riscv64::encode_leaf(kRvPage, c.perm, c.mt, c.user); const auto a64 = aarch64::encode_leaf(kA64Page, c.perm, c.mt, c.user); + const auto x86 = x86_64::encode_leaf(kX86Page, c.perm, c.mt, c.user); // Every entry either maps its page or is not valid; there is no third // outcome, and a mis-shifted address field produces one. check(riscv64::entry_valid(rv), "riscv64 entry is valid"); check(aarch64::entry_valid(a64), "aarch64 entry is valid"); + check(x86_64::entry_valid(x86), "x86_64 entry is valid"); check_eq(riscv64::entry_phys(rv), kRvPage, "riscv64 round-trips phys"); check_eq(aarch64::entry_phys(a64), kA64Page, "aarch64 round-trips phys"); + check_eq(x86_64::entry_phys(x86), kX86Page, "x86_64 round-trips phys"); (void)c.name; } @@ -114,8 +129,8 @@ void agreement() { // written, and an encoder ported by analogy would omit it and produce a // machine on which user memory is kernel-executable. Nothing on riscv can // fail this check; it is here for the machine that can. - constexpr unsigned long long kPxn = 1ULL << 53; - constexpr unsigned long long kUxn = 1ULL << 54; + constexpr arch_u64 kPxn = 1ULL << 53; + constexpr arch_u64 kUxn = 1ULL << 54; for (int p = kRead; p <= kReadWriteExec; ++p) { const auto user = aarch64::encode_leaf(kA64Page, p, kNormal, true); check((user & kPxn) != 0, @@ -126,7 +141,7 @@ void agreement() { } // Execution is denied unless it was asked for, on both machines. - constexpr unsigned long long kRvX = 1ULL << 3; + constexpr arch_u64 kRvX = 1ULL << 3; check((riscv64::encode_leaf(kRvPage, kReadWrite, kNormal, false) & kRvX) == 0, "riscv64 leaves X clear for a non-executable mapping"); check((aarch64::encode_leaf(kA64Page, kReadWrite, kNormal, false) & kPxn) != 0, @@ -155,8 +170,40 @@ void agreement() { // Nothing is a valid entry by accident. check(!riscv64::entry_valid(0), "riscv64 zero is not a valid entry"); check(!aarch64::entry_valid(0), "aarch64 zero is not a valid entry"); + check(!x86_64::entry_valid(0), "x86_64 zero is not a valid entry"); check_eq(riscv64::entry_phys(0), 0, "riscv64 invalid entry maps nothing"); check_eq(aarch64::entry_phys(0), 0, "aarch64 invalid entry maps nothing"); + check_eq(x86_64::entry_phys(0), 0, "x86_64 invalid entry maps nothing"); + + // ⭐ THE ASSERTION THE THIRD ARCHITECTURE EXISTS TO MAKE, AND THE ONE THAT + // RECORDS WHAT IT COULD NOT DO. + // + // The two checks above about kernel/user execution cannot be written for + // x86_64: it has ONE `NX` bit covering every privilege level, so "a user + // page is not kernel-executable" is not expressible in the entry at all. + // The rule is enforced by `CR4.SMEP`, which the backend's + // `install_memory_attributes` sets. What IS assertable here is that the + // encoder does not pretend otherwise — an executable user page and an + // executable kernel page differ only in `U/S`, and both leave `NX` clear. + constexpr arch_u64 kNx = 1ULL << 63; + constexpr arch_u64 kUs = 1ULL << 2; + const auto x_user = x86_64::encode_leaf(kX86Page, kReadExec, kNormal, true); + const auto x_kern = x86_64::encode_leaf(kX86Page, kReadExec, kNormal, false); + check((x_user & kNx) == 0 && (x_kern & kNx) == 0, + "x86_64 leaves NX clear for an executable mapping at either level"); + check_eq(x_user, x_kern | kUs, + "x86_64 user and kernel executable pages differ only in U/S"); + check((x86_64::encode_leaf(kX86Page, kReadWrite, kNormal, false) & kNx) != 0, + "x86_64 sets NX for a non-executable mapping"); + + // Device memory differs from normal memory on this machine too, and in a + // third field again: a PAT index rather than a type or an AttrIndx. + check(x86_64::encode_leaf(kX86Page, kReadWrite, kDevice, false) + != x86_64::encode_leaf(kX86Page, kReadWrite, kNormal, false), + "x86_64 distinguishes device from normal"); + check_eq(x86_64::entry_phys(x86_64::encode_leaf(kX86Page + 0xFFF, + kReadWrite, kNormal, false)), + kX86Page, "x86_64 discards sub-page bits of phys"); } } // namespace From 809684b5700d2ea589716714df2c5e7d52593729 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 21 Aug 2026 03:06:16 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=A8=A1=E6=9D=BF:=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E3=80=81=E4=B8=89=E5=8F=B0=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E8=A3=B8=E6=9C=BA=E8=B5=B7=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpp new mykernel --template openarch` 生成的工程就是探针本身:一份 src/main.cpp、三个带控制台与断电寄存器的 machine_.cpp、三份链接脚本, 以及只有 x86_64 才需要的那一百来条到达长模式的指令。 ⚠️ CI 用 sed 手工渲染模板而不是走 `mcpp new`。scaffolder 从**索引**解析 --template 且不接受路径,所以为一个「本次提交才加进来的模板」调用它,解析到的 是上一个已发布版本,而那个版本不带这个模板 —— 新模板的 CI 永远不可能在它被发布 之前通过。属于本仓库的是模板的**内容**。 ⚠️ 只在 Linux 上跑:那一步把 $PWD 写进清单,而 Windows runner 的 Git Bash 下 $PWD 是 /d/a/openarch/openarch,mcpp 要的是原生路径 —— 本生态已经被这个形状咬过 一次(一个 C++ 字符串字面量里出现了 "D:\a\openkal\openkal/include")。 --- .github/workflows/ci.yml | 49 +++++ README.md | 22 +++ templates/three-machines/README.md.in | 50 +++++ templates/three-machines/aarch64.ld | 10 + templates/three-machines/build.mcpp.in | 74 +++++++ templates/three-machines/mcpp.toml.in | 65 ++++++ templates/three-machines/riscv64.ld | 10 + templates/three-machines/src/boot_x86_64.S | 185 ++++++++++++++++++ templates/three-machines/src/machine.h | 26 +++ .../three-machines/src/machine_aarch64.cpp | 49 +++++ .../three-machines/src/machine_riscv64.cpp | 47 +++++ .../three-machines/src/machine_x86_64.cpp | 96 +++++++++ templates/three-machines/src/main.cpp | 159 +++++++++++++++ templates/three-machines/template.toml | 13 ++ templates/three-machines/x86_64.ld | 56 ++++++ 15 files changed, 911 insertions(+) create mode 100644 templates/three-machines/README.md.in create mode 100644 templates/three-machines/aarch64.ld create mode 100644 templates/three-machines/build.mcpp.in create mode 100644 templates/three-machines/mcpp.toml.in create mode 100644 templates/three-machines/riscv64.ld create mode 100644 templates/three-machines/src/boot_x86_64.S create mode 100644 templates/three-machines/src/machine.h create mode 100644 templates/three-machines/src/machine_aarch64.cpp create mode 100644 templates/three-machines/src/machine_riscv64.cpp create mode 100644 templates/three-machines/src/machine_x86_64.cpp create mode 100644 templates/three-machines/src/main.cpp create mode 100644 templates/three-machines/template.toml create mode 100644 templates/three-machines/x86_64.ld diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b5964c..472622e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,6 +309,55 @@ jobs: # header now: a target build instantiates only what it calls — measured, # zero foreign symbols in either image — and a host build that calls both # gets both, with nothing to activate. + # ⚠️ THE TEMPLATE IS RENDERED BY HAND HERE, AND IT HAS TO BE. + # + # `mcpp new --template` resolves the package from the INDEX and takes no + # path, so asking it for a template this commit ADDS would resolve the + # previously published version and fail on a template that version does + # not carry. The CI for a new template could never pass before the + # template was published, which is the wrong way round. + # + # What belongs to this repository is the template's CONTENT: that the + # files it ships generate a project which builds for all three machines. + # That the scaffolder can fetch it is mcpp's own concern and mcpp's own + # tests. + # + # ⚠️ The dependency is rewritten to a path. The rendered manifest names + # `openarch = ""`, which is correct for a user and + # unresolvable here — this version is not published yet, and a check that + # silently fell back to the previous one would be testing the wrong tree. + # + # ⚠️ LINUX ONLY, AND THAT IS A CHOICE ABOUT PATHS RATHER THAN ABOUT + # COVERAGE. The rewrite below puts `$PWD` into a manifest, and on a + # Windows runner `$PWD` under Git Bash is `/d/a/openarch/openarch` while + # mcpp wants a native path — a mismatch this repository has already been + # bitten by once, in a C++ string literal that came out as + # `"D:\a\openkal\openkal/include"`. The template's CONTENT is + # host-independent; what varies per host is the toolchain payload, and + # that is what the `portability` job covers. + - name: The template generates a project that builds for all three machines + if: runner.os == 'Linux' + run: | + set -euo pipefail + T=templates/three-machines + V=$(grep -m1 '^version' mcpp.toml | cut -d'"' -f2) + D=$(mktemp -d); mkdir -p "$D/src" + for f in mcpp.toml README.md build.mcpp; do + sed -e "s/{{project\.name}}/k/g" -e "s/{{self\.version}}/$V/g" \ + "$T/$f.in" > "$D/$f" + done + cp "$T"/*.ld "$D/" + cp "$T"/src/* "$D/src/" + sed -i.bak "s|openarch = \"$V\"|openarch = { path = \"$PWD\" }|" "$D/mcpp.toml" + rm -f "$D/mcpp.toml.bak" + grep -q "path = " "$D/mcpp.toml" || { cat "$D/mcpp.toml"; echo "the dependency rewrite did not apply"; exit 1; } + for t in riscv64-none-elf aarch64-none-elf x86_64-none-elf; do + ( cd "$D" && mcpp build --target "$t" > /dev/null 2>&1 ) || true + ( cd "$D" && mcpp build --target "$t" ) \ + || { echo "the template does not build for $t"; exit 1; } + done + echo "the template builds for riscv64, aarch64 and x86_64" + # ⚠️ THREE ENCODERS SINCE 0.4.0, AND THE THIRD SETTLED A QUESTION THE # FIRST TWO LEFT OPEN. `openarch.pte` owns `MAIR_EL1` because aarch64's # entry holds an INDEX into it rather than a memory type, while riscv's diff --git a/README.md b/README.md index 03e25b8..4fe1717 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,28 @@ The feature decides *whether* a backend is linked; the predicate decides *which* runner, for instance — has an operating system already, and matching on the architecture alone once compiled ELF assembly for Mach-O. +## Starting from it + +``` +mcpp new mykernel --template openarch +cd mykernel +mcpp run --target riscv64-none-elf +mcpp run --target aarch64-none-elf +mcpp build --target x86_64-none-elf +``` + +The template is the probe: one `src/main.cpp`, three `machine_.cpp` files +carrying the console and the power-off register, three linker scripts, and — on +x86_64 only — the hundred instructions that reach long mode. ⭐ It is worth +building for two targets and comparing the output rather than taking the claim +on trust. + +⚠️ CI renders the template by hand rather than through `mcpp new`. The +scaffolder resolves `--template` from the index and takes no path, so asking it +for a template a commit ADDS would resolve the previously published version and +fail on a template that version does not carry. What belongs here is the +template's content; that the scaffolder can fetch it is mcpp's own concern. + ## Why one repository diff --git a/templates/three-machines/README.md.in b/templates/three-machines/README.md.in new file mode 100644 index 0000000..2ad4c71 --- /dev/null +++ b/templates/three-machines/README.md.in @@ -0,0 +1,50 @@ +# {{project.name}} + +``` +mcpp run --target riscv64-none-elf +mcpp run --target aarch64-none-elf +mcpp build --target x86_64-none-elf +``` + +⭐ **The same `src/main.cpp` on all three.** That is the claim this template +exists to hand over, and it is worth checking rather than believing: build it +for two targets and compare the output. + +## What is shared and what is not + +| | | +|---|---| +| `src/main.cpp` | Everything the program does. Names no architecture except to emit a trap instruction, for which there is no portable spelling | +| `src/machine.h` | Three functions — print a character, print an integer, power off | +| `src/machine_.cpp` | The console and the power-off register, which are BOARD facts rather than architecture facts | +| `.ld` | Where the image is loaded, which is also a board fact | +| `src/boot_x86_64.S` | Reaching long mode. It has no counterpart on the other two, and that absence is the point — see below | + +## Why x86_64 has an extra file + +riscv64 and aarch64 begin executing 64-bit instructions at reset, so their half +of the machine is a four-instruction `asm` block: set a stack pointer, call C. + +x86_64 begins in 16-bit real mode, and a multiboot loader hands over in 32-bit +protected mode with paging off. Everything `openarch` assumes — long mode, a +64-bit code segment, page tables — has to be built first, in about a hundred +instructions that belong to no layer of the library. They are in the project +rather than in the backend for the same reason no linker script is in the +backend: how a particular image reaches that state is a property of how it is +LOADED, and a multiboot image, a UEFI application and a bzImage arrive by three +different routes. + +## Running it + +`riscv64` and `aarch64` run under emulators the package index carries, and +`build.mcpp` configures them. + +⚠️ **x86_64 builds but does not run from `mcpp run`,** and the manifest says so +rather than working around it. The index has no `xim:qemu-x86` — xPack publishes +QEMU per target family and has no x86 build — and a build program that fell back +to whatever `qemu-system-x86_64` happened to be on the machine would make the +build depend on what is installed. Run it by hand: + +``` +qemu-system-x86_64 -machine q35 -nographic -no-reboot -kernel +``` diff --git a/templates/three-machines/aarch64.ld b/templates/three-machines/aarch64.ld new file mode 100644 index 0000000..3c07764 --- /dev/null +++ b/templates/three-machines/aarch64.ld @@ -0,0 +1,10 @@ +/* QEMU aarch64 `virt` places a `-kernel` image at 0x40000000. */ +ENTRY(_start) +SECTIONS { + . = 0x40000000; + .text : { *(.text.entry) *(.text*) } + .rodata : { *(.rodata*) } + .data : { *(.data*) } + .bss : { __bss_start = .; *(.bss*) *(COMMON) __bss_end = .; } + . = ALIGN(16); . = . + 0x4000; __stack_top = .; +} diff --git a/templates/three-machines/build.mcpp.in b/templates/three-machines/build.mcpp.in new file mode 100644 index 0000000..5eb50b8 --- /dev/null +++ b/templates/three-machines/build.mcpp.in @@ -0,0 +1,74 @@ +import mcpp; +import std; + +// The two things this project cannot ask a board package for, because no board +// package serves both of its machines: where the image is loaded, and which +// emulator boots it. +// +// ⚠️ BOTH ARE SELECTED BY THE TARGET'S ARCHITECTURE RATHER THAN WRITTEN INTO +// THE MANIFEST. The probe's whole claim is that one project serves two +// machines; a manifest key would have to be edited between them, which would +// make that claim false in exactly the way the probe exists to disprove. +int main() { + const std::string arch = mcpp::target_arch() ? mcpp::target_arch() : ""; + + // The memory map. A relative path resolves against the PACKAGE root, which + // is this directory. + if (arch == "riscv64") mcpp::link_script("riscv64.ld"); + else if (arch == "aarch64") mcpp::link_script("aarch64.ld"); + else if (arch == "x86_64") mcpp::link_script("x86_64.ld"); + else { + std::cerr << "openarch probe: no memory map for arch '" << arch + << "'. The probe runs on the two machines openarch has " + "backends for; adding a third means adding a linker " + "script beside this file and a machine_.cpp.\n"; + return 1; + } + + // The emulator. ⚠️ Asked for by absolute path rather than named: a bare + // `qemu-system-riscv64` resolves through PATH to a shim that dispatches + // against whichever home owns it, which is not necessarily the home this + // build is using. + // + // The two packages are different — xPack builds QEMU per target family, so + // `qemu-riscv` carries only the two RISC-V emulators and `qemu-arm` only + // the two Arm ones. There is no single package that runs both machines. + // ⚠️ x86_64 IS ABSENT FROM THIS TABLE, AND THE ABSENCE IS THE ECOSYSTEM'S + // RATHER THAN THIS PROBE'S. xPack publishes QEMU per target family, and the + // index carries `qemu-riscv` and `qemu-arm` because those are the families + // it publishes. There is no `xim:qemu-x86`, so on that target this build + // program configures no runner and `mcpp build` is the whole of what CI can + // do — which is stated here rather than worked around, because a runner + // silently pointing at a host `qemu-system-x86_64` would make the build + // depend on what happens to be installed. + if (arch == "x86_64") { + mcpp::rerun_if_env_changed("MCPP_TARGET_ARCH"); + mcpp::rerun_if_changed("x86_64.ld"); + return 0; + } + + const char* pkg = (arch == "riscv64") ? "qemu-riscv" : "qemu-arm"; + const char* sys = (arch == "riscv64") ? "riscv64" : "aarch64"; + if (const char* dir = mcpp::xpkg_dir("xim", pkg); dir && *dir) { + mcpp::runner(std::format("{}/bin/qemu-system-{}", dir, sys).c_str()); + mcpp::runner("-machine"); + mcpp::runner("virt"); + // ⚠️ aarch64's `virt` has no default CPU that implements the features + // this image needs; riscv's does. Naming one on the machine that + // requires it rather than on both, so that the argument list says which + // machine needed it. + if (arch == "aarch64") { mcpp::runner("-cpu"); mcpp::runner("cortex-a53"); } + mcpp::runner("-nographic"); + mcpp::runner("-no-reboot"); + // riscv's `virt` loads OpenSBI as firmware unless told otherwise, and + // this image is the whole program. aarch64's `virt` needs no such + // instruction: with `-kernel` and no `-bios` it runs the image directly. + if (arch == "riscv64") { mcpp::runner("-bios"); mcpp::runner("none"); } + mcpp::runner("-kernel"); + } + + mcpp::rerun_if_env_changed("MCPP_TARGET_ARCH"); + mcpp::rerun_if_changed("riscv64.ld"); + mcpp::rerun_if_changed("aarch64.ld"); + return 0; +} diff --git a/templates/three-machines/mcpp.toml.in b/templates/three-machines/mcpp.toml.in new file mode 100644 index 0000000..a094b76 --- /dev/null +++ b/templates/three-machines/mcpp.toml.in @@ -0,0 +1,65 @@ +[package] +name = "{{project.name}}" +version = "0.1.0" + +# ⚠️ NO `[build] target`. This project's whole claim is that ONE source serves +# three machines, so the target arrives on the command line — `mcpp run --target +# riscv64-none-elf`, `--target aarch64-none-elf`, `mcpp build --target +# x86_64-none-elf` — and nothing in this file names any of them except the three +# source selections below. +# +# Adding `target = "..."` here would make the claim untestable, which is a +# larger loss than the convenience. +[build] +sources = ["src/main.cpp"] + +# The zero-libc tier on every machine. openarch references no C library symbol, +# and this project prints by storing to a UART (or, on x86, by an `out`) rather +# than by calling one, so a C library would be a payload nothing here calls. +[target.riscv64-none-elf] +sysroot = "" + +[target.aarch64-none-elf] +sysroot = "" + +[target.x86_64-none-elf] +sysroot = "" + +[target.'cfg(arch = "riscv64")'.build] +sources = ["src/machine_riscv64.cpp"] + +[target.'cfg(arch = "aarch64")'.build] +sources = ["src/machine_aarch64.cpp"] + +# ⚠️ TWO FILES AND NOT ONE. Every other machine's half is a single `.cpp` whose +# `asm` block carries a four-instruction entry stub. Reaching the state openarch +# assumes on x86_64 — long mode, a 64-bit code segment, page tables — takes its +# own linker section, so it is a file rather than a block. +[target.'cfg(arch = "x86_64")'.build] +sources = ["src/machine_x86_64.cpp", "src/boot_x86_64.S"] + +[dependencies] +# ⭐ ONE LINE, AND IT NAMES NEITHER AN ARCHITECTURE NOR A BACKEND. The +# `backend-auto` feature is on by default and its target-conditional +# `feature-deps` row selects the implementation for whatever `--target` this +# build was given. `mcpp new --template` fills the version in. +openarch = "{{self.version}}" + +# ⚠️ THE EMULATORS ARE DECLARED, AND THE PROBE DOES NOT WORK WITHOUT THIS. +# +# `mcpp::xpkg_dir` resolves a package the PROJECT declares, not any package that +# happens to be installed — measured: with the payload present on disk and no +# declaration here, it returned an empty string and build.mcpp emitted no +# runner, so `mcpp run` reported "no runner is configured" for a project whose +# build program had just tried to configure one. +# +# A board-support package declares its emulator in the index descriptor's +# platform `deps`, which is why an ordinary bare-metal project never writes this +# section. This probe has no board package — it cannot, because no board package +# serves both of its machines — so it declares them itself. +# +# Both, on every build: which one is used is decided by the target, and +# declaring only one would make the manifest depend on the target the same way +# a `runner` key would. +[xlings] +deps = ["qemu-riscv", "qemu-arm"] diff --git a/templates/three-machines/riscv64.ld b/templates/three-machines/riscv64.ld new file mode 100644 index 0000000..a14c513 --- /dev/null +++ b/templates/three-machines/riscv64.ld @@ -0,0 +1,10 @@ +/* QEMU riscv `virt` begins executing at 0x80000000 with `-bios none -kernel`. */ +ENTRY(_start) +SECTIONS { + . = 0x80000000; + .text : { *(.text.entry) *(.text*) } + .rodata : { *(.rodata*) } + .data : { *(.data*) } + .bss : { __bss_start = .; *(.bss*) *(COMMON) __bss_end = .; } + . = ALIGN(16); . = . + 0x4000; __stack_top = .; +} diff --git a/templates/three-machines/src/boot_x86_64.S b/templates/three-machines/src/boot_x86_64.S new file mode 100644 index 0000000..83fcdfe --- /dev/null +++ b/templates/three-machines/src/boot_x86_64.S @@ -0,0 +1,185 @@ +/* The x86_64 half of the probe's machine: reaching the state openarch assumes. + * + * ⭐ THIS FILE HAS NO COUNTERPART ON THE OTHER TWO MACHINES, AND ITS EXISTENCE + * IS THE THIRD ARCHITECTURE'S MOST VISIBLE DISAGREEMENT. + * + * riscv64 and aarch64 begin executing 64-bit instructions at reset. A probe for + * them sets a stack pointer and calls C. x86_64 begins in 16-bit real mode, and + * a multiboot loader hands over in 32-bit protected mode with paging off — so + * everything openarch's backend assumes (long mode, a 64-bit code segment, a + * stack) has to be constructed first, in about a hundred instructions that + * belong to no layer of the library. + * + * ⚠️ AND THAT IS WHY THEY ARE HERE RATHER THAN IN THE BACKEND. `backends/x86_64` + * states that it assumes long mode is already entered, for the same reason no + * backend carries a linker script: how a particular image reaches that state is + * a property of how it is LOADED, and a multiboot image, a UEFI application and + * a bzImage arrive by three different routes. A backend that picked one would + * be a bootloader wearing a machine layer's name. + */ + +/* ── The multiboot header ────────────────────────────────────────────────── + * + * Bit 16 is the "a.out kludge": the header carries the load addresses itself and + * the loader never parses the ELF. That is what lets QEMU load this image at + * all — its multiboot loader accepts only a 32-bit ELF, and this one is 64-bit. + * See x86_64.ld for why `SIZEOF_HEADERS` makes the address arithmetic come out. + * + * The checksum is defined as the value that makes the first three fields sum to + * zero in 32-bit arithmetic. */ +.set MB_MAGIC, 0x1BADB002 +.set MB_FLAGS, 0x00010000 /* bit 16: the a.out kludge */ +.set MB_CHECKSUM, -(MB_MAGIC + MB_FLAGS) + + .section .multiboot,"a",@progbits + .align 4 +mb_header: + .long MB_MAGIC + .long MB_FLAGS + .long MB_CHECKSUM + /* The five address fields the kludge requires. With them the loader copies + * bytes and jumps; it never looks at the ELF, which is what lets a 64-bit + * image be loaded by a loader that only parses 32-bit ones. */ + .long mb_header /* header_addr */ + .long __load_start /* load_addr */ + .long __load_end /* load_end_addr */ + .long __bss_end /* bss_end_addr — zeroed by the loader */ + .long _start /* entry_addr */ + +/* ── The 32-bit entry ──────────────────────────────────────────────────────*/ + .code32 + .section .text.entry,"ax",@progbits + .globl _start + .type _start, @function +_start: + cli + movl $boot_stack_top, %esp + + /* ⚠️ ZERO `.bss` BEFORE ANYTHING READS IT, AND THE PAGE TABLES ARE WHY + * THIS IS NOT OPTIONAL HERE. On the other two machines an unzeroed `.bss` + * gives the probe a wrong counter. Here it gives the processor a page table + * full of whatever the previous occupant of that memory left, and the walk + * reads those bytes as present entries pointing at addresses that are not + * page tables. */ + movl $__bss_start, %edi + movl $__bss_end, %ecx + subl %edi, %ecx + xorl %eax, %eax + rep stosb + + /* PML4[0] → PDPT, PDPT[0] → PD. Present, writable. */ + movl $pdpt, %eax + orl $0x03, %eax + movl %eax, pml4 + + movl $pd, %eax + orl $0x03, %eax + movl %eax, pdpt + + /* PD[i] maps a 2 MiB page at i * 2 MiB, identity, for the first gigabyte. + * + * ⚠️ 2 MiB PAGES RATHER THAN 1 GiB ONES. A single PDPT entry with `PS` set + * would map the whole gigabyte in one line of code, and 1 GiB pages are an + * OPTIONAL feature — `cpuid` leaf 0x80000001, `edx` bit 26. Emulators and + * processors that lack it do not fault on the attempt; they interpret the + * `PS` bit as reserved and take a page fault on the first access, from + * inside the code that has just enabled paging. */ + xorl %ecx, %ecx +1: movl %ecx, %eax + shll $21, %eax + orl $0x83, %eax /* present | writable | page-size */ + movl %eax, pd(,%ecx,8) + incl %ecx + cmpl $512, %ecx + jb 1b + + movl $pml4, %eax + movl %eax, %cr3 + + /* CR4.PAE. Long mode requires it; without it the mode switch below is a + * #GP rather than a transition. */ + movl %cr4, %eax + orl $(1 << 5), %eax + movl %eax, %cr4 + + /* EFER: LME to enable long mode, NXE so that bit 63 of a page-table entry + * means "no execute" rather than "reserved". + * + * ⚠️ NXE IS NOT DECORATION HERE. `openarch.pte`'s encoder sets that bit for + * a non-executable mapping. With NXE clear the processor treats it as a + * reserved bit and every access through such an entry is a page fault — + * which is the encoder producing entries that are correct by the manual and + * fatal on the machine. The backend's `install_memory_attributes` sets it + * too, for a kernel that reaches this layer by some other route. */ + movl $0xC0000080, %ecx + rdmsr + orl $((1 << 8) | (1 << 11)), %eax + wrmsr + + /* CR0.PG. Paging on; the processor is in long mode from this instruction, + * but still executing in a 32-bit compatibility segment. */ + movl %cr0, %eax + orl $(1 << 31), %eax + movl %eax, %cr0 + + lgdt gdt64_descriptor + ljmp $0x08, $long_mode_entry + .size _start, . - _start + +/* ── The 64-bit entry ──────────────────────────────────────────────────────*/ + .code64 + .section .text.entry64,"ax",@progbits + .type long_mode_entry, @function +long_mode_entry: + /* ⚠️ THE DATA SEGMENTS STILL HOLD 32-BIT SELECTORS AND MUST BE RELOADED. + * In long mode the processor ignores the base and limit of `ds`, `es` and + * `ss`, which makes it easy to believe they need no attention — but their + * SELECTORS are still checked, and one left pointing at a descriptor the + * new GDT does not define faults on the first stack operation. */ + movw $0x10, %ax + movw %ax, %ds + movw %ax, %es + movw %ax, %ss + movw %ax, %fs + movw %ax, %gs + + movq $__stack_top, %rsp + xorq %rbp, %rbp + + call kmain +1: hlt + jmp 1b + .size long_mode_entry, . - long_mode_entry + +/* ── The descriptor table ────────────────────────────────────────────────── + * + * Three entries: the required null descriptor, a 64-bit code segment, and a + * data segment. + * + * ⚠️ THE `L` BIT — BIT 53, THE `0xA` IN THE SIXTH BYTE — IS WHAT MAKES THE CODE + * SEGMENT 64-BIT, AND `D` MUST BE CLEAR BESIDE IT. A descriptor with both set + * is reserved: the far jump below faults instead of transferring, and the fault + * is delivered through an IDT that does not exist yet, so the machine triple- + * faults and the emulator resets with nothing printed. */ + .section .rodata.gdt,"a",@progbits + .align 8 +gdt64: + .quad 0x0000000000000000 /* null */ + .quad 0x00AF9A000000FFFF /* code: P, DPL0, execute/read, L=1, D=0 */ + .quad 0x00AF92000000FFFF /* data: P, DPL0, read/write */ +gdt64_end: + +gdt64_descriptor: + .word gdt64_end - gdt64 - 1 + .long gdt64 + +/* ── Storage ───────────────────────────────────────────────────────────────*/ + .section .bss + .align 4096 +pml4: .skip 4096 +pdpt: .skip 4096 +pd: .skip 4096 +boot_stack: .skip 4096 +boot_stack_top: + + .section .note.GNU-stack,"",@progbits diff --git a/templates/three-machines/src/machine.h b/templates/three-machines/src/machine.h new file mode 100644 index 0000000..38c35f3 --- /dev/null +++ b/templates/three-machines/src/machine.h @@ -0,0 +1,26 @@ +/* The three things a probe needs from a machine, and nothing else. + * + * ⚠️ THIS FILE EXISTS SO THAT THE PROBE ITSELF DOES NOT. + * + * The property under test is that ONE piece of code compiles and runs on two + * genuinely different instruction sets. That test is worthless if the code is + * written twice, and it is also worthless if the difference is hidden — so the + * difference is put here, in three functions and two implementations of about + * thirty lines each, and everything else is shared. + * + * A console address and a power-off register are BOARD facts. openarch does not + * carry them, and a probe that depended on a board package would be limited to + * the architectures that have one — which today is one architecture, which is + * the situation the gate exists to leave. + */ +#ifndef OPENARCH_PROBE_MACHINE_H +#define OPENARCH_PROBE_MACHINE_H + +namespace machine { +void putc(char c); +void print(const char* s); +void print_int(int v); +[[noreturn]] void poweroff(int code); +} // namespace machine + +#endif diff --git a/templates/three-machines/src/machine_aarch64.cpp b/templates/three-machines/src/machine_aarch64.cpp new file mode 100644 index 0000000..ee68e5d --- /dev/null +++ b/templates/three-machines/src/machine_aarch64.cpp @@ -0,0 +1,49 @@ +// The aarch64 half of the probe's machine: QEMU's `virt`. +#include "machine.h" + +extern "C" int probe_main(); + +namespace { +// The PL011 UART. ⚠️ A different device at a different address from the +// riscv64 machine's 16550A — which is the point: nothing about the probe above +// changes, and everything about reaching a terminal does. +volatile unsigned int* const kUart = reinterpret_cast(0x09000000); +} // namespace + +namespace machine { + +void putc(char c) { *kUart = static_cast(c); } +void print(const char* s) { while (s && *s) putc(*s++); } + +void print_int(int v) { + if (v < 0) { putc('-'); v = -v; } + char d[12]; int n = 0; + do { d[n++] = static_cast('0' + v % 10); v /= 10; } while (v); + while (n-- > 0) putc(d[n]); +} + +// ⚠️ NO SYSCON HERE, AND THE DIFFERENCE IS REAL RATHER THAN AN OMISSION. +// +// QEMU's aarch64 `virt` has no memory-mapped power-off register of the kind +// riscv's syscon provides. Shutdown goes through PSCI, a firmware call — +// `SYSTEM_OFF` is function 0x84000008, reached by `hvc` when the machine +// starts at EL1 under QEMU's default configuration. +[[noreturn]] void poweroff(int code) { + (void)code; // PSCI SYSTEM_OFF carries no status + register unsigned long x0 asm("x0") = 0x84000008UL; + asm volatile("hvc #0" :: "r"(x0) : "memory"); + for (;;) { asm volatile("wfi"); } +} + +} // namespace machine + +extern "C" [[noreturn]] void kmain() { machine::poweroff(probe_main()); } + +asm(".section .text.entry,\"ax\",@progbits\n" + ".globl _start\n" + "_start:\n" + " ldr x30, =__stack_top\n" + " mov sp, x30\n" + " bl kmain\n" + "1:\n" + " b 1b\n"); diff --git a/templates/three-machines/src/machine_riscv64.cpp b/templates/three-machines/src/machine_riscv64.cpp new file mode 100644 index 0000000..b2f2b67 --- /dev/null +++ b/templates/three-machines/src/machine_riscv64.cpp @@ -0,0 +1,47 @@ +// The riscv64 half of the probe's machine: QEMU's `virt`. +#include "machine.h" + +extern "C" int probe_main(); + +namespace { +volatile unsigned char* const kUart = reinterpret_cast(0x10000000); +volatile unsigned int* const kPowerOff = reinterpret_cast(0x100000); +} // namespace + +namespace machine { + +void putc(char c) { *kUart = static_cast(c); } +void print(const char* s) { while (s && *s) putc(*s++); } + +// ⚠️ Shared by both machines in behaviour but written twice, because a third +// translation unit for six lines would be a file whose only purpose is to be +// shared. If a fourth machine arrives, that trade changes. +void print_int(int v) { + if (v < 0) { putc('-'); v = -v; } + char d[12]; int n = 0; + do { d[n++] = static_cast('0' + v % 10); v /= 10; } while (v); + while (n-- > 0) putc(d[n]); +} + +// QEMU's `virt` syscon: 0x5555 is "pass", and the exit status the emulator +// reports is derived from it. Ending on the firmware's terms rather than on a +// timeout is what lets CI read a verdict. +[[noreturn]] void poweroff(int code) { + *kPowerOff = code == 0 ? 0x5555u : 0x3333u; + for (;;) { } +} + +} // namespace machine + +// The entry point. `.text.entry` is placed first by the linker script, because +// execution begins at the load address rather than at whichever function the +// linker happened to put there. +extern "C" [[noreturn]] void kmain() { machine::poweroff(probe_main()); } + +asm(".section .text.entry,\"ax\",@progbits\n" + ".globl _start\n" + "_start:\n" + " la sp, __stack_top\n" + " call kmain\n" + "1:\n" + " j 1b\n"); diff --git a/templates/three-machines/src/machine_x86_64.cpp b/templates/three-machines/src/machine_x86_64.cpp new file mode 100644 index 0000000..2e548b7 --- /dev/null +++ b/templates/three-machines/src/machine_x86_64.cpp @@ -0,0 +1,96 @@ +// The x86_64 half of the probe's machine: QEMU's `pc`/`q35`. +// +// ⭐ THE CONSOLE IS NOT MEMORY, WHICH IS THE THIRD MACHINE'S QUIETEST +// DISAGREEMENT. +// +// Both other halves of this probe reach a terminal by storing to an address: +// riscv's 16550A at 0x10000000, aarch64's PL011 at 0x09000000. x86 has a +// SEPARATE ADDRESS SPACE for devices, reached only by the `in` and `out` +// instructions, and no pointer can name port 0x3F8. A `machine.h` that had +// offered "the console's address" instead of "print a character" would have +// been an interface that two machines could implement and a third could not. +#include "machine.h" + +extern "C" int probe_main(); + +namespace { + +constexpr unsigned short kCom1 = 0x3F8; + +// Line status register bit 5: the transmit holding register is empty. +constexpr unsigned char kThre = 0x20; + +inline void outb(unsigned short port, unsigned char v) noexcept { + asm volatile("outb %0, %1" :: "a"(v), "Nd"(port)); +} + +inline void outw(unsigned short port, unsigned short v) noexcept { + asm volatile("outw %0, %1" :: "a"(v), "Nd"(port)); +} + +inline unsigned char inb(unsigned short port) noexcept { + unsigned char v; + asm volatile("inb %1, %0" : "=a"(v) : "Nd"(port)); + return v; +} + +// ⚠️ A NAMESPACE-SCOPE `bool` AND NOT A FUNCTION-LOCAL `static`. A local static +// with a non-constant initialiser needs `__cxa_guard_acquire`, which lives in +// the C++ runtime this image does not link. The compiler emits the call without +// complaint and the link fails naming a symbol that appears nowhere in the +// source. +bool g_uart_ready = false; + +void uart_init() noexcept { + outb(kCom1 + 1, 0x00); // no interrupts + outb(kCom1 + 3, 0x80); // DLAB: the next two writes are the divisor + outb(kCom1 + 0, 0x01); // 115200 baud + outb(kCom1 + 1, 0x00); + outb(kCom1 + 3, 0x03); // 8 bits, no parity, one stop bit + outb(kCom1 + 2, 0xC7); // enable and clear the FIFOs + outb(kCom1 + 4, 0x03); // data terminal ready, request to send + g_uart_ready = true; +} + +} // namespace + +namespace machine { + +void putc(char c) { + if (!g_uart_ready) uart_init(); + while ((inb(kCom1 + 5) & kThre) == 0) { } + outb(kCom1, static_cast(c)); +} + +void print(const char* s) { while (s && *s) putc(*s++); } + +void print_int(int v) { + if (v < 0) { putc('-'); v = -v; } + char d[12]; int n = 0; + do { d[n++] = static_cast('0' + v % 10); v /= 10; } while (v); + while (n-- > 0) putc(d[n]); +} + +// ⚠️ TWO PORTS, BECAUSE QEMU MOVED THE REGISTER AND BOTH SPELLINGS ARE STILL +// IN THE FIELD. Writing 0x2000 to the ACPI PM1a control block requests soft-off; +// QEMU's `q35` and modern `pc` place that block at 0x604, and versions before +// 2.0 placed it at 0xB004. Neither write faults on a machine that does not +// decode the port, so issuing both costs nothing and removes a dependency on +// which QEMU is installed. +// +// The `hlt` loop is what runs if neither is decoded — a machine that will not +// power itself off, which is a correct outcome rather than a hang to diagnose. +[[noreturn]] void poweroff(int code) { + (void)code; // the ACPI request carries no status + outw(0x604, 0x2000); + outw(0xB004, 0x2000); + for (;;) { asm volatile("hlt"); } +} + +} // namespace machine + +// ⚠️ NO `_start` HERE. The other two machine files carry an entry stub in an +// `asm` block, because on those machines the entry sets a stack pointer and +// calls C. Reaching that state on x86_64 takes a hundred instructions and its +// own linker section, so it lives in `boot_x86_64.S` beside this file. +extern "C" [[noreturn]] void kmain() { machine::poweroff(probe_main()); } diff --git a/templates/three-machines/src/main.cpp b/templates/three-machines/src/main.cpp new file mode 100644 index 0000000..cc656b2 --- /dev/null +++ b/templates/three-machines/src/main.cpp @@ -0,0 +1,159 @@ +// The gate: one probe, two instruction sets. +// +// WHAT THIS ASSERTS, AND WHY IT IS THE WHOLE POINT +// +// An interface shaped around one instruction set always fits it. The only way +// to learn that `arch::context` is a real abstraction rather than a +// transcription of riscv64's register file is to run the same source on a +// machine whose register file, calling convention and stack rules are +// different. aarch64 is that machine: different callee-saved set, different +// stack alignment rule, a link register instead of a return-address register. +// +// Three observations, each of which would fail differently if the abstraction +// were wrong: +// +// 1. the switch reaches the task — the saved PC/LR is right +// 2. the task's argument arrives — the argument survives a transfer +// that restores no argument register +// 3. a callee-saved local survives — the switch really saves and +// the round trip restores the callee-saved set +// +// ⚠️ Observation 3 is the one that catches a half-correct backend. A switch +// that saves the return address and the stack pointer and nothing else passes +// the first two and corrupts the caller. +// ⭐ ONE IMPORT. The layer divides into four modules and a consumer does not +// have to know that; `mcpplibs.openarch` re-exports them. Importing the parts +// individually still works and is what a boot path that needs only `pte` would +// do. +import mcpplibs.openarch; + +#include "machine.h" + +namespace { + +arch::context g_main; +arch::context g_task; + +alignas(16) unsigned char g_stack[4096]; + +volatile int g_witness = 0; + +[[noreturn]] void task(void* arg) { + machine::print("task: arg="); + machine::print_int(static_cast(reinterpret_cast(arg))); + machine::putc('\n'); + g_witness = 7; + arch::context_switch(g_task, g_main); + // Unreachable: nothing switches back to this context. + for (;;) { } +} + +} // namespace + + +// ── The trap interface, on both machines ──────────────────────────────────── +// +// ⭐ THE ASSERTION THAT COULD NOT BE WRITTEN WITH ONE BACKEND. +// +// riscv delivers every trap to one address and puts the cause in a register. +// aarch64 delivers to one of sixteen slots and puts half the cause in WHICH +// SLOT RAN. A `breakpoint` that arrives correctly classified on both is +// therefore evidence that the interface hid a structural difference rather +// than a naming one. +// +// The handler advances `pc` past the trapping instruction by `instr_len`, +// which the backend fills in. ⚠️ An earlier version of this probe advanced by +// four, on the reading that both trap instructions are four bytes. On rv64gc +// the assembler emits the two-byte `c.ebreak`, so the handler landed inside the +// next instruction and the machine looped on `illegal_instruction` forever. +// That is what added `instr_len` to the interface. +namespace { + +volatile int g_trapped = 0; + +// ⚠️ A POINTER, WHICH IS THE ABI'S SPELLING. The interface takes the C +// contract's signature rather than wrapping it in a reference, because a +// wrapper would need a thunk and a thunk would need a global in the module +// interface that every importer instantiates. +void on_trap(arch::trap_frame* f) { + if (arch::kind_of(*f) == arch::trap_kind::breakpoint) { + g_trapped = 1; + f->pc += f->instr_len; + } +} + +void probe_trap() { + arch::set_handler(&on_trap); + machine::print("trap: raising\n"); +#if defined(__riscv) + asm volatile("ebreak"); +#elif defined(__aarch64__) + asm volatile("brk #0"); +#elif defined(__x86_64__) + // ⚠️ AND THIS ONE IS A TRAP RATHER THAN A FAULT, WHICH THE HANDLER ABOVE + // NEVER LEARNS. x86_64 reports `int3` with `RIP` already past it, where + // both RISC machines report the address of the trapping instruction. The + // backend normalises that before the handler runs — walking `pc` back and + // setting `instr_len` to match — so `f->pc += f->instr_len` resumes in the + // same place on all three. The alternative was to tell every handler ever + // written that `pc` means something different here. + asm volatile("int3"); +#else +# error "the probe has no breakpoint instruction for this architecture" +#endif + machine::print("trap: back, witness="); + machine::print_int(g_trapped); + machine::putc('\n'); +} + +// ── The per-CPU pointer and the barriers ─────────────────────────────────── +// +// The pointer is a round trip: what a kernel stores is what it reads back, and +// nothing in between touches it. The barriers are executed rather than +// inspected — there is no architectural way to observe that a fence happened, +// so what is asserted is that all four are ACCEPTED and that the program +// continues, which is what catches a backend that emitted an instruction the +// machine does not have. +int g_percpu_area = 0; + +void probe_cpu() { + arch::set_percpu(&g_percpu_area); + const bool same = (arch::percpu() == &g_percpu_area); + + arch::fence(arch::barrier::memory); + arch::fence(arch::barrier::store); + arch::fence(arch::barrier::complete); + arch::fence(arch::barrier::fetch); + + machine::print(same ? "cpu: percpu round-trips\n" : "cpu: percpu FAILED\n"); + machine::print("cpu: four barriers accepted\n"); +} + +} // namespace + +extern "C" int probe_main() { + arch::context_init(g_task, &task, reinterpret_cast(42L), + g_stack + sizeof g_stack); + + // ⚠️ `volatile` and read after the round trip. A plain local would be + // allowed to live in a caller-saved register or be re-materialised, and + // then it would prove nothing about what the switch preserved. + volatile int before = 1234; + + machine::print("main: switching to task\n"); + arch::context_switch(g_main, g_task); + + machine::print("main: back, witness="); + machine::print_int(g_witness); + machine::print(" before="); + machine::print_int(static_cast(before)); + machine::putc('\n'); + + probe_trap(); + probe_cpu(); + + const bool ok = (g_witness == 7 && before == 1234 && g_trapped == 1 + && arch::percpu() == &g_percpu_area); + machine::print(ok ? "switch ok\n" : "switch FAILED\n"); + return ok ? 0 : 1; +} diff --git a/templates/three-machines/template.toml b/templates/three-machines/template.toml new file mode 100644 index 0000000..97a085a --- /dev/null +++ b/templates/three-machines/template.toml @@ -0,0 +1,13 @@ +[template] +description = "A bare-metal starting point that targets three instruction sets from one source: riscv64, aarch64 and x86_64" +default = true + +# ⚠️ THE SELF-DEPENDENCY IS INJECTED, AND UNLIKE A BOARD PACKAGE'S TEMPLATE IT +# NEEDS NO FEATURE. +# +# `openarch = "0.4.0"` is the whole of it: the `backend-auto` feature is on by +# default and resolves to whichever backend matches the `--target` the project +# is built for. A template that named a feature here would be writing one +# architecture into a project whose subject is that it does not have one. +[template.inject] +self = true diff --git a/templates/three-machines/x86_64.ld b/templates/three-machines/x86_64.ld new file mode 100644 index 0000000..3e195ae --- /dev/null +++ b/templates/three-machines/x86_64.ld @@ -0,0 +1,56 @@ +/* QEMU's x86 machines load a multiboot `-kernel` image at the addresses its + * multiboot header names. + * + * ⚠️ QEMU'S MULTIBOOT LOADER ACCEPTS ONLY A 32-BIT ELF, AND THIS IMAGE IS A + * 64-BIT ONE. Measured, with the image linked the obvious way: + * + * qemu-system-x86_64: Cannot load x86-64 image, give a 32bit one. + * + * There is no way to produce an ELF32 here — the code is x86-64 and an ELF's + * class is a property of the whole file — so the image is loaded through + * multiboot's OTHER route: the "a.out kludge", flag bit 16, under which the + * header carries explicit load addresses and the loader never parses the ELF + * at all. GRUB2 accepts ELF64 and does not need this; QEMU does. + * + * ⭐ `SIZEOF_HEADERS` IS WHAT MAKES THE KLUDGE'S ARITHMETIC COME OUT. + * + * The loader computes the file offset it starts copying from as + * + * header_addr - load_addr + * + * so that difference must equal the multiboot header's ACTUAL offset in the + * file. Starting the image at `0x100000 + SIZEOF_HEADERS` makes the ELF + * headers occupy exactly the bytes between `load_addr` and `header_addr`: + * offset and address stay congruent, the linker emits no padding, and the + * difference is the offset by construction rather than by a number somebody + * has to keep correct. + * + * Linking it the plain way — `. = 0x100000` — put `.multiboot` at file offset + * 0x1000 with address 0x100000, so `load_addr` would have had to be 0xFF000, + * inside the legacy BIOS window where a write is discarded. + */ +ENTRY(_start) +SECTIONS { + __load_start = 0x100000; + . = __load_start + SIZEOF_HEADERS; + + .multiboot : { KEEP(*(.multiboot)) } + .text : { *(.text.entry) *(.text.entry64) *(.text*) } + .rodata : { *(.rodata*) } + .data : { *(.data*) } + __load_end = .; + + /* ⚠️ 4 KiB, BECAUSE THE PAGE TABLES LIVE HERE. The boot stub builds a + * PML4, a PDPT and a PD in `.bss`, and the processor requires each to be + * page-aligned; a table at a lower alignment is not diagnosed, it is walked + * with the low bits of its address read as flags. */ + .bss ALIGN(4096) : { + __bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(16); + __bss_end = .; + } + + . = ALIGN(16); . = . + 0x4000; __stack_top = .; +}