From d2e2c02240e8f587b2ed3709fe203fac0a12b52f Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Sat, 5 Sep 2026 22:44:49 +0530 Subject: [PATCH] test(qemu): run no_std code on emulated Cortex-M0 and M3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #20 added a CI matrix proving the crate compiles for 8 bare-metal targets. `cargo check` does not link and executes nothing, so the `critical-section` path on Cortex-M0 was still an unverified claim. New `qemu-test/` crate links a real `cortex-m-rt` binary and boots it under `qemu-system-arm`, on two machines: -cpu cortex-m3 thumbv7m — portable-atomic's spinlock fallback -machine microbit thumbv6m — Cortex-M0, no CAS instruction at all The second is the point. `MetaWord::on_access` runs an AtomicU64 CAS on every `get` hit, and on ARMv6-M that can only work through `critical-section`, so a passing `get` there is runtime proof. microbit (nRF51822) is the only emulated ARM machine that is actually thumbv6m. 12 assertions: capacity, empty state, hit/miss, insert past 4x capacity to force eviction then peek every key (absence is legal, a value the key was never stored with is not), eviction_count, TTL live-then-expired by insertion count, remove. Failures exit EXIT_FAILURE through semihosting, which reaches `cargo run` as a non-zero exit; confirmed by breaking one assertion on purpose. Also measured, and now documented: a map costs 128 bytes per bucket up front — 64 for the Bucket, 4x16 for its SlotTTL entries — not the 64 the cache-line framing suggests. A 64-bucket map exhausted an 8 KiB heap. riscv32imc stays compile-checked only: qemu-system-riscv32 -machine virt has the A extension, so it would test a target that doesn't need the feature. --- .github/workflows/build.yml | 35 ++++++++ CHANGELOG.md | 10 +++ README.md | 24 +++++- qemu-test/.cargo/config.toml | 9 ++ qemu-test/.gitignore | 2 + qemu-test/Cargo.toml | 27 ++++++ qemu-test/memory.x | 8 ++ qemu-test/src/main.rs | 154 +++++++++++++++++++++++++++++++++++ 8 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 qemu-test/.cargo/config.toml create mode 100644 qemu-test/.gitignore create mode 100644 qemu-test/Cargo.toml create mode 100644 qemu-test/memory.x create mode 100644 qemu-test/src/main.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19e5a0a..592cb41 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,6 +148,41 @@ jobs: - name: Check no_std compilation run: cargo check -p pulse_map --target ${{ matrix.target }} --no-default-features ${{ matrix.extra }} + # ═══════════════════════════════════════════════════════════ + # QEMU (runs the no_std code, instead of only compiling it) + # ═══════════════════════════════════════════════════════════ + # The no_std job above proves the crate compiles for these targets; cargo check + # does not even link. This one boots a real ELF on an emulated Cortex-M and + # exercises MetaWord's AtomicU64 CAS at runtime. Cortex-M0 is the case that + # matters: it has no CAS instruction at all, so the `critical-section` feature + # is the only thing making the map work there. + qemu: + name: QEMU (${{ matrix.machine }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { target: thumbv7m-none-eabi, machine: cortex-m3, extra: "" } + - { target: thumbv6m-none-eabi, machine: cortex-m0, extra: "--features m0" } + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: qemu-test + # qemu-system-arm only; its qemu-efi-aarch64 recommend is 322 MB of + # aarch64 UEFI firmware that nothing here boots. + - name: Install QEMU + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends qemu-system-arm + # Separate workspace with its own runner in .cargo/config.toml, so this + # cannot be driven from the repo root. + - name: Run on emulated hardware + working-directory: qemu-test + run: cargo run --release --target ${{ matrix.target }} ${{ matrix.extra }} + # ═══════════════════════════════════════════════════════════ # MSRV (Minimum Supported Rust Version) # ═══════════════════════════════════════════════════════════ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1c206..edf97b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,16 @@ A validation release, not a feature release: it exists to prove the correctness - `fail-fast: false`, so one unsupported target can't mask the state of the other seven - Scope, stated plainly: these are compile checks. Nothing was executed on real silicon or under QEMU, and `cargo check` does not link — a downstream binary on thumbv6m or riscv32imc still has to supply the `critical-section` impl or it fails at link time + +**Runs on emulated Cortex-M hardware — PR #21** +- PR #20 proved the crate *compiles* for 8 bare-metal targets. `cargo check` does not link and never executes an instruction, so the `critical-section` path on Cortex-M0 was still an untested claim. New `qemu-test/` crate closes that: a real `cortex-m-rt` binary, linked with a panic handler and a `memory.x`, booted under `qemu-system-arm` in CI +- **Cortex-M0 is the case that matters.** ARMv6-M has no CAS instruction, so `MetaWord`'s `AtomicU64` can only work through `critical-section` — and `MetaWord::on_access` runs that CAS on every `get` hit. `-machine microbit` (nRF51822) is the only emulated ARM machine that is actually `thumbv6m`, so it is the only one that exercises the path. `-cpu cortex-m3` (`thumbv7m`, portable-atomic's spinlock fallback) runs alongside it as the control +- 12 assertions: capacity, empty state, hit/miss, insert past 4× capacity to force eviction, then `peek` every key checking that no key ever reads back a value it was not stored with (absence is legal — which of a bucket's 4 slots loses is not observable — a wrong value never is), `eviction_count() > 0`, TTL live-then-expired by insertion count, and `remove` +- Failures are real failures: the checks report through semihosting and exit `EXIT_FAILURE`, which propagates as a non-zero process exit to `cargo run` and fails the job. Verified by deliberately breaking one assertion and confirming CI-visible exit 1 +- **Measured: a map costs 128 bytes per bucket**, allocated upfront regardless of occupancy — 64 B for the cache-line `Bucket` plus 4 × 16 B of `SlotTTL` for its slots. Found the hard way: a 64-bucket map exhausted an 8 KiB heap. The test now runs 16 buckets / 2 KiB and prints its own heap usage (3072 B for three maps) into the CI log. The README's 40.0 B/entry figure is consistent with this, but bucket count, not entry count, is the number to budget with on a 16 KiB part +- Still not covered, deliberately: `riscv32imc` (ESP32-C3). `qemu-system-riscv32 -machine virt` has the A extension, so emulating it would test a target that does not need the feature. And nothing has run on physical silicon +- `qemu-test/` is its own workspace with its own `.cargo/config.toml` runner, the same isolation `fuzz/` uses, so it never affects a host build of `pulse_map` + --- ## [v0.6.4] — 2026-08-19 diff --git a/README.md b/README.md index 2e52596..6eba545 100644 --- a/README.md +++ b/README.md @@ -394,9 +394,27 @@ ESP32-C3. pulse_map = { version = "0.6", default-features = false, features = ["critical-section"] } ``` -These are compile checks — nothing here has been executed on real silicon or under -QEMU, and `cargo check` doesn't link, so supplying that `critical-section` impl is -still on you. +**Executed, not just compiled.** Two of those targets also run in CI, on emulated +hardware rather than a `cargo check`: `qemu-test/` links a real `cortex-m-rt` +binary and boots it under `qemu-system-arm` — `-cpu cortex-m3` and +`-machine microbit` (nRF51822, Cortex-M0). It builds a map, inserts past capacity +to force eviction, checks no key ever reads back a value it wasn't stored with, +and exercises TTL expiry and `remove`; a failed check exits non-zero through +semihosting, so CI catches it. Every `get` hit runs `MetaWord`'s `AtomicU64` CAS, +which is the whole point on Cortex-M0 — that chip has no CAS instruction, so the +map works there only through `critical-section`. That path is now known to work at +runtime, not merely to typecheck. + +The remaining gap: `riscv32imc` (ESP32-C3) is still compile-checked only. +`qemu-system-riscv32 -machine virt` has the A extension, so emulating it would +test a different target than the one that needs the feature. And nothing here has +run on physical silicon. + +**Sizing it for a small part.** A map costs **128 bytes per bucket**, allocated up +front regardless of how many entries you store: 64 B for the cache-line `Bucket`, +plus 4 × 16 B of TTL metadata for its four slots. So `TypedPulseMap::new(16)` is +2 KiB for 64 nominal slots — the size the QEMU test uses, since a micro:bit has +16 KiB of RAM in total. Budget by bucket count, not by entry count. **Memory.** Measured as RSS delta in a fresh child process per cache, capacity 65,536 entries, filled to capacity, divided by entries actually resident: diff --git a/qemu-test/.cargo/config.toml b/qemu-test/.cargo/config.toml new file mode 100644 index 0000000..3520d41 --- /dev/null +++ b/qemu-test/.cargo/config.toml @@ -0,0 +1,9 @@ +# `cargo run` hands the ELF straight to QEMU. Semihosting is what lets the guest +# print and set the process exit code, so a failed assert fails the CI job. +[target.thumbv7m-none-eabi] +runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel" +rustflags = ["-C", "link-arg=-Tlink.x"] + +[target.thumbv6m-none-eabi] +runner = "qemu-system-arm -machine microbit -nographic -semihosting-config enable=on,target=native -kernel" +rustflags = ["-C", "link-arg=-Tlink.x"] diff --git a/qemu-test/.gitignore b/qemu-test/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/qemu-test/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/qemu-test/Cargo.toml b/qemu-test/Cargo.toml new file mode 100644 index 0000000..e29aa86 --- /dev/null +++ b/qemu-test/Cargo.toml @@ -0,0 +1,27 @@ +# Own [workspace] so this never joins the parent: it only ever builds for +# bare-metal targets and carries its own .cargo/config.toml runner. Same reason +# fuzz/ is isolated. +[workspace] + +[package] +name = "pulse_map-qemu-test" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +pulse_map = { path = "..", default-features = false } +cortex-m = "0.7" +cortex-m-rt = "0.7" +cortex-m-semihosting = "0.5" +# `exit` matters in CI: a panic must end the QEMU process, not spin forever. +panic-semihosting = { version = "0.6", features = ["exit"] } + +[features] +# Cortex-M0/M0+ has no atomic CAS, so pulse_map's AtomicU64 goes through a +# critical section — and the impl belongs to the HAL, here cortex-m. +m0 = ["pulse_map/critical-section", "cortex-m/critical-section-single-core"] + +[profile.release] +# Costs nothing at runtime and makes a QEMU backtrace readable. +debug = true diff --git a/qemu-test/memory.x b/qemu-test/memory.x new file mode 100644 index 0000000..a80c662 --- /dev/null +++ b/qemu-test/memory.x @@ -0,0 +1,8 @@ +/* Both machines put FLASH at 0 and RAM at 0x20000000, so one script links for + both. The sizes are the smaller pair — the micro:bit's nRF51822 (256K flash, + 16K RAM); lm3s6965evb has 256K/64K and is happy with less. */ +MEMORY +{ + FLASH : ORIGIN = 0x00000000, LENGTH = 256K + RAM : ORIGIN = 0x20000000, LENGTH = 16K +} diff --git a/qemu-test/src/main.rs b/qemu-test/src/main.rs new file mode 100644 index 0000000..bbd9e5e --- /dev/null +++ b/qemu-test/src/main.rs @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Deendayal Kumawat. All rights reserved. +// Licensed under the MIT OR Apache-2.0 license. + +//! Runs PulseMap on an emulated Cortex-M — real instructions, not a `cargo check`. +//! +//! The point of this is `MetaWord`'s `AtomicU64`. On Cortex-M3 (`thumbv7m`) it +//! comes from portable-atomic's spinlock fallback; on Cortex-M0 (`thumbv6m`) +//! there is no atomic CAS in the instruction set at all, so it can only work +//! through the `critical-section` feature. `MetaWord::on_access` performs that +//! CAS on every `get` hit, so a passing `get` here is the runtime proof. +//! +//! ```text +//! cargo run --release --target thumbv7m-none-eabi # lm3s6965evb, Cortex-M3 +//! cargo run --release --target thumbv6m-none-eabi --features m0 # microbit, Cortex-M0 +//! ``` + +#![no_std] +#![no_main] + +use core::alloc::{GlobalAlloc, Layout}; + +use cortex_m_rt::entry; +use cortex_m_semihosting::{debug, hprintln}; +use pulse_map::TypedPulseMap; + +// Linked for their side effects: the panic handler, and — with `m0` — the +// critical-section impl that portable-atomic calls into. +use cortex_m as _; +use panic_semihosting as _; + +const HEAP_SIZE: usize = 8 * 1024; +static mut HEAP: [u8; HEAP_SIZE] = [0; HEAP_SIZE]; +// Plain `usize`, not an atomic: this is single-threaded with interrupts never +// enabled, and Cortex-M0 has no atomic CAS to use here anyway. +static mut NEXT: usize = 0; + +/// ponytail: bump allocator, `dealloc` is a no-op. Correct for a run-once test +/// that allocates its buckets at startup and exits; real firmware wants +/// `embedded-alloc`. +struct Bump; + +unsafe impl GlobalAlloc for Bump { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let base = core::ptr::addr_of_mut!(HEAP) as usize; + let aligned = (base + NEXT + layout.align() - 1) & !(layout.align() - 1); + let end = aligned + layout.size(); + if end > base + HEAP_SIZE { + return core::ptr::null_mut(); + } + NEXT = end - base; + aligned as *mut u8 + } + + unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {} +} + +#[global_allocator] +static ALLOC: Bump = Bump; + +// PulseMapRaw::new allocates two Vecs, not one: `buckets` at 64 B each, plus +// `slots_ttl` at 4 x sizeof(SlotTTL) = 4 x 16 B per bucket. That is **128 bytes +// per bucket**, taken upfront whether or not you ever fill it. 16 buckets is +// 2 KiB, which is what fits comfortably beside the stack in the micro:bit's +// 16 KiB of RAM. +const BUCKETS: usize = 16; +const CAPACITY: usize = BUCKETS * 4; + +#[entry] +fn main() -> ! { + let mut failed = 0u32; + + let mut map: TypedPulseMap = TypedPulseMap::new(BUCKETS); + check(&mut failed, "capacity", map.capacity() == CAPACITY); + check(&mut failed, "starts empty", map.is_empty()); + + // Each of these get hits runs the AtomicU64 CAS in MetaWord::on_access. + map.insert(7, 700); + check(&mut failed, "get hit", map.get(&7) == Some(700)); + check(&mut failed, "get miss", map.get(&8).is_none()); + check(&mut failed, "len after one insert", map.len() == 1); + + // 4x capacity of distinct keys, so eviction has to run. Absence is legal — + // which of a bucket's 4 slots loses is not observable from out here — but a + // value that was never stored under that key never is. + let mut wrong = 0u32; + for k in 0..(CAPACITY as u32 * 4) { + map.insert(k, k.wrapping_mul(10)); + } + for k in 0..(CAPACITY as u32 * 4) { + // peek, so the check itself doesn't promote anything. + if let Some(v) = map.peek(&k) { + if v != k.wrapping_mul(10) { + wrong += 1; + } + } + } + check(&mut failed, "no corrupt value after eviction", wrong == 0); + check( + &mut failed, + "len stays within capacity", + map.len() <= CAPACITY, + ); + check(&mut failed, "eviction ran", map.eviction_count() > 0); + + // TTL counts insertions, not seconds. + let mut ttl: TypedPulseMap = TypedPulseMap::new(4); + ttl.set_ttl(4); + ttl.insert(1, 11); + check(&mut failed, "fresh entry is live", ttl.get(&1) == Some(11)); + for k in 100..106 { + ttl.insert(k, k); + } + check( + &mut failed, + "entry expired by insert count", + ttl.get(&1).is_none(), + ); + + let mut rm: TypedPulseMap = TypedPulseMap::new(4); + rm.insert(3, 33); + check(&mut failed, "remove reports hit", rm.remove(&3)); + check(&mut failed, "removed key is gone", rm.get(&3).is_none()); + + hprintln!( + "qemu-test: {} buckets, {} nominal slots, {} heap bytes used", + BUCKETS, + CAPACITY, + heap_used() + ); + + if failed == 0 { + hprintln!("qemu-test: all checks passed"); + debug::exit(debug::EXIT_SUCCESS); + } else { + hprintln!("qemu-test: {} check(s) FAILED", failed); + debug::exit(debug::EXIT_FAILURE); + } + + // debug::exit does not return `!`; QEMU is already gone by here. + loop {} +} + +/// Bytes handed out by the bump allocator so far — printed so the CI log +/// records what PulseMap actually costs on a 16 KiB part. +fn heap_used() -> usize { + unsafe { NEXT } +} + +fn check(failed: &mut u32, what: &str, ok: bool) { + if !ok { + *failed += 1; + hprintln!("FAIL: {}", what); + } +}