Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ═══════════════════════════════════════════════════════════
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions qemu-test/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions qemu-test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
target/
Cargo.lock
27 changes: 27 additions & 0 deletions qemu-test/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions qemu-test/memory.x
Original file line number Diff line number Diff line change
@@ -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
}
154 changes: 154 additions & 0 deletions qemu-test/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<u32, u32> = 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<u32, u32> = 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<u32, u32> = 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);
}
}
Loading